diff --git "a/.github/ISSUE_TEMPLATE/\353\260\261\354\227\224\353\223\234-\354\235\264\354\212\210.md" "b/.github/ISSUE_TEMPLATE/\353\260\261\354\227\224\353\223\234-\354\235\264\354\212\210.md" new file mode 100644 index 00000000..f728915f --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/\353\260\261\354\227\224\353\223\234-\354\235\264\354\212\210.md" @@ -0,0 +1,14 @@ +--- +name: 백엔드 이슈 +about: 백엔드와 관련된 이슈 +title: "[백엔드] " +labels: Backend +assignees: '' + +--- + +# 이슈 내용 + + +# 작업 목록 +- [ ] diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..fb57f1ee --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,9 @@ +## 작업 요약 + +## Issue Link + +## 문제점 및 어려움 + +## 해결 방안 + +## Reference diff --git a/.github/workflows/deploy-admin-docker.yml b/.github/workflows/deploy-admin-docker.yml new file mode 100644 index 00000000..fbcb3858 --- /dev/null +++ b/.github/workflows/deploy-admin-docker.yml @@ -0,0 +1,143 @@ +name: Dev Admin CI/CD With Docker + +on: + push: + branches: + - develop + paths: + - 'nowait-app-admin-api/**' + - 'nowait-common/**' + - 'nowait-domain/domain-core-rdb/**' + - 'nowait-domain/domain-admin-rdb/**' + - 'nowait-infra/**' + - 'build.gradle' + - 'settings.gradle' + - 'gradle/**' + +env: + DOCKERHUB_REPO: jihuneddie/nowait_dev_admin + DIVISION: admin + EC2_DEPLOY_PATH: /home/ubuntu/spring-github-action-admin-docker + + +jobs: + build-with-gradle: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'zulu' + + - name: Make gradlew executable + run: chmod +x ./gradlew + + - name: Set YML + run: | + mkdir -p nowait-app-admin-api/src/main/resources + echo "${{ secrets.APPLICATION_DEV_ADMIN_YML }}" \ + | base64 --decode \ + > nowait-app-admin-api/src/main/resources/application-admin.yml + find nowait-app-admin-api/src + + - name: Build Admin JAR + run: ./gradlew clean :nowait-app-admin-api:bootJar -x test + + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_ACCESS_TOKEN }} + + + - name: Build and Push Docker Image + run: | + TAG=${GITHUB_SHA::7} + docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/nowait_dev_admin:${TAG} -t ${{ secrets.DOCKERHUB_USERNAME }}/nowait_dev_admin:latest ./nowait-app-admin-api + docker push ${{ secrets.DOCKERHUB_USERNAME }}/nowait_dev_admin:${TAG} + docker push ${{ secrets.DOCKERHUB_USERNAME }}/nowait_dev_admin:latest + + + + deploy: + name: Deploy to EC2 + runs-on: ubuntu-latest + needs: build-with-gradle + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set Prometheus YML + run: | + echo "${{ secrets.PROMETHEUS_DEV_ADMIN_YML }}" \ + | base64 --decode \ + > prometheus-admin.yml + + - name: Copy docker-compose.admin.yml to EC2 + uses: appleboy/scp-action@master + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + port: ${{ secrets.EC2_PORT }} + source: "docker-compose.admin.yml" + target: ${{ env.EC2_DEPLOY_PATH }} + + - name: Copy docker-compose.admin-monitoring.yml to EC2 + uses: appleboy/scp-action@master + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + port: ${{ secrets.EC2_PORT }} + source: "docker-compose.admin-monitoring.yml" + target: ${{ env.EC2_DEPLOY_PATH }} + + - name: Copy prometheus-admin.yml to EC2 + uses: appleboy/scp-action@master + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + port: ${{ secrets.EC2_PORT }} + source: "prometheus-admin.yml" + target: ${{ env.EC2_DEPLOY_PATH }} + + - name: Copy deploy-admin-docker.sh to EC2 + uses: appleboy/scp-action@master + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + port: ${{ secrets.EC2_PORT }} + source: "scripts/deploy-admin-docker.sh" + target: "${{ env.EC2_DEPLOY_PATH }}" + + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_ACCESS_TOKEN }} + + - name: Deploy to EC2 + uses: appleboy/ssh-action@master + env: + AWS_ACCESS_KEY_ID: ${{ secrets.DEVSERVER_CICD_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.DEVSERVER_CICD_SECRET_KEY }} + AWS_REGION: ap-northeast-2 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + port: ${{ secrets.EC2_PORT }} + envs: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION + script: | + cd ${{ env.EC2_DEPLOY_PATH }} + chmod +x ./scripts/deploy-admin-docker.sh + ./scripts/deploy-admin-docker.sh + diff --git a/.github/workflows/deploy-admin.yml b/.github/workflows/deploy-admin.yml new file mode 100644 index 00000000..e29c7a65 --- /dev/null +++ b/.github/workflows/deploy-admin.yml @@ -0,0 +1,84 @@ +name: Dev Admin CI/CD + +on: + push: + branches: + - legacy + paths: + - 'nowait-app-admin-api/**' + - 'nowait-common/**' + - 'nowait-domain/domain-core-rdb/**' + - 'nowait-domain/domain-admin-rdb/**' + - 'nowait-infra/**' + - 'build.gradle' + - 'settings.gradle' + - 'gradle/**' + +env: + PROJECT_NAME: NoWait + DIVISION: admin + AWS_REGION: ap-northeast-2 + AWS_S3_BUCKET: nowait-deploy-github-actions + AWS_CODE_DEPLOY_APPLICATION: nowaiting + AWS_CODE_DEPLOY_GROUP: nowaiting-deploy-dev-admin + +jobs: + build-with-gradle: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'zulu' + + - name: Make gradlew executable + run: chmod +x ./gradlew + + - name: Set YML + run: | + mkdir -p nowait-app-admin-api/src/main/resources + echo "${{ secrets.APPLICATION_DEV_ADMIN_YML }}" \ + | base64 --decode \ + > nowait-app-admin-api/src/main/resources/application-admin.yml + find nowait-app-admin-api/src + + - name: Build Admin JAR + run: ./gradlew clean :nowait-app-admin-api:bootJar -x test + + - name: Prepare deployment package + run: | + rm -rf deploy + mkdir -p deploy/scripts + cp scripts/start-admin.sh scripts/stop-admin.sh deploy/scripts/ + cp appspec-admin.yml deploy/appspec.yml + cp nowait-app-admin-api/build/libs/nowait-app-admin-api-*.jar deploy/nowait-app-admin-api.jar + cd deploy + zip -r ../NoWait-admin-${GITHUB_SHA}.zip . + cd .. + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-region: ${{ env.AWS_REGION }} + aws-access-key-id: ${{ secrets.DEVSERVER_CICD_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.DEVSERVER_CICD_SECRET_KEY }} + + - name: Upload to S3 + run: | + aws deploy push \ + --application-name "${{ env.AWS_CODE_DEPLOY_APPLICATION }}" \ + --ignore-hidden-files \ + --s3-location "s3://${{ env.AWS_S3_BUCKET }}/${{ env.PROJECT_NAME }}/${{ env.DIVISION }}/${{ github.sha }}.zip" \ + --source deploy + + - name: Create CodeDeploy deployment + run: | + aws deploy create-deployment \ + --application-name ${{ env.AWS_CODE_DEPLOY_APPLICATION }} \ + --deployment-config-name CodeDeployDefault.AllAtOnce \ + --deployment-group-name ${{ env.AWS_CODE_DEPLOY_GROUP }} \ + --s3-location bucket=${{ env.AWS_S3_BUCKET }},key=${{ env.PROJECT_NAME }}/${{ env.DIVISION }}/${{ github.sha }}.zip,bundleType=zip diff --git a/.github/workflows/deploy-user-docker.yml b/.github/workflows/deploy-user-docker.yml new file mode 100644 index 00000000..0683ae85 --- /dev/null +++ b/.github/workflows/deploy-user-docker.yml @@ -0,0 +1,140 @@ +name: Dev User CI/CD With Docker + +on: + push: + branches: + - develop + paths: + - 'nowait-app-user-api/**' + - 'nowait-common/**' + - 'nowait-domain/domain-core-rdb/**' + - 'nowait-domain/domain-user-rdb/**' + - 'nowait-infra/**' + - 'build.gradle' + - 'settings.gradle' + - 'gradle/**' + +env: + DOCKERHUB_REPO: jihuneddie/nowait_dev_user + DIVISION: user + EC2_DEPLOY_PATH: /home/ubuntu/spring-github-action-user-docker + +jobs: + build-with-gradle: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'zulu' + + - name: Make gradlew executable + run: chmod +x ./gradlew + + - name: Set YML + run: | + mkdir -p nowait-app-user-api/src/main/resources + echo "${{ secrets.APPLICATION_DEV_USER_YML }}" \ + | base64 --decode \ + > nowait-app-user-api/src/main/resources/application-user.yml + find nowait-app-user-api/src + + - name: Build User JAR + run: ./gradlew clean :nowait-app-user-api:bootJar -x test + + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_ACCESS_TOKEN }} + + + - name: Build and Push Docker Image + run: | + TAG=${GITHUB_SHA::7} + docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/nowait_dev_user:${TAG} -t ${{ secrets.DOCKERHUB_USERNAME }}/nowait_dev_user:latest ./nowait-app-user-api + docker push ${{ secrets.DOCKERHUB_USERNAME }}/nowait_dev_user:${TAG} + docker push ${{ secrets.DOCKERHUB_USERNAME }}/nowait_dev_user:latest + + deploy: + name: Deploy to EC2 + runs-on: ubuntu-latest + needs: build-with-gradle + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set Prometheus YML + run: | + echo "${{ secrets.PROMETHEUS_DEV_USER_YML }}" \ + | base64 --decode \ + > prometheus-user.yml + + - name: Copy docker-compose.user.yml to EC2 + uses: appleboy/scp-action@master + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + port: ${{ secrets.EC2_PORT }} + source: "docker-compose.user.yml" + target: ${{ env.EC2_DEPLOY_PATH }} + + - name: Copy docker-compose.user-monitoring.yml to EC2 + uses: appleboy/scp-action@master + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + port: ${{ secrets.EC2_PORT }} + source: "docker-compose.user-monitoring.yml" + target: ${{ env.EC2_DEPLOY_PATH }} + + - name: Copy prometheus.yml to EC2 + uses: appleboy/scp-action@master + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + port: ${{ secrets.EC2_PORT }} + source: "prometheus-user.yml" + target: ${{ env.EC2_DEPLOY_PATH }} + + - name: Copy deploy-user-docker to EC2 + uses: appleboy/scp-action@master + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + port: ${{ secrets.EC2_PORT }} + source: "scripts/deploy-user-docker.sh" + target: "${{ env.EC2_DEPLOY_PATH }}" + + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_ACCESS_TOKEN }} + + - name: Deploy to EC2 + uses: appleboy/ssh-action@master + env: + AWS_ACCESS_KEY_ID: ${{ secrets.DEVSERVER_CICD_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.DEVSERVER_CICD_SECRET_KEY }} + AWS_REGION: ap-northeast-2 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + port: ${{ secrets.EC2_PORT }} + envs: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION + script: | + cd ${{ env.EC2_DEPLOY_PATH }} + chmod +x ./scripts/deploy-user-docker.sh + ./scripts/deploy-user-docker.sh + diff --git a/.github/workflows/deploy-user.yml b/.github/workflows/deploy-user.yml new file mode 100644 index 00000000..bf98972d --- /dev/null +++ b/.github/workflows/deploy-user.yml @@ -0,0 +1,86 @@ +name: Dev User CI/CD + +on: + push: + branches: + - legacy + paths: + - 'nowait-app-user-api/**' + - 'nowait-common/**' + - 'nowait-domain/domain-core-rdb/**' + - 'nowait-domain/domain-user-rdb/**' + - 'nowait-infra/**' + - 'build.gradle' + - 'settings.gradle' + - 'gradle/**' + +env: + PROJECT_NAME: NoWait + DIVISION: user + AWS_REGION: ap-northeast-2 + AWS_S3_BUCKET: nowait-deploy-github-actions + AWS_CODE_DEPLOY_APPLICATION: nowaiting + AWS_CODE_DEPLOY_GROUP: nowaiting-deploy-dev-user + +jobs: + build-with-gradle: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: JDK 17 설치 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'zulu' + + - name: gradlew에 실행 권한 부여 + run: chmod +x ./gradlew + + - name: Set YML + run: | + mkdir -p nowait-app-user-api/src/main/resources + echo "${{ secrets.APPLICATION_DEV_USER_YML }}" \ + | base64 --decode \ + > nowait-app-user-api/src/main/resources/application-user.yml + find nowait-app-user-api/src + + - name: Build User JAR + run: ./gradlew clean :nowait-app-user-api:bootJar -x test + + - name: Prepare deployment package + run: | + rm -rf deploy + mkdir -p deploy/scripts + cp scripts/start-user.sh scripts/stop-user.sh deploy/scripts/ + cp appspec-user.yml deploy/appspec.yml + cp nowait-app-user-api/build/libs/nowait-app-user-api-*.jar deploy/nowait-app-user-api.jar + cd deploy + zip -r ../NoWait-user-${GITHUB_SHA}.zip . + cd .. + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-region: ${{ env.AWS_REGION }} + aws-access-key-id: ${{ secrets.DEVSERVER_CICD_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.DEVSERVER_CICD_SECRET_KEY }} + + - name: Upload to S3 + run: | + aws deploy push \ + --application-name "${{ env.AWS_CODE_DEPLOY_APPLICATION }}" \ + --ignore-hidden-files \ + --s3-location "s3://${{ env.AWS_S3_BUCKET }}/${{ env.PROJECT_NAME }}/${{ env.DIVISION }}/${{ github.sha }}.zip" \ + --source deploy + + - name: Create CodeDeploy deployment + run: | + aws deploy create-deployment \ + --application-name ${{ env.AWS_CODE_DEPLOY_APPLICATION }} \ + --deployment-config-name CodeDeployDefault.AllAtOnce \ + --deployment-group-name ${{ env.AWS_CODE_DEPLOY_GROUP }} \ + --s3-location bucket=${{ env.AWS_S3_BUCKET }},key=${{ env.PROJECT_NAME }}/${{ env.DIVISION }}/${{ github.sha }}.zip,bundleType=zip + + diff --git a/.github/workflows/pr_notification.yml b/.github/workflows/pr_notification.yml new file mode 100644 index 00000000..74cd0f47 --- /dev/null +++ b/.github/workflows/pr_notification.yml @@ -0,0 +1,140 @@ +name: PR Discord Notification + +on: + pull_request: + types: [opened, closed, reopened] + + workflow_dispatch: + +env: + USERNAME: "돌아왔다 PR 리뷰 시간" + AVATAR_URL: "https://i.ibb.co/GvfVG729/R1280x0-fjpg.jpg" + +jobs: + notify: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Install jq + run: sudo apt-get install -y jq + + - name: Assign Random Reviewer + id: assign-reviewer + if: github.event.action == 'opened' || github.event.action == 'reopened' + uses: actions/github-script@v6 + env: + REVIEWER_MAPPING: ${{ secrets.REVIEWER_MAPPING }} + github-token: ${{ secrets.GITHUB_TOKEN }} + with: + script: | + const mappingJson = process.env.REVIEWER_MAPPING; + let githubToDiscordMap; + try { + githubToDiscordMap = JSON.parse(mappingJson); + } catch (error) { + throw new Error('Invalid JSON in REVIEWER_MAPPING secret.'); + } + + const allReviewers = Object.keys(githubToDiscordMap); + const { data: pullRequest } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number + }); + + const prAuthor = pullRequest.user.login; + const reviewers = allReviewers.filter(reviewer => reviewer.toLowerCase() !== prAuthor.toLowerCase()); + + if (reviewers.length === 0) { + throw new Error('No valid reviewers available (all reviewers are PR authors).'); + } + + const randomReviewer = reviewers[Math.floor(Math.random() * reviewers.length)]; + + if (pullRequest.requested_reviewers.length === 0) { + await github.rest.pulls.requestReviewers({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + reviewers: [randomReviewer] + }); + + const discordId = githubToDiscordMap[randomReviewer]; + if (!discordId) { + throw new Error(`Discord ID not found for GitHub user: ${randomReviewer}`); + } + + core.setOutput('reviewer', randomReviewer); + core.setOutput('discord_id', discordId); + } else { + const existingReviewer = pullRequest.requested_reviewers[0].login; + const discordId = githubToDiscordMap[existingReviewer]; + if (!discordId) { + throw new Error(`Discord ID not found for GitHub user: ${existingReviewer}`); + } + + core.setOutput('reviewer', existingReviewer); + core.setOutput('discord_id', discordId); + } + + - name: Send Discord Notification + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + + run: | + prTitle="${{ github.event.pull_request.title }}" + prUser="${{ github.event.pull_request.user.login }}" + prAction="${{ github.event.action }}" + prUrl="${{ github.event.pull_request.html_url }}" + prMerged="${{ github.event.pull_request.merged }}" + prActor="${{ github.actor }}" + prReviewer="${{ steps.assign-reviewer.outputs.reviewer }}" + prReviewerDiscordID="${{ steps.assign-reviewer.outputs.discord_id }}" + + if [ "$prAction" == "opened" ]; then + MESSAGE="**📢 새로운 PR이 도착했습니다!** + - <@${prReviewerDiscordID}> 님 리뷰어로 할당되었습니다!! 🙏 + - PR 제목 : ${prTitle} + - 등록한 사람 : ${prUser} + - 리뷰어 : <@${prReviewerDiscordID}> + - 리뷰하러 가기 >> [Click](${prUrl})" + + elif [ "$prAction" == "closed" ] && [ "$prMerged" == "true" ]; then + MESSAGE="**🎉 PR이 머지되었습니다!** + - PR 제목 : ${prTitle} + - 작업 수행자 : ${prActor} + - PR 확인하기 >> [Click](${prUrl})" + + elif [ "$prAction" == "closed" ]; then + MESSAGE="**🚨 PR이 닫혔습니다!** + - PR 제목 : ${prTitle} + - 작업 수행자 : ${prActor} + - PR 확인하기 >> [Click](${prUrl})" + + elif [ "$prAction" == "reopened" ]; then + MESSAGE="**🔎 PR이 다시 열렸습니다!** + - <@${prReviewerDiscordID}> 님 리뷰어로 할당되었습니다!! 🙏 + - PR 제목 : ${prTitle} + - 작업 수행자 : ${prActor} + - 리뷰어 : <@${prReviewerDiscordID}> + - 리뷰하러 가기 >> [Click](${prUrl})" + + else + MESSAGE="❓ 알 수 없는 PR 이벤트: **${prTitle}** by ${prUser}. **[클릭](${prUrl})**" + fi + + DATA=$(jq -n \ + --arg content "$MESSAGE" \ + --arg username "$USERNAME" \ + --arg avatar_url "$AVATAR_URL" \ + '{content: $content, username: $username, avatar_url: $avatar_url}') + + RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" -X POST -H 'Content-Type: application/json' -d "$DATA" "$DISCORD_WEBHOOK_URL") + + if [ "$RESPONSE" -ne 204 ]; then + echo "Failed to send Discord notification. HTTP Status Code: $RESPONSE" + exit 1 + fi diff --git a/README.md b/README.md new file mode 100644 index 00000000..34efd07b --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# NoWait +Logo-primary + +## 💡프로젝트 소개 +주점 대기 예약부터 메뉴 주문까지 원격으로 처리할 수 있는 웨이팅 서비스입니다. +![NoWait-Architecture](https://github.com/user-attachments/assets/408f468c-4ac2-4eb6-93d5-e39c4d5cd557) + + +## 📜 프로젝트 목표 + +### 핵심 목표 +- Redis SortedSet기반 대기열으로 급격한 트래픽에도 서비스 중단 없이 대기열 유지 +- Redis SPOF 해결 +- 멀티 모듈 프로젝트 구조로 서비스 영역을 분리하여 독립 빌드·배포 수행 + + +## 📌 Architecture +### 1️⃣ System Architecture +![NoWait-Architecture](https://github.com/user-attachments/assets/b85dc5fd-0a42-4f1a-a79e-448044a08c47) + +### 2️⃣ Multi Module Architecture +![NoWait-MultiModule-Architecture](https://github.com/user-attachments/assets/e30dcaf6-c1dc-4a5b-b2a3-29e22f1536f1) + +### 3️⃣ ERD +erd + +## 🔌 API 문서 +### 관리자 +Admin Server Swagger + +### 사용자 +User Server Swagger + + +## 🛠️ 사용 기술 + +
+ + + + + +
+ + + + +
+
+ +## 👨‍👩‍👧‍👦 팀원 소개 + + + + + + + + + + + + + +
BackendBackend
+ 김지훈 + + 정혜민 +
+ + + +
diff --git a/api-admin/build.gradle b/api-admin/build.gradle deleted file mode 100644 index 31009a7f..00000000 --- a/api-admin/build.gradle +++ /dev/null @@ -1,57 +0,0 @@ -plugins { - id 'org.springframework.boot' - id 'io.spring.dependency-management' - id 'java' -} - -group = 'com.nowaiting' -version = '0.0.1-SNAPSHOT' - -java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } -} - -repositories { - mavenCentral() -} - -// api-admin에서 사용하는 도메인, 인프라 모듈만 추가 -dependencies { - implementation project(':domain-menu') // 메뉴 관련 도메인 - implementation project(':domain-base') // 시간 관련 도메인 - implementation project(':domain-store') // 주점 관련 도메인 - implementation project(':domain-user') - implementation project(':domain-token') - implementation project(':domain-order') - implementation project(':domain-reservation') // 예약 관련 도메인 - implementation project(':security-admin') // 인증 관련 도메인 - implementation project(':infra-aws') // aws 관련 도메인 - - // Spring Boot Starter - implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springframework.boot:spring-boot-starter-validation' - - // SPRING SECURITY - implementation 'org.springframework.boot:spring-boot-starter-security' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' - - // SWAGGER - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' - - // DB - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - runtimeOnly 'com.mysql:mysql-connector-j' - - // 기타 필요 라이브러리 (예: Lombok) - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' - - testImplementation 'org.springframework.boot:spring-boot-starter-test' -} - -test { - useJUnitPlatform() -} diff --git a/api-admin/src/main/java/com/example/apiadmin/ApiAdminApplication.java b/api-admin/src/main/java/com/example/apiadmin/ApiAdminApplication.java deleted file mode 100644 index 29363011..00000000 --- a/api-admin/src/main/java/com/example/apiadmin/ApiAdminApplication.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.example.apiadmin; - -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.autoconfigure.domain.EntityScan; -import org.springframework.data.jpa.repository.config.EnableJpaAuditing; -import org.springframework.data.jpa.repository.config.EnableJpaRepositories; - -@EnableJpaAuditing -@SpringBootApplication(scanBasePackages = { - "com.example.apiadmin", - "com.example.infrastorage", - "com.nowait.adminsecurity" -}) -@EntityScan(basePackages = { - "com.example.menu.entity", // domain-menu - "com.example.domainstore.entity", // domain-store - "com.example.domaintoken.entity", - "com.nowaiting.user.entity", - "com.nowait.domainreservation.entity", - "com.nowait.domainorder.entity", - "com.nowait.domainorder.entity" - -}) -@EnableJpaRepositories(basePackages = { - "com.example.menu.repository", - "com.nowaiting.user.repository", - "com.example.domainstore.repository", - "com.example.domaintoken.repository", - "com.nowait.domainorder.repository", - "com.nowait.domainreservation.repository" -}) -public class ApiAdminApplication { - public static void main(String[] args) { - org.springframework.boot.SpringApplication.run(ApiAdminApplication.class, args); - } -} diff --git a/api-admin/src/main/java/com/example/apiadmin/exception/GlobalExceptionHandler.java b/api-admin/src/main/java/com/example/apiadmin/exception/GlobalExceptionHandler.java deleted file mode 100644 index 5c5c5c1d..00000000 --- a/api-admin/src/main/java/com/example/apiadmin/exception/GlobalExceptionHandler.java +++ /dev/null @@ -1,144 +0,0 @@ -package com.example.apiadmin.exception; - -import static com.nowaiting.common.exception.ErrorMessage.*; -import static org.springframework.http.HttpStatus.UNAUTHORIZED; -import static org.springframework.http.HttpStatus.*; - -import java.util.Map; -import java.util.stream.Collectors; - -import org.springframework.http.converter.HttpMessageNotReadableException; -import org.springframework.validation.FieldError; -import org.springframework.validation.ObjectError; -import org.springframework.web.bind.MethodArgumentNotValidException; -import org.springframework.web.bind.MissingRequestValueException; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.ResponseStatus; -import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.web.multipart.MultipartException; - -import com.example.apiadmin.reservation.exception.ReservationNotFoundException; -import com.example.domaintoken.exception.BusinessException; -import com.nowait.adminsecurity.exception.ResourceNotFoundException; -import com.nowait.adminsecurity.exception.UnauthorizedException; -import com.nowait.domainorder.exception.DuplicateOrderException; -import com.nowait.domainorder.exception.OrderItemsEmptyException; -import com.nowait.domainorder.exception.OrderParameterEmptyException; -import com.nowaiting.common.exception.ErrorMessage; -import com.nowaiting.common.exception.ErrorResponse; -import com.nowaiting.user.exception.UserNotFoundException; - -import io.swagger.v3.oas.annotations.Hidden; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@Hidden -@RestControllerAdvice -public class GlobalExceptionHandler { - - @ResponseStatus(value = BAD_REQUEST) - @ExceptionHandler(BusinessException.class) - public ErrorResponse handleBusinessException(BusinessException e) { - log.error("handleBusinessException", e); - return new ErrorResponse(e.getMessage(), e.getCode()); - } - - @ResponseStatus(value = BAD_REQUEST) - @ExceptionHandler(MethodArgumentNotValidException.class) - public ErrorResponse handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { - log.error("handleMethodArgumentNotValidException", e); - Map errors = getErrors(e); - return new ErrorResponse(INVALID_INPUT_VALUE.getMessage(), INVALID_INPUT_VALUE.getCode(), errors); - } - - @ResponseStatus(value = BAD_REQUEST) - @ExceptionHandler(HttpMessageNotReadableException.class) - public ErrorResponse handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { - log.error("handleHttpMessageNotReadableException", e); - return new ErrorResponse(INVALID_INPUT_VALUE.getMessage(), INVALID_INPUT_VALUE.getCode()); - } - - @ResponseStatus(value = BAD_REQUEST) - @ExceptionHandler(IllegalArgumentException.class) - public ErrorResponse handleIllegalArgumentException(IllegalArgumentException e) { - log.error("handleIllegalArgumentException", e); - return new ErrorResponse(e.getMessage(), INVALID_INPUT_VALUE.getCode()); - } - - @ResponseStatus(value = BAD_REQUEST) - @ExceptionHandler(MissingRequestValueException.class) - public ErrorResponse handleMissingRequestValueException(MissingRequestValueException e) { - log.error("handleMissingRequestValueExceptionException", e); - return new ErrorResponse(INVALID_INPUT_VALUE.getMessage(), INVALID_INPUT_VALUE.getCode()); - } - - @ResponseStatus(value = UNAUTHORIZED) - @ExceptionHandler(UnauthorizedException.class) - public ErrorResponse handleUnauthorizedException(UnauthorizedException e) { - log.error("handleUnauthorizedExceptionException", e); - return new ErrorResponse(e.getMessage(), e.getCode()); - } - - @ResponseStatus(value = NOT_FOUND) - @ExceptionHandler(ResourceNotFoundException.class) - public ErrorResponse handleResourceNotFoundException(ResourceNotFoundException e) { - log.error("handleResourceNotFoundExceptionException", e); - return new ErrorResponse(e.getMessage(), e.getCode()); - } - - @ResponseStatus(value = BAD_REQUEST) - @ExceptionHandler(MultipartException.class) - public ErrorResponse handleMultipartException(MultipartException e) { - log.error("handleMultipartException", e); - return new ErrorResponse(e.getMessage(), INVALID_INPUT_VALUE.getCode()); - } - - @ResponseStatus(value = NOT_FOUND) - @ExceptionHandler(UserNotFoundException.class) - public ErrorResponse userNotFoundException(UserNotFoundException e) { - log.error("userNotFoundException", e); - return new ErrorResponse(e.getMessage(), NOTFOUND_USER.getCode()); - } - - @ResponseStatus(value = BAD_REQUEST) - @ExceptionHandler(OrderParameterEmptyException.class) - public ErrorResponse orderParameterEmptyException(OrderParameterEmptyException e) { - log.error("orderParameterEmptyException", e); - return new ErrorResponse(e.getMessage(), ORDER_PARAMETER_EMPTY.getCode()); - } - - @ResponseStatus(value = BAD_REQUEST) - @ExceptionHandler(OrderItemsEmptyException.class) - public ErrorResponse orderItemsEmptyException(OrderItemsEmptyException e) { - log.error("orderItemsEmptyException", e); - return new ErrorResponse(e.getMessage(), ORDER_ITEMS_EMPTY.getCode()); - } - - @ResponseStatus(value = BAD_REQUEST) - @ExceptionHandler(DuplicateOrderException.class) - public ErrorResponse duplicateOrderException(DuplicateOrderException e) { - log.error("duplicateOrderException", e); - return new ErrorResponse(e.getMessage(), ErrorMessage.DUPLICATE_ORDER.getCode()); - } - - @ResponseStatus(value = NOT_FOUND) - @ExceptionHandler(ReservationNotFoundException.class) - public ErrorResponse reservationNotFoundException(ReservationNotFoundException e) { - log.error("reservationNotFoundException", e); - return new ErrorResponse(e.getMessage(), NOTFOUND_RESERVATION.getCode()); - } - - - private static Map getErrors(MethodArgumentNotValidException e) { - return e.getBindingResult() - .getAllErrors() - .stream() - .filter(ObjectError.class::isInstance) - .collect(Collectors.toMap( - error -> error instanceof FieldError ? ((FieldError)error).getField() : error.getObjectName(), - ObjectError::getDefaultMessage, - (msg1, msg2) -> msg1 + ";" + msg2 - )); - } - -} diff --git a/api-admin/src/main/java/com/example/apiadmin/menu/controller/MenuController.java b/api-admin/src/main/java/com/example/apiadmin/menu/controller/MenuController.java deleted file mode 100644 index eb7523b7..00000000 --- a/api-admin/src/main/java/com/example/apiadmin/menu/controller/MenuController.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.example.apiadmin.menu.controller; - -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import com.example.apiadmin.menu.dto.MenuCreateRequest; -import com.example.apiadmin.menu.dto.MenuCreateResponse; -import com.example.apiadmin.menu.service.MenuService; -import com.nowaiting.common.api.ApiUtils; - -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; - -@RestController -@RequestMapping("/admin/menus") -@RequiredArgsConstructor -public class MenuController { - - private final MenuService menuService; - - @PostMapping("/create") - public ResponseEntity createMenu(@Valid @RequestBody MenuCreateRequest request) { - MenuCreateResponse response = menuService.createMenu(request); - - return ResponseEntity - .status(HttpStatus.CREATED) - .body( - ApiUtils.success( - response - ) - ); - } - - @GetMapping("/all-menus/stores/{storeId}") - public ResponseEntity getMenusByStoreId(@PathVariable Long storeId) { - return ResponseEntity - .status(HttpStatus.OK) - .body( - ApiUtils.success( - menuService.getMenusByStoreId(storeId) - ) - ); - } -} diff --git a/api-admin/src/main/java/com/example/apiadmin/menu/service/MenuImageService.java b/api-admin/src/main/java/com/example/apiadmin/menu/service/MenuImageService.java deleted file mode 100644 index 5aec2e7a..00000000 --- a/api-admin/src/main/java/com/example/apiadmin/menu/service/MenuImageService.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.example.apiadmin.menu.service; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.multipart.MultipartFile; - -import com.example.apiadmin.menu.dto.MenuImageUploadResponse; -import com.example.infrastorage.s3.S3Service; -import com.example.menu.entity.Menu; -import com.example.menu.entity.MenuImage; -import com.example.menu.repository.MenuImageRepository; -import com.example.menu.repository.MenuRepository; - -import lombok.RequiredArgsConstructor; - -@Service -@RequiredArgsConstructor -public class MenuImageService { - - private final MenuRepository menuRepository; - private final MenuImageRepository menuImageRepository; - private final S3Service s3Service; - - @Transactional - public MenuImageUploadResponse save(Long menuId, MultipartFile file) { - - String type = "menu"; - Menu menu = menuRepository.findById(menuId) - .orElseThrow(() -> new IllegalArgumentException("Menu not found with id: " + menuId)); - - S3Service.S3UploadResult uploadResult = s3Service.upload(type, menuId, file).join(); - - // MenuImage 엔티티 생성 및 저장 - MenuImage menuImage = MenuImage.builder() - .menu(menu) - .imageUrl(uploadResult.url()) - .fileKey(uploadResult.key()) - .build(); - - menuImageRepository.save(menuImage); - - // 응답 생성 - return MenuImageUploadResponse.fromEntity(menuImage); - } - - @Transactional - public void delete(Long id) { - MenuImage menuImage = menuImageRepository.findById(id) - .orElseThrow(() -> new IllegalArgumentException("MenuImage not found with id: " + id)); - - s3Service.delete(menuImage.getFileKey()); - menuImageRepository.delete(menuImage); - } -} diff --git a/api-admin/src/main/java/com/example/apiadmin/menu/service/MenuService.java b/api-admin/src/main/java/com/example/apiadmin/menu/service/MenuService.java deleted file mode 100644 index eedb55a8..00000000 --- a/api-admin/src/main/java/com/example/apiadmin/menu/service/MenuService.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.example.apiadmin.menu.service; - -import java.util.List; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.example.apiadmin.menu.dto.MenuCreateRequest; -import com.example.apiadmin.menu.dto.MenuCreateResponse; -import com.example.apiadmin.menu.dto.MenuImageUploadResponse; -import com.example.apiadmin.menu.dto.MenuReadDto; -import com.example.apiadmin.menu.dto.MenuReadResponse; -import com.example.menu.entity.Menu; -import com.example.menu.entity.MenuImage; -import com.example.menu.repository.MenuImageRepository; -import com.example.menu.repository.MenuRepository; - -import lombok.RequiredArgsConstructor; - -@Service -@RequiredArgsConstructor -public class MenuService { - - private final MenuRepository menuRepository; - private final MenuImageRepository menuImageRepository; - - @Transactional - public MenuCreateResponse createMenu(MenuCreateRequest request) { - Menu toSave = request.toEntity(); - - Menu saved = menuRepository.save(toSave); - - return MenuCreateResponse.fromEntity(saved); - } - - @Transactional(readOnly = true) - public MenuReadResponse getMenusByStoreId(Long storeId) { - List menus = menuRepository.findAllByStoreId(storeId); - - List menuReadRespons = menus.stream() - .map(menu -> { - List images = menuImageRepository.findByMenu(menu); - List imageDto = images.stream() - .map(MenuImageUploadResponse::fromEntity) - .toList(); - return MenuReadDto.fromEntity(menu, imageDto); - }) - .toList(); - - return MenuReadResponse.of(menuReadRespons); - } -} diff --git a/api-admin/src/main/java/com/example/apiadmin/order/controller/OrderController.java b/api-admin/src/main/java/com/example/apiadmin/order/controller/OrderController.java deleted file mode 100644 index 69c7e3f8..00000000 --- a/api-admin/src/main/java/com/example/apiadmin/order/controller/OrderController.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.example.apiadmin.order.controller; - -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; - -@Tag(name = "Order API", description = "주문 API") -@RestController -@RequestMapping("admin//orders") -@RequiredArgsConstructor -public class OrderController { - -} diff --git a/api-admin/src/main/java/com/example/apiadmin/reservation/controller/ReservationController.java b/api-admin/src/main/java/com/example/apiadmin/reservation/controller/ReservationController.java deleted file mode 100644 index db32a09f..00000000 --- a/api-admin/src/main/java/com/example/apiadmin/reservation/controller/ReservationController.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.example.apiadmin.reservation.controller; - -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -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.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import com.example.apiadmin.reservation.dto.CallGetResponseDto; -import com.example.apiadmin.reservation.dto.ReservationStatusSummaryDto; -import com.example.apiadmin.reservation.dto.ReservationStatusUpdateRequestDto; -import com.example.apiadmin.reservation.service.ReservationService; -import com.nowaiting.common.api.ApiUtils; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; - -@Tag(name = "Reservation API", description = "예약 API") -@RestController -@RequestMapping("/reservations") -@RequiredArgsConstructor -public class ReservationController { - - private final ReservationService reservationService; - - @GetMapping("/admin/{storeId}") - @Operation(summary = "주점별 예약리스트 조회", description = "특정 주점에 대한 예약리스트 조회") - @ApiResponse(responseCode = "200", description = "예약리스트 조회") - public ResponseEntity getReservationListByStoreId(@PathVariable Long storeId) { - ReservationStatusSummaryDto response = reservationService.getReservationListByStoreId(storeId); - return ResponseEntity - .status(HttpStatus.OK) - .body( - ApiUtils.success( - response - ) - ); - } - - @PatchMapping("/admin/updates/{reservationId}") - @Operation(summary = "예약팀 상태 변경", description = "특정 예약에 대한 상태 변경(예약중->호출중,호출중->입장완료,취소)") - @ApiResponse(responseCode = "200", description = "예약팀 상태 변경") - public ResponseEntity updateReservationStatus(@PathVariable Long reservationId,@RequestBody - ReservationStatusUpdateRequestDto requestDto) { - CallGetResponseDto response = reservationService.updateReservationStatus(reservationId,requestDto); - return ResponseEntity - .status(HttpStatus.OK) - .body( - ApiUtils.success( - response - ) - ); - } - - -} diff --git a/api-admin/src/main/java/com/example/apiadmin/reservation/exception/ReservationNotFoundException.java b/api-admin/src/main/java/com/example/apiadmin/reservation/exception/ReservationNotFoundException.java deleted file mode 100644 index 0f9c37ad..00000000 --- a/api-admin/src/main/java/com/example/apiadmin/reservation/exception/ReservationNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.example.apiadmin.reservation.exception; - -import com.nowaiting.common.exception.ErrorMessage; - -public class ReservationNotFoundException extends RuntimeException { - public ReservationNotFoundException() { - super(ErrorMessage.NOTFOUND_RESERVATION.getMessage()); - } -} diff --git a/api-admin/src/main/java/com/example/apiadmin/reservation/service/ReservationService.java b/api-admin/src/main/java/com/example/apiadmin/reservation/service/ReservationService.java deleted file mode 100644 index e0bfbfd5..00000000 --- a/api-admin/src/main/java/com/example/apiadmin/reservation/service/ReservationService.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.example.apiadmin.reservation.service; - -import java.util.ArrayList; -import java.util.List; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.example.apiadmin.reservation.dto.CallGetResponseDto; -import com.example.apiadmin.reservation.dto.ReservationGetResponseDto; -import com.example.apiadmin.reservation.dto.ReservationStatusSummaryDto; -import com.example.apiadmin.reservation.dto.ReservationStatusUpdateRequestDto; -import com.example.apiadmin.reservation.exception.ReservationNotFoundException; -import com.example.domainstore.repository.StoreRepository; -import com.nowait.domainreservation.entity.Reservation; -import com.nowait.domainreservation.repository.ReservationRepository; -import com.nowaiting.common.enums.ReservationStatus; - -import lombok.RequiredArgsConstructor; - -@Service -@RequiredArgsConstructor -public class ReservationService { - - private final ReservationRepository reservationRepository; - - @Transactional(readOnly = true) - public ReservationStatusSummaryDto getReservationListByStoreId(Long storeId) { - List reservations = reservationRepository.findAllByStore_StoreIdOrderByRequestedAtAsc(storeId); - - // 상태별 카운트 집계 - int waitingCount = 0; - int confirmedCount = 0; - int cancelledCount = 0; - int callingCount = 0; - List reservationDtoList = new ArrayList<>(); - for (Reservation r : reservations) { - if (r.getStatus() == ReservationStatus.WAITING) waitingCount++; - if (r.getStatus() == ReservationStatus.CONFIRMED) confirmedCount++; - if (r.getStatus() == ReservationStatus.CANCELLED) cancelledCount++; - if (r.getStatus() == ReservationStatus.CALLING) callingCount++; - reservationDtoList.add(ReservationGetResponseDto.fromEntity(r)); - } - - return ReservationStatusSummaryDto.builder() - .waitingCount(waitingCount) - .confirmedCount(confirmedCount) - .cancelledCount(cancelledCount) - .callingCount(callingCount) - .reservationList(reservationDtoList) - .build(); - } - @Transactional - public CallGetResponseDto updateReservationStatus(Long reservationId, ReservationStatusUpdateRequestDto requestDto) { - Reservation reservation = reservationRepository.findById(reservationId) - .orElseThrow(ReservationNotFoundException::new); - reservation.updateStatus(requestDto.getStatus()); - return CallGetResponseDto.fromEntity(reservation); - } - - -} - diff --git a/api-admin/src/main/java/com/example/apiadmin/store/controller/StoreController.java b/api-admin/src/main/java/com/example/apiadmin/store/controller/StoreController.java deleted file mode 100644 index 29824348..00000000 --- a/api-admin/src/main/java/com/example/apiadmin/store/controller/StoreController.java +++ /dev/null @@ -1,89 +0,0 @@ -package com.example.apiadmin.store.controller; - -import org.springframework.http.HttpStatus; -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.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.example.apiadmin.store.dto.StoreCreateRequest; -import com.example.apiadmin.store.dto.StoreCreateResponse; -import com.example.apiadmin.store.dto.StoreUpdateRequest; -import com.example.apiadmin.store.service.StoreService; -import com.nowaiting.common.api.ApiUtils; - -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; - -@RestController -@RequestMapping("admin/stores") -@RequiredArgsConstructor -public class StoreController { - - private final StoreService storeService; - - @PostMapping("/create") - public ResponseEntity createStore(@Valid @RequestBody StoreCreateRequest request) { - StoreCreateResponse response = storeService.createStore(request); - - return ResponseEntity - .status(HttpStatus.CREATED) - .body( - ApiUtils.success( - response - ) - ); - } - - @GetMapping("/all-stores") - public ResponseEntity getAllStores() { - return ResponseEntity - .status(HttpStatus.OK) - .body( - ApiUtils.success( - storeService.getAllStores() - ) - ); - } - - @GetMapping("/{storeId}") - public ResponseEntity getStoreById(@PathVariable Long storeId) { - return ResponseEntity - .status(HttpStatus.OK) - .body( - ApiUtils.success( - storeService.getStoreByStoreId(storeId) - ) - ); - } - - @PatchMapping("/{storeId}") - public ResponseEntity updateStore( - @PathVariable Long storeId, - @Valid @RequestBody StoreUpdateRequest request - ) { - return ResponseEntity - .status(HttpStatus.OK) - .body( - ApiUtils.success( - storeService.updateStore(storeId, request) - ) - ); - } - - @DeleteMapping("/{storeId}") - public ResponseEntity deleteStore(@PathVariable Long storeId) { - return ResponseEntity - .ok() - .body( - ApiUtils.success( - storeService.deleteStore(storeId) - ) - ); - } -} diff --git a/api-admin/src/main/java/com/example/apiadmin/store/controller/StoreImageController.java b/api-admin/src/main/java/com/example/apiadmin/store/controller/StoreImageController.java deleted file mode 100644 index 531a1bea..00000000 --- a/api-admin/src/main/java/com/example/apiadmin/store/controller/StoreImageController.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.example.apiadmin.store.controller; - -import java.util.List; - -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -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 org.springframework.web.multipart.MultipartFile; - -import com.example.apiadmin.store.dto.StoreImageUploadResponse; -import com.example.apiadmin.store.service.StoreImageService; -import com.nowaiting.common.api.ApiUtils; - -import lombok.RequiredArgsConstructor; - -@RestController -@RequestMapping("/admin/stores") -@RequiredArgsConstructor -public class StoreImageController { - - private final StoreImageService storeImageService; - - @PostMapping("/store-images/{storeId}") - public ResponseEntity uploadStoreImage( - @PathVariable Long storeId, - @RequestParam("files") List files, - @RequestParam(value = "types") List types - ) { - // TODO 관련 정책 확정되면 메서드로 분리 예정 - // 파일 개수 제한 검증 - if (files.isEmpty() || files.size() > 10) { - throw new IllegalArgumentException("파일은 1개 이상 10개 이하로 업로드해 주세요."); - } - // 파일 크기 검증 - for (MultipartFile file : files) { - if (file.isEmpty()) { - throw new IllegalArgumentException("빈 파일은 업로드할 수 없습니다."); - } - if (file.getSize() > 10 * 1024 * 1024) { // 10MB 제한 - throw new IllegalArgumentException("파일 크기는 10MB를 초과할 수 없습니다."); - } - } - - List response = storeImageService.saveAll(storeId, files, types); - return ResponseEntity - .status(HttpStatus.CREATED) - .body( - ApiUtils.success( - response - ) - ); - } - - @DeleteMapping("/store-images/{storeImageId}") - public ResponseEntity deleteStoreImage(@PathVariable Long imageId) { - storeImageService.delete(imageId); - return ResponseEntity - .status(HttpStatus.NO_CONTENT) - .body( - ApiUtils - .success( - "Store image deleted successfully." - ) - ); - } -} diff --git a/api-admin/src/main/java/com/example/apiadmin/store/service/StoreImageService.java b/api-admin/src/main/java/com/example/apiadmin/store/service/StoreImageService.java deleted file mode 100644 index 330f0f9c..00000000 --- a/api-admin/src/main/java/com/example/apiadmin/store/service/StoreImageService.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.example.apiadmin.store.service; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.CompletableFuture; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.multipart.MultipartFile; - -import com.example.apiadmin.store.dto.StoreImageUploadResponse; -import com.example.domainstore.entity.Store; - -import com.example.domainstore.entity.StoreImage; -import com.example.domainstore.repository.StoreImageRepository; -import com.example.domainstore.repository.StoreRepository; -import com.example.infrastorage.s3.S3Service; - -import jakarta.persistence.EntityNotFoundException; -import lombok.RequiredArgsConstructor; - -@Service -@RequiredArgsConstructor -public class StoreImageService { - - private final StoreRepository storeRepository; - private final StoreImageRepository storeImageRepository; - private final S3Service s3Service; - - @Transactional - public List saveAll(Long storeId, List files, List types) { - if (files.size() != types.size()) { - throw new IllegalArgumentException("파일과 타입의 개수가 일치해야 합니다."); - } - - String type = "store"; - Store store = storeRepository.findById(storeId) - .orElseThrow(() -> new EntityNotFoundException("Store not found with id: " + storeId)); - - // 모든 파일을 비동기로 업로드 - List> uploadFutures = new ArrayList<>(); - for (MultipartFile file : files) { - uploadFutures.add(s3Service.upload(type, storeId, file)); - } - - // 모든 업로드 완료 대기 - List uploadResults; - try { - uploadResults = uploadFutures.stream() - .map(CompletableFuture::join) - .toList(); - } catch (Exception e) { - throw new RuntimeException("S3 업로드 실패", e); - } - - // DB 저장은 모든 S3 업로드 성공 후 수행 - List imageUploadResponses = new ArrayList<>(); - for (int i = 0; i < uploadResults.size(); i++) { - S3Service.S3UploadResult uploadResult = uploadResults.get(i); - StoreImage storeImage = StoreImage.builder() - .store(store) - .imageUrl(uploadResult.url()) - .fileKey(uploadResult.key()) - .type(types.get(i)) - .build(); - - storeImageRepository.save(storeImage); - imageUploadResponses.add(StoreImageUploadResponse.fromEntity(storeImage)); - } - - return imageUploadResponses; - } - - @Transactional - public void delete(Long storeImageId) { - StoreImage storeImage = storeImageRepository.findById(storeImageId) - .orElseThrow(() -> new EntityNotFoundException("StoreImage not found with id: " + storeImageId)); - - s3Service.delete(storeImage.getFileKey()); - storeImageRepository.delete(storeImage); - } -} diff --git a/api-admin/src/main/java/com/example/apiadmin/store/service/StoreService.java b/api-admin/src/main/java/com/example/apiadmin/store/service/StoreService.java deleted file mode 100644 index 00ee1e4a..00000000 --- a/api-admin/src/main/java/com/example/apiadmin/store/service/StoreService.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.example.apiadmin.store.service; - -import com.example.apiadmin.store.dto.StoreCreateRequest; -import com.example.apiadmin.store.dto.StoreCreateResponse; -import com.example.apiadmin.store.dto.StoreReadDto; -import com.example.apiadmin.store.dto.StoreReadResponse; -import com.example.apiadmin.store.dto.StoreUpdateRequest; - -public interface StoreService { - - StoreCreateResponse createStore(StoreCreateRequest request); - - StoreReadResponse getAllStores(); - - StoreReadDto getStoreByStoreId(Long storeId); - - StoreReadDto updateStore(Long storeId, StoreUpdateRequest request); - - String deleteStore(Long storeId); - -} diff --git a/api-admin/src/main/java/com/example/apiadmin/store/service/StoreServiceImpl.java b/api-admin/src/main/java/com/example/apiadmin/store/service/StoreServiceImpl.java deleted file mode 100644 index fa04162e..00000000 --- a/api-admin/src/main/java/com/example/apiadmin/store/service/StoreServiceImpl.java +++ /dev/null @@ -1,106 +0,0 @@ -package com.example.apiadmin.store.service; - -import java.util.List; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.example.apiadmin.store.dto.StoreCreateRequest; -import com.example.apiadmin.store.dto.StoreCreateResponse; -import com.example.apiadmin.store.dto.StoreImageUploadResponse; -import com.example.apiadmin.store.dto.StoreReadDto; -import com.example.apiadmin.store.dto.StoreReadResponse; -import com.example.apiadmin.store.dto.StoreUpdateRequest; -import com.example.domainstore.entity.Store; -import com.example.domainstore.entity.StoreImage; -import com.example.domainstore.repository.StoreImageRepository; -import com.example.domainstore.repository.StoreRepository; - -import jakarta.persistence.EntityNotFoundException; -import lombok.RequiredArgsConstructor; - -@Service -@RequiredArgsConstructor -public class StoreServiceImpl implements StoreService { - - private final StoreRepository storeRepository; - private final StoreImageRepository storeImageRepository; - - @Override - @Transactional - public StoreCreateResponse createStore(StoreCreateRequest request) { - Store toSave = request.toEntity(); - - Store saved = storeRepository.save(toSave); - - return StoreCreateResponse.fromEntity(saved); - } - - @Override - @Transactional(readOnly = true) - public StoreReadResponse getAllStores() { - List stores = storeRepository.findAllByDeletedFalse(); - - List storeRead = stores.stream() - .map(store -> { - List images = storeImageRepository.findByStore(store); - List imageDto = images.stream() - .map(StoreImageUploadResponse::fromEntity) - .toList(); - return StoreReadDto.fromEntity(store, imageDto); - }) - .toList(); - - boolean hasNext = false; - - return StoreReadResponse.of(storeRead, hasNext); - } - - @Override - @Transactional(readOnly = true) - public StoreReadDto getStoreByStoreId(Long storeId) { - Store store = storeRepository.findByStoreIdAndDeletedFalse(storeId) - .orElseThrow(() -> new EntityNotFoundException(storeId + " store not found.")); - - List images = storeImageRepository.findByStore(store); - List imageDto = images.stream() - .map(StoreImageUploadResponse::fromEntity) - .toList(); - - return StoreReadDto.fromEntity(store, imageDto); - } - - @Override - @Transactional - public StoreReadDto updateStore(Long storeId, StoreUpdateRequest request) { - Store store = storeRepository.findByStoreIdAndDeletedFalse(storeId) - .orElseThrow(() -> new EntityNotFoundException(storeId + " store not found.")); - - store.updateInfo( - request.getName(), - request.getLocation(), - request.getDescription() - ); - - Store updatedStore = storeRepository.save(store); - - List images = storeImageRepository.findByStore(updatedStore); - List imageDto = images.stream() - .map(StoreImageUploadResponse::fromEntity) - .toList(); - - return StoreReadDto.fromEntity(updatedStore, imageDto); - } - - @Override - @Transactional - public String deleteStore(Long storeId) { - Store store = storeRepository.findByStoreIdAndDeletedFalse(storeId) - .orElseThrow(() -> new EntityNotFoundException(storeId + " store not found.")); - - store.markAsDeleted(); - storeRepository.save(store); - - return "Store ID " + storeId + " 삭제되었습니다."; - } -} diff --git a/api-admin/src/main/java/com/example/apiadmin/token/service/TokenService.java b/api-admin/src/main/java/com/example/apiadmin/token/service/TokenService.java deleted file mode 100644 index 458ec8e2..00000000 --- a/api-admin/src/main/java/com/example/apiadmin/token/service/TokenService.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.example.apiadmin.token.service; - -import java.time.LocalDateTime; -import java.util.Optional; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.example.domaintoken.entity.Token; -import com.example.domaintoken.repository.TokenRepository; -import com.nowait.adminsecurity.auth.jwt.JwtUtil; -import com.nowait.adminsecurity.exception.RefreshTokenNotFoundException; -import com.nowait.adminsecurity.exception.TokenBadRequestException; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Service -@RequiredArgsConstructor -@Slf4j -public class TokenService { - private final TokenRepository tokenRepository; - private final JwtUtil jwtUtil; - - @Transactional - public Boolean validateToken(String token, Long userId){ - // DB에서 해당 userId와 일치하는 리프레시토큰을 찾는다. - Optional savedToken = tokenRepository.findByUserId(userId); - - // DB에서 userId에 대응되는 리프레시토큰 없으면, 유효하지 않음 - if (savedToken.isEmpty()){ - log.info("여기에 걸렸니 ? -- 1 "); - return false; - } - - // 리프레시 토큰이 DB에 저장된 토큰과 일치하는지 확인 - if (!savedToken.get().getRefreshToken().equals(token)){ - log.info("여기에 걸렸니 ? -- 2 "); - return false; - } - - // 리프레시 토큰의 만료여부 확인 - if(jwtUtil.isExpired(token)){ - log.info("여기에 걸렸니 ? -- 3 "); - return false; // 만료된 토큰은 유효하지 않음 - } - - log.info("여기에 걸렸니 ? -- 4 "); - return true; // 모든 조건 만족 시, 유효한 토큰 - } - - @Transactional - public void updateRefreshToken(Long userId, String oldRefreshToken, String newRefreshToken){ - Token token = tokenRepository.findByUserId(userId) - .orElseThrow(RefreshTokenNotFoundException::new); // 404 - - if (!token.getRefreshToken().equals(oldRefreshToken)){ - throw new TokenBadRequestException(); // 400 - } - - // 기존 토큰 삭제 및 새 토큰 저장 - tokenRepository.delete(token); - Token newToken = Token.toEntity(token.getUser(), newRefreshToken, LocalDateTime.now().plusDays(30)); - tokenRepository.save(newToken); - } -} diff --git a/api-admin/src/main/java/com/example/apiadmin/user/dto/ManagerSignupRequestDto.java b/api-admin/src/main/java/com/example/apiadmin/user/dto/ManagerSignupRequestDto.java deleted file mode 100644 index 3d61b7ad..00000000 --- a/api-admin/src/main/java/com/example/apiadmin/user/dto/ManagerSignupRequestDto.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.example.apiadmin.user.dto; - -import com.nowaiting.common.enums.Role; -import com.nowaiting.common.enums.SocialType; -import com.nowaiting.user.entity.User; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Pattern; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor -public class ManagerSignupRequestDto { - - @NotBlank - @Email(regexp = "^(?=.{1,64}@)[A-Za-z0-9_-]+(\\.[A-Za-z0-9_-]+)*@" - + "[^-][A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)*(\\.[A-Za-z]{2,})$") - @Schema(description = "이메일", example = "abc@gmail.com") - private String email; - - @NotBlank - @Pattern(regexp = "(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\\W)(?=\\S+$).{8,20}") - @Schema(description = "비밀번호", example = "1234568!@") - private String password; - - @NotBlank - @Pattern(regexp = "^[a-zA-Z가-힣]{2,12}$") - @Schema(description = "닉네임", example = "가십이") - private String nickname; - - @NotBlank - @Schema(description = "로그인타입", example = "LOCAL") - private String socialType; - - public User toEntity() { - return User.builder() - .email(email) - .password(password) - .nickname(nickname) - .socialType(SocialType.LOCAL) - .role(Role.MANAGER) - .build(); - - } -} diff --git a/api-admin/src/main/java/com/example/apiadmin/user/serivce/UserService.java b/api-admin/src/main/java/com/example/apiadmin/user/serivce/UserService.java deleted file mode 100644 index 1e026c56..00000000 --- a/api-admin/src/main/java/com/example/apiadmin/user/serivce/UserService.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.example.apiadmin.user.serivce; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.security.authentication.AuthenticationProvider; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.example.apiadmin.user.dto.ManagerLoginRequestDto; -import com.example.apiadmin.user.dto.ManagerLoginResponseDto; -import com.example.apiadmin.user.dto.ManagerSignupRequestDto; -import com.example.apiadmin.user.dto.ManagerSignupResponseDto; -import com.nowait.adminsecurity.auth.jwt.JwtUtil; -import com.nowaiting.user.entity.MemberDetails; -import com.nowaiting.user.entity.User; -import com.nowaiting.user.repository.UserRepository; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Service -@RequiredArgsConstructor -@Slf4j -public class UserService { - private final UserRepository userRepository; - private final PasswordEncoder passwordEncoder; - private final AuthenticationProvider authenticationProvider; - private final JwtUtil jwtUtil; - @Value("${jwt.access-token-expiration-ms}") - private long accessTokenExpiration; - - @Transactional - public ManagerSignupResponseDto signup(ManagerSignupRequestDto managerSignupRequestDto) { - validateEmailDuplicated(managerSignupRequestDto); - validateNickNameDuplicated(managerSignupRequestDto.getNickname()); - User user = managerSignupRequestDto.toEntity(); - user.encodePassword(passwordEncoder); - - return ManagerSignupResponseDto.fromEntity(userRepository.save(user)); - - } - private void validateEmailDuplicated(ManagerSignupRequestDto managerSignupRequestDto) { - userRepository.findByEmail(managerSignupRequestDto.getEmail()).ifPresent(member -> { - throw new IllegalArgumentException(); - } - ); - } - private void validateNickNameDuplicated(String nickName) { - userRepository.findByNickname(nickName).ifPresent(member -> { - throw new IllegalArgumentException(); - } - ); - } - @Transactional - public ManagerLoginResponseDto login(ManagerLoginRequestDto managerLoginRequestDto) { - Authentication authentication = authenticationProvider.authenticate( - new UsernamePasswordAuthenticationToken(managerLoginRequestDto.getEmail(), managerLoginRequestDto.getPassword()) - ); - MemberDetails memberDetails = (MemberDetails) authentication.getPrincipal(); - User user = userRepository.getReferenceById(memberDetails.getId()); - String accessToken = jwtUtil.createAccessToken("accessToken", user.getId(), String.valueOf(user.getRole()), accessTokenExpiration); - return ManagerLoginResponseDto.fromEntity(user,accessToken); - } -} diff --git a/api-user/build.gradle b/api-user/build.gradle deleted file mode 100644 index 74bdca87..00000000 --- a/api-user/build.gradle +++ /dev/null @@ -1,57 +0,0 @@ -plugins { - id 'org.springframework.boot' version '3.5.0' - id 'io.spring.dependency-management' version '1.1.7' - id 'java' -} - -group = 'com.nowaiting' -version = '0.0.1-SNAPSHOT' - -java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } -} - -repositories { - mavenCentral() -} - -dependencies { - implementation project(':domain-user') // 도메인 계층 의존성 - implementation project(':domain-menu') // 메뉴 관련 도메인 - implementation project(':domain-base') // 시간 관련 도메인 - implementation project(':domain-store') // 주점 관련 도메인 - implementation project(':domain-token') // 토큰 관련 도메인 - implementation project(':domain-bookmark') // 북마크 관련 도메인 - implementation project(':domain-order') // 주문 관련 도메인 - implementation project(':domain-reservation') // 예약 관련 도메인 - implementation project(':infra-aws') // 사용자 관련 도메인 - implementation project(':security-front') // 사용자 관련 도메인 - - // Spring Boot Starter - implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springframework.boot:spring-boot-starter-validation' - - // SPRING SECURITY - implementation 'org.springframework.boot:spring-boot-starter-security' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' - - // DB - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - runtimeOnly 'com.mysql:mysql-connector-j' - - // SWAGGER - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' - - // 기타 필요 라이브러리 (예: Lombok) - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' - - testImplementation 'org.springframework.boot:spring-boot-starter-test' -} - -test { - useJUnitPlatform() -} diff --git a/api-user/src/main/java/com/example/apiuser/ApiUserApplication.java b/api-user/src/main/java/com/example/apiuser/ApiUserApplication.java deleted file mode 100644 index fcaf441e..00000000 --- a/api-user/src/main/java/com/example/apiuser/ApiUserApplication.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.example.apiuser; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.autoconfigure.domain.EntityScan; -import org.springframework.data.jpa.repository.config.EnableJpaAuditing; -import org.springframework.data.jpa.repository.config.EnableJpaRepositories; - -@EnableJpaAuditing -@SpringBootApplication(scanBasePackages = { - "com.example.apiuser", - "com.nowait.auth" -}) -@EntityScan(basePackages = { - "com.example.menu.entity", // domain-menu - "com.example.domainstore.entity", // domain-store - "com.example.domaintoken.entity", - "com.nowaiting.user.entity", - "com.nowait.domainbookmark.entity", - "com.nowait.domainreservation.entity", - "com.nowait.domainorder.entity", - "com.nowait.domainorder.entity" -}) -@EnableJpaRepositories(basePackages = { - "com.example.menu.repository", - "com.nowaiting.user.repository", - "com.example.domainstore.repository", - "com.example.domaintoken.repository", - "com.nowait.domainbookmark.repository", - "com.nowait.domainorder.repository", - "com.nowait.domainreservation.repository" -}) -public class ApiUserApplication { - - public static void main(String[] args) { - SpringApplication.run(ApiUserApplication.class, args); - } - -} diff --git a/api-user/src/main/java/com/example/apiuser/bookmark/service/BookmarkService.java b/api-user/src/main/java/com/example/apiuser/bookmark/service/BookmarkService.java deleted file mode 100644 index 91ce0ff8..00000000 --- a/api-user/src/main/java/com/example/apiuser/bookmark/service/BookmarkService.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.example.apiuser.bookmark.service; - -import java.util.List; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.example.apiuser.bookmark.dto.BookmarkCreateResponse; -import com.example.apiuser.bookmark.dto.BookmarkGetResponse; -import com.example.domainstore.entity.Store; -import com.example.domainstore.repository.StoreRepository; -import com.nowait.auth.dto.CustomOAuth2User; -import com.nowait.domainbookmark.entity.Bookmark; -import com.nowait.domainbookmark.repository.BookmarkRepository; -import com.nowaiting.user.entity.User; -import com.nowaiting.user.repository.UserRepository; - -import jakarta.persistence.EntityNotFoundException; -import lombok.RequiredArgsConstructor; - -@Service -@RequiredArgsConstructor -public class BookmarkService { - private final BookmarkRepository bookmarkRepository; - private final StoreRepository storeRepository; - private final UserRepository userRepository; - @Transactional - public BookmarkCreateResponse createBookmark(Long storeId, CustomOAuth2User customOAuth2User) { - parameterValidation(storeId, customOAuth2User); - Store store = storeRepository.findById(storeId) - .orElseThrow(() -> new EntityNotFoundException(storeId + " store not found.")); - User user = userRepository.findById(customOAuth2User.getUserId()) - .orElseThrow(() -> new EntityNotFoundException("User not found")); - - if (bookmarkRepository.existsByUserAndStore(user, store)) { - throw new IllegalArgumentException("already bookmarked"); - } - - Bookmark bookmark = Bookmark.builder() - .store(store) - .user(user) - .build(); - - return BookmarkCreateResponse.fromEntity(bookmarkRepository.save(bookmark)); - } - - @Transactional(readOnly = true) - public List getBookmarks(CustomOAuth2User customOAuth2User) { - User user = userRepository.findById(customOAuth2User.getUserId()) - .orElseThrow(() -> new EntityNotFoundException("User not found")); - return bookmarkRepository.findAllByUser(user) - .stream() - .map(BookmarkGetResponse::fromEntity) - .toList(); - } - - @Transactional - public String deleteBookmark(Long bookmarkId, CustomOAuth2User customOAuth2User) { - parameterValidation(bookmarkId, customOAuth2User); - Bookmark bookmark = bookmarkRepository.findById(bookmarkId) - .orElseThrow(() -> new EntityNotFoundException(bookmarkId + " bookmark not found.")); - if (bookmark.getUser().getId() != customOAuth2User.getUserId()) { - throw new IllegalArgumentException("you can only delete your own bookmark"); - } - bookmarkRepository.delete(bookmark); - return "Bookmark ID " + bookmarkId + " deleted."; - } - - private static void parameterValidation(Long storeId, CustomOAuth2User customOAuth2User) { - // 파라미터 유효성 검사 - if (storeId == null || storeId < 0) { - throw new IllegalArgumentException("storeId must be a positive number"); - } - if (customOAuth2User == null || customOAuth2User.getUserId() == null) { - throw new IllegalArgumentException("UserInfo is required"); - } - } -} diff --git a/api-user/src/main/java/com/example/apiuser/exception/GlobalExceptionHandler.java b/api-user/src/main/java/com/example/apiuser/exception/GlobalExceptionHandler.java deleted file mode 100644 index fedb029f..00000000 --- a/api-user/src/main/java/com/example/apiuser/exception/GlobalExceptionHandler.java +++ /dev/null @@ -1,170 +0,0 @@ -package com.example.apiuser.exception; - -import static com.nowaiting.common.exception.ErrorMessage.*; -import static org.springframework.http.HttpStatus.*; -import static org.springframework.http.HttpStatus.UNAUTHORIZED; - -import java.util.Map; -import java.util.stream.Collectors; - -import org.springframework.http.converter.HttpMessageNotReadableException; -import org.springframework.security.oauth2.core.OAuth2AuthenticationException; -import org.springframework.validation.FieldError; -import org.springframework.validation.ObjectError; -import org.springframework.web.bind.MethodArgumentNotValidException; -import org.springframework.web.bind.MissingRequestValueException; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.ResponseStatus; -import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.web.multipart.MultipartException; - -import com.example.apiuser.reservation.exception.ReservationNotFoundException; -import com.nowait.domainbookmark.exception.BookmarkOwnerMismatchException; -import com.nowait.domainbookmark.exception.DuplicateBookmarkException; -import com.nowait.domainorder.exception.DuplicateOrderException; -import com.nowait.domainorder.exception.OrderItemsEmptyException; -import com.nowait.domainorder.exception.OrderParameterEmptyException; -import com.nowait.exception.BusinessException; -import com.nowait.exception.ResourceNotFoundException; -import com.nowait.exception.UnauthorizedException; -import com.nowaiting.common.exception.ErrorMessage; -import com.nowaiting.common.exception.ErrorResponse; -import com.nowaiting.user.exception.UserNotFoundException; - -import io.swagger.v3.oas.annotations.Hidden; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@Hidden -@RestControllerAdvice -public class GlobalExceptionHandler { - - // OAUTH 인증 실패 에러처리 메서드 - @ResponseStatus(value = BAD_REQUEST) - @ExceptionHandler(OAuth2AuthenticationException.class) - public ErrorResponse handlerOAuth2AuthenticationException(OAuth2AuthenticationException e) { - log.error("handleOAuth2AuthenticationException", e); - - return new ErrorResponse("OAuth 인증 실패 : " + e.getMessage(), INVALID_INPUT_VALUE.getCode()); - } - - @ResponseStatus(value = BAD_REQUEST) - @ExceptionHandler(BusinessException.class) - public ErrorResponse handleBusinessException(BusinessException e) { - log.error("handleBusinessException", e); - return new ErrorResponse(e.getMessage(), e.getCode()); - } - - @ResponseStatus(value = BAD_REQUEST) - @ExceptionHandler(MethodArgumentNotValidException.class) - public ErrorResponse handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { - log.error("handleMethodArgumentNotValidException", e); - Map errors = getErrors(e); - return new ErrorResponse(INVALID_INPUT_VALUE.getMessage(), INVALID_INPUT_VALUE.getCode(), errors); - } - - @ResponseStatus(value = BAD_REQUEST) - @ExceptionHandler(HttpMessageNotReadableException.class) - public ErrorResponse handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { - log.error("handleHttpMessageNotReadableException", e); - return new ErrorResponse(INVALID_INPUT_VALUE.getMessage(), INVALID_INPUT_VALUE.getCode()); - } - - @ResponseStatus(value = BAD_REQUEST) - @ExceptionHandler(IllegalArgumentException.class) - public ErrorResponse handleIllegalArgumentException(IllegalArgumentException e) { - log.error("handleIllegalArgumentException", e); - return new ErrorResponse(e.getMessage(), INVALID_INPUT_VALUE.getCode()); - } - - @ResponseStatus(value = BAD_REQUEST) - @ExceptionHandler(MissingRequestValueException.class) - public ErrorResponse handleMissingRequestValueException(MissingRequestValueException e) { - log.error("handleMissingRequestValueExceptionException", e); - return new ErrorResponse(INVALID_INPUT_VALUE.getMessage(), INVALID_INPUT_VALUE.getCode()); - } - - @ResponseStatus(value = UNAUTHORIZED) - @ExceptionHandler(UnauthorizedException.class) - public ErrorResponse handleUnauthorizedException(UnauthorizedException e) { - log.error("handleUnauthorizedExceptionException", e); - return new ErrorResponse(e.getMessage(), e.getCode()); - } - - @ResponseStatus(value = NOT_FOUND) - @ExceptionHandler(ResourceNotFoundException.class) - public ErrorResponse handleResourceNotFoundException(ResourceNotFoundException e) { - log.error("handleResourceNotFoundExceptionException", e); - return new ErrorResponse(e.getMessage(), e.getCode()); - } - - @ResponseStatus(value = BAD_REQUEST) - @ExceptionHandler(MultipartException.class) - public ErrorResponse handleMultipartException(MultipartException e) { - log.error("handleMultipartException", e); - return new ErrorResponse(e.getMessage(), INVALID_INPUT_VALUE.getCode()); - } - - @ResponseStatus(value = BAD_REQUEST) - @ExceptionHandler(DuplicateBookmarkException.class) - public ErrorResponse handleDuplicateBookmarkException(DuplicateBookmarkException e) { - log.error("handleDuplicateBookmarkException", e); - return new ErrorResponse(e.getMessage(), ErrorMessage.DUPLICATE_BOOKMARK.getCode()); - } - - @ResponseStatus(value = FORBIDDEN) - @ExceptionHandler(BookmarkOwnerMismatchException.class) - public ErrorResponse bookmarkOwnerMismatchException(BookmarkOwnerMismatchException e) { - log.error("bookmarkOwnerMismatchException", e); - return new ErrorResponse(e.getMessage(), NOT_OWN_BOOKMARK.getCode()); - } - - @ResponseStatus(value = NOT_FOUND) - @ExceptionHandler(UserNotFoundException.class) - public ErrorResponse userNotFoundException(UserNotFoundException e) { - log.error("userNotFoundException", e); - return new ErrorResponse(e.getMessage(), NOTFOUND_USER.getCode()); - } - - @ResponseStatus(value = BAD_REQUEST) - @ExceptionHandler(OrderParameterEmptyException.class) - public ErrorResponse orderParameterEmptyException(OrderParameterEmptyException e) { - log.error("orderParameterEmptyException", e); - return new ErrorResponse(e.getMessage(), ORDER_PARAMETER_EMPTY.getCode()); - } - - @ResponseStatus(value = BAD_REQUEST) - @ExceptionHandler(OrderItemsEmptyException.class) - public ErrorResponse orderItemsEmptyException(OrderItemsEmptyException e) { - log.error("orderItemsEmptyException", e); - return new ErrorResponse(e.getMessage(), ORDER_ITEMS_EMPTY.getCode()); - } - - @ResponseStatus(value = BAD_REQUEST) - @ExceptionHandler(DuplicateOrderException.class) - public ErrorResponse duplicateOrderException(DuplicateOrderException e) { - log.error("duplicateOrderException", e); - return new ErrorResponse(e.getMessage(), ErrorMessage.DUPLICATE_ORDER.getCode()); - } - - @ResponseStatus(value = NOT_FOUND) - @ExceptionHandler(ReservationNotFoundException.class) - public ErrorResponse reservationNotFoundException(ReservationNotFoundException e) { - log.error("reservationNotFoundException", e); - return new ErrorResponse(e.getMessage(), NOTFOUND_RESERVATION.getCode()); - } - - - private static Map getErrors(MethodArgumentNotValidException e) { - return e.getBindingResult() - .getAllErrors() - .stream() - .filter(ObjectError.class::isInstance) - .collect(Collectors.toMap( - error -> error instanceof FieldError ? ((FieldError)error).getField() : error.getObjectName(), - ObjectError::getDefaultMessage, - (msg1, msg2) -> msg1 + ";" + msg2 - )); - } - -} diff --git a/api-user/src/main/java/com/example/apiuser/menu/controller/MenuController.java b/api-user/src/main/java/com/example/apiuser/menu/controller/MenuController.java deleted file mode 100644 index d3fb6d02..00000000 --- a/api-user/src/main/java/com/example/apiuser/menu/controller/MenuController.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.example.apiuser.menu.controller; - -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import com.example.apiuser.menu.service.MenuService; -import com.nowaiting.common.api.ApiUtils; - -import lombok.RequiredArgsConstructor; - -@RestController -@RequestMapping("/v1/menus") -@RequiredArgsConstructor -public class MenuController { - - private final MenuService menuService; - - @GetMapping("/all-menus/stores/{storeId}") - public ResponseEntity getMenusByStoreId(@PathVariable Long storeId) { - return ResponseEntity - .status(HttpStatus.OK) - .body( - ApiUtils.success( - menuService.getMenusByStoreId(storeId) - ) - ); - } -} diff --git a/api-user/src/main/java/com/example/apiuser/menu/service/MenuService.java b/api-user/src/main/java/com/example/apiuser/menu/service/MenuService.java deleted file mode 100644 index 64b9e0b9..00000000 --- a/api-user/src/main/java/com/example/apiuser/menu/service/MenuService.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.example.apiuser.menu.service; - -import java.util.List; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.example.apiuser.menu.dto.MenuImageUploadResponse; -import com.example.apiuser.menu.dto.MenuReadDto; -import com.example.apiuser.menu.dto.MenuReadResponse; -import com.example.menu.entity.Menu; -import com.example.menu.entity.MenuImage; -import com.example.menu.repository.MenuImageRepository; -import com.example.menu.repository.MenuRepository; - -import lombok.RequiredArgsConstructor; - -@Service -@RequiredArgsConstructor -public class MenuService { - - private final MenuRepository menuRepository; - private final MenuImageRepository menuImageRepository; - - - @Transactional(readOnly = true) - public MenuReadResponse getMenusByStoreId(Long storeId) { - List menus = menuRepository.findAllByStoreId(storeId); - - List menuReadResponse = menus.stream() - .map(menu -> { - List images = menuImageRepository.findByMenu(menu); - List imageDto = images.stream() - .map(MenuImageUploadResponse::fromEntity) - .toList(); - return MenuReadDto.fromEntity(menu, imageDto); - }) - .toList(); - - return MenuReadResponse.of(menuReadResponse); - } -} diff --git a/api-user/src/main/java/com/example/apiuser/order/dto/OrderCreateRequestDto.java b/api-user/src/main/java/com/example/apiuser/order/dto/OrderCreateRequestDto.java deleted file mode 100644 index 056d44a6..00000000 --- a/api-user/src/main/java/com/example/apiuser/order/dto/OrderCreateRequestDto.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.example.apiuser.order.dto; - -import java.util.List; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; - -@Getter -@AllArgsConstructor -@Builder -public class OrderCreateRequestDto { - private final List items; // 장바구니 항목 리스트 - -} diff --git a/api-user/src/main/java/com/example/apiuser/order/dto/OrderItemResponseDTO.java b/api-user/src/main/java/com/example/apiuser/order/dto/OrderItemResponseDTO.java deleted file mode 100644 index 12f04514..00000000 --- a/api-user/src/main/java/com/example/apiuser/order/dto/OrderItemResponseDTO.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.example.apiuser.order.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class OrderItemResponseDTO { - private Long menuId; // 메뉴 ID - private String menuName; // 메뉴 이름 - private int quantity; // 수량 -} diff --git a/api-user/src/main/java/com/example/apiuser/order/service/OrderService.java b/api-user/src/main/java/com/example/apiuser/order/service/OrderService.java deleted file mode 100644 index 1644ba16..00000000 --- a/api-user/src/main/java/com/example/apiuser/order/service/OrderService.java +++ /dev/null @@ -1,128 +0,0 @@ -package com.example.apiuser.order.service; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.function.Function; -import java.util.stream.Collectors; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.DigestUtils; - -import com.example.apiuser.order.dto.CartItemDto; -import com.example.apiuser.order.dto.OrderCreateRequestDto; -import com.example.apiuser.order.dto.OrderCreateResponseDto; -import com.example.apiuser.order.dto.OrderItemListGetResponseDto; -import com.example.domainstore.entity.Store; -import com.example.domainstore.repository.StoreRepository; -import com.example.menu.entity.Menu; -import com.example.menu.repository.MenuRepository; -import com.nowait.domainorder.entity.OrderItem; -import com.nowait.domainorder.entity.UserOrder; -import com.nowait.domainorder.exception.DuplicateOrderException; -import com.nowait.domainorder.exception.OrderItemsEmptyException; -import com.nowait.domainorder.exception.OrderParameterEmptyException; -import com.nowait.domainorder.repository.OrderItemRepository; -import com.nowait.domainorder.repository.OrderRepository; - -import lombok.RequiredArgsConstructor; - -@Service -@RequiredArgsConstructor -public class OrderService { - private final OrderRepository orderRepository; - private final StoreRepository storeRepository; - private final MenuRepository menuRepository; - private final OrderItemRepository orderItemRepository; - @Transactional - public OrderCreateResponseDto createOrder(Long storeId, Long tableId, - OrderCreateRequestDto orderCreateRequestDto, String sessionId) { - parameterValidation(storeId, tableId, orderCreateRequestDto); - - // 💡 [중복 주문 방지] signature 생성 및 체크 - String signature = generateOrderSignature(storeId, tableId, orderCreateRequestDto.getItems()); - checkDuplicateOrderSignature(signature); - - // 1. Store 조회 - Store store = storeRepository.findById(storeId) - .orElseThrow(() -> new IllegalArgumentException("store not found")); - - // 2. UserOrder 생성 및 signature 저장 - UserOrder order = UserOrder.builder() - .tableId(tableId) - .store(store) - .signature(signature) // signature 저장 - .sessionId(sessionId) // sessionId 저장 - .build(); - UserOrder savedOrder = orderRepository.save(order); - - // 3. 메뉴 ID 리스트 수집 -> Map으로 캐싱 - List menuIds = orderCreateRequestDto.getItems().stream() - .map(CartItemDto::getMenuId) - .toList(); - - List menus = menuRepository.findAllById(menuIds); - Map menuMap = menus.stream() - .collect(Collectors.toMap(Menu::getId, Function.identity())); - - // 4. 각 장바구니 항목에 대해 OrderItem 생성 및 저장 - List orderItems = orderCreateRequestDto.getItems().stream() - .map(item -> { - Menu menu = Optional.ofNullable(menuMap.get(item.getMenuId())) - .orElseThrow(() -> new IllegalArgumentException("menu not found: " + item.getMenuId())); - return OrderItem.builder() - .userOrder(savedOrder) - .menu(menu) - .quantity(item.getQuantity()) - .build(); - }) - .collect(Collectors.toList()); - - - orderItemRepository.saveAll(orderItems); - - // 5. 응답 반환 - return OrderCreateResponseDto.fromEntity(savedOrder); - } - - @Transactional(readOnly = true) - public List getOrderItems(Long storeId, Long tableId, String sessionId) { - // 1. UserOrder 목록 조회 (storeId, tableId, sessionId 기준) - List userOrders = orderRepository.findByStore_StoreIdAndTableIdAndSessionId(storeId, tableId, sessionId); - - // 2. OrderItem으로 변환 - return userOrders.stream() - .flatMap(order -> order.getOrderItems().stream()) - .map(OrderItemListGetResponseDto::fromEntity) - .toList(); - } - - - private static void parameterValidation(Long storeId, Long tableId, OrderCreateRequestDto orderCreateRequestDto) { - if (storeId == null || tableId == null || orderCreateRequestDto == null) { - throw new OrderParameterEmptyException(); - } - if (orderCreateRequestDto.getItems() == null || orderCreateRequestDto.getItems().isEmpty()) { - throw new OrderItemsEmptyException(); - } - } - private String generateOrderSignature(Long storeId, Long tableId, List items) { - String cartString = items.stream() - .sorted((a, b) -> a.getMenuId().compareTo(b.getMenuId())) // 메뉴 ID 기준 정렬 - .map(item -> item.getMenuId() + ":" + item.getQuantity()) - .collect(Collectors.joining(",")); - String raw = storeId + "-" + tableId + "-" + cartString; - return DigestUtils.md5DigestAsHex(raw.getBytes()); - } - - private void checkDuplicateOrderSignature(String signature) { - // 최근 2초 이내 동일 signature 주문이 있는지 검사 - LocalDateTime threshold = LocalDateTime.now().minusSeconds(2); - boolean exists = orderRepository.existsBySignatureAndCreatedAtAfter(signature, threshold); - if (exists) { - throw new DuplicateOrderException(); - } - } -} diff --git a/api-user/src/main/java/com/example/apiuser/reservation/controller/ReservationController.java b/api-user/src/main/java/com/example/apiuser/reservation/controller/ReservationController.java deleted file mode 100644 index 75416513..00000000 --- a/api-user/src/main/java/com/example/apiuser/reservation/controller/ReservationController.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.example.apiuser.reservation.controller; - -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -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.example.apiuser.reservation.dto.ReservationCreateRequestDto; -import com.example.apiuser.reservation.dto.ReservationCreateResponseDto; -import com.example.apiuser.reservation.service.ReservationService; -import com.nowait.auth.dto.CustomOAuth2User; -import com.nowaiting.common.api.ApiUtils; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; - -@Tag(name = "Reservation API", description = "예약 API") -@RestController -@RequestMapping("/reservations") -@RequiredArgsConstructor -public class ReservationController { - - private final ReservationService reservationService; - - @PostMapping("/create/{storeId}") - @Operation(summary = "예약 생성", description = "특정 주점에 대한 예약하기 생성") - @ApiResponse(responseCode = "201", description = "예약 생성") - public ResponseEntity create( - @PathVariable Long storeId, - @AuthenticationPrincipal CustomOAuth2User customOAuth2User, - @RequestBody ReservationCreateRequestDto requestDto) { - ReservationCreateResponseDto response = reservationService.create(storeId, customOAuth2User, requestDto); - return ResponseEntity - .status(HttpStatus.CREATED) - .body( - ApiUtils.success( - response - ) - ); - } -} diff --git a/api-user/src/main/java/com/example/apiuser/reservation/service/ReservationService.java b/api-user/src/main/java/com/example/apiuser/reservation/service/ReservationService.java deleted file mode 100644 index a5394ec8..00000000 --- a/api-user/src/main/java/com/example/apiuser/reservation/service/ReservationService.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.example.apiuser.reservation.service; - -import java.time.LocalDateTime; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.example.apiuser.reservation.dto.ReservationCreateRequestDto; -import com.example.apiuser.reservation.dto.ReservationCreateResponseDto; -import com.example.domainstore.entity.Store; -import com.example.domainstore.repository.StoreRepository; -import com.nowait.auth.dto.CustomOAuth2User; -import com.nowait.domainreservation.entity.Reservation; -import com.nowait.domainreservation.repository.ReservationRepository; -import com.nowaiting.common.enums.ReservationStatus; -import com.nowaiting.user.entity.User; -import com.nowaiting.user.exception.UserNotFoundException; -import com.nowaiting.user.repository.UserRepository; - -import lombok.RequiredArgsConstructor; - -@Service -@RequiredArgsConstructor -public class ReservationService { - - private final ReservationRepository reservationRepository; - private final StoreRepository storeRepository; - private final UserRepository userRepository; - - @Transactional - public ReservationCreateResponseDto create(Long storeId, CustomOAuth2User customOAuth2User, - ReservationCreateRequestDto requestDto) { - Store store = storeRepository.findById(storeId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 store")); - User user = userRepository.findById(customOAuth2User.getUserId()) - .orElseThrow(UserNotFoundException::new); - - Reservation reservation = Reservation.builder() - .store(store) - .user(user) - .requestedAt(LocalDateTime.now()) - .status(ReservationStatus.WAITING) - .partySize(requestDto.getPartySize()) - .build(); - - Reservation saved = reservationRepository.save(reservation); - - return ReservationCreateResponseDto.builder() - .id(saved.getId()) - .storeId(saved.getStore().getStoreId()) - .userId(saved.getUser().getId()) - .requestedAt(saved.getRequestedAt()) - .status(saved.getStatus().name()) - .partySize(saved.getPartySize()) - .build(); - } -} - diff --git a/api-user/src/main/java/com/example/apiuser/store/controller/StoreController.java b/api-user/src/main/java/com/example/apiuser/store/controller/StoreController.java deleted file mode 100644 index 510b19d7..00000000 --- a/api-user/src/main/java/com/example/apiuser/store/controller/StoreController.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.example.apiuser.store.controller; - -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -import com.example.apiuser.store.service.StoreService; -import com.nowaiting.common.api.ApiUtils; - -import lombok.RequiredArgsConstructor; - -@RestController -@RequestMapping("v1/stores") -@RequiredArgsConstructor -public class StoreController { - - private final StoreService storeService; - - - @GetMapping("/all-stores") - public ResponseEntity getAllStores() { - return ResponseEntity - .status(HttpStatus.OK) - .body( - ApiUtils.success( - storeService.getAllStores() - ) - ); - } - - @GetMapping("/{storeId}") - public ResponseEntity getStoreById(@PathVariable Long storeId) { - return ResponseEntity - .status(HttpStatus.OK) - .body( - ApiUtils.success( - storeService.getStoreByStoreId(storeId) - ) - ); - } - - @GetMapping("/search") - public ResponseEntity searchStores(@RequestParam("name") String name) { - return ResponseEntity - .ok() - .body( - ApiUtils.success( - storeService.searchStoresByName(name) - ) - ); - } -} diff --git a/api-user/src/main/java/com/example/apiuser/store/dto/StoreReadDto.java b/api-user/src/main/java/com/example/apiuser/store/dto/StoreReadDto.java deleted file mode 100644 index d6cfab6e..00000000 --- a/api-user/src/main/java/com/example/apiuser/store/dto/StoreReadDto.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.example.apiuser.store.dto; - -import java.time.LocalDateTime; -import java.util.List; - -import com.example.domainstore.entity.Store; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; - -@Getter -@AllArgsConstructor -@Builder -public class StoreReadDto { - private Long storeId; - private Long departmentId; - private String name; - private String location; - private String description; - private List images; - private Boolean isActive; - private Boolean deleted; - private LocalDateTime createdAt; - - public static StoreReadDto fromEntity(Store store, List images) { - return StoreReadDto.builder() - .createdAt(store.getCreatedAt()) - .storeId(store.getStoreId()) - .departmentId(store.getDepartmentId()) - .name(store.getName()) - .location(store.getLocation()) - .description(store.getDescription()) - .isActive(store.getIsActive()) - .deleted(store.getDeleted()) - .images(images) - .build(); - } -} diff --git a/api-user/src/main/java/com/example/apiuser/store/dto/StoreReadResponse.java b/api-user/src/main/java/com/example/apiuser/store/dto/StoreReadResponse.java deleted file mode 100644 index a580e562..00000000 --- a/api-user/src/main/java/com/example/apiuser/store/dto/StoreReadResponse.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.example.apiuser.store.dto; - -import java.util.List; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; - -@Getter -@AllArgsConstructor -@Builder -public class StoreReadResponse { - - private List storeReadDtos; - private boolean hasNext; - - public static StoreReadResponse of(List storeReadDtos, boolean hasNext) { - return StoreReadResponse.builder() - .storeReadDtos(storeReadDtos) - .hasNext(hasNext) - .build(); - } -} diff --git a/api-user/src/main/java/com/example/apiuser/store/service/StoreService.java b/api-user/src/main/java/com/example/apiuser/store/service/StoreService.java deleted file mode 100644 index fd90de2a..00000000 --- a/api-user/src/main/java/com/example/apiuser/store/service/StoreService.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.example.apiuser.store.service; - -import java.util.List; - -import com.example.apiuser.store.dto.StoreReadDto; -import com.example.apiuser.store.dto.StoreReadResponse; - -public interface StoreService { - - StoreReadResponse getAllStores(); - - StoreReadDto getStoreByStoreId(Long storeId); - - List searchStoresByName(String name); - -} diff --git a/api-user/src/main/java/com/example/apiuser/store/service/StoreServiceImpl.java b/api-user/src/main/java/com/example/apiuser/store/service/StoreServiceImpl.java deleted file mode 100644 index d641f03e..00000000 --- a/api-user/src/main/java/com/example/apiuser/store/service/StoreServiceImpl.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.example.apiuser.store.service; - -import java.util.List; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import com.example.apiuser.store.dto.StoreImageUploadResponse; -import com.example.apiuser.store.dto.StoreReadDto; -import com.example.apiuser.store.dto.StoreReadResponse; -import com.example.domainstore.entity.Store; -import com.example.domainstore.entity.StoreImage; -import com.example.domainstore.repository.StoreImageRepository; -import com.example.domainstore.repository.StoreRepository; - -import jakarta.persistence.EntityNotFoundException; -import lombok.RequiredArgsConstructor; - -@Service -@RequiredArgsConstructor -public class StoreServiceImpl implements StoreService { - - private final StoreRepository storeRepository; - private final StoreImageRepository storeImageRepository; - - - @Override - @Transactional(readOnly = true) - public StoreReadResponse getAllStores() { - List stores = storeRepository.findAllByDeletedFalse(); - - List storeRead = stores.stream() - .map(store -> { - List images = storeImageRepository.findByStore(store); - List imageDto = images.stream() - .map(StoreImageUploadResponse::fromEntity) - .toList(); - return StoreReadDto.fromEntity(store, imageDto); - }) - .toList(); - - boolean hasNext = false; - - return StoreReadResponse.of(storeRead, hasNext); - } - - @Override - @Transactional(readOnly = true) - public StoreReadDto getStoreByStoreId(Long storeId) { - Store store = storeRepository.findByStoreIdAndDeletedFalse(storeId) - .orElseThrow(() -> new EntityNotFoundException(storeId + " store not found.")); - - List images = storeImageRepository.findByStore(store); - List imageDto = images.stream() - .map(StoreImageUploadResponse::fromEntity) - .toList(); - - return StoreReadDto.fromEntity(store, imageDto); - } - - @Override - public List searchStoresByName(String name) { - List stores = storeRepository.findByNameContainingIgnoreCaseAndDeletedFalse(name); - return stores.stream() - .map(store -> { - List images = storeImageRepository.findByStore(store); - List imageDto = images.stream() - .map(StoreImageUploadResponse::fromEntity) - .toList(); - return StoreReadDto.fromEntity(store, imageDto); - }) - .toList(); - } -} diff --git a/api-user/src/main/java/com/example/apiuser/token/controller/TokenController.java b/api-user/src/main/java/com/example/apiuser/token/controller/TokenController.java deleted file mode 100644 index 11120f30..00000000 --- a/api-user/src/main/java/com/example/apiuser/token/controller/TokenController.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.example.apiuser.token.controller; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -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.example.apiuser.token.dto.AuthenticationResponse; -import com.example.apiuser.token.dto.RefreshTokenRequest; -import com.example.apiuser.token.service.TokenService; -import com.nowait.auth.jwt.JwtUtil; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/refresh-token") -@Slf4j -public class TokenController { - private final JwtUtil jwtUtil; - private final TokenService tokenService; - @Value("${jwt.access-token-expiration-ms}") - private long accessTokenExpiration; - @Value("${jwt.refresh-token-expiration-ms}") - private long refreshTokenExpiration; - @PostMapping - public ResponseEntity refreshToken(@RequestBody RefreshTokenRequest request){ - String refreshToken = request.getRefreshToken(); - - // 리프레시 토큰 검증 - Long userId = jwtUtil.getUserId(refreshToken); - String role = jwtUtil.getRole(refreshToken); - - // 리프레시 토큰 유효성 검증 - if (tokenService.validateToken(refreshToken, userId)){ - // 유효한 토큰이라면, 새로운 accessToken, refreshToken 생성 - String newAccessToken = jwtUtil.createAccessToken("accessToken", userId, role, accessTokenExpiration); - String newRefreshToken = jwtUtil.createRefreshToken("refreshToken", userId, refreshTokenExpiration); - - // DB에 새로운 refreshToken으로 교체 - tokenService.updateRefreshToken(userId, refreshToken, newRefreshToken); - - AuthenticationResponse authenticationResponse = new AuthenticationResponse(newAccessToken, refreshToken); - return ResponseEntity.ok().body(authenticationResponse); - - } - - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid or expired refresh token"); - } -} diff --git a/application-config/build.gradle b/application-config/build.gradle deleted file mode 100644 index a6b89a70..00000000 --- a/application-config/build.gradle +++ /dev/null @@ -1,14 +0,0 @@ -plugins { - id 'java-library' -} - -group = 'org.example' -version = '0.0.1-SNAPSHOT' - -repositories { - mavenCentral() -} - -dependencies { - api 'org.springframework.boot:spring-boot-starter-web' -} diff --git a/appspec-admin.yml b/appspec-admin.yml new file mode 100644 index 00000000..66c44ad6 --- /dev/null +++ b/appspec-admin.yml @@ -0,0 +1,20 @@ +version: 0.0 +os: linux + +files: + - source: / + destination: /home/ubuntu/spring-github-action-admin + overwrite: yes + +permissions: + - object: / + owner: ubuntu + group: ubuntu + +hooks: + AfterInstall: + - location: scripts/stop-admin.sh + timeout: 60 + ApplicationStart: + - location: scripts/start-admin.sh + timeout: 60 diff --git a/appspec-user.yml b/appspec-user.yml new file mode 100644 index 00000000..1edca8d4 --- /dev/null +++ b/appspec-user.yml @@ -0,0 +1,20 @@ +version: 0.0 +os: linux + +files: + - source: / + destination: /home/ubuntu/spring-github-action-user + overwrite: yes + +permissions: + - object: / + owner: ubuntu + group: ubuntu + +hooks: + AfterInstall: + - location: scripts/stop-user.sh + timeout: 60 + ApplicationStart: + - location: scripts/start-user.sh + timeout: 60 diff --git a/build.gradle b/build.gradle index 51bc6932..02c381b0 100644 --- a/build.gradle +++ b/build.gradle @@ -1,32 +1,33 @@ plugins { - id 'org.springframework.boot' version '3.5.0' apply false - id 'io.spring.dependency-management' version '1.1.7' apply false + id 'org.springframework.boot' version '3.5.0' + id 'io.spring.dependency-management' version '1.1.7' } -group = 'com.nowaiting' +group = 'com.nowait' version = '0.0.1-SNAPSHOT' repositories { mavenCentral() + maven { url 'https://jitpack.io' } } -subprojects { - apply plugin: 'java' - apply plugin: 'org.springframework.boot' - apply plugin: 'io.spring.dependency-management' +allprojects { + group = 'com.nowait' + version = '0.0.1-SNAPSHOT' +} - group = rootProject.group - version = rootProject.version +subprojects { + apply plugin: "java" + apply plugin: 'java-library' + apply plugin: "io.spring.dependency-management" + if (project.name.startsWith('nowait-app-')) { + apply plugin: "org.springframework.boot" + } repositories { mavenCentral() } - java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } - } dependencyManagement { imports { @@ -34,20 +35,7 @@ subprojects { } } - if (project.name != 'common') { - dependencies { - implementation project(':common') - } - } - test { useJUnitPlatform() } - - bootJar { - enabled = false - } - jar { - enabled = true - } } diff --git a/common/build.gradle b/common/build.gradle deleted file mode 100644 index 75d320e1..00000000 --- a/common/build.gradle +++ /dev/null @@ -1,5 +0,0 @@ -bootJar { enabled = false } -jar { enabled = true } - -dependencies { -} diff --git a/common/src/main/java/com/nowaiting/common/enums/ReservationStatus.java b/common/src/main/java/com/nowaiting/common/enums/ReservationStatus.java deleted file mode 100644 index 597c460c..00000000 --- a/common/src/main/java/com/nowaiting/common/enums/ReservationStatus.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.nowaiting.common.enums; - -public enum ReservationStatus { - WAITING,CALLING, CONFIRMED, CANCELLED -} diff --git a/common/src/main/java/com/nowaiting/common/exception/ErrorMessage.java b/common/src/main/java/com/nowaiting/common/exception/ErrorMessage.java deleted file mode 100644 index dc7e34da..00000000 --- a/common/src/main/java/com/nowaiting/common/exception/ErrorMessage.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.nowaiting.common.exception; - -public enum ErrorMessage { - // global - INVALID_INPUT_VALUE("입력값이 올바르지 않습니다.", "global001"), - - // auth - UNAUTHORIZED("권한이 없습니다", "auth001"), - - // token - REFRESH_TOKEN_NOT_FOUND("기존 리프레시 토큰을 찾을 수 없습니다.", "token001"), - DOES_NOT_MATCH_REFRESH_TOKEN("기존 리프레시 토큰이 일치하지 않습니다.", "token002"), - - // user - NOTFOUND_USER("저장된 사용자 정보가 없습니다.", "user001"), - - //order - ORDER_PARAMETER_EMPTY("주문 생성 시 파라미터 정보가 없습니다.", "order001"), - ORDER_ITEMS_EMPTY("주문 항목이 없습니다.", "order002"), - DUPLICATE_ORDER("동일한 주문이 접수되었습니다.", "order003"), - - //reservation - NOTFOUND_RESERVATION("저장된 예약 정보가 없습니다.", "reservation001"), - - // bookmark - DUPLICATE_BOOKMARK("이미 북마크한 주점입니다.", "bookmark001"), - NOT_OWN_BOOKMARK("해당 주점은 다른 사용자가 북마크한 주점입니다.", "bookmark002"); - - private final String message; - private final String code; - - ErrorMessage(String message, String code) { - this.message = message; - this.code = code; - } - - public String getMessage() { - return message; - } - - public String getCode() { - return code; - } -} diff --git a/docker-compose.admin-monitoring.yml b/docker-compose.admin-monitoring.yml new file mode 100644 index 00000000..4281170d --- /dev/null +++ b/docker-compose.admin-monitoring.yml @@ -0,0 +1,27 @@ +services: + prometheus-admin: + image: prom/prometheus:latest + container_name: prometheus-admin + ports: + - "9091:9090" + volumes: + - ./prometheus-admin.yml:/etc/prometheus/prometheus.yml:ro + command: + - '--config.file=/etc/prometheus/prometheus.yml' + depends_on: + - nowait-app-admin-api + restart: always + + grafana-admin: + image: grafana/grafana:latest + container_name: grafana-admin + ports: + - "3004:3000" + volumes: + - grafana-admin-data:/var/lib/grafana + depends_on: + - prometheus-admin + restart: always + +volumes: + grafana-admin-data: diff --git a/docker-compose.admin.yml b/docker-compose.admin.yml new file mode 100644 index 00000000..fe9a891c --- /dev/null +++ b/docker-compose.admin.yml @@ -0,0 +1,30 @@ +services: + mysql: + image: mysql:8.0 + container_name: mysql_nowait_admin_container + environment: + MYSQL_ROOT_PASSWORD: gtable1118! + MYSQL_DATABASE: gtable_dev + MYSQL_USER: developer + MYSQL_PASSWORD: gtable1118! + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + + nowait-app-admin-api: + image: jihuneddie/nowait_dev_admin:latest + container_name: nowait-app-admin-api + ports: + - "8085:8085" + depends_on: + - mysql +# volumes: +# - ./nowait-app-admin-api:/app + restart: always + pull_policy: always + environment: + - TZ=Asia/Seoul + +volumes: + mysql_data: diff --git a/docker-compose.user-monitoring.yml b/docker-compose.user-monitoring.yml new file mode 100644 index 00000000..39b54dd7 --- /dev/null +++ b/docker-compose.user-monitoring.yml @@ -0,0 +1,27 @@ +services: + prometheus-user: + image: prom/prometheus:latest + container_name: prometheus-user + ports: + - "9090:9090" + volumes: + - ./prometheus-user.yml:/etc/prometheus/prometheus.yml:ro + command: + - '--config.file=/etc/prometheus/prometheus.yml' + depends_on: + - nowait-app-user-api + restart: always + + grafana-user: + image: grafana/grafana:latest + container_name: grafana-user + ports: + - "3003:3000" + volumes: + - grafana-user-data:/var/lib/grafana + depends_on: + - prometheus-user + restart: always + +volumes: + grafana-user-data: diff --git a/docker-compose.user.yml b/docker-compose.user.yml new file mode 100644 index 00000000..d7c56560 --- /dev/null +++ b/docker-compose.user.yml @@ -0,0 +1,30 @@ +services: + mysql: + image: mysql:8.0 + container_name: mysql_nowait_user_container + environment: + MYSQL_ROOT_PASSWORD: gtable1118! + MYSQL_DATABASE: gtable_dev + MYSQL_USER: developer + MYSQL_PASSWORD: gtable1118! + ports: + - "3307:3306" + volumes: + - mysql_data:/var/lib/mysql + + nowait-app-user-api: + image: jihuneddie/nowait_dev_user:latest + container_name: nowait-app-user-api + ports: + - "8081:8081" + depends_on: + - mysql +# volumes: +# - ./nowait-app-user-api:/app + restart: always + pull_policy: always + environment: + - TZ=Asia/Seoul + +volumes: + mysql_data: diff --git a/domain-base/.gitattributes b/domain-base/.gitattributes deleted file mode 100644 index 8af972cd..00000000 --- a/domain-base/.gitattributes +++ /dev/null @@ -1,3 +0,0 @@ -/gradlew text eol=lf -*.bat text eol=crlf -*.jar binary diff --git a/domain-base/build.gradle b/domain-base/build.gradle deleted file mode 100644 index 6ae34bc8..00000000 --- a/domain-base/build.gradle +++ /dev/null @@ -1,29 +0,0 @@ -plugins { - id 'java-library' -} - -group = 'com.nowaiting' -version = rootProject.version - -java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } -} - -repositories { - mavenCentral() -} - -dependencies { - // JPA Auditing 및 @MappedSuperclass 를 위한 - api 'org.springframework.boot:spring-boot-starter-data-jpa' - // JPA API - api 'jakarta.persistence:jakarta.persistence-api:3.1.0' - // Lombok - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' - // Jackson - api 'com.fasterxml.jackson.core:jackson-databind:2.15.2' // Jackson 핵심 - api 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.15.2' -} diff --git a/domain-bookmark/build.gradle b/domain-bookmark/build.gradle deleted file mode 100644 index 82ebe024..00000000 --- a/domain-bookmark/build.gradle +++ /dev/null @@ -1,32 +0,0 @@ -plugins { - id 'java-library' -} - -group = 'com.nowaiting' -version = rootProject.version - -java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } -} - -repositories { - mavenCentral() -} - -dependencies { - implementation project(':common') - implementation project(':domain-base') - implementation project(':domain-user') - implementation project(':domain-store') - - api 'org.springframework.boot:spring-boot-starter-data-jpa' - api 'jakarta.persistence:jakarta.persistence-api:3.1.0' - - compileOnly 'org.projectlombok:lombok:1.18.26' - annotationProcessor 'org.projectlombok:lombok:1.18.26' - - testImplementation 'org.junit.jupiter:junit-jupiter' - testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' -} diff --git a/domain-bookmark/src/main/java/com/nowait/domainbookmark/repository/BookmarkRepository.java b/domain-bookmark/src/main/java/com/nowait/domainbookmark/repository/BookmarkRepository.java deleted file mode 100644 index 1865bfea..00000000 --- a/domain-bookmark/src/main/java/com/nowait/domainbookmark/repository/BookmarkRepository.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.nowait.domainbookmark.repository; - -import java.util.Collection; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import com.example.domainstore.entity.Store; -import com.nowait.domainbookmark.entity.Bookmark; -import com.nowaiting.user.entity.User; - -@Repository -public interface BookmarkRepository extends JpaRepository { - boolean existsByUserAndStore(User user, Store store); - - Collection findAllByUser(User user); -} diff --git a/domain-menu/.gitattributes b/domain-menu/.gitattributes deleted file mode 100644 index 8af972cd..00000000 --- a/domain-menu/.gitattributes +++ /dev/null @@ -1,3 +0,0 @@ -/gradlew text eol=lf -*.bat text eol=crlf -*.jar binary diff --git a/domain-menu/build.gradle b/domain-menu/build.gradle deleted file mode 100644 index 62e0b97b..00000000 --- a/domain-menu/build.gradle +++ /dev/null @@ -1,30 +0,0 @@ -plugins { - id 'java-library' -} - -group = 'com.nowaiting' -version = rootProject.version - -java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } -} - -repositories { - mavenCentral() -} - -dependencies { - implementation project(':common') - implementation project(':domain-base') - - api 'org.springframework.boot:spring-boot-starter-data-jpa' - api 'jakarta.persistence:jakarta.persistence-api:3.1.0' - - compileOnly 'org.projectlombok:lombok:1.18.26' - annotationProcessor 'org.projectlombok:lombok:1.18.26' - - testImplementation 'org.junit.jupiter:junit-jupiter' - testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' -} diff --git a/domain-menu/gradle/wrapper/gradle-wrapper.jar b/domain-menu/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index 1b33c55b..00000000 Binary files a/domain-menu/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/domain-menu/gradle/wrapper/gradle-wrapper.properties b/domain-menu/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index ff23a68d..00000000 --- a/domain-menu/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,7 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-bin.zip -networkTimeout=10000 -validateDistributionUrl=true -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/domain-menu/src/main/java/com/example/menu/entity/Menu.java b/domain-menu/src/main/java/com/example/menu/entity/Menu.java deleted file mode 100644 index 5ac6ceef..00000000 --- a/domain-menu/src/main/java/com/example/menu/entity/Menu.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.example.menu.entity; - -import java.time.LocalDateTime; -import com.nowait.base.BaseTimeEntity; - -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.experimental.SuperBuilder; - -@Entity -@Table(name = "menus") -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) -@SuperBuilder -public class Menu extends BaseTimeEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long Id; - private Long storeId; - private String name; - private String description; - private Integer price; - - public Menu(LocalDateTime createdAt, Long id, Long storeId, String name, String description, Integer price) { - super(createdAt); - this.Id = id; - this.storeId = storeId; - this.name = name; - this.description = description; - this.price = price; - } -} diff --git a/domain-menu/src/main/java/com/example/menu/repository/MenuImageRepository.java b/domain-menu/src/main/java/com/example/menu/repository/MenuImageRepository.java deleted file mode 100644 index 05e125b8..00000000 --- a/domain-menu/src/main/java/com/example/menu/repository/MenuImageRepository.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.example.menu.repository; - -import java.util.List; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import com.example.menu.entity.Menu; -import com.example.menu.entity.MenuImage; - -@Repository -public interface MenuImageRepository extends JpaRepository { - List findByMenu(Menu menu); -} diff --git a/domain-menu/src/main/java/com/example/menu/repository/MenuRepository.java b/domain-menu/src/main/java/com/example/menu/repository/MenuRepository.java deleted file mode 100644 index 4bdde11d..00000000 --- a/domain-menu/src/main/java/com/example/menu/repository/MenuRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.example.menu.repository; - -import java.util.List; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import com.example.menu.entity.Menu; - -@Repository -public interface MenuRepository extends JpaRepository { - List findAllByStoreId(Long storeId); -} diff --git a/domain-order/src/main/java/com/nowait/domainorder/entity/UserOrder.java b/domain-order/src/main/java/com/nowait/domainorder/entity/UserOrder.java deleted file mode 100644 index 8831f74a..00000000 --- a/domain-order/src/main/java/com/nowait/domainorder/entity/UserOrder.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.nowait.domainorder.entity; - -import java.util.ArrayList; -import java.util.List; - -import com.example.domainstore.entity.Store; -import com.nowait.base.BaseTimeEntity; - -import jakarta.persistence.CascadeType; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.OneToMany; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) -@Builder -public class UserOrder extends BaseTimeEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - private Long tableId; - - @Column(length = 64) - private String signature; - - // Store와 연관관계 (N:1) - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "store_id") - private Store store; - - @OneToMany(mappedBy = "userOrder", cascade = CascadeType.ALL, orphanRemoval = true) - private List orderItems = new ArrayList<>(); - - private String sessionId; - -} diff --git a/domain-order/src/main/java/com/nowait/domainorder/repository/OrderRepository.java b/domain-order/src/main/java/com/nowait/domainorder/repository/OrderRepository.java deleted file mode 100644 index 94e7a09e..00000000 --- a/domain-order/src/main/java/com/nowait/domainorder/repository/OrderRepository.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.nowait.domainorder.repository; - -import java.time.LocalDateTime; -import java.util.List; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import com.nowait.domainorder.entity.UserOrder; - -@Repository -public interface OrderRepository extends JpaRepository { - boolean existsBySignatureAndCreatedAtAfter(String signature, LocalDateTime createdAt); - - List findByStore_StoreIdAndTableIdAndSessionId(Long storeId, Long tableId, String sessionId); - - -} diff --git a/domain-reservation/.gitignore b/domain-reservation/.gitignore deleted file mode 100644 index c2065bc2..00000000 --- a/domain-reservation/.gitignore +++ /dev/null @@ -1,37 +0,0 @@ -HELP.md -.gradle -build/ -!gradle/wrapper/gradle-wrapper.jar -!**/src/main/**/build/ -!**/src/test/**/build/ - -### STS ### -.apt_generated -.classpath -.factorypath -.project -.settings -.springBeans -.sts4-cache -bin/ -!**/src/main/**/bin/ -!**/src/test/**/bin/ - -### IntelliJ IDEA ### -.idea -*.iws -*.iml -*.ipr -out/ -!**/src/main/**/out/ -!**/src/test/**/out/ - -### NetBeans ### -/nbproject/private/ -/nbbuild/ -/dist/ -/nbdist/ -/.nb-gradle/ - -### VS Code ### -.vscode/ diff --git a/domain-reservation/build.gradle b/domain-reservation/build.gradle deleted file mode 100644 index 20c2ae06..00000000 --- a/domain-reservation/build.gradle +++ /dev/null @@ -1,32 +0,0 @@ -plugins { - id 'java-library' -} - -group = 'com.nowaiting' -version = rootProject.version - -java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } -} - -repositories { - mavenCentral() -} - -dependencies { - implementation project(':common') - implementation project(':domain-base') - implementation project(':domain-store') - implementation project(':domain-user') - - api 'org.springframework.boot:spring-boot-starter-data-jpa' - api 'jakarta.persistence:jakarta.persistence-api:3.1.0' - - compileOnly 'org.projectlombok:lombok:1.18.26' - annotationProcessor 'org.projectlombok:lombok:1.18.26' - - testImplementation 'org.junit.jupiter:junit-jupiter' - testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' -} diff --git a/domain-reservation/src/main/java/com/nowait/domainreservation/entity/Reservation.java b/domain-reservation/src/main/java/com/nowait/domainreservation/entity/Reservation.java deleted file mode 100644 index 90b9eeb3..00000000 --- a/domain-reservation/src/main/java/com/nowait/domainreservation/entity/Reservation.java +++ /dev/null @@ -1,60 +0,0 @@ -package com.nowait.domainreservation.entity; - -import java.time.LocalDateTime; - -import com.example.domainstore.entity.Store; -import com.nowaiting.common.enums.ReservationStatus; -import com.nowaiting.user.entity.User; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Entity -@Table(name = "waiting_requests") -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) -@Builder -public class Reservation { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "store_id") - private Store store; - - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "user_id") - private User user; - - @Column(name = "requested_at", nullable = false) - private LocalDateTime requestedAt; - - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private ReservationStatus status; - - @Column(name = "party_size", nullable = false) - private Integer partySize; - - public void updateStatus(ReservationStatus status) { - this.status = status; - } - -} diff --git a/domain-reservation/src/main/java/com/nowait/domainreservation/repository/ReservationRepository.java b/domain-reservation/src/main/java/com/nowait/domainreservation/repository/ReservationRepository.java deleted file mode 100644 index 3483e91b..00000000 --- a/domain-reservation/src/main/java/com/nowait/domainreservation/repository/ReservationRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.nowait.domainreservation.repository; - -import java.util.List; - -import org.springframework.data.jpa.repository.JpaRepository; - -import com.nowait.domainreservation.entity.Reservation; - -public interface ReservationRepository extends JpaRepository { - List findAllByStore_StoreIdOrderByRequestedAtAsc(Long storeId); -} diff --git a/domain-store/.gitattributes b/domain-store/.gitattributes deleted file mode 100644 index 8af972cd..00000000 --- a/domain-store/.gitattributes +++ /dev/null @@ -1,3 +0,0 @@ -/gradlew text eol=lf -*.bat text eol=crlf -*.jar binary diff --git a/domain-store/.gitignore b/domain-store/.gitignore deleted file mode 100644 index c2065bc2..00000000 --- a/domain-store/.gitignore +++ /dev/null @@ -1,37 +0,0 @@ -HELP.md -.gradle -build/ -!gradle/wrapper/gradle-wrapper.jar -!**/src/main/**/build/ -!**/src/test/**/build/ - -### STS ### -.apt_generated -.classpath -.factorypath -.project -.settings -.springBeans -.sts4-cache -bin/ -!**/src/main/**/bin/ -!**/src/test/**/bin/ - -### IntelliJ IDEA ### -.idea -*.iws -*.iml -*.ipr -out/ -!**/src/main/**/out/ -!**/src/test/**/out/ - -### NetBeans ### -/nbproject/private/ -/nbbuild/ -/dist/ -/nbdist/ -/.nb-gradle/ - -### VS Code ### -.vscode/ diff --git a/domain-store/build.gradle b/domain-store/build.gradle deleted file mode 100644 index 62e0b97b..00000000 --- a/domain-store/build.gradle +++ /dev/null @@ -1,30 +0,0 @@ -plugins { - id 'java-library' -} - -group = 'com.nowaiting' -version = rootProject.version - -java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } -} - -repositories { - mavenCentral() -} - -dependencies { - implementation project(':common') - implementation project(':domain-base') - - api 'org.springframework.boot:spring-boot-starter-data-jpa' - api 'jakarta.persistence:jakarta.persistence-api:3.1.0' - - compileOnly 'org.projectlombok:lombok:1.18.26' - annotationProcessor 'org.projectlombok:lombok:1.18.26' - - testImplementation 'org.junit.jupiter:junit-jupiter' - testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' -} diff --git a/domain-store/gradle/wrapper/gradle-wrapper.jar b/domain-store/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index 1b33c55b..00000000 Binary files a/domain-store/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/domain-store/gradle/wrapper/gradle-wrapper.properties b/domain-store/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index ff23a68d..00000000 --- a/domain-store/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,7 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-bin.zip -networkTimeout=10000 -validateDistributionUrl=true -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/domain-store/src/main/java/com/example/domainstore/entity/Store.java b/domain-store/src/main/java/com/example/domainstore/entity/Store.java deleted file mode 100644 index 47166005..00000000 --- a/domain-store/src/main/java/com/example/domainstore/entity/Store.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.example.domainstore.entity; - -import java.time.LocalDateTime; - -import com.nowait.base.BaseTimeEntity; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.experimental.SuperBuilder; - -@Entity -@Table(name = "stores") -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) -@SuperBuilder -public class Store extends BaseTimeEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long storeId; - - private Long departmentId; - - private String name; - - private String location; - - private String description; - - @Builder.Default - @Column(name = "is_active", nullable = false) - private Boolean isActive = false; - - @Builder.Default - @Column - private Boolean deleted = false; - - public Store(LocalDateTime createdAt, Long storeId, Long departmentId, String name, String location, - String description, Boolean isActive, Boolean deleted) { - super(createdAt); - this.storeId = storeId; - this.departmentId = departmentId; - this.name = name; - this.location = location; - this.description = description; - this.isActive = isActive; - this.deleted = deleted; - } - - public void updateInfo(String name, String location, String description) { - this.name = name; - this.location = location; - this.description = description; - } - - public void markAsDeleted() { - this.deleted = true; - } - - public void activate() { - this.isActive = true; - } - - public void deactivate() { - this.isActive = false; - } -} diff --git a/domain-store/src/main/java/com/example/domainstore/repository/StoreImageRepository.java b/domain-store/src/main/java/com/example/domainstore/repository/StoreImageRepository.java deleted file mode 100644 index dcbe07ba..00000000 --- a/domain-store/src/main/java/com/example/domainstore/repository/StoreImageRepository.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.example.domainstore.repository; - -import java.util.List; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import com.example.domainstore.entity.Store; -import com.example.domainstore.entity.StoreImage; - -@Repository -public interface StoreImageRepository extends JpaRepository { - - List findByStore(Store store); -} diff --git a/domain-store/src/main/java/com/example/domainstore/repository/StoreRepository.java b/domain-store/src/main/java/com/example/domainstore/repository/StoreRepository.java deleted file mode 100644 index 81a08ac2..00000000 --- a/domain-store/src/main/java/com/example/domainstore/repository/StoreRepository.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.example.domainstore.repository; - -import java.util.List; -import java.util.Optional; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import com.example.domainstore.entity.Store; - -@Repository -public interface StoreRepository extends JpaRepository { - - List findAllByDeletedFalse(); - - Optional findByStoreIdAndDeletedFalse(Long storeId); - - List findByNameContainingIgnoreCaseAndDeletedFalse(String name); -} diff --git a/domain-token/.gitattributes b/domain-token/.gitattributes deleted file mode 100644 index 8af972cd..00000000 --- a/domain-token/.gitattributes +++ /dev/null @@ -1,3 +0,0 @@ -/gradlew text eol=lf -*.bat text eol=crlf -*.jar binary diff --git a/domain-token/.gitignore b/domain-token/.gitignore deleted file mode 100644 index c2065bc2..00000000 --- a/domain-token/.gitignore +++ /dev/null @@ -1,37 +0,0 @@ -HELP.md -.gradle -build/ -!gradle/wrapper/gradle-wrapper.jar -!**/src/main/**/build/ -!**/src/test/**/build/ - -### STS ### -.apt_generated -.classpath -.factorypath -.project -.settings -.springBeans -.sts4-cache -bin/ -!**/src/main/**/bin/ -!**/src/test/**/bin/ - -### IntelliJ IDEA ### -.idea -*.iws -*.iml -*.ipr -out/ -!**/src/main/**/out/ -!**/src/test/**/out/ - -### NetBeans ### -/nbproject/private/ -/nbbuild/ -/dist/ -/nbdist/ -/.nb-gradle/ - -### VS Code ### -.vscode/ diff --git a/domain-token/build.gradle b/domain-token/build.gradle deleted file mode 100644 index 966d1748..00000000 --- a/domain-token/build.gradle +++ /dev/null @@ -1,31 +0,0 @@ -plugins { - id 'java-library' -} - -group = 'com.nowaiting' -version = rootProject.version - -java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } -} - -repositories { - mavenCentral() -} - -dependencies { - implementation project(':common') - implementation project(':domain-base') - implementation project(':domain-user') - - api 'org.springframework.boot:spring-boot-starter-data-jpa' - api 'jakarta.persistence:jakarta.persistence-api:3.1.0' - - compileOnly 'org.projectlombok:lombok:1.18.26' - annotationProcessor 'org.projectlombok:lombok:1.18.26' - - testImplementation 'org.junit.jupiter:junit-jupiter' - testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' -} diff --git a/domain-token/src/main/java/com/example/domaintoken/repository/TokenRepository.java b/domain-token/src/main/java/com/example/domaintoken/repository/TokenRepository.java deleted file mode 100644 index d813fd09..00000000 --- a/domain-token/src/main/java/com/example/domaintoken/repository/TokenRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.example.domaintoken.repository; - -import java.util.Optional; - -import org.springframework.data.jpa.repository.JpaRepository; - -import com.example.domaintoken.entity.Token; - -public interface TokenRepository extends JpaRepository { - Optional findByUserId(Long userId); -} diff --git a/domain-token/src/main/resources/application.properties b/domain-token/src/main/resources/application.properties deleted file mode 100644 index f2dcfe32..00000000 --- a/domain-token/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=domain-token diff --git a/domain-token/src/test/java/com/example/domaintoken/DomainTokenApplicationTests.java b/domain-token/src/test/java/com/example/domaintoken/DomainTokenApplicationTests.java deleted file mode 100644 index 6a53ad8c..00000000 --- a/domain-token/src/test/java/com/example/domaintoken/DomainTokenApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.example.domaintoken; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class DomainTokenApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/domain-user/.gitattributes b/domain-user/.gitattributes deleted file mode 100644 index 8af972cd..00000000 --- a/domain-user/.gitattributes +++ /dev/null @@ -1,3 +0,0 @@ -/gradlew text eol=lf -*.bat text eol=crlf -*.jar binary diff --git a/domain-user/.gitignore b/domain-user/.gitignore deleted file mode 100644 index c2065bc2..00000000 --- a/domain-user/.gitignore +++ /dev/null @@ -1,37 +0,0 @@ -HELP.md -.gradle -build/ -!gradle/wrapper/gradle-wrapper.jar -!**/src/main/**/build/ -!**/src/test/**/build/ - -### STS ### -.apt_generated -.classpath -.factorypath -.project -.settings -.springBeans -.sts4-cache -bin/ -!**/src/main/**/bin/ -!**/src/test/**/bin/ - -### IntelliJ IDEA ### -.idea -*.iws -*.iml -*.ipr -out/ -!**/src/main/**/out/ -!**/src/test/**/out/ - -### NetBeans ### -/nbproject/private/ -/nbbuild/ -/dist/ -/nbdist/ -/.nb-gradle/ - -### VS Code ### -.vscode/ diff --git a/domain-user/src/main/java/com/nowaiting/user/exception/UserNotFoundException.java b/domain-user/src/main/java/com/nowaiting/user/exception/UserNotFoundException.java deleted file mode 100644 index bd2dfdc9..00000000 --- a/domain-user/src/main/java/com/nowaiting/user/exception/UserNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.nowaiting.user.exception; - -import com.nowaiting.common.exception.ErrorMessage; - -public class UserNotFoundException extends RuntimeException { - public UserNotFoundException() { - super(ErrorMessage.NOTFOUND_USER.getMessage()); - } -} diff --git a/domain-user/src/main/java/com/nowaiting/user/repository/UserRepository.java b/domain-user/src/main/java/com/nowaiting/user/repository/UserRepository.java deleted file mode 100644 index eea680f1..00000000 --- a/domain-user/src/main/java/com/nowaiting/user/repository/UserRepository.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.nowaiting.user.repository; - -import java.util.Optional; - -import org.springframework.data.jpa.repository.JpaRepository; - -import com.nowaiting.user.entity.User; - -public interface UserRepository extends JpaRepository { - Optional findByEmail(String email); - Optional findByNickname(String nickName); -} diff --git a/infra-aws/.gitattributes b/infra-aws/.gitattributes deleted file mode 100644 index 8af972cd..00000000 --- a/infra-aws/.gitattributes +++ /dev/null @@ -1,3 +0,0 @@ -/gradlew text eol=lf -*.bat text eol=crlf -*.jar binary diff --git a/infra-aws/.gitignore b/infra-aws/.gitignore deleted file mode 100644 index c2065bc2..00000000 --- a/infra-aws/.gitignore +++ /dev/null @@ -1,37 +0,0 @@ -HELP.md -.gradle -build/ -!gradle/wrapper/gradle-wrapper.jar -!**/src/main/**/build/ -!**/src/test/**/build/ - -### STS ### -.apt_generated -.classpath -.factorypath -.project -.settings -.springBeans -.sts4-cache -bin/ -!**/src/main/**/bin/ -!**/src/test/**/bin/ - -### IntelliJ IDEA ### -.idea -*.iws -*.iml -*.ipr -out/ -!**/src/main/**/out/ -!**/src/test/**/out/ - -### NetBeans ### -/nbproject/private/ -/nbbuild/ -/dist/ -/nbdist/ -/.nb-gradle/ - -### VS Code ### -.vscode/ diff --git a/api-admin/.gitattributes b/nowait-app-admin-api/.gitattributes similarity index 100% rename from api-admin/.gitattributes rename to nowait-app-admin-api/.gitattributes diff --git a/api-admin/.gitignore b/nowait-app-admin-api/.gitignore similarity index 100% rename from api-admin/.gitignore rename to nowait-app-admin-api/.gitignore diff --git a/nowait-app-admin-api/Dockerfile b/nowait-app-admin-api/Dockerfile new file mode 100644 index 00000000..a3f3e994 --- /dev/null +++ b/nowait-app-admin-api/Dockerfile @@ -0,0 +1,9 @@ +FROM eclipse-temurin:21 +ARG JAR_FILE=./build/libs/*.jar +COPY ${JAR_FILE} nowait-app-admin-api.jar + +ARG PROFILE=admin + +ENV TZ=Asia/Seoul + +CMD ["java", "-Djava.security.egd=file:/dev/./urandom", "-Duser.timezone=Asia/Seoul", "-jar", "nowait-app-admin-api.jar", "--spring.profiles.active=admin"] diff --git a/nowait-app-admin-api/build.gradle b/nowait-app-admin-api/build.gradle new file mode 100644 index 00000000..2dee33e3 --- /dev/null +++ b/nowait-app-admin-api/build.gradle @@ -0,0 +1,96 @@ +plugins { + id 'java' +} + +jar { + enabled = false +} +bootJar { + enabled = true +} + +group = 'com.nowait' +version = '0.0.1-SNAPSHOT' + + +repositories { + mavenCentral() + maven { url 'https://jitpack.io' } +} + +ext { + set('springCloudVersion', "2025.0.0") +} + +dependencyManagement { + imports { + mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" + } +} + +dependencies { + implementation project(':nowait-common') + implementation project(':nowait-infra') // 사용자 관련 도메인 + implementation project(':nowait-domain:domain-admin-rdb') + implementation project(':nowait-domain:domain-core-rdb') + implementation project(':nowait-domain:domain-redis') + implementation project(':nowait-event') + + // Spring Boot Starter + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + + // SPRING SECURITY + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + + // jwt + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' + implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' + + // OAUTH2 + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' + implementation 'jakarta.servlet:jakarta.servlet-api:6.0.0' + + // DB + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + runtimeOnly 'com.mysql:mysql-connector-j' + + // SWAGGER + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' + + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // 디스코드 웹훅 + implementation 'com.github.napstr:logback-discord-appender:1.0.0' + + //feign + implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' + implementation group: 'io.github.openfeign', name: 'feign-gson', version: '11.0' + + // 기타 필요 라이브러리 (예: Lombok) + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + // actuator + implementation 'org.springframework.boot:spring-boot-starter-actuator' + + // prometheus + implementation 'io.micrometer:micrometer-registry-prometheus' + + // test + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.junit.jupiter:junit-jupiter-api' + testImplementation 'org.junit.jupiter:junit-jupiter-engine' + testImplementation 'org.mockito:mockito-core' + testImplementation 'org.mockito:mockito-junit-jupiter' + testImplementation 'org.testcontainers:junit-jupiter' + testImplementation 'com.redis:testcontainers-redis:2.2.4' +} + +test { + useJUnitPlatform() +} \ No newline at end of file diff --git a/nowait-app-admin-api/src/main/java/com/nowait/ApiAdminApplication.java b/nowait-app-admin-api/src/main/java/com/nowait/ApiAdminApplication.java new file mode 100644 index 00000000..1d8d85f7 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/ApiAdminApplication.java @@ -0,0 +1,16 @@ +package com.nowait; + +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; + +@EnableJpaAuditing +@EnableScheduling +@EnableFeignClients +@SpringBootApplication +public class ApiAdminApplication { + public static void main(String[] args) { + org.springframework.boot.SpringApplication.run(ApiAdminApplication.class, args); + } +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/aws/controller/healthCheckController.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/aws/controller/healthCheckController.java new file mode 100644 index 00000000..72438f27 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/aws/controller/healthCheckController.java @@ -0,0 +1,28 @@ +package com.nowait.applicationadmin.aws.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.nowait.common.api.ApiUtils; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("") +@RequiredArgsConstructor +public class healthCheckController { + @GetMapping("/health-check") + public ResponseEntity healthCheck() { + return ResponseEntity + .status(HttpStatus.OK) + .body( + ApiUtils + .success( + "정상 작동 중입니다." + ) + ); + } +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/cancelOrder/dto/CancelOrderRequest.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/cancelOrder/dto/CancelOrderRequest.java new file mode 100644 index 00000000..76c6e86e --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/cancelOrder/dto/CancelOrderRequest.java @@ -0,0 +1,5 @@ +package com.nowait.applicationadmin.cancelOrder.dto; + +import com.nowait.domainadminrdb.cancelOrder.entity.CancelReason; + +public record CancelOrderRequest(CancelReason reason) { } diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/cancelOrder/dto/CancelOrderResponse.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/cancelOrder/dto/CancelOrderResponse.java new file mode 100644 index 00000000..7ee7a2f8 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/cancelOrder/dto/CancelOrderResponse.java @@ -0,0 +1,29 @@ +package com.nowait.applicationadmin.cancelOrder.dto; + +import java.time.Instant; + +import com.nowait.domainadminrdb.cancelOrder.entity.CancelOrder; +import com.nowait.domainadminrdb.cancelOrder.entity.CancelReason; + +import lombok.AllArgsConstructor; +import lombok.Builder; + +@Builder +public class CancelOrderResponse { + + private Long id; + private Long orderId; + private String orderSignature; + private CancelReason reason; + private Instant cancelAt; + + public CancelOrder fromEntity() { + return CancelOrder.builder() + .id(id) + .orderId(orderId) + .orderSignature(orderSignature) + .reason(reason) + .cancelAt(cancelAt) + .build(); + } +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/cancelOrder/listener/OrderCancelledEventListener.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/cancelOrder/listener/OrderCancelledEventListener.java new file mode 100644 index 00000000..c6fcbd39 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/cancelOrder/listener/OrderCancelledEventListener.java @@ -0,0 +1,41 @@ +package com.nowait.applicationadmin.cancelOrder.listener; + +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import com.nowait.domainadminrdb.cancelOrder.entity.CancelOrder; +import com.nowait.domainadminrdb.cancelOrder.repository.CancelOrderRepository; +import com.nowait.nowaitevent.order.event.OrderCancelledEvent; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@RequiredArgsConstructor +@Slf4j +public class OrderCancelledEventListener { + + private final CancelOrderRepository cancelOrderRepository; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void on(OrderCancelledEvent event) { + try { + cancelOrderRepository.save( + CancelOrder.builder() + .orderId(event.getOrderId()) + .storeId(event.getStoreId()) + .orderSignature(event.getOrderSignature()) + .reason(event.getReason()) + .cancelAt(event.getCancelAt()) + .build() + ); + } catch (DataIntegrityViolationException e) { + log.debug("cancel_orders duplicate orderId={}, ignore", event.getOrderId()); + } + } +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/config/SwaggerConfig.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/config/SwaggerConfig.java new file mode 100644 index 00000000..0bc46aad --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/config/SwaggerConfig.java @@ -0,0 +1,29 @@ +package com.nowait.applicationadmin.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; + +@Configuration +public class SwaggerConfig { + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .addSecurityItem(new SecurityRequirement().addList("JWT")) + .components(new Components() + .addSecuritySchemes("JWT", new SecurityScheme() + .name("Authorization") + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .in(SecurityScheme.In.HEADER))) + .info(new Info().title("NOWAIT API") + .description("NOWAIT API Specification") + .version("v0.0.1")); + } +} diff --git a/application-config/src/main/java/com/example/applicationconfig/config/CorsConfig.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/config/security/CorsConfig.java similarity index 76% rename from application-config/src/main/java/com/example/applicationconfig/config/CorsConfig.java rename to nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/config/security/CorsConfig.java index d8615194..da611607 100644 --- a/application-config/src/main/java/com/example/applicationconfig/config/CorsConfig.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/config/security/CorsConfig.java @@ -1,4 +1,4 @@ -package com.example.applicationconfig.config; +package com.nowait.applicationadmin.config.security; import java.util.List; @@ -15,11 +15,11 @@ public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); config.setAllowCredentials(true); // 쿠키나 인증헤더 자격증명 허용 - config.setAllowedOrigins(List.of("http://localhost:3000")); // 허용할 출처 설정 + config.setAllowedOrigins(List.of("http://localhost:5173","http://localhost:63342", "https://no-wait-fe-nowait-admin-2y5y.vercel.app", "https://nowait-admin.co.kr", "https://www.nowait-admin.com")); // 허용할 출처 설정 config.setAllowedMethods(List.of("GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS")); // 메서드 허용 config.setAllowedHeaders(List.of("*")); //클라이언트가 보낼 수 있는 헤더 config.setExposedHeaders(List.of("Authorization")); //클라이언트(브라우저)가 접근할 수 있는 헤더 지정 - // config.setAllowCredentials(true); // 쿠키 포함 허용 + config.setAllowCredentials(true); // 쿠키 포함 허용 UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); //** 뜻은 모든 URL 경로에 적용한다는 의미 diff --git a/security-admin/src/main/java/com/nowait/adminsecurity/auth/config/SecurityConfig.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/config/security/SecurityConfig.java similarity index 82% rename from security-admin/src/main/java/com/nowait/adminsecurity/auth/config/SecurityConfig.java rename to nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/config/security/SecurityConfig.java index f45e5ec1..93249ce0 100644 --- a/security-admin/src/main/java/com/nowait/adminsecurity/auth/config/SecurityConfig.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/config/security/SecurityConfig.java @@ -1,4 +1,4 @@ -package com.nowait.adminsecurity.auth.config; +package com.nowait.applicationadmin.config.security; import org.springframework.context.annotation.Bean; @@ -13,10 +13,11 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfigurationSource; -import com.nowait.adminsecurity.auth.jwt.JwtAuthorizationFilter; -import com.nowait.adminsecurity.auth.jwt.JwtUtil; -import com.nowait.adminsecurity.auth.service.CustomUserDetailService; +import com.nowait.applicationadmin.security.jwt.JwtAuthorizationFilter; +import com.nowait.applicationadmin.security.jwt.JwtUtil; +import com.nowait.applicationadmin.security.service.CustomUserDetailService; import lombok.RequiredArgsConstructor; @@ -26,10 +27,12 @@ public class SecurityConfig { private final JwtUtil jwtUtil; private final CustomUserDetailService customUserDetailService; + private final CorsConfigurationSource corsConfigurationSource; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http + .cors(cors -> cors.configurationSource(corsConfigurationSource)) // CSRF 방어 기능 비활성화 (jwt 토큰을 사용할 것이기에 필요없음) .csrf(AbstractHttpConfigurer::disable) // 시큐리티 폼 로그인 비활성화 @@ -43,8 +46,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .authorizeHttpRequests(auth -> auth .requestMatchers( "/api/refresh-token", // refresh token (토큰 갱신) - "/admin/menus/**", - "/admin/stores/**", + "/orders/**", "/admin/users/**", "/swagger-ui/**", "/v3/api-docs/**", @@ -52,7 +54,10 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/api-docs/**", "/swagger-resources/**", "/webjars/**", - "/demo-ui.html" + "/demo-ui.html", + "/nowait/management", + "/nowait/management/**", + "/health-check" ) .permitAll() .anyRequest().authenticated() // 그외 요청은 허가된 사람만 인가 @@ -73,5 +78,4 @@ public AuthenticationProvider authenticationProvider() { authenticationProvider.setPasswordEncoder(passwordEncoder()); return authenticationProvider; } - } diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/exception/GlobalExceptionHandler.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..43ab7ca4 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/exception/GlobalExceptionHandler.java @@ -0,0 +1,602 @@ +package com.nowait.applicationadmin.exception; + +import static com.nowait.common.exception.ErrorMessage.*; +import static org.springframework.http.HttpStatus.*; +import static org.springframework.http.HttpStatus.UNAUTHORIZED; + +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.FieldError; +import org.springframework.validation.ObjectError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingRequestValueException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.multipart.MultipartException; + +import com.nowait.applicationadmin.security.exception.ResourceNotFoundException; +import com.nowait.applicationadmin.security.exception.UnauthorizedException; +import com.nowait.common.exception.ErrorMessage; +import com.nowait.common.exception.ErrorResponse; +import com.nowait.discord.service.DiscordAlarmService; +import com.nowait.domaincorerdb.menu.exception.MenuAlreadyDeletedException; +import com.nowait.domaincorerdb.menu.exception.MenuCreationUnauthorizedException; +import com.nowait.domaincorerdb.menu.exception.MenuCrossStoreConflictException; +import com.nowait.domaincorerdb.menu.exception.MenuDeleteUnauthorizedException; +import com.nowait.domaincorerdb.menu.exception.MenuDuplicateIdException; +import com.nowait.domaincorerdb.menu.exception.MenuImageEmptyException; +import com.nowait.domaincorerdb.menu.exception.MenuInvalidSortOrderException; +import com.nowait.domaincorerdb.menu.exception.MenuNotFoundException; +import com.nowait.domaincorerdb.menu.exception.MenuParamEmptyException; +import com.nowait.domaincorerdb.menu.exception.MenuToggleUnauthorizedException; +import com.nowait.domaincorerdb.menu.exception.MenuUpdateUnauthorizedException; +import com.nowait.domaincorerdb.menu.exception.MenuViewUnauthorizedException; +import com.nowait.domaincorerdb.order.exception.DepositorNameTooLongException; +import com.nowait.domaincorerdb.order.exception.DuplicateOrderException; +import com.nowait.domaincorerdb.order.exception.OrderAlreadyCancelledException; +import com.nowait.domaincorerdb.order.exception.OrderItemsEmptyException; +import com.nowait.domaincorerdb.order.exception.OrderNotFoundException; +import com.nowait.domaincorerdb.order.exception.OrderParameterEmptyException; +import com.nowait.domaincorerdb.order.exception.OrderUpdateUnauthorizedException; +import com.nowait.domaincorerdb.order.exception.OrderViewUnauthorizedException; +import com.nowait.domaincorerdb.reservation.exception.AlreadyDeletedWaitingException; +import com.nowait.domaincorerdb.reservation.exception.DuplicateReservationException; +import com.nowait.domaincorerdb.reservation.exception.InvalidReservationParameterException; +import com.nowait.domaincorerdb.reservation.exception.InvalidReservationStatusTransitionException; +import com.nowait.domaincorerdb.reservation.exception.ReservationAddUnauthorizedException; +import com.nowait.domaincorerdb.reservation.exception.ReservationAlreadyCancelledException; +import com.nowait.domaincorerdb.reservation.exception.ReservationAlreadyConfirmedException; +import com.nowait.domaincorerdb.reservation.exception.ReservationNotFoundException; +import com.nowait.domaincorerdb.reservation.exception.ReservationNumberIssueFailException; +import com.nowait.domaincorerdb.reservation.exception.ReservationUpdateUnauthorizedException; +import com.nowait.domaincorerdb.reservation.exception.ReservationViewUnauthorizedException; +import com.nowait.domaincorerdb.reservation.exception.UnsupportedReservationStatusException; +import com.nowait.domaincorerdb.store.exception.StoreDeleteUnauthorizedException; +import com.nowait.domaincorerdb.store.exception.StoreImageEmptyException; +import com.nowait.domaincorerdb.store.exception.StoreImageNotFoundException; +import com.nowait.domaincorerdb.store.exception.StoreNotFoundException; +import com.nowait.domaincorerdb.store.exception.StoreParamEmptyException; +import com.nowait.domaincorerdb.store.exception.StoreUpdateUnauthorizedException; +import com.nowait.domaincorerdb.store.exception.StoreViewUnauthorizedException; +import com.nowait.domaincorerdb.store.exception.StoreWaitingDisabledException; +import com.nowait.domaincorerdb.storepayment.exception.StorePaymentAlreadyExistsException; +import com.nowait.domaincorerdb.storepayment.exception.StorePaymentCreationUnauthorizedException; +import com.nowait.domaincorerdb.storepayment.exception.StorePaymentNotFoundException; +import com.nowait.domaincorerdb.storepayment.exception.StorePaymentParamEmptyException; +import com.nowait.domaincorerdb.storepayment.exception.StorePaymentUpdateUnauthorizedException; +import com.nowait.domaincorerdb.storepayment.exception.StorePaymentViewUnauthorizedException; +import com.nowait.domaincorerdb.token.exception.BusinessException; +import com.nowait.domaincorerdb.user.exception.UserNotFoundException; +import com.nowait.domaincoreredis.rank.exception.MenuCounterUpdateException; +import com.nowait.domaincoreredis.reservation.exception.ReservationDataInconsistencyException; + +import io.swagger.v3.oas.annotations.Hidden; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Hidden +@RestControllerAdvice +@RequiredArgsConstructor +public class GlobalExceptionHandler { + + private final DiscordAlarmService discordAlarmService; + + // Discord 알림 헬퍼 + private void alarm(Exception e, WebRequest request) { + discordAlarmService.sendDiscordAdminAlarm(e, request); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(BusinessException.class) + public ErrorResponse handleBusinessException(BusinessException e, WebRequest request) { + alarm(e, request); + log.error("handleBusinessException", e); + return new ErrorResponse(e.getMessage(), e.getCode()); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(MethodArgumentNotValidException.class) + public ErrorResponse handleMethodArgumentNotValidException(MethodArgumentNotValidException e, WebRequest request) { + alarm(e, request); + log.error("handleMethodArgumentNotValidException", e); + Map errors = getErrors(e); + return new ErrorResponse(INVALID_INPUT_VALUE.getMessage(), INVALID_INPUT_VALUE.getCode(), errors); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(HttpMessageNotReadableException.class) + public ErrorResponse handleHttpMessageNotReadableException(HttpMessageNotReadableException e, WebRequest request) { + alarm(e, request); + log.error("handleHttpMessageNotReadableException", e); + return new ErrorResponse(INVALID_INPUT_VALUE.getMessage(), INVALID_INPUT_VALUE.getCode()); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(IllegalArgumentException.class) + public ErrorResponse handleIllegalArgumentException(IllegalArgumentException e, WebRequest request) { + alarm(e, request); + log.error("handleIllegalArgumentException", e); + return new ErrorResponse(e.getMessage(), INVALID_INPUT_VALUE.getCode()); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(MissingRequestValueException.class) + public ErrorResponse handleMissingRequestValueException(MissingRequestValueException e, WebRequest request) { + alarm(e, request); + log.error("handleMissingRequestValueExceptionException", e); + return new ErrorResponse(INVALID_INPUT_VALUE.getMessage(), INVALID_INPUT_VALUE.getCode()); + } + + @ResponseStatus(UNAUTHORIZED) + @ExceptionHandler(UnauthorizedException.class) + public ErrorResponse handleUnauthorizedException(UnauthorizedException e, WebRequest request) { + alarm(e, request); + log.error("handleUnauthorizedException", e); + return new ErrorResponse(e.getMessage(), e.getCode()); + } + + @ResponseStatus(NOT_FOUND) + @ExceptionHandler(ResourceNotFoundException.class) + public ErrorResponse handleResourceNotFoundException(ResourceNotFoundException e, WebRequest request) { + alarm(e, request); + log.error("handleResourceNotFoundExceptionException", e); + return new ErrorResponse(e.getMessage(), e.getCode()); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(MultipartException.class) + public ErrorResponse handleMultipartException(MultipartException e, WebRequest request) { + alarm(e, request); + log.error("handleMultipartException", e); + return new ErrorResponse(e.getMessage(), INVALID_INPUT_VALUE.getCode()); + } + + @ResponseStatus(NOT_FOUND) + @ExceptionHandler(UserNotFoundException.class) + public ErrorResponse userNotFoundException(UserNotFoundException e, WebRequest request) { + alarm(e, request); + log.error("userNotFoundException", e); + return new ErrorResponse(e.getMessage(), NOT_FOUND_USER.getCode()); + } + + + /** + * 주문 관련 예외 처리 + */ + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(DepositorNameTooLongException.class) + public ErrorResponse depositorNameTooLongException(DepositorNameTooLongException e, WebRequest request) { + alarm(e, request); + log.error("depositorNameTooLongException", e); + return new ErrorResponse(e.getMessage(), DEPOSITOR_NAME_TOO_LONG.getCode()); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(DuplicateOrderException.class) + public ErrorResponse duplicateOrderException(DuplicateOrderException e, WebRequest request) { + alarm(e, request); + log.error("duplicateOrderException", e); + return new ErrorResponse(e.getMessage(), ErrorMessage.DUPLICATE_ORDER.getCode()); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(OrderAlreadyCancelledException.class) + public ErrorResponse orderAlreadyCancelledException(OrderAlreadyCancelledException e, WebRequest request) { + alarm(e, request); + log.error("orderAlreadyCancelledException", e); + return new ErrorResponse(e.getMessage(), ORDER_ALREADY_CANCELLED.getCode()); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(OrderItemsEmptyException.class) + public ErrorResponse orderItemsEmptyException(OrderItemsEmptyException e, WebRequest request) { + alarm(e, request); + log.error("orderItemsEmptyException", e); + return new ErrorResponse(e.getMessage(), ORDER_ITEMS_EMPTY.getCode()); + } + + @ResponseStatus(NOT_FOUND) + @ExceptionHandler(OrderNotFoundException.class) + public ErrorResponse orderNotFoundException(OrderNotFoundException e, WebRequest request) { + alarm(e, request); + log.error("orderNotFoundException", e); + return new ErrorResponse(e.getMessage(), ORDER_NOT_FOUND.getCode()); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(OrderParameterEmptyException.class) + public ErrorResponse orderParameterEmptyException(OrderParameterEmptyException e, WebRequest request) { + alarm(e, request); + log.error("orderParameterEmptyException", e); + return new ErrorResponse(e.getMessage(), ORDER_PARAMETER_EMPTY.getCode()); + } + + @ResponseStatus(FORBIDDEN) + @ExceptionHandler(OrderUpdateUnauthorizedException.class) + public ErrorResponse orderUpdateUnauthorizedException(OrderUpdateUnauthorizedException e, WebRequest request) { + alarm(e, request); + log.error("orderUpdateUnauthorizedException", e); + return new ErrorResponse(e.getMessage(), ORDER_UPDATE_UNAUTHORIZED.getCode()); + } + + @ResponseStatus(FORBIDDEN) + @ExceptionHandler(OrderViewUnauthorizedException.class) + public ErrorResponse orderViewUnauthorizedException(OrderViewUnauthorizedException e, WebRequest request) { + alarm(e, request); + log.error("orderViewUnauthorizedException", e); + return new ErrorResponse(e.getMessage(), ORDER_VIEW_UNAUTHORIZED.getCode()); + } + + /** + * 예약 관련 예외 처리 + */ + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(DuplicateReservationException.class) + public ErrorResponse duplicateReservationException(DuplicateReservationException e, WebRequest request) { + alarm(e, request); + log.error("duplicateReservationException", e); + return new ErrorResponse(e.getMessage(), DUPLICATE_RESERVATION.getCode()); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(InvalidReservationParameterException.class) + public ErrorResponse invalidReservationParameterException(InvalidReservationParameterException e, WebRequest request) { + alarm(e, request); + log.error("invalidReservationParameterException", e); + return new ErrorResponse(e.getMessage(), INVALID_RESERVATION_PARAMETER.getCode()); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(InvalidReservationStatusTransitionException.class) + public ErrorResponse invalidReservationStatusTransitionException(InvalidReservationStatusTransitionException e, WebRequest request) { + alarm(e, request); + log.error("invalidReservationStatusTransitionException", e); + return new ErrorResponse(e.getMessage(), INVALID_RESERVATION_STATUS_TRANSITION.getCode()); + } + + @ResponseStatus(FORBIDDEN) + @ExceptionHandler(ReservationAddUnauthorizedException.class) + public ErrorResponse reservationAddUnauthorizedException(ReservationAddUnauthorizedException e, WebRequest request) { + alarm(e, request); + log.error("reservationAddUnauthorizedException", e); + return new ErrorResponse(e.getMessage(), RESERVATION_ADD_UNAUTHORIZED.getCode()); + } + + @ResponseStatus(CONFLICT) + @ExceptionHandler(ReservationAlreadyCancelledException.class) + public ErrorResponse reservationAlreadyCancelledException(ReservationAlreadyCancelledException e, WebRequest request) { + alarm(e, request); + log.error("reservationAlreadyCancelledException", e); + return new ErrorResponse(e.getMessage(), RESERVATION_ALREADY_CANCELLED.getCode()); + } + + @ResponseStatus(CONFLICT) + @ExceptionHandler(ReservationAlreadyConfirmedException.class) + public ErrorResponse reservationAlreadyConfirmedException(ReservationAlreadyConfirmedException e, WebRequest request) { + alarm(e, request); + log.error("reservationAlreadyConfirmedException", e); + return new ErrorResponse(e.getMessage(), RESERVATION_ALREADY_CONFIRMED.getCode()); + } + + @ResponseStatus(NOT_FOUND) + @ExceptionHandler(ReservationNotFoundException.class) + public ErrorResponse reservationNotFoundException(ReservationNotFoundException e, WebRequest request) { + alarm(e, request); + log.error("reservationNotFoundException", e); + return new ErrorResponse(e.getMessage(), NOTFOUND_RESERVATION.getCode()); + } + + @ResponseStatus(INTERNAL_SERVER_ERROR) + @ExceptionHandler(ReservationNumberIssueFailException.class) + public ErrorResponse reservationNumberIssueFailException(ReservationNumberIssueFailException e, WebRequest request) { + alarm(e, request); + log.error("reservationNumberIssueFailException", e); + return new ErrorResponse(e.getMessage(), RESERVATION_NUMBER_ISSUE_FAIL.getCode()); + } + + @ResponseStatus(FORBIDDEN) + @ExceptionHandler(ReservationUpdateUnauthorizedException.class) + public ErrorResponse reservationUpdateUnauthorizedException(ReservationUpdateUnauthorizedException e, WebRequest request) { + alarm(e, request); + log.error("reservationUpdateUnauthorizedException", e); + return new ErrorResponse(e.getMessage(), RESERVATION_UPDATE_UNAUTHORIZED.getCode()); + } + + @ResponseStatus(FORBIDDEN) + @ExceptionHandler(ReservationViewUnauthorizedException.class) + public ErrorResponse reservationViewUnauthorizedException(ReservationViewUnauthorizedException e, WebRequest request) { + alarm(e, request); + log.error("reservation_viewUnauthorizedException", e); + return new ErrorResponse(e.getMessage(), RESERVATION_VIEW_UNAUTHORIZED.getCode()); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(UnsupportedReservationStatusException.class) + public ErrorResponse unsupportedReservationStatusException(UnsupportedReservationStatusException e, WebRequest request) { + alarm(e, request); + log.error("unsupportedReservationStatusException", e); + return new ErrorResponse(e.getMessage(), UNSUPPORTED_RESERVATION_STATUS.getCode()); + } + + + @ResponseStatus(CONFLICT) + @ExceptionHandler(AlreadyDeletedWaitingException.class) + public ErrorResponse alreadyDeletedWaitingException(AlreadyDeletedWaitingException e, WebRequest request) { + alarm(e, request); + log.error("alreadyDeletedWaitingException", e); + return new ErrorResponse(e.getMessage(), ALREADY_DELETED_RESERVATION.getCode()); + } + + + /** + * 메뉴 관련 예외 처리 + */ + @ResponseStatus(CONFLICT) + @ExceptionHandler(MenuAlreadyDeletedException.class) + public ErrorResponse menuAlreadyDeletedException(MenuAlreadyDeletedException e, WebRequest request) { + alarm(e, request); + log.error("menuAlreadyDeletedException", e); + return new ErrorResponse(e.getMessage(), MENU_ALREADY_DELETED.getCode()); + } + + @ResponseStatus(FORBIDDEN) + @ExceptionHandler(MenuCreationUnauthorizedException.class) + public ErrorResponse menuCreationUnauthorizedException(MenuCreationUnauthorizedException e, WebRequest request) { + alarm(e, request); + log.error("menuCreationUnauthorizedException", e); + return new ErrorResponse(e.getMessage(), MENU_CREATION_UNAUTHORIZED.getCode()); + } + + @ResponseStatus(CONFLICT) + @ExceptionHandler(MenuCrossStoreConflictException.class) + public ErrorResponse menuCrossStoreConflictException(MenuCrossStoreConflictException e, WebRequest request) { + alarm(e, request); + log.error("menuCrossStoreConflictException", e); + return new ErrorResponse(e.getMessage(), MENU_CROSS_STORE_CONFLICT.getCode()); + } + + @ResponseStatus(FORBIDDEN) + @ExceptionHandler(MenuDeleteUnauthorizedException.class) + public ErrorResponse menuDeleteUnauthorizedException(MenuDeleteUnauthorizedException e, WebRequest request) { + alarm(e, request); + log.error("menuDeleteUnauthorizedException", e); + return new ErrorResponse(e.getMessage(), MENU_DELETE_UNAUTHORIZED.getCode()); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(MenuDuplicateIdException.class) + public ErrorResponse menuDuplicateIdException(MenuDuplicateIdException e, WebRequest request) { + alarm(e, request); + log.error("menuDuplicateIdException", e); + return new ErrorResponse(e.getMessage(), MENU_DUPLICATE_ID.getCode()); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(MenuImageEmptyException.class) + public ErrorResponse menuImageEmptyException(MenuImageEmptyException e, WebRequest request) { + alarm(e, request); + log.error("menuImageEmptyException", e); + return new ErrorResponse(e.getMessage(), IMAGE_FILE_EMPTY.getCode()); + } + + @ResponseStatus(NOT_FOUND) + @ExceptionHandler(MenuNotFoundException.class) + public ErrorResponse menuNotFoundException(MenuNotFoundException e, WebRequest request) { + alarm(e, request); + log.error("menuNotFoundException", e); + return new ErrorResponse(e.getMessage(), MENU_NOT_FOUND.getCode()); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(MenuInvalidSortOrderException.class) + public ErrorResponse menuInvalidSortOrderException(MenuInvalidSortOrderException e, WebRequest request) { + alarm(e, request); + log.error("menuInvalidSortOrderException", e); + return new ErrorResponse(e.getMessage(), MENU_INVALID_SORT_ORDER.getCode()); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(MenuParamEmptyException.class) + public ErrorResponse menuParamEmptyException(MenuParamEmptyException e, WebRequest request) { + alarm(e, request); + log.error("menuParamEmptyException", e); + return new ErrorResponse(e.getMessage(), MENU_PARAMETER_EMPTY.getCode()); + } + + @ResponseStatus(FORBIDDEN) + @ExceptionHandler(MenuUpdateUnauthorizedException.class) + public ErrorResponse menuUpdateUnauthorizedException(MenuUpdateUnauthorizedException e, WebRequest request) { + alarm(e, request); + log.error("menuUpdateUnauthorizedException", e); + return new ErrorResponse(e.getMessage(), MENU_UPDATE_UNAUTHORIZED.getCode()); + } + + @ResponseStatus(FORBIDDEN) + @ExceptionHandler(MenuToggleUnauthorizedException.class) + public ErrorResponse menuToggleUnauthorizedException(MenuToggleUnauthorizedException e, WebRequest request) { + alarm(e, request); + log.error("menuToggleUnauthorizedException", e); + return new ErrorResponse(e.getMessage(), MENU_TOGGLE_UNAUTHORIZED.getCode()); + } + + @ResponseStatus(FORBIDDEN) + @ExceptionHandler(MenuViewUnauthorizedException.class) + public ErrorResponse menuViewUnauthorizedException(MenuViewUnauthorizedException e, WebRequest request) { + alarm(e, request); + log.error("menuViewUnauthorizedException", e); + return new ErrorResponse(e.getMessage(), MENU_VIEW_UNAUTHORIZED.getCode()); + } + + + /** + * 주점 관련 예외 처리 + */ + @ResponseStatus(FORBIDDEN) + @ExceptionHandler(StoreDeleteUnauthorizedException.class) + public ErrorResponse storeDeleteUnauthorizedException(StoreDeleteUnauthorizedException e, WebRequest request) { + alarm(e, request); + log.error("storeDeleteUnauthorizedException", e); + return new ErrorResponse(e.getMessage(), STORE_DELETE_UNAUTHORIZED.getCode()); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(StoreImageEmptyException.class) + public ErrorResponse storeImageEmptyException(StoreImageEmptyException e, WebRequest request) { + alarm(e, request); + log.error("storeImageEmptyException", e); + return new ErrorResponse(e.getMessage(), IMAGE_FILE_EMPTY.getCode()); + } + + @ResponseStatus(NOT_FOUND) + @ExceptionHandler(StoreImageNotFoundException.class) + public ErrorResponse storeImageNotFoundException(StoreImageNotFoundException e, WebRequest request) { + alarm(e, request); + log.error("storeImageNotFoundException", e); + return new ErrorResponse(e.getMessage(), IMAGE_FILE_NOT_FOUND.getCode()); + } + + @ResponseStatus(NOT_FOUND) + @ExceptionHandler(StoreNotFoundException.class) + public ErrorResponse storeNotFoundException(StoreNotFoundException e, WebRequest request) { + alarm(e, request); + log.error("storeNotFoundException", e); + return new ErrorResponse(e.getMessage(), STORE_NOT_FOUND.getCode()); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(StoreParamEmptyException.class) + public ErrorResponse storeParamEmptyException(StoreParamEmptyException e, WebRequest request) { + alarm(e, request); + log.error("storeParamEmptyException", e); + return new ErrorResponse(e.getMessage(), STORE_PARAMETER_EMPTY.getCode()); + } + + @ResponseStatus(FORBIDDEN) + @ExceptionHandler(StoreUpdateUnauthorizedException.class) + public ErrorResponse storeUpdateUnauthorizedException(StoreUpdateUnauthorizedException e, WebRequest request) { + alarm(e, request); + log.error("storeUpdateUnauthorizedException", e); + return new ErrorResponse(e.getMessage(), STORE_UPDATE_UNAUTHORIZED.getCode()); + } + + @ResponseStatus(FORBIDDEN) + @ExceptionHandler(StoreViewUnauthorizedException.class) + public ErrorResponse storeViewUnauthorizedException(StoreViewUnauthorizedException e, WebRequest request) { + alarm(e, request); + log.error("storeViewUnauthorizedException", e); + return new ErrorResponse(e.getMessage(), STORE_VIEW_UNAUTHORIZED.getCode()); + } + + @ResponseStatus(FORBIDDEN) + @ExceptionHandler(StoreWaitingDisabledException.class) + public ErrorResponse storeWaitingDisabledException(StoreWaitingDisabledException e, WebRequest request) { + alarm(e, request); + log.error("storeWaitingDisabledException", e); + return new ErrorResponse(e.getMessage(), STORE_WAITING_DISABLED.getCode()); + } + + /** + * 결제 수단 관련 예외처리 + */ + @ResponseStatus(CONFLICT) + @ExceptionHandler(StorePaymentAlreadyExistsException.class) + public ErrorResponse storePaymentAlreadyExistsException(StorePaymentAlreadyExistsException e, WebRequest request) { + alarm(e, request); + log.error("storePaymentAlreadyExistsException", e); + return new ErrorResponse(e.getMessage(), STORE_PAYMENT_ALREADY_EXISTS.getCode()); + } + + @ResponseStatus(FORBIDDEN) + @ExceptionHandler(StorePaymentCreationUnauthorizedException.class) + public ErrorResponse storePaymentCreationUnauthorizedException(StorePaymentCreationUnauthorizedException e, WebRequest request) { + alarm(e, request); + log.error("storePaymentCreationUnauthorizedException", e); + return new ErrorResponse(e.getMessage(), STORE_PAYMENT_CREATION_UNAUTHORIZED.getCode()); + } + + @ResponseStatus(NOT_FOUND) + @ExceptionHandler(StorePaymentNotFoundException.class) + public ErrorResponse storePaymentNotFoundException(StorePaymentNotFoundException e, WebRequest request) { + alarm(e, request); + log.error("storePaymentNotFoundException", e); + return new ErrorResponse(e.getMessage(), STORE_PAYMENT_NOT_FOUND.getCode()); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(StorePaymentParamEmptyException.class) + public ErrorResponse storePaymentParamEmptyException(StorePaymentParamEmptyException e, WebRequest request) { + alarm(e, request); + log.error("storePaymentParamEmptyException", e); + return new ErrorResponse(e.getMessage(), STORE_PAYMENT_PARAMETER_EMPTY.getCode()); + } + + @ResponseStatus(FORBIDDEN) + @ExceptionHandler(StorePaymentUpdateUnauthorizedException.class) + public ErrorResponse storePaymentUpdateUnauthorizedException(StorePaymentUpdateUnauthorizedException e, WebRequest request) { + alarm(e, request); + log.error("storePaymentUpdateUnauthorizedException", e); + return new ErrorResponse(e.getMessage(), STORE_PAYMENT_UPDATE_UNAUTHORIZED.getCode()); + } + + @ResponseStatus(FORBIDDEN) + @ExceptionHandler(StorePaymentViewUnauthorizedException.class) + public ErrorResponse storePaymentViewUnauthorizedException(StorePaymentViewUnauthorizedException e, WebRequest request) { + alarm(e, request); + log.error("storePaymentViewUnauthorizedException", e); + return new ErrorResponse(e.getMessage(), STORE_PAYMENT_VIEW_UNAUTHORIZED.getCode()); + } + + /** + * redis 관련 예외처리 + */ + @ResponseStatus(INTERNAL_SERVER_ERROR) + @ExceptionHandler(MenuCounterUpdateException.class) + public ErrorResponse menuCounterUpdateException(MenuCounterUpdateException e, WebRequest request) { + alarm(e, request); + log.error("menuCounterUpdateException", e); + return new ErrorResponse(e.getMessage(), MENU_COUNTER_UPDATE.getCode()); + } + + @ResponseStatus(NOT_FOUND) + @ExceptionHandler(ReservationDataInconsistencyException.class) + public ErrorResponse reservationDataInconsistencyException(ReservationDataInconsistencyException e, WebRequest request) { + alarm(e, request); + log.error("reservationDataInconsistencyException", e); + return new ErrorResponse(e.getMessage(), RESERVATION_DATA_INCONSISTENCY.getCode()); + } + + /** + * * 그 외 예외처리 + */ + @ResponseStatus(INTERNAL_SERVER_ERROR) + @ExceptionHandler(Exception.class) + public ErrorResponse handleUnexpectedException(Exception e, WebRequest request) { + alarm(e, request); + log.error("handleUnexpectedException", e); + return new ErrorResponse( + ErrorMessage.UNEXPECTED_ERROR.getMessage(), + ErrorMessage.UNEXPECTED_ERROR.getCode() + ); + } + + + // 공통 에러 Map 생성 + private static Map getErrors(MethodArgumentNotValidException e) { + return e.getBindingResult() + .getAllErrors() + .stream() + .filter(ObjectError.class::isInstance) + .collect(Collectors.toMap( + error -> error instanceof FieldError ? ((FieldError)error).getField() : error.getObjectName(), + ObjectError::getDefaultMessage, + (msg1, msg2) -> msg1 + ";" + msg2 + )); + } +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/controller/MenuController.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/controller/MenuController.java new file mode 100644 index 00000000..3b28ee64 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/controller/MenuController.java @@ -0,0 +1,147 @@ +package com.nowait.applicationadmin.menu.controller; + +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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.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.RestController; + +import com.nowait.applicationadmin.menu.dto.MenuCreateRequest; +import com.nowait.applicationadmin.menu.dto.MenuCreateResponse; +import com.nowait.applicationadmin.menu.dto.MenuSortUpdateRequest; +import com.nowait.applicationadmin.menu.dto.MenuUpdateRequest; +import com.nowait.applicationadmin.menu.service.MenuService; +import com.nowait.common.api.ApiUtils; +import com.nowait.domaincorerdb.user.entity.MemberDetails; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Tag(name = "Menu API", description = "메뉴 API") +@RestController +@RequestMapping("/admin/menus") +@RequiredArgsConstructor +@Slf4j +public class MenuController { + + private final MenuService menuService; + + @PostMapping("/create") + @Operation(summary = "메뉴 생성", description = "새로운 메뉴를 생성합니다.") + @ApiResponse(responseCode = "201", description = "메뉴 생성") + public ResponseEntity createMenu(@Valid @RequestBody MenuCreateRequest request, + @AuthenticationPrincipal MemberDetails memberDetails) { + MenuCreateResponse response = menuService.createMenu(request,memberDetails); + + return ResponseEntity + .status(HttpStatus.CREATED) + .body( + ApiUtils.success( + response + ) + ); + } + + @GetMapping("/all-menus/stores/{storeId}") + @Operation(summary = "가게의 모든 메뉴 조회", description = "특정 가게의 모든 메뉴를 조회") + @ApiResponse(responseCode = "200", description = "가게의 모든 메뉴 조회") + public ResponseEntity getMenusByStoreId(@PathVariable Long storeId, + @AuthenticationPrincipal MemberDetails memberDetails) { + return ResponseEntity + .status(HttpStatus.OK) + .body( + ApiUtils.success( + menuService.getAllMenusByStoreId(storeId,memberDetails) + ) + ); + } + + @GetMapping("/{storeId}/{menuId}") + @Operation(summary = "메뉴 상세 조회", description = "특정 메뉴의 상세 정보를 조회") + @ApiResponse(responseCode = "200", description = "메뉴 상세 조회") + public ResponseEntity getMenuById( + @PathVariable Long storeId, + @PathVariable Long menuId, + @AuthenticationPrincipal MemberDetails memberDetails + ) { + return ResponseEntity + .status(HttpStatus.OK) + .body( + ApiUtils.success( + menuService.getMenuById(storeId, menuId,memberDetails) + ) + ); + } + + + @PatchMapping("/update/{menuId}") + @Operation(summary = "메뉴 수정", description = "특정 메뉴의 정보를 수정합니다.") + @ApiResponse(responseCode = "200", description = "메뉴 수정") + public ResponseEntity updateMenu( + @PathVariable Long menuId, + @Valid @RequestBody MenuUpdateRequest request, + @AuthenticationPrincipal MemberDetails memberDetails + ) { + return ResponseEntity + .status(HttpStatus.OK) + .body( + ApiUtils.success( + menuService.updateMenu(menuId, request,memberDetails) + ) + ); + } + + @DeleteMapping("/delete/{menuId}") + @Operation(summary = "메뉴 삭제", description = "특정 메뉴를 삭제합니다.") + @ApiResponse(responseCode = "200", description = "메뉴 삭제") + public ResponseEntity deleteMenu(@PathVariable Long menuId,@AuthenticationPrincipal MemberDetails memberDetails) { + return ResponseEntity + .status(HttpStatus.OK) + .body( + ApiUtils.success( + menuService.deleteMenu(menuId,memberDetails) + ) + ); + } + + @PatchMapping("/toggle-soldout/{menuId}") + @Operation(summary = "메뉴 판매 중지/재개", description = "특정 메뉴의 판매 상태를 판매 중지 또는 재개로 토글합니다.") + @ApiResponse(responseCode = "200", description = "메뉴 판매 상태 토글") + public ResponseEntity toggleSoldOut(@PathVariable Long menuId) { + return ResponseEntity + .status(HttpStatus.OK) + .body( + ApiUtils.success( + menuService.toggleSoldOut(menuId) + ) + ); + } + + @PatchMapping("/update-sort") + @Operation(summary = "메뉴 순서 수정", description = "메뉴의 순서를 수정합니다.") + @ApiResponse(responseCode = "200", description = "메뉴 순서 수정") + public ResponseEntity updateMenuSortOrder( + @Valid @RequestBody List request, + @AuthenticationPrincipal MemberDetails memberDetails + ) { + return ResponseEntity + .status(HttpStatus.OK) + .body( + ApiUtils.success( + menuService.updateMenuSortOrder(request, memberDetails) + ) + ); + } +} diff --git a/api-admin/src/main/java/com/example/apiadmin/menu/controller/MenuImageController.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/controller/MenuImageController.java similarity index 52% rename from api-admin/src/main/java/com/example/apiadmin/menu/controller/MenuImageController.java rename to nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/controller/MenuImageController.java index a64d8bca..9d096bc3 100644 --- a/api-admin/src/main/java/com/example/apiadmin/menu/controller/MenuImageController.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/controller/MenuImageController.java @@ -1,4 +1,4 @@ -package com.example.apiadmin.menu.controller; +package com.nowait.applicationadmin.menu.controller; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -10,20 +10,31 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; -import com.example.apiadmin.menu.dto.MenuImageUploadResponse; -import com.example.apiadmin.menu.service.MenuImageService; -import com.nowaiting.common.api.ApiUtils; +import com.nowait.applicationadmin.menu.dto.MenuImageUploadResponse; +import com.nowait.applicationadmin.menu.service.MenuImageService; +import com.nowait.common.api.ApiUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Tag(name = "Menu Image API", description = "메뉴 이미지 API") @RestController @RequestMapping("/admin/menus") @RequiredArgsConstructor +@Slf4j public class MenuImageController { private final MenuImageService menuImageService; @PostMapping("/images/{menuId}") + @Operation( + summary = "메뉴 이미지 업로드", + description = "특정 메뉴에 대한 이미지를 업로드합니다. 파일 크기는 최대 10MB로 제한됩니다." + ) + @ApiResponse(responseCode = "201", description = "메뉴 이미지 업로드 성공") public ResponseEntity uploadMenuImage( @PathVariable Long menuId, @RequestParam("file") MultipartFile file @@ -36,15 +47,29 @@ public ResponseEntity uploadMenuImage( } MenuImageUploadResponse response = menuImageService.save(menuId, file); - return ResponseEntity.status(HttpStatus.CREATED).body(ApiUtils.success(response)); + return ResponseEntity + .status( + HttpStatus.CREATED + ) + .body( + ApiUtils + .success( + response + ) + ); } @DeleteMapping("/images/{menuImageId}") - public ResponseEntity deleteMenuImage(@PathVariable Long id) { - menuImageService.delete(id); + @Operation( + summary = "메뉴 이미지 삭제", + description = "특정 메뉴 이미지 ID에 해당하는 이미지를 삭제합니다." + ) + @ApiResponse(responseCode = "200", description = "메뉴 이미지 삭제 성공") + public ResponseEntity deleteMenuImage(@PathVariable Long menuImageId) { + menuImageService.delete(menuImageId); return ResponseEntity .status( - HttpStatus.NO_CONTENT + HttpStatus.OK ) .body( ApiUtils diff --git a/api-admin/src/main/java/com/example/apiadmin/menu/dto/MenuCreateRequest.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/dto/MenuCreateRequest.java similarity index 66% rename from api-admin/src/main/java/com/example/apiadmin/menu/dto/MenuCreateRequest.java rename to nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/dto/MenuCreateRequest.java index 518dd06b..89813c71 100644 --- a/api-admin/src/main/java/com/example/apiadmin/menu/dto/MenuCreateRequest.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/dto/MenuCreateRequest.java @@ -1,7 +1,7 @@ -package com.example.apiadmin.menu.dto; +package com.nowait.applicationadmin.menu.dto; -import com.example.menu.entity.Menu; +import com.nowait.domaincorerdb.menu.entity.Menu; import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; @@ -15,6 +15,7 @@ public class MenuCreateRequest { @NotNull private Long storeId; + private String adminDisplayName; @NotNull private String name; @NotNull @@ -25,9 +26,13 @@ public class MenuCreateRequest { public Menu toEntity() { return Menu.builder() .storeId(storeId) + .adminDisplayName(adminDisplayName != null ? adminDisplayName : name) .name(name) .description(description) .price(price) + .isSoldOut(false) + .sortOrder(0L) + .deleted(false) .build(); } } diff --git a/api-admin/src/main/java/com/example/apiadmin/menu/dto/MenuCreateResponse.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/dto/MenuCreateResponse.java similarity index 60% rename from api-admin/src/main/java/com/example/apiadmin/menu/dto/MenuCreateResponse.java rename to nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/dto/MenuCreateResponse.java index fccba560..41d2affe 100644 --- a/api-admin/src/main/java/com/example/apiadmin/menu/dto/MenuCreateResponse.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/dto/MenuCreateResponse.java @@ -1,8 +1,8 @@ -package com.example.apiadmin.menu.dto; +package com.nowait.applicationadmin.menu.dto; import java.time.LocalDateTime; -import com.example.menu.entity.Menu; +import com.nowait.domaincorerdb.menu.entity.Menu; import lombok.AllArgsConstructor; import lombok.Builder; @@ -14,19 +14,28 @@ public class MenuCreateResponse { private Long menuId; private Long storeId; + private String adminDisplayName; private String name; private String description; private Integer price; + private Boolean isSoldOut; + private Long sortOrder; + private Boolean deleted; private LocalDateTime createdAt; public static MenuCreateResponse fromEntity(Menu menu) { return MenuCreateResponse.builder() .createdAt(menu.getCreatedAt()) + .adminDisplayName(menu.getAdminDisplayName()) .menuId(menu.getId()) .storeId(menu.getStoreId()) + .adminDisplayName(menu.getAdminDisplayName()) .name(menu.getName()) .description(menu.getDescription()) .price(menu.getPrice()) + .isSoldOut(menu.getIsSoldOut()) + .sortOrder(menu.getSortOrder()) + .deleted(menu.getDeleted()) .build(); } } diff --git a/api-user/src/main/java/com/example/apiuser/menu/dto/MenuImageUploadResponse.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/dto/MenuImageUploadResponse.java similarity index 78% rename from api-user/src/main/java/com/example/apiuser/menu/dto/MenuImageUploadResponse.java rename to nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/dto/MenuImageUploadResponse.java index c32be356..edb4b0ca 100644 --- a/api-user/src/main/java/com/example/apiuser/menu/dto/MenuImageUploadResponse.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/dto/MenuImageUploadResponse.java @@ -1,6 +1,6 @@ -package com.example.apiuser.menu.dto; +package com.nowait.applicationadmin.menu.dto; -import com.example.menu.entity.MenuImage; +import com.nowait.domaincorerdb.menu.entity.MenuImage; import lombok.Builder; import lombok.Getter; diff --git a/api-admin/src/main/java/com/example/apiadmin/menu/dto/MenuReadDto.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/dto/MenuReadDto.java similarity index 64% rename from api-admin/src/main/java/com/example/apiadmin/menu/dto/MenuReadDto.java rename to nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/dto/MenuReadDto.java index 17fd26ee..d4d3dc36 100644 --- a/api-admin/src/main/java/com/example/apiadmin/menu/dto/MenuReadDto.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/dto/MenuReadDto.java @@ -1,8 +1,8 @@ -package com.example.apiadmin.menu.dto; +package com.nowait.applicationadmin.menu.dto; import java.util.List; -import com.example.menu.entity.Menu; +import com.nowait.domaincorerdb.menu.entity.Menu; import lombok.AllArgsConstructor; import lombok.Builder; @@ -14,18 +14,26 @@ public class MenuReadDto { private Long menuId; private Long storeId; + private String adminDisplayName; private String name; private String description; private Integer price; + private Long sortOrder; + private Boolean isSoldOut; + private Boolean deleted; private List images; public static MenuReadDto fromEntity(Menu menu, List images) { return MenuReadDto.builder() .menuId(menu.getId()) .storeId(menu.getStoreId()) + .adminDisplayName(menu.getAdminDisplayName()) .name(menu.getName()) .description(menu.getDescription()) .price(menu.getPrice()) + .sortOrder(menu.getSortOrder()) + .isSoldOut(menu.getIsSoldOut()) + .deleted(menu.getDeleted()) .images(images) .build(); } diff --git a/api-admin/src/main/java/com/example/apiadmin/menu/dto/MenuReadResponse.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/dto/MenuReadResponse.java similarity index 88% rename from api-admin/src/main/java/com/example/apiadmin/menu/dto/MenuReadResponse.java rename to nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/dto/MenuReadResponse.java index 5df79e2f..61c206e1 100644 --- a/api-admin/src/main/java/com/example/apiadmin/menu/dto/MenuReadResponse.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/dto/MenuReadResponse.java @@ -1,4 +1,4 @@ -package com.example.apiadmin.menu.dto; +package com.nowait.applicationadmin.menu.dto; import java.util.List; diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/dto/MenuSortUpdateRequest.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/dto/MenuSortUpdateRequest.java new file mode 100644 index 00000000..c9509e1f --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/dto/MenuSortUpdateRequest.java @@ -0,0 +1,20 @@ +package com.nowait.applicationadmin.menu.dto; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class MenuSortUpdateRequest { + @NotNull + private Long menuId; + @NotNull + @PositiveOrZero + private Long sortOrder; +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/dto/MenuUpdateRequest.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/dto/MenuUpdateRequest.java new file mode 100644 index 00000000..cf404747 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/dto/MenuUpdateRequest.java @@ -0,0 +1,17 @@ +package com.nowait.applicationadmin.menu.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class MenuUpdateRequest { + private String adminDisplayName; + private String name; + private String description; + private Integer price; +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/service/MenuImageService.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/service/MenuImageService.java new file mode 100644 index 00000000..2b23b597 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/service/MenuImageService.java @@ -0,0 +1,75 @@ +package com.nowait.applicationadmin.menu.service; + +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import com.nowait.applicationadmin.menu.dto.MenuImageUploadResponse; +import com.nowait.domaincorerdb.menu.entity.Menu; +import com.nowait.domaincorerdb.menu.entity.MenuImage; +import com.nowait.domaincorerdb.menu.exception.MenuImageEmptyException; +import com.nowait.domaincorerdb.menu.exception.MenuImageNotFoundException; +import com.nowait.domaincorerdb.menu.exception.MenuNotFoundException; +import com.nowait.domaincorerdb.menu.repository.MenuImageRepository; +import com.nowait.domaincorerdb.menu.repository.MenuRepository; +import com.nowait.domaincoreredis.common.cache.CacheInvalidationPublisher; +import com.nowait.domaincoreredis.common.util.RedisKeyUtils; +import com.nowait.infraaws.aws.s3.S3Service; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class MenuImageService { + + private final MenuRepository menuRepository; + private final MenuImageRepository menuImageRepository; + private final S3Service s3Service; + private final CacheInvalidationPublisher cacheInvalidationPublisher; + + @Transactional + public MenuImageUploadResponse save(Long menuId, MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new MenuImageEmptyException(); + } + + String type = "menu"; + Menu menu = menuRepository.findById(menuId) + .orElseThrow(MenuNotFoundException::new); + + Optional existingMenuImage = menuImageRepository.findByMenuId(menuId); + + existingMenuImage.ifPresent(menuImage -> { + s3Service.delete(menuImage.getFileKey()); + menuImageRepository.delete(menuImage); + }); + + S3Service.S3UploadResult uploadResult = s3Service.upload(type, menuId, file).join(); + + // MenuImage 엔티티 생성 및 저장 + MenuImage menuImage = MenuImage.builder() + .menu(menu) + .imageUrl(uploadResult.resizedUrl()) + .fileKey(uploadResult.key()) + .build(); + + menuImageRepository.save(menuImage); + cacheInvalidationPublisher.invalidate(RedisKeyUtils.buildMenuCacheChannel(), String.valueOf(menu.getStoreId())); + + // 응답 생성 + return MenuImageUploadResponse.fromEntity(menuImage); + } + + @Transactional + public void delete(Long id) { + MenuImage menuImage = menuImageRepository.findById(id) + .orElseThrow(MenuImageNotFoundException::new); + + s3Service.delete(menuImage.getFileKey()); + menuImageRepository.delete(menuImage); + cacheInvalidationPublisher.invalidate(RedisKeyUtils.buildMenuCacheChannel(), + String.valueOf(menuImage.getMenu().getStoreId())); + } +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/service/MenuService.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/service/MenuService.java new file mode 100644 index 00000000..e986f9a8 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/service/MenuService.java @@ -0,0 +1,224 @@ +package com.nowait.applicationadmin.menu.service; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.nowait.applicationadmin.menu.dto.MenuCreateRequest; +import com.nowait.applicationadmin.menu.dto.MenuCreateResponse; +import com.nowait.applicationadmin.menu.dto.MenuImageUploadResponse; +import com.nowait.applicationadmin.menu.dto.MenuReadDto; +import com.nowait.applicationadmin.menu.dto.MenuReadResponse; +import com.nowait.applicationadmin.menu.dto.MenuSortUpdateRequest; +import com.nowait.applicationadmin.menu.dto.MenuUpdateRequest; +import com.nowait.common.enums.Role; +import com.nowait.domaincorerdb.menu.entity.Menu; +import com.nowait.domaincorerdb.menu.entity.MenuImage; +import com.nowait.domaincorerdb.menu.exception.MenuCreationUnauthorizedException; +import com.nowait.domaincorerdb.menu.exception.MenuCrossStoreConflictException; +import com.nowait.domaincorerdb.menu.exception.MenuDeleteUnauthorizedException; +import com.nowait.domaincorerdb.menu.exception.MenuDuplicateIdException; +import com.nowait.domaincorerdb.menu.exception.MenuInvalidSortOrderException; +import com.nowait.domaincorerdb.menu.exception.MenuNotFoundException; +import com.nowait.domaincorerdb.menu.exception.MenuParamEmptyException; +import com.nowait.domaincorerdb.menu.exception.MenuUpdateUnauthorizedException; +import com.nowait.domaincorerdb.menu.exception.MenuViewUnauthorizedException; +import com.nowait.domaincorerdb.menu.repository.MenuImageRepository; +import com.nowait.domaincorerdb.menu.repository.MenuRepository; +import com.nowait.domaincorerdb.order.exception.OrderUpdateUnauthorizedException; +import com.nowait.domaincorerdb.order.exception.OrderViewUnauthorizedException; +import com.nowait.domaincorerdb.user.entity.MemberDetails; +import com.nowait.domaincorerdb.user.entity.User; +import com.nowait.domaincorerdb.user.exception.UserNotFoundException; +import com.nowait.domaincorerdb.user.repository.UserRepository; +import com.nowait.domaincoreredis.common.cache.CacheInvalidationPublisher; +import com.nowait.domaincoreredis.common.util.RedisKeyUtils; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class MenuService { + + private final MenuRepository menuRepository; + private final MenuImageRepository menuImageRepository; + private final UserRepository userRepository; + private final CacheInvalidationPublisher cacheInvalidationPublisher; + + @Transactional + public MenuCreateResponse createMenu(MenuCreateRequest request, MemberDetails memberDetails) { + // 사용자 정보 가져오기 + User user = memberDetails.getUser(); + // 사용자 역할이 SUPER_ADMIN이거나, storeId가 일치하는지 확인 + validateViewAuthorization(user, request.getStoreId()); + + // 메뉴 생성 로직 + Menu toSave = request.toEntity(); + Menu saved = menuRepository.save(toSave); + invalidateMenuCache(saved.getStoreId()); + + return MenuCreateResponse.fromEntity(saved); + } + + @Transactional(readOnly = true) + public MenuReadResponse getAllMenusByStoreId(Long storeId, MemberDetails memberDetails) { + // 사용자 정보 가져오기 + User user = memberDetails.getUser(); + // 사용자 역할이 SUPER_ADMIN이거나, storeId가 일치하는지 확인 + validateViewAuthorization(user, storeId); + + List menuReadResponse = menuRepository.findAllWithImagesByStoreId(storeId).entrySet().stream() + .map(e -> MenuReadDto.fromEntity(e.getKey(), + e.getValue().stream().map(MenuImageUploadResponse::fromEntity).toList())) + .toList(); + + return MenuReadResponse.of(menuReadResponse); + } + + @Transactional(readOnly = true) + public MenuReadDto getMenuById(Long storeId, Long menuId, MemberDetails memberDetails) { + if (storeId == null || menuId == null) { + throw new MenuParamEmptyException(); + } + // 사용자 정보 가져오기 + User user = memberDetails.getUser(); + Menu menu = getMenu(menuId); + // 사용자 역할이 SUPER_ADMIN이거나, storeId가 일치하는지 확인 + validateViewAuthorization(user, menu.getStoreId()); + + List images = menuImageRepository.findByMenu(menu); + List imageDto = images.stream() + .map(MenuImageUploadResponse::fromEntity) + .toList(); + + return MenuReadDto.fromEntity(menu, imageDto); + + } + + @Transactional + public MenuReadDto updateMenu(Long menuId, MenuUpdateRequest request, MemberDetails memberDetails) { + User user = memberDetails.getUser(); + Menu menu = getMenu(menuId); + + validateUpdateAuthorization(user, menu.getStoreId()); + + menu.updateInfo( + request.getAdminDisplayName(), + request.getName(), + request.getDescription(), + request.getPrice() + ); + + Menu saved = menuRepository.save(menu); + invalidateMenuCache(saved.getStoreId()); + + List images = menuImageRepository.findByMenu(saved); + List imageDto = images.stream() + .map(MenuImageUploadResponse::fromEntity) + .toList(); + + return MenuReadDto.fromEntity(saved, imageDto); + } + + @Transactional + public String updateMenuSortOrder(List requests, MemberDetails memberDetails) { + User user = userRepository.findById(memberDetails.getId()).orElseThrow(UserNotFoundException::new); + + if (!Role.SUPER_ADMIN.equals(user.getRole())) { + throw new MenuUpdateUnauthorizedException(); + } + + if (requests == null || requests.isEmpty()) { + throw new MenuParamEmptyException(); + } + + if (requests.stream().map(MenuSortUpdateRequest::getMenuId).distinct().count() != requests.size()) { + throw new MenuDuplicateIdException(); + } + + if (requests.stream().anyMatch(r -> r.getSortOrder() == null || r.getSortOrder() < 0)) { + throw new MenuInvalidSortOrderException(); + } + + List ids = requests.stream().map(MenuSortUpdateRequest::getMenuId).toList(); + List menus = menuRepository.findAllById(ids); + if (menus.size() != ids.size()) { + throw new MenuNotFoundException(); + } + + Long storeId = menus.get(0).getStoreId(); + if (!menus.stream().allMatch(m -> storeId.equals(m.getStoreId()))) { + throw new MenuCrossStoreConflictException(); + } + + Map idToSort = requests.stream() + .collect(Collectors.toMap(MenuSortUpdateRequest::getMenuId, + MenuSortUpdateRequest::getSortOrder)); + + menus.forEach(m -> m.updateSortOrder(idToSort.get(m.getId()))); + + menuRepository.saveAll(menus); + invalidateMenuCache(storeId); + + return "메뉴 순서가 성공적으로 업데이트되었습니다."; + } + + @Transactional + public String deleteMenu(Long menuId, MemberDetails memberDetails) { + User user = memberDetails.getUser(); + Menu menu = getMenu(menuId); + + validateDeleteAuthorization(user, menu.getStoreId()); + menu.markAsDeleted(); + menuRepository.save(menu); + invalidateMenuCache(menu.getStoreId()); + + return "Menu with ID " + menuId + " 삭제되었습니다."; + } + + @Transactional + public Boolean toggleSoldOut(Long menuId) { + Menu menu = menuRepository.findById(menuId) + .orElseThrow(MenuNotFoundException::new); + + menu.toggleSoldOut(); + menuRepository.save(menu); + invalidateMenuCache(menu.getStoreId()); + return menu.getIsSoldOut(); + } + + + // user-api 의 DistributedCachedMenuDao 무효화 (커밋 후 발행) + private void invalidateMenuCache(Long storeId) { + cacheInvalidationPublisher.invalidate(RedisKeyUtils.buildMenuCacheChannel(), String.valueOf(storeId)); + } + + private void validateViewAuthorization(User user, Long storeId) { + if (!(Role.SUPER_ADMIN.equals(user.getRole()) + || (Role.MANAGER.equals(user.getRole()) && storeId.equals(user.getStoreId())))) { + throw new MenuViewUnauthorizedException(); + } + } + + private void validateUpdateAuthorization(User user, Long storeId) { + if (!(Role.SUPER_ADMIN.equals(user.getRole()) + || (Role.MANAGER.equals(user.getRole()) && storeId.equals(user.getStoreId())))) { + throw new MenuUpdateUnauthorizedException(); + } + } + + private void validateDeleteAuthorization(User user, Long storeId) { + if (!(Role.SUPER_ADMIN.equals(user.getRole()) + || (Role.MANAGER.equals(user.getRole()) && storeId.equals(user.getStoreId())))) { + throw new MenuDeleteUnauthorizedException(); + } + } + + private Menu getMenu(Long menuId) { + return menuRepository.findByIdAndDeletedFalse(menuId) + .orElseThrow(MenuNotFoundException::new); + } +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/controller/OrderController.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/controller/OrderController.java new file mode 100644 index 00000000..5a7ea897 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/controller/OrderController.java @@ -0,0 +1,84 @@ +package com.nowait.applicationadmin.order.controller; + +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.nowait.applicationadmin.cancelOrder.dto.CancelOrderRequest; +import com.nowait.applicationadmin.order.dto.OrderResponseDto; +import com.nowait.applicationadmin.order.dto.OrderStatusUpdateRequestDto; +import com.nowait.applicationadmin.order.dto.OrderStatusUpdateResponseDto; +import com.nowait.applicationadmin.order.service.OrderService; +import com.nowait.common.api.ApiUtils; +import com.nowait.domaincorerdb.user.entity.MemberDetails; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Order API", description = "주문 API") +@RestController +@RequestMapping("/admin/orders") +@RequiredArgsConstructor +public class OrderController { + + private final OrderService orderService; + + @GetMapping("/{storeId}") + @Operation(summary = "주점별 주문 리스트 조회", description = "특정 주점에 대한 주문 리스트 조회") + @ApiResponse(responseCode = "200", description = "주문 리스트 조회") + public ResponseEntity getOrderListByStoreId(@PathVariable Long storeId, + @AuthenticationPrincipal MemberDetails memberDetails) { + List response = orderService.findAllOrders(storeId, memberDetails); + return ResponseEntity + .status(HttpStatus.OK) + .body( + ApiUtils.success( + response + ) + ); + } + + @PatchMapping("/status/{orderId}") + @Operation(summary = "주문 상태 변경", description = "특정 주문의 상태를 변경.") + @ApiResponse(responseCode = "200", description = "주문 상태 변경 성공") + @ApiResponse(responseCode = "400", description = "주문을 찾을 수 없음") + public ResponseEntity updateOrderStatus( + @PathVariable Long orderId, + @RequestBody @Valid OrderStatusUpdateRequestDto requestDto, + @AuthenticationPrincipal MemberDetails memberDetails + ) { + OrderStatusUpdateResponseDto response = orderService.updateOrderStatus( + orderId, requestDto.getOrderStatus(), memberDetails); + return ResponseEntity + .status(HttpStatus.OK) + .body(ApiUtils.success(response)); + } + + @DeleteMapping("/{orderId}") + @Operation(summary = "주문 삭제", description = "특정 주문을 삭제") + @ApiResponse(responseCode = "200", description = "주문 삭제 성공") + public ResponseEntity deleteOrder( + @PathVariable Long orderId, + @RequestBody CancelOrderRequest cancelOrderRequest, + @AuthenticationPrincipal MemberDetails memberDetails + ) { + + return ResponseEntity + .status(HttpStatus.OK) + .body(ApiUtils.success( + orderService.cancelOrder(orderId, cancelOrderRequest, memberDetails) + )); + } +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/dto/MenuDetail.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/dto/MenuDetail.java new file mode 100644 index 00000000..cb99d483 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/dto/MenuDetail.java @@ -0,0 +1,11 @@ +package com.nowait.applicationadmin.order.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class MenuDetail { + private int quantity; + private int price; +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/dto/OrderResponseDto.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/dto/OrderResponseDto.java new file mode 100644 index 00000000..3bd2e1b9 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/dto/OrderResponseDto.java @@ -0,0 +1,60 @@ +package com.nowait.applicationadmin.order.dto; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +import com.nowait.domaincorerdb.order.entity.OrderItem; +import com.nowait.domaincorerdb.order.entity.OrderStatus; +import com.nowait.domaincorerdb.order.entity.UserOrder; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class OrderResponseDto { + private Long id; + private Long tableId; + private String depositorName; + private Integer totalPrice; + private OrderStatus status; + private HashMap menuDetails; + private LocalDateTime createdAt; + + public static OrderResponseDto fromEntity(UserOrder userOrder) { + HashMap menuDetails = new LinkedHashMap<>(); + + for (OrderItem item : userOrder.getOrderItems()) { + String adminDisplayName = item.getMenu().getAdminDisplayName(); + String displayName = (adminDisplayName == null || adminDisplayName.isBlank()) + ? item.getMenu().getName() // 관리자 표시명이 없거나 공백이면 일반 메뉴명 사용 + : adminDisplayName; + + int quantity = item.getQuantity(); + int price = item.getMenu().getPrice(); // 메뉴 단가 + + // 메뉴명이 중복되면 수량만 누적 + // merge 람다함수 활용(해당 key가 있으면 수량 누적, 없으면 새로 생성) + menuDetails.merge(displayName, + new MenuDetail(quantity, price), + (oldVal, newVal) -> new MenuDetail( + oldVal.getQuantity() + newVal.getQuantity(), + oldVal.getPrice() // 단가는 동일하다는 가정 + ) + ); + } + + return OrderResponseDto.builder() + .id(userOrder.getId()) + .tableId(userOrder.getTableId()) + .depositorName(userOrder.getDepositorName()) + .totalPrice(userOrder.getTotalPrice()) + .status(userOrder.getStatus()) + .menuDetails(menuDetails) + .createdAt(userOrder.getCreatedAt()) + .build(); + } +} + diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/dto/OrderStatusUpdateRequestDto.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/dto/OrderStatusUpdateRequestDto.java new file mode 100644 index 00000000..a28f0434 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/dto/OrderStatusUpdateRequestDto.java @@ -0,0 +1,16 @@ +package com.nowait.applicationadmin.order.dto; + +import com.nowait.domaincorerdb.order.entity.OrderStatus; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class OrderStatusUpdateRequestDto { + @Schema(description = "주문 상태", example = "WAITING_FOR_PAYMENT", allowableValues = {"WAITING_FOR_PAYMENT", "COOKING", "COOKED"}) + @NotNull(message = "주문상태는 필수입니다") + private final OrderStatus orderStatus; +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/dto/OrderStatusUpdateResponseDto.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/dto/OrderStatusUpdateResponseDto.java new file mode 100644 index 00000000..dfcec919 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/dto/OrderStatusUpdateResponseDto.java @@ -0,0 +1,17 @@ +package com.nowait.applicationadmin.order.dto; + +import com.nowait.domaincorerdb.order.entity.OrderStatus; +import com.nowait.domaincorerdb.order.entity.UserOrder; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class OrderStatusUpdateResponseDto { + private final OrderStatus orderStatus; + + public static OrderStatusUpdateResponseDto fromEntity(UserOrder userOrder) { + return new OrderStatusUpdateResponseDto(userOrder.getStatus()); + } +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/service/OrderService.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/service/OrderService.java new file mode 100644 index 00000000..d4b500f2 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/service/OrderService.java @@ -0,0 +1,151 @@ +package com.nowait.applicationadmin.order.service; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.nowait.applicationadmin.cancelOrder.dto.CancelOrderRequest; +import com.nowait.applicationadmin.order.dto.OrderResponseDto; +import com.nowait.applicationadmin.order.dto.OrderStatusUpdateResponseDto; +import com.nowait.common.enums.Role; +import com.nowait.domainadminrdb.statistic.dto.OrderSalesSumDetail; +import com.nowait.domainadminrdb.statistic.dto.TopSalesStoresDetail; +import com.nowait.domainadminrdb.statistic.repository.StatisticCustomRepository; +import com.nowait.domaincorerdb.order.entity.OrderStatus; +import com.nowait.domaincorerdb.order.entity.UserOrder; +import com.nowait.domaincorerdb.order.exception.OrderNotFoundException; +import com.nowait.domaincorerdb.order.exception.OrderUpdateUnauthorizedException; +import com.nowait.domaincorerdb.order.exception.OrderViewUnauthorizedException; +import com.nowait.domaincorerdb.order.repository.OrderRepository; +import com.nowait.domaincorerdb.store.exception.StoreNotFoundException; +import com.nowait.domaincorerdb.store.repository.StoreRepository; +import com.nowait.domaincorerdb.user.entity.MemberDetails; +import com.nowait.domaincorerdb.user.entity.User; +import com.nowait.domaincorerdb.user.exception.UserNotFoundException; +import com.nowait.domaincorerdb.user.repository.UserRepository; +import com.nowait.nowaitevent.order.event.CookingCompleteEvent; +import com.nowait.nowaitevent.order.event.OrderCancelledEvent; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class OrderService { + private final OrderRepository orderRepository; + private final StatisticCustomRepository statisticCustomRepository; + private final UserRepository userRepository; + private final StoreRepository storeRepository; + private final ApplicationEventPublisher publisher; + + @Transactional(readOnly = true) + public List findAllOrders(Long storeId, MemberDetails memberDetails) { + User user = memberDetails.getUser(); + storeRepository.findByStoreIdAndDeletedFalse(storeId).orElseThrow(StoreNotFoundException::new); + + validateViewAuthorization(user, storeId); + + LocalDate today = LocalDate.now(ZoneId.of("Asia/Seoul")); + LocalDateTime startDateTime = today.atStartOfDay(); + LocalDateTime endDateTime = today.plusDays(1).atStartOfDay(); + List order = orderRepository.findAllByStore_StoreIdAndCreatedAtBetween(storeId, startDateTime, endDateTime) + .stream() + .map(OrderResponseDto::fromEntity) + .collect(Collectors.toList()); + return order; + } + + @Transactional + public OrderStatusUpdateResponseDto updateOrderStatus(Long orderId, OrderStatus newStatus, + MemberDetails memberDetails) { + User user = memberDetails.getUser(); + UserOrder userOrder = orderRepository.findById(orderId).orElseThrow(OrderNotFoundException::new); + Long storeId = userOrder.getStore().getStoreId(); + + validateUpdateAuthorization(user, storeId); + + userOrder.updateStatus(newStatus); + + if (OrderStatus.COOKED.equals(newStatus)) { + List items = userOrder.getOrderItems().stream() + .map(item -> new CookingCompleteEvent.Item( + item.getMenu().getId(), + item.getQuantity() + )) + .toList(); + + publisher.publishEvent( + new CookingCompleteEvent( + userOrder.getStore().getStoreId(), + items + ) + ); + } + + return OrderStatusUpdateResponseDto.fromEntity(userOrder); + } + + @Transactional + public OrderStatusUpdateResponseDto cancelOrder(Long orderId, CancelOrderRequest cancelOrderRequest, MemberDetails memberDetails) { + User user = memberDetails.getUser(); + UserOrder userOrder = orderRepository.findById(orderId).orElseThrow(OrderNotFoundException::new); + + validateUpdateAuthorization(user, userOrder.getStore().getStoreId()); + + userOrder.cancelOrder(); + + publisher.publishEvent(new OrderCancelledEvent( + userOrder.getId(), + userOrder.getStore().getStoreId(), + userOrder.getSignature(), + cancelOrderRequest.reason(), + Instant.now() + )); + + + return OrderStatusUpdateResponseDto.fromEntity(userOrder); + } + + @Transactional(readOnly = true) + public OrderSalesSumDetail getSaleSumByStoreId(MemberDetails memberDetails, LocalDate date) { + User user = memberDetails.getUser(); + Long storeId = user.getStoreId(); + + validateViewAuthorization(user, storeId); + + return statisticCustomRepository.findSalesSumByStoreId(storeId, date); + } + + // 현재는 사용하지 않음. 향후 관리자 통계 페이지 확장 시 활용 가능 + @Transactional(readOnly = true) + public List getTop5StoresBySalesToday(MemberDetails memberDetails) { + User user = memberDetails.getUser(); + Long storeId = user.getStoreId(); + + validateUpdateAuthorization(user, storeId); + + return statisticCustomRepository.getTop4PlusMine(storeId); + } + + private void validateViewAuthorization(User user, Long storeId) { + if (!(Role.SUPER_ADMIN.equals(user.getRole()) + || (Role.MANAGER.equals(user.getRole()) && storeId.equals(user.getStoreId())))) { + throw new OrderViewUnauthorizedException(); + } + } + + private void validateUpdateAuthorization(User user, Long storeId) { + if (!(Role.SUPER_ADMIN.equals(user.getRole()) + || (Role.MANAGER.equals(user.getRole()) && storeId.equals(user.getStoreId())))) { + throw new OrderUpdateUnauthorizedException(); + } + } +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/reservation/controller/ReservationController.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/reservation/controller/ReservationController.java new file mode 100644 index 00000000..3d5eaf74 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/reservation/controller/ReservationController.java @@ -0,0 +1,117 @@ +package com.nowait.applicationadmin.reservation.controller; + +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.nowait.applicationadmin.reservation.dto.EntryStatusResponseDto; +import com.nowait.applicationadmin.reservation.dto.ReservationStatusRequest; +import com.nowait.applicationadmin.reservation.dto.WaitingUserResponse; +import com.nowait.applicationadmin.reservation.service.ReservationService; +import com.nowait.common.api.ApiUtils; +import com.nowait.domaincorerdb.user.entity.MemberDetails; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Reservation API", description = "예약 API") +@RestController +@RequestMapping("/reservations") +@RequiredArgsConstructor +public class ReservationController { + + private final ReservationService reservationService; + + @GetMapping("/admin/{storeId}/waiting/users") + @Operation(summary = "주점별 전체 대기/호출중 리스트 조회", description = "주점에 대한 대기 리스트 조회(WAITING,CALLING)") + @ApiResponse(responseCode = "200", description = "주점별 전체 대기 리스트 조회") + public ResponseEntity getWaitingUsersWithScore(@PathVariable Long storeId) { + List response = reservationService.getAllWaitingUserDetails(storeId); + return ResponseEntity + .ok() + .body( + ApiUtils.success( + response + ) + ); + } + + @GetMapping("/admin/{storeId}/completed") + @Operation(summary = "주점별 전체 취소/완료 리스트 조회", description = "주점에 대한 완료/취소 리스트 조회(CANCELED,CONFIRMED)") + @ApiResponse(responseCode = "200", description = "주점별 전체 완료/취소 리스트 조회") + public ResponseEntity getCompletedReservationList( + @PathVariable Long storeId, + @AuthenticationPrincipal MemberDetails memberDetails + ) { + List response = reservationService.getCompletedWaitingUserDetails(storeId, memberDetails); + return ResponseEntity + .ok() + .body( + ApiUtils.success( + response + ) + ); + } + + // @PatchMapping("/admin/{storeId}/call/{userId}") + // @Operation(summary = "예약팀 호출", description = "특정 예약에 대한 호출 진행(호출하는 순간 10분 타임어택)") + // @ApiResponse(responseCode = "200", description = "예약팀 상태 변경 : WAITING -> CALLING") + // public ResponseEntity callWaiting(@PathVariable Long storeId, + // @PathVariable String userId, + // @AuthenticationPrincipal MemberDetails memberDetails) { + // CallingWaitingResponseDto response = reservationService.callWaiting(storeId, userId, memberDetails); + // return ResponseEntity + // .status(HttpStatus.OK) + // .body( + // ApiUtils.success( + // response + // ) + // ); + // } + @PatchMapping("/admin/update/{storeId}/{userId}") + @Operation(summary = "예약팀 상태 업데이트 처리", description = "특정 예약에 대한 입장 완료 처리") + @ApiResponse(responseCode = "200", description = "예약팀 상태 변경 : CALLING -> CONFIRMED") + public ResponseEntity updateEntry( + @PathVariable Long storeId, + @PathVariable String userId, + @RequestBody ReservationStatusRequest request, + @AuthenticationPrincipal MemberDetails memberDetails + ) { + EntryStatusResponseDto response = reservationService.processEntryStatus(storeId, userId, memberDetails, request.getStatus()); + return ResponseEntity + .status(HttpStatus.OK) + .body( + ApiUtils.success( + response + )); + } + + @PatchMapping("/admin/{storeId}/{reservationNumber}") + @Operation(summary = "예약팀 상태 업데이트 처리", description = "특정 예약에 대한 입장 완료 처리") + @ApiResponse(responseCode = "200", description = "예약팀 상태 변경 : CALLING -> CONFIRMED") + public ResponseEntity updateEntryWithReservationNumber( + @PathVariable Long storeId, + @PathVariable String reservationNumber, + @RequestBody ReservationStatusRequest request, + @AuthenticationPrincipal MemberDetails memberDetails + ) { + EntryStatusResponseDto response = reservationService.processEntryStatusByReservationNumber(storeId, reservationNumber, memberDetails, request.getStatus()); + return ResponseEntity + .status(HttpStatus.OK) + .body( + ApiUtils.success( + response + )); + } + +} diff --git a/api-admin/src/main/java/com/example/apiadmin/reservation/dto/CallGetResponseDto.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/reservation/dto/CallGetResponseDto.java similarity index 52% rename from api-admin/src/main/java/com/example/apiadmin/reservation/dto/CallGetResponseDto.java rename to nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/reservation/dto/CallGetResponseDto.java index 927638ee..cecc91b7 100644 --- a/api-admin/src/main/java/com/example/apiadmin/reservation/dto/CallGetResponseDto.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/reservation/dto/CallGetResponseDto.java @@ -1,20 +1,33 @@ -package com.example.apiadmin.reservation.dto; +package com.nowait.applicationadmin.reservation.dto; import java.time.LocalDateTime; -import com.nowait.domainreservation.entity.Reservation; +import com.nowait.domaincorerdb.reservation.entity.Reservation; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Getter; @Getter @Builder +@Schema(description = "호출(대기) 사용자 응답 DTO") public class CallGetResponseDto { + @Schema(description = "예약 ID", example = "123") private Long id; + + @Schema(description = "가게 ID", example = "99") private Long storeId; + + @Schema(description = "사용자 닉네임", example = "혜민이") private String userName; + + @Schema(description = "예약 요청 시각", example = "2025-07-22T14:45:09.492253") private LocalDateTime requestedAt; + + @Schema(description = "예약 상태", example = "CALLING") private String status; + + @Schema(description = "파티 인원 수", example = "4") private Integer partySize; public static CallGetResponseDto fromEntity(Reservation reservation) { diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/reservation/dto/CallingWaitingResponseDto.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/reservation/dto/CallingWaitingResponseDto.java new file mode 100644 index 00000000..2a995b84 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/reservation/dto/CallingWaitingResponseDto.java @@ -0,0 +1,35 @@ +package com.nowait.applicationadmin.reservation.dto; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "관리자 호출(호출 상태 변경) 응답 DTO") +public class CallingWaitingResponseDto { + @Schema(description = "매장 ID", example = "7") + private Long storeId; + + @Schema(description = "유저 ID", example = "123") + private String userId; + + @Schema(description = "대기 상태", example = "CALLING") + private String status; + + @Schema(description = "호출 시각", example = "2025-07-21T17:01:00") + private LocalDateTime calledAt; + + @Schema(description = "대기 순번", example = "3") + private Integer rank; + + @Schema(description = "파티 인원", example = "4") + private Integer partySize; + + +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/reservation/dto/EntryStatusResponseDto.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/reservation/dto/EntryStatusResponseDto.java new file mode 100644 index 00000000..5608a458 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/reservation/dto/EntryStatusResponseDto.java @@ -0,0 +1,62 @@ +package com.nowait.applicationadmin.reservation.dto; + +import java.time.LocalDateTime; + +import com.nowait.common.enums.ReservationStatus; +import com.nowait.domaincorerdb.reservation.entity.Reservation; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +// DTO: 호출 시각(calledAt) 또는 완료 메시지(message)를 선택적으로 담습니다. +@Getter +@Builder +public class EntryStatusResponseDto { + @Schema(description = "예약 ID", example = "1201") + private String reservationId; // reservationId + + @Schema(description = "예약 번호", example = "23-240504-0001") + private String reservationNumber; + + @Schema(description = "유저 ID", example = "16") + private String userId; + + @Schema(description = "파티 인원", example = "3") + private Integer partySize; + + @Schema(description = "사용자 이름(닉네임)", example = "혜민이") + private String userName; + + @Schema(description = "대기 등록 시각", example = "2025-07-22T16:00:00") + private LocalDateTime createdAt; + + @Schema(description = "웨이팅 취소 시각", example = "2025-07-22T16:00:00") + private LocalDateTime updatedAt; // 취소 시각 + + @Schema(description = "대기 상태", example = "CALLING") + private String status; + + @Schema(description = "호출 메시지", example = "호출 메시지") + private String message; + + public static EntryStatusResponseDto fromEntity(Reservation r) { + return EntryStatusResponseDto.builder() + .reservationId(r.getId().toString()) + .reservationNumber(r.getReservationNumber()) + .userId(r.getUser().getId().toString()) + .partySize(r.getPartySize()) + .userName(r.getUser().getNickname()) + .createdAt(r.getRequestedAt()) + .status(r.getStatus().name()) + .updatedAt(r.getUpdatedAt()) + .message(switch (r.getStatus()) { + case CALLING -> r.getUser().getNickname() + "님을 호출하였습니다."; + case CONFIRMED -> r.getUser().getNickname() + "님의 입장이 완료되었습니다."; + case CANCELLED -> r.getUser().getNickname() + "님의 예약이 취소되었습니다."; + default -> ""; + }) + .build(); + } +} + diff --git a/api-admin/src/main/java/com/example/apiadmin/reservation/dto/ReservationGetResponseDto.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/reservation/dto/ReservationGetResponseDto.java similarity index 53% rename from api-admin/src/main/java/com/example/apiadmin/reservation/dto/ReservationGetResponseDto.java rename to nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/reservation/dto/ReservationGetResponseDto.java index 3f7aca3d..14933902 100644 --- a/api-admin/src/main/java/com/example/apiadmin/reservation/dto/ReservationGetResponseDto.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/reservation/dto/ReservationGetResponseDto.java @@ -1,20 +1,34 @@ -package com.example.apiadmin.reservation.dto; +package com.nowait.applicationadmin.reservation.dto; import java.time.LocalDateTime; -import com.nowait.domainreservation.entity.Reservation; +import com.nowait.domaincorerdb.reservation.entity.Reservation; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Getter; @Getter @Builder +@Schema(description = "예약 상세 조회 응답 DTO") public class ReservationGetResponseDto { + + @Schema(description = "예약 ID", example = "123") private Long id; + + @Schema(description = "매장 ID", example = "99") private Long storeId; + + @Schema(description = "사용자 이름(닉네임)", example = "혜민이") private String userName; + + @Schema(description = "예약 요청 시각", example = "2025-07-22T14:45:09.492253") private LocalDateTime requestedAt; + + @Schema(description = "예약 상태", example = "WAITING") private String status; + + @Schema(description = "파티 인원", example = "2") private Integer partySize; public static ReservationGetResponseDto fromEntity(Reservation reservation) { diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/reservation/dto/ReservationStatusRequest.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/reservation/dto/ReservationStatusRequest.java new file mode 100644 index 00000000..9508a8e8 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/reservation/dto/ReservationStatusRequest.java @@ -0,0 +1,14 @@ +package com.nowait.applicationadmin.reservation.dto; + +import com.nowait.common.enums.ReservationStatus; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class ReservationStatusRequest { + private ReservationStatus status; +} diff --git a/api-admin/src/main/java/com/example/apiadmin/reservation/dto/ReservationStatusSummaryDto.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/reservation/dto/ReservationStatusSummaryDto.java similarity index 87% rename from api-admin/src/main/java/com/example/apiadmin/reservation/dto/ReservationStatusSummaryDto.java rename to nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/reservation/dto/ReservationStatusSummaryDto.java index 3cd9d406..7c49552b 100644 --- a/api-admin/src/main/java/com/example/apiadmin/reservation/dto/ReservationStatusSummaryDto.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/reservation/dto/ReservationStatusSummaryDto.java @@ -1,4 +1,4 @@ -package com.example.apiadmin.reservation.dto; +package com.nowait.applicationadmin.reservation.dto; import java.util.List; diff --git a/api-admin/src/main/java/com/example/apiadmin/reservation/dto/ReservationStatusUpdateRequestDto.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/reservation/dto/ReservationStatusUpdateRequestDto.java similarity index 59% rename from api-admin/src/main/java/com/example/apiadmin/reservation/dto/ReservationStatusUpdateRequestDto.java rename to nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/reservation/dto/ReservationStatusUpdateRequestDto.java index 688e4c7f..161eed90 100644 --- a/api-admin/src/main/java/com/example/apiadmin/reservation/dto/ReservationStatusUpdateRequestDto.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/reservation/dto/ReservationStatusUpdateRequestDto.java @@ -1,6 +1,6 @@ -package com.example.apiadmin.reservation.dto; +package com.nowait.applicationadmin.reservation.dto; -import com.nowaiting.common.enums.ReservationStatus; +import com.nowait.common.enums.ReservationStatus; import lombok.Builder; import lombok.Getter; diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/reservation/dto/WaitingUserResponse.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/reservation/dto/WaitingUserResponse.java new file mode 100644 index 00000000..3f8838fe --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/reservation/dto/WaitingUserResponse.java @@ -0,0 +1,76 @@ +package com.nowait.applicationadmin.reservation.dto; + +import java.time.LocalDateTime; + +import com.nowait.domaincorerdb.reservation.entity.Reservation; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Builder +@Schema(description = "대기 사용자 응답 DTO") +public class WaitingUserResponse { + + @Schema(description = "예약 ID", example = "16-20240201-0002") + private String reservationNumber; + + @Schema(description = "휴대폰 번호", example = "010-1234-5678") + private String phoneNumber; + + @Schema(description = "유저 ID", example = "16") + private String userId; + + @Schema(description = "파티 인원", example = "3") + private Integer partySize; + + @Schema(description = "사용자 이름(닉네임)", example = "혜민이") + private String userName; + + @Schema(description = "대기 등록 시각", example = "2025-07-22T16:00:00") + private LocalDateTime createdAt; + + @Schema(description = "호출 시각", example = "2025-07-22T16:10:00") + private LocalDateTime calledAt; + + @Schema(description = "호출 시각", example = "2025-07-22T16:10:00") + private LocalDateTime updatedAt; + + @Schema(description = "대기 상태", example = "CALLING") + private String status; + + @Schema(description = "대기 순번/점수", example = "2.0") + private Double score; + + public static WaitingUserResponse fromEntity(Reservation reservation) { + return WaitingUserResponse.builder() + .reservationNumber(reservation.getReservationNumber()) + .userId(reservation.getUser().getId().toString()) + .partySize(reservation.getPartySize()) + .phoneNumber(reservation.getUser().getPhoneNumber()) + .userName(reservation.getUser().getNickname()) + .createdAt(reservation.getRequestedAt()) + .updatedAt(reservation.getUpdatedAt()) + .status(reservation.getStatus().name()) + .build(); + } + + public static WaitingUserResponse fromRedis(String reservationId, String userId, String phoneNumber, + Integer partySize, String userName, + LocalDateTime createdAt, LocalDateTime calledAt, String status, Double score) { + return WaitingUserResponse.builder() + .reservationNumber(reservationId) + .userId(userId) + .phoneNumber(phoneNumber) + .partySize(partySize) + .userName(userName) + .createdAt(createdAt) + .calledAt(calledAt) + .status(status) + .score(score) + .build(); + } +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/reservation/service/ReservationService.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/reservation/service/ReservationService.java new file mode 100644 index 00000000..a9a3e597 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/reservation/service/ReservationService.java @@ -0,0 +1,545 @@ +package com.nowait.applicationadmin.reservation.service; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.nowait.applicationadmin.reservation.dto.CallGetResponseDto; +import com.nowait.applicationadmin.reservation.dto.EntryStatusResponseDto; +import com.nowait.applicationadmin.reservation.dto.ReservationGetResponseDto; +import com.nowait.applicationadmin.reservation.dto.ReservationStatusSummaryDto; +import com.nowait.applicationadmin.reservation.dto.ReservationStatusUpdateRequestDto; +import com.nowait.applicationadmin.reservation.dto.WaitingUserResponse; +import com.nowait.common.enums.ReservationStatus; +import com.nowait.common.enums.Role; +import com.nowait.domaincorerdb.reservation.entity.Reservation; +import com.nowait.domaincorerdb.reservation.exception.InvalidReservationParameterException; +import com.nowait.domaincorerdb.reservation.exception.InvalidReservationStatusTransitionException; +import com.nowait.domaincorerdb.store.exception.StoreViewUnauthorizedException; +import com.nowait.domaincoreredis.reservation.exception.ReservationDataInconsistencyException; +import com.nowait.domaincorerdb.reservation.exception.ReservationNotFoundException; +import com.nowait.domaincorerdb.reservation.exception.ReservationUpdateUnauthorizedException; +import com.nowait.domaincorerdb.reservation.exception.ReservationViewUnauthorizedException; +import com.nowait.domaincorerdb.reservation.exception.UnsupportedReservationStatusException; +import com.nowait.domaincorerdb.reservation.repository.ReservationRepository; +import com.nowait.domaincorerdb.store.repository.StoreRepository; +import com.nowait.domaincorerdb.user.entity.MemberDetails; +import com.nowait.domaincorerdb.user.entity.User; +import com.nowait.domaincorerdb.user.exception.UserNotFoundException; +import com.nowait.domaincorerdb.user.repository.UserRepository; +import com.nowait.domaincoreredis.common.util.RedisKeyUtils; +import com.nowait.domaincoreredis.reservation.repository.WaitingPermitLuaRepository; +import com.nowait.domaincoreredis.reservation.repository.WaitingRedisRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ReservationService { + + private final ReservationRepository reservationRepository; + private final UserRepository userRepository; + private final WaitingRedisRepository waitingRedisRepository; + private final StoreRepository storeRepository; + private final WaitingPermitLuaRepository waitingPermitLuaRepository; + private final RedisTemplate redisTemplate; + + //TODO 성능 비교를 위해 남겨둔 로직 + @Transactional(readOnly = true) + public ReservationStatusSummaryDto getReservationListByStoreId(Long storeId, MemberDetails memberDetails) { + User user = memberDetails.getUser(); + validateViewAuthorization(user, storeId); + List reservations = reservationRepository.findAllByStore_StoreIdOrderByRequestedAtAsc(storeId); + + // 상태별 카운트 집계 + int waitingCount = 0; + int confirmedCount = 0; + int cancelledCount = 0; + int callingCount = 0; + List reservationDtoList = new ArrayList<>(); + for (Reservation r : reservations) { + if (r.getStatus() == ReservationStatus.WAITING) + waitingCount++; + if (r.getStatus() == ReservationStatus.CONFIRMED) + confirmedCount++; + if (r.getStatus() == ReservationStatus.CANCELLED) + cancelledCount++; + if (r.getStatus() == ReservationStatus.CALLING) + callingCount++; + reservationDtoList.add(ReservationGetResponseDto.fromEntity(r)); + } + + return ReservationStatusSummaryDto.builder() + .waitingCount(waitingCount) + .confirmedCount(confirmedCount) + .cancelledCount(cancelledCount) + .callingCount(callingCount) + .reservationList(reservationDtoList) + .build(); + } + + //TODO 성능 비교를 위해 남겨둔 로직 + @Transactional + public CallGetResponseDto updateReservationStatus(Long reservationId, ReservationStatusUpdateRequestDto requestDto, + MemberDetails memberDetails) { + User user = memberDetails.getUser(); + Reservation reservation = reservationRepository.findById(reservationId) + .orElseThrow(ReservationNotFoundException::new); + validateUpdateAuthorization(user, reservation.getStore().getStoreId()); + + reservation.markUpdated(LocalDateTime.now(), requestDto.getStatus()); + return CallGetResponseDto.fromEntity(reservation); + } + + // Redis queue에 있는 주점별 전체 대기열 조회 + //TODO 개선필요 -> createAt 정확성 및 reservationhId 생성 방법(예약생성부터 DB에 박아야하나....) + @Transactional(readOnly = true) + public List getAllWaitingUserDetails(Long storeId) { + + // 1) Redis로부터 전체 대기 userId + score(등록 시각) + List> waitingList = waitingRedisRepository.getAllWaitingWithScore(storeId); + System.out.println(waitingList); + if (waitingList.isEmpty()) { + return List.of(); + } + + // 2) userId 목록 분리 + List userIds = waitingList.stream() + .map(ZSetOperations.TypedTuple::getValue) + .toList(); + + // 3) User 닉네임을 한 번에 배치 조회 + List userIdLongs = userIds.stream() + .map(Long::valueOf) + .toList(); + + List userList = userRepository.findByIdIn(userIdLongs); + + Map nicknameMap = new HashMap<>(userList.size()); + Map phoneNumberMap = new HashMap<>(userList.size()); + + for (User user : userList) { + String key = user.getId().toString(); + nicknameMap.put(key, user.getNickname()); + phoneNumberMap.put(key, user.getPhoneNumber()); + } + + // 4) Redis 파이프라인: partySize, status, reservationId + String pk = RedisKeyUtils.buildWaitingPartySizeKeyPrefix() + storeId; + String sk = RedisKeyUtils.buildWaitingStatusKeyPrefix() + storeId; + String nk = RedisKeyUtils.buildReservationNumberKey(storeId); + String cak = RedisKeyUtils.buildWaitingCalledAtKeyPrefix() + storeId; + String qk = RedisKeyUtils.buildWaitingKeyPrefix() + storeId; + + List pipeline = redisTemplate.executePipelined((RedisCallback)conn -> { + byte[] uid; + for (String userId : userIds) { + uid = redisTemplate.getStringSerializer().serialize(userId); + conn.hGet(pk.getBytes(), uid); // partySize + conn.hGet(sk.getBytes(), uid); // status + conn.hGet(nk.getBytes(), uid); // reservationId + conn.hGet(cak.getBytes(), uid); // calledAt + conn.zScore(qk.getBytes(), uid); // score (등록 시각) + } + return null; + }); + + // 5) 결과 매핑 + List result = new ArrayList<>(userIds.size()); + Iterator it = pipeline.iterator(); + ZoneId zone = ZoneId.of("Asia/Seoul"); + + for (ZSetOperations.TypedTuple tuple : waitingList) { + String userId = tuple.getValue(); + Integer partySize = Optional.ofNullable((String)it.next()).map(Integer::valueOf).orElse(0); + String status = (String)it.next(); + String reservationId = (String)it.next(); + String calledAtStr = (String)it.next(); + Double score = (Double)it.next(); + + // score → createdAt + LocalDateTime createdAt = score != null + ? Instant.ofEpochMilli(score.longValue()).atZone(zone).toLocalDateTime() + : null; + + LocalDateTime calledAt = calledAtStr != null + ? Instant.ofEpochMilli(Long.parseLong(calledAtStr)).atZone(zone).toLocalDateTime() + : null; + + String userName = nicknameMap.getOrDefault(userId, "Unknown"); + String phoneNumber = phoneNumberMap.getOrDefault(userId, "Unknown"); + + result.add( + WaitingUserResponse.fromRedis(reservationId, userId, phoneNumber, partySize, userName, createdAt, + calledAt, status, + score)); + } + + return result; + } + + // 완료 or 취소 처리된 대기 리스트 조회 + @Transactional(readOnly = true) + public List getCompletedWaitingUserDetails(Long storeId, MemberDetails memberDetails) { + User user = memberDetails.getUser(); + validateViewAuthorization(user, storeId); + List reservations = findTodayWaiting(storeId); + + return reservations.stream() + .map(WaitingUserResponse::fromEntity) + .toList(); + } + + /** + * 상태를 하나의 메서드에서 처리합니다. + * - CALLING : Redis 상태를 CALLING으로 변경 → DB 저장 → calledAt 반환 + * - CONFIRMED : Redis에서 삭제 → DB 저장 → 완료 메시지 반환 + * - CANCELLED : Redis에서 삭제 → DB 저장 → 취소 메시지 반환 + */ + @Transactional + public EntryStatusResponseDto processEntryStatus(Long storeId, String userId, MemberDetails member, + ReservationStatus newStatus) { + + User user = member.getUser(); + validateUpdateAuthorization(user, storeId); + + if (userId == null || userId.isBlank()) { + throw new InvalidReservationParameterException("userId 값이 비어있습니다."); + } + + String queueKey = RedisKeyUtils.buildWaitingKeyPrefix() + storeId; + + // Redis에서 상태·score·partySize·calledAt 조회 + String reservationNumber = waitingRedisRepository.getReservationId(storeId, userId); + String currStatus = waitingRedisRepository.getWaitingStatus(storeId, userId); + Double score = redisTemplate.opsForZSet().score(queueKey, userId); + Integer partySize = waitingRedisRepository.getWaitingPartySize(storeId, userId); + + if (partySize == null || partySize <= 0) { + throw new InvalidReservationParameterException("partySize가 유효하지 않습니다. (storeId=" + storeId + ", userId=" + userId + ")"); + } + + if (reservationNumber == null || currStatus == null) { + throw new ReservationDataInconsistencyException( + String.format("storeId=%d, userId=%s, reservationNumber=%s, status=%s, partySize=%s", + storeId, userId, reservationNumber, currStatus, partySize) + ); + } + + LocalDateTime requestedAt = score != null + ? Instant.ofEpochMilli(score.longValue()).atZone(ZoneId.of("Asia/Seoul")).toLocalDateTime() + : LocalDateTime.now(); + LocalDateTime now = LocalDateTime.now(ZoneId.of("Asia/Seoul")); + + switch (newStatus) { + case CALLING: + if (!ReservationStatus.WAITING.name().equals(currStatus)) { + throw new InvalidReservationStatusTransitionException( + ReservationStatus.valueOf(currStatus), ReservationStatus.CALLING + ); + } + waitingRedisRepository.setWaitingStatus(storeId, userId, ReservationStatus.CALLING.name()); + waitingRedisRepository.setWaitingCalledAt(storeId, userId, + now.atZone(ZoneId.of("Asia/Seoul")).toInstant().toEpochMilli()); + + return EntryStatusResponseDto.builder() + .reservationNumber(reservationNumber) + .userId(userId) + .partySize(partySize) + .userName(userRepository.getReferenceById(Long.valueOf(userId)).getNickname()) + .createdAt(requestedAt) + .status("CALLING") + .updatedAt(now) + .message("호출되었습니다.") + .build(); + + case CONFIRMED: + // 1) 기존 대기 중이거나 호출 중일 때: Redis → DB 최초 저장 + if (ReservationStatus.WAITING.name().equals(currStatus) || ReservationStatus.CALLING.name() + .equals(currStatus)) { + + if (reservationNumber != null) { + waitingPermitLuaRepository.removeActiveMember( + userId, String.valueOf(storeId), reservationNumber + ); + } + + // 새 Reservation 생성 & 저장 + Reservation r = Reservation.builder() + .reservationNumber(reservationNumber) + .store(storeRepository.getReferenceById(storeId)) + .user(userRepository.getReferenceById(Long.valueOf(userId))) + .partySize(partySize) + .requestedAt(requestedAt) + .updatedAt(LocalDateTime.now(ZoneId.of("Asia/Seoul"))) + .status(ReservationStatus.valueOf(currStatus)) + .build(); + + // 호출 시각 반영 + r.markUpdated(LocalDateTime.now(), ReservationStatus.CONFIRMED); + Reservation saved = reservationRepository.save(r); + // Redis 전부 삭제 + waitingRedisRepository.deleteWaiting(storeId, userId); + return EntryStatusResponseDto.fromEntity(saved); + } else { + if (reservationNumber != null) { + try { + waitingPermitLuaRepository.removeActiveMember(userId, String.valueOf(storeId), + reservationNumber); + } catch (Exception ignore) { + } + } + + // 2) 이미 취소(CANCELLED)된 경우: DB 레코드 찾아 바로 CONFIRMED 로 전환 + // TODO 메서드로 분리 + LocalDateTime start = LocalDate.now().atStartOfDay(); + LocalDateTime end = LocalDate.now().atTime(LocalTime.MAX); + Reservation existing = reservationRepository + .findFirstByStore_StoreIdAndUserIdAndStatusInAndRequestedAtBetweenOrderByRequestedAtDesc( + storeId, + Long.valueOf(userId), + List.of(ReservationStatus.CANCELLED), + start, + end + ).orElseThrow(() -> new ReservationDataInconsistencyException( + String.format("취소된 예약이 DB에 존재하지 않습니다. (storeId=%d, userId=%s)", storeId, userId) + )); + + existing.markUpdated(LocalDateTime.now(ZoneId.of("Asia/Seoul")), ReservationStatus.CONFIRMED); + Reservation saved = reservationRepository.save(existing); + return EntryStatusResponseDto.fromEntity(saved); + } + + case CANCELLED: + if (!(ReservationStatus.WAITING.name().equals(currStatus) + || ReservationStatus.CALLING.name().equals(currStatus))) { + throw new InvalidReservationStatusTransitionException( + ReservationStatus.valueOf(currStatus), ReservationStatus.CANCELLED + ); + } + + if (reservationNumber != null) { + waitingPermitLuaRepository.removeActiveMember( + userId, String.valueOf(storeId), reservationNumber + ); + } + + Reservation r = Reservation.builder() + .reservationNumber(reservationNumber) + .store(storeRepository.getReferenceById(storeId)) + .user(userRepository.getReferenceById(Long.valueOf(userId))) + .partySize(partySize) + .requestedAt(requestedAt) + .updatedAt(LocalDateTime.now(ZoneId.of("Asia/Seoul"))) + .status(ReservationStatus.valueOf(currStatus)) + .build(); + + r.markUpdated(LocalDateTime.now(), ReservationStatus.CANCELLED); + Reservation saved = reservationRepository.save(r); + waitingRedisRepository.deleteWaiting(storeId, userId); + + return EntryStatusResponseDto.fromEntity(saved); + + default: + throw new UnsupportedReservationStatusException(newStatus); + } + } + + + @Transactional + public EntryStatusResponseDto processEntryStatusByReservationNumber(Long storeId, String reservationNumber, + MemberDetails member, ReservationStatus newStatus) { + + User user = member.getUser(); + validateUpdateAuthorization(user, storeId); + + if (reservationNumber == null || reservationNumber.isBlank()) { + throw new InvalidReservationParameterException("reservationNumber 값이 비어있습니다."); + } + + // Redis에서 userId 역추적 + String userId = waitingRedisRepository.getUserIdByReservationNumber(storeId, reservationNumber); + if (userId == null) { + throw new ReservationDataInconsistencyException( + String.format("storeId=%d, reservationNumber=%s 에 해당하는 userId를 찾을 수 없습니다.", storeId, reservationNumber) + ); + } + + String queueKey = RedisKeyUtils.buildWaitingKeyPrefix() + storeId; + + String currStatus = waitingRedisRepository.getWaitingStatus(storeId, userId); + Double score = redisTemplate.opsForZSet().score(queueKey, userId); + Integer partySize = waitingRedisRepository.getWaitingPartySize(storeId, userId); + + + if (partySize == null || partySize <= 0) { + throw new InvalidReservationParameterException("partySize가 유효하지 않습니다. (storeId=" + storeId + ", reservationNumber=" + reservationNumber + ")"); + } + + if (currStatus == null) { + throw new ReservationDataInconsistencyException( + String.format("storeId=%d, reservationNumber=%s 의 상태값을 찾을 수 없습니다.", storeId, reservationNumber) + ); + } + + LocalDateTime requestedAt = score != null + ? Instant.ofEpochMilli(score.longValue()).atZone(ZoneId.of("Asia/Seoul")).toLocalDateTime() + : LocalDateTime.now(); + LocalDateTime now = LocalDateTime.now(ZoneId.of("Asia/Seoul")); + + switch (newStatus) { + case CALLING: + if (!ReservationStatus.WAITING.name().equals(currStatus)) { + throw new InvalidReservationStatusTransitionException( + ReservationStatus.valueOf(currStatus), ReservationStatus.CALLING + ); + } + waitingRedisRepository.setWaitingStatus(storeId, userId, ReservationStatus.CALLING.name()); + waitingRedisRepository.setWaitingCalledAt(storeId, userId, + now.atZone(ZoneId.of("Asia/Seoul")).toInstant().toEpochMilli()); + + return EntryStatusResponseDto.builder() + .reservationNumber(reservationNumber) + .userId(userId) + .partySize(partySize) + .userName(userRepository.getReferenceById(Long.valueOf(userId)).getNickname()) + .createdAt(requestedAt) + .status("CALLING") + .updatedAt(now) + .message("호출되었습니다.") + .build(); + + case CONFIRMED: + // 1) 기존 대기 중이거나 호출 중일 때: Redis → DB 최초 저장 + if (ReservationStatus.WAITING.name().equals(currStatus) || ReservationStatus.CALLING.name() + .equals(currStatus)) { + + if (reservationNumber != null) { + waitingPermitLuaRepository.removeActiveMember( + userId, String.valueOf(storeId), reservationNumber + ); + } + + // 새 Reservation 생성 & 저장 + Reservation r = Reservation.builder() + .reservationNumber(reservationNumber) + .store(storeRepository.getReferenceById(storeId)) + .user(userRepository.getReferenceById(Long.valueOf(userId))) + .partySize(partySize) + .requestedAt(requestedAt) + .updatedAt(LocalDateTime.now(ZoneId.of("Asia/Seoul"))) + .status(ReservationStatus.valueOf(currStatus)) + .build(); + + // 호출 시각 반영 + r.markUpdated(LocalDateTime.now(), ReservationStatus.CONFIRMED); + Reservation saved = reservationRepository.save(r); + // Redis 전부 삭제 + waitingRedisRepository.deleteWaiting(storeId, userId); + return EntryStatusResponseDto.fromEntity(saved); + } else { + if (reservationNumber != null) { + try { + waitingPermitLuaRepository.removeActiveMember(userId, String.valueOf(storeId), + reservationNumber); + } catch (Exception ignore) { + } + } + + // 2) 이미 취소(CANCELLED)된 경우: DB 레코드 찾아 바로 CONFIRMED 로 전환 + LocalDateTime start = LocalDate.now().atStartOfDay(); + LocalDateTime end = LocalDate.now().atTime(LocalTime.MAX); + Reservation existing = reservationRepository + .findFirstByStore_StoreIdAndUserIdAndStatusInAndRequestedAtBetweenOrderByRequestedAtDesc( + storeId, + Long.valueOf(userId), + List.of(ReservationStatus.CANCELLED), + start, + end + ).orElseThrow(() -> new ReservationDataInconsistencyException( + String.format("취소된 예약이 DB에 존재하지 않습니다. (storeId=%d, userId=%s)", storeId, userId) + )); + + existing.markUpdated(LocalDateTime.now(ZoneId.of("Asia/Seoul")), ReservationStatus.CONFIRMED); + Reservation saved = reservationRepository.save(existing); + return EntryStatusResponseDto.fromEntity(saved); + } + + case CANCELLED: + if (!(ReservationStatus.WAITING.name().equals(currStatus) + || ReservationStatus.CALLING.name().equals(currStatus))) { + throw new InvalidReservationStatusTransitionException( + ReservationStatus.valueOf(currStatus), ReservationStatus.CANCELLED + ); + } + + waitingPermitLuaRepository.removeActiveMember(userId, String.valueOf(storeId), reservationNumber); + + Reservation r = Reservation.builder() + .reservationNumber(reservationNumber) + .store(storeRepository.getReferenceById(storeId)) + .user(userRepository.getReferenceById(Long.valueOf(userId))) + .partySize(partySize) + .requestedAt(requestedAt) + .updatedAt(now) + .status(ReservationStatus.valueOf(currStatus)) + .build(); + + r.markUpdated(now, ReservationStatus.CANCELLED); + Reservation saved = reservationRepository.save(r); + waitingRedisRepository.deleteWaiting(storeId, userId); + + return EntryStatusResponseDto.fromEntity(saved); + + default: + throw new UnsupportedReservationStatusException(newStatus); + } + } + + + /** + * 공통 메서드 + */ + // 오늘 날짜 예약 조회 + private List findTodayWaiting(Long storeId) { + ZoneId zone = ZoneId.of("Asia/Seoul"); + LocalDate today = LocalDate.now(zone); + + return reservationRepository.findAllByStore_StoreIdAndStatusInAndRequestedAtBetween( + storeId, + List.of(ReservationStatus.CONFIRMED, ReservationStatus.CANCELLED), + today.atStartOfDay(zone).toLocalDateTime(), + today.atTime(LocalTime.MAX) + ); + } + + // 사용자 인증 + private void validateViewAuthorization(User user, Long storeId) { + if (!(Role.SUPER_ADMIN.equals(user.getRole()) + || (Role.MANAGER.equals(user.getRole()) && storeId.equals(user.getStoreId())))) { + throw new ReservationViewUnauthorizedException(); + } + } + + private void validateUpdateAuthorization(User user, Long storeId) { + if (!(Role.SUPER_ADMIN.equals(user.getRole()) + || (Role.MANAGER.equals(user.getRole()) && storeId.equals(user.getStoreId())))) { + throw new ReservationUpdateUnauthorizedException(); + } + } +} + diff --git a/security-front/src/main/java/com/nowait/exception/RefreshTokenNotFoundException.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/security/exception/RefreshTokenNotFoundException.java similarity index 62% rename from security-front/src/main/java/com/nowait/exception/RefreshTokenNotFoundException.java rename to nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/security/exception/RefreshTokenNotFoundException.java index 5365ef52..8a2dbe2d 100644 --- a/security-front/src/main/java/com/nowait/exception/RefreshTokenNotFoundException.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/security/exception/RefreshTokenNotFoundException.java @@ -1,4 +1,6 @@ -package com.nowait.exception; +package com.nowait.applicationadmin.security.exception; + +import com.nowait.common.exception.ErrorMessage; public class RefreshTokenNotFoundException extends ResourceNotFoundException { diff --git a/security-front/src/main/java/com/nowait/exception/ResourceNotFoundException.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/security/exception/ResourceNotFoundException.java similarity index 75% rename from security-front/src/main/java/com/nowait/exception/ResourceNotFoundException.java rename to nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/security/exception/ResourceNotFoundException.java index ae4e4b7b..251c6fd4 100644 --- a/security-front/src/main/java/com/nowait/exception/ResourceNotFoundException.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/security/exception/ResourceNotFoundException.java @@ -1,4 +1,6 @@ -package com.nowait.exception; +package com.nowait.applicationadmin.security.exception; + +import com.nowait.common.exception.ErrorMessage; public abstract class ResourceNotFoundException extends RuntimeException { private final ErrorMessage errorMessage; diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/security/exception/TokenBadRequestException.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/security/exception/TokenBadRequestException.java new file mode 100644 index 00000000..3048219c --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/security/exception/TokenBadRequestException.java @@ -0,0 +1,11 @@ +package com.nowait.applicationadmin.security.exception; + +import com.nowait.common.exception.ErrorMessage; +import com.nowait.domaincorerdb.token.exception.BusinessException; + +public class TokenBadRequestException extends BusinessException { + public TokenBadRequestException() { + super(ErrorMessage.DOES_NOT_MATCH_REFRESH_TOKEN); + } + +} diff --git a/security-front/src/main/java/com/nowait/exception/UnauthorizedException.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/security/exception/UnauthorizedException.java similarity index 80% rename from security-front/src/main/java/com/nowait/exception/UnauthorizedException.java rename to nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/security/exception/UnauthorizedException.java index 65a8fd0a..53a1d78a 100644 --- a/security-front/src/main/java/com/nowait/exception/UnauthorizedException.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/security/exception/UnauthorizedException.java @@ -1,4 +1,6 @@ -package com.nowait.exception; +package com.nowait.applicationadmin.security.exception; + +import com.nowait.common.exception.ErrorMessage; public class UnauthorizedException extends RuntimeException { private final ErrorMessage errorMessage; diff --git a/security-admin/src/main/java/com/nowait/adminsecurity/auth/jwt/JwtAuthorizationFilter.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/security/jwt/JwtAuthorizationFilter.java similarity index 88% rename from security-admin/src/main/java/com/nowait/adminsecurity/auth/jwt/JwtAuthorizationFilter.java rename to nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/security/jwt/JwtAuthorizationFilter.java index 7aed61b9..beb76161 100644 --- a/security-admin/src/main/java/com/nowait/adminsecurity/auth/jwt/JwtAuthorizationFilter.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/security/jwt/JwtAuthorizationFilter.java @@ -1,4 +1,4 @@ -package com.nowait.adminsecurity.auth.jwt; +package com.nowait.applicationadmin.security.jwt; import java.io.IOException; @@ -6,7 +6,7 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.OncePerRequestFilter; -import com.nowait.adminsecurity.auth.service.CustomUserDetailService; +import com.nowait.applicationadmin.security.service.CustomUserDetailService; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -65,4 +65,10 @@ private String extractTokenFromRequest(HttpServletRequest request) { } return null; } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getRequestURI(); + return path.startsWith("/nowait/management/"); + } } diff --git a/security-front/src/main/java/com/nowait/auth/jwt/JwtUtil.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/security/jwt/JwtUtil.java similarity index 97% rename from security-front/src/main/java/com/nowait/auth/jwt/JwtUtil.java rename to nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/security/jwt/JwtUtil.java index fd0620e9..d4b17e3d 100644 --- a/security-front/src/main/java/com/nowait/auth/jwt/JwtUtil.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/security/jwt/JwtUtil.java @@ -1,4 +1,4 @@ -package com.nowait.auth.jwt; +package com.nowait.applicationadmin.security.jwt; import java.nio.charset.StandardCharsets; import java.util.Date; diff --git a/security-admin/src/main/java/com/nowait/adminsecurity/auth/service/CustomUserDetailService.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/security/service/CustomUserDetailService.java similarity index 78% rename from security-admin/src/main/java/com/nowait/adminsecurity/auth/service/CustomUserDetailService.java rename to nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/security/service/CustomUserDetailService.java index 130f7669..6af0ae5b 100644 --- a/security-admin/src/main/java/com/nowait/adminsecurity/auth/service/CustomUserDetailService.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/security/service/CustomUserDetailService.java @@ -1,13 +1,13 @@ -package com.nowait.adminsecurity.auth.service; +package com.nowait.applicationadmin.security.service; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Component; -import com.nowaiting.user.entity.MemberDetails; -import com.nowaiting.user.entity.User; -import com.nowaiting.user.repository.UserRepository; +import com.nowait.domaincorerdb.user.entity.MemberDetails; +import com.nowait.domaincorerdb.user.entity.User; +import com.nowait.domaincorerdb.user.repository.UserRepository; import lombok.RequiredArgsConstructor; diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/controller/StatisticsController.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/controller/StatisticsController.java new file mode 100644 index 00000000..e552f624 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/controller/StatisticsController.java @@ -0,0 +1,91 @@ +package com.nowait.applicationadmin.statistic.controller; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.nowait.applicationadmin.order.service.OrderService; +import com.nowait.applicationadmin.statistic.dto.PopularMenuDto; +import com.nowait.applicationadmin.statistic.dto.StoreRankingDto; +import com.nowait.applicationadmin.statistic.service.PopularMenuRedisService; +import com.nowait.applicationadmin.statistic.service.RankingService; +import com.nowait.common.api.ApiUtils; +import com.nowait.domainadminrdb.statistic.dto.OrderSalesSumDetail; +import com.nowait.domaincorerdb.user.entity.MemberDetails; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Statistics API", description = "통계 API") +@RestController +@RequestMapping("/admin/statistics") +@RequiredArgsConstructor +public class StatisticsController { + + private final OrderService orderService; + private final RankingService rankingService; + private final PopularMenuRedisService popularMenuRedisService; + + @GetMapping("/sales") + @Operation(summary = "지정일 매출 조회", description = "날짜(date) 파라미터로 매출을 조회합니다. 포맷: yyyy-MM-dd") + @ApiResponse(responseCode = "200", description = "오늘의 매출 조회 성공") + public ResponseEntity getTodaySales( + @AuthenticationPrincipal MemberDetails memberDetails, + @RequestParam(value = "date", required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) { + LocalDate targetDate = (date != null ? date : LocalDate.now()); + OrderSalesSumDetail sales = orderService.getSaleSumByStoreId(memberDetails, targetDate); + + if (sales.isAllZero()) { + return ResponseEntity.ok(ApiUtils.success("해당일 매출 데이터가 없습니다.")); + } + + return ResponseEntity + .status(HttpStatus.OK) + .body( + ApiUtils.success( + sales + ) + ); + } + + @GetMapping("/top-sales") + @Operation(summary = "주점별 매출 통계 조회", description = "주점별 매출 통계를 조회합니다.") + @ApiResponse(responseCode = "200", description = "주점별 매출 통계 조회 성공") + public ResponseEntity getTopSalesStores(@AuthenticationPrincipal MemberDetails memberDetails) { + List response = rankingService.getStatisticsRankings(memberDetails); + return ResponseEntity + .status(HttpStatus.OK) + .body( + ApiUtils.success( + response + ) + ); + } + + @GetMapping("/popular-menu") + @Operation(summary = "인기 메뉴 조회", description = "인기 메뉴를 조회합니다.") + @ApiResponse(responseCode = "200", description = "인기 메뉴 조회 성공") + public ResponseEntity getPopularMenu(@AuthenticationPrincipal MemberDetails memberDetails) { + + List popularMenu = popularMenuRedisService.getTodayTop5(memberDetails); + + return ResponseEntity + .status(HttpStatus.OK) + .body( + ApiUtils.success( + popularMenu + ) + ); + } +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/dto/PopularMenuDto.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/dto/PopularMenuDto.java new file mode 100644 index 00000000..ebf17cfc --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/dto/PopularMenuDto.java @@ -0,0 +1,13 @@ +package com.nowait.applicationadmin.statistic.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class PopularMenuDto { + private Long menuId; + private String menuName; + private Long soldCount; + private String imageUrl; +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/dto/StoreRankingDto.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/dto/StoreRankingDto.java new file mode 100644 index 00000000..36a014c1 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/dto/StoreRankingDto.java @@ -0,0 +1,29 @@ +package com.nowait.applicationadmin.statistic.dto; + +import lombok.Getter; + +@Getter +public class StoreRankingDto { + private final Long storeId; + private final String storeName; + private final Long departmentId; + private final String departmentName; + private final Integer totalSales; + private final Integer orderCount; + private final Long currentRank; + private final Integer delta; + private final String profileUrl; + + public StoreRankingDto(Long storeId, String storeName, Long departmentId, String departmentName, Integer totalSales, + Integer orderCount, Long currentRank, Integer delta, String profileUrl) { + this.storeId = storeId; + this.storeName = storeName; + this.departmentId = departmentId; + this.departmentName = departmentName; + this.totalSales = totalSales; + this.orderCount = orderCount; + this.currentRank = currentRank; + this.delta = delta; + this.profileUrl = profileUrl; + } +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/scheduler/RankingRefreshScheduler.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/scheduler/RankingRefreshScheduler.java new file mode 100644 index 00000000..f99c4c09 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/scheduler/RankingRefreshScheduler.java @@ -0,0 +1,100 @@ +package com.nowait.applicationadmin.statistic.scheduler; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import com.nowait.domainadminrdb.statistic.dto.StoreSales; +import com.nowait.domainadminrdb.statistic.repository.StatisticCustomRepository; +import com.nowait.domaincoreredis.common.util.RedisKeyUtils; +import com.nowait.domaincoreredis.rank.repository.RankingQueryRepository; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + + +@Component +@RequiredArgsConstructor +@Slf4j +public class RankingRefreshScheduler { + + private final StatisticCustomRepository statisticCustomRepository; + private final RankingQueryRepository rankingQueryRepository; + private final RedisTemplate redis; + + @PostConstruct + public void init() { + // 초기화 작업 + refresh(); + } + + @Scheduled(cron = "0 */5 * * * *") // 매 5분마다 실행 + public void refresh() { + log.info("RankingRefreshScheduler.refresh() called at {}", LocalDateTime.now()); + + try { + doRefresh(); + } catch (Exception e) { + log.error("랭킹 데이터 갱신 중 오류 발생", e); + // 예외 발생 시 알림 또는 로깅 처리 + } + } + + private void doRefresh() { + String nextKey = RedisKeyUtils.buildNextKey(); + String currentKey = RedisKeyUtils.buildCurrentKey(); + String previousKey = RedisKeyUtils.buildPreviousKey(); + + // 1) 다음 스냅샷 키 초기화 + redis.delete(nextKey); + + // 2) DB에서 매출 합계 가져와 ZADD + List salesList = statisticCustomRepository.findTotalSales(); + + if (salesList.isEmpty()) { + log.warn("매출 데이터가 없습니다. 다음 스냅샷 키를 초기화합니다."); + redis.delete(currentKey); + redis.delete(previousKey); + + return; + } + + salesList.forEach(s -> + rankingQueryRepository.addToRanking(nextKey, s.getStoreId(), s.getTotalSales()) + ); + + // 3) 현재 스냅샷 키를 이전 스냅샷 키로 이동 + rotateKeys(currentKey, previousKey, nextKey); + } + + private void rotateKeys(String currentKey, String previousKey, String nextKey) { + try { + redis.execute((RedisCallback)connection -> { + // 현재 스냅샷 키를 이전 스냅샷 키로 이동하고, 다음 스냅샷 키를 현재 스냅샷 키로 이동 + if (redis.hasKey(previousKey)) { + redis.delete(previousKey); + } + // 현재 스냅샷 키를 다음 스냅샷 키로 이동 + if (redis.hasKey(currentKey)) { + redis.rename(currentKey, previousKey); + } + // 다음 스냅샷 키가 존재하면 현재 스냅샷 키로 이동 + if (redis.hasKey(nextKey)) { + redis.rename(nextKey, currentKey); + } + return null; + }); + log.info("Keys rotated: current -> {}, previous -> {}, next -> {}", currentKey, previousKey, nextKey); + } catch (Exception e) { + log.error("Redis 키 교체 중 오류 발생", e); + throw new RuntimeException("랭킹 데이터 갱신 실패", e); + } + } +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/service/PopularMenuRedisService.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/service/PopularMenuRedisService.java new file mode 100644 index 00000000..95fd281c --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/service/PopularMenuRedisService.java @@ -0,0 +1,10 @@ +package com.nowait.applicationadmin.statistic.service; + +import java.util.List; + +import com.nowait.applicationadmin.statistic.dto.PopularMenuDto; +import com.nowait.domaincorerdb.user.entity.MemberDetails; + +public interface PopularMenuRedisService { + List getTodayTop5(MemberDetails memberDetails); +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/service/RankingService.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/service/RankingService.java new file mode 100644 index 00000000..b34f2f8b --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/service/RankingService.java @@ -0,0 +1,10 @@ +package com.nowait.applicationadmin.statistic.service; + +import java.util.List; + +import com.nowait.applicationadmin.statistic.dto.StoreRankingDto; +import com.nowait.domaincorerdb.user.entity.MemberDetails; + +public interface RankingService { + List getStatisticsRankings(MemberDetails memberDetails); +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/service/impl/PopularMenuRedisServiceImpl.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/service/impl/PopularMenuRedisServiceImpl.java new file mode 100644 index 00000000..e5f43a16 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/service/impl/PopularMenuRedisServiceImpl.java @@ -0,0 +1,78 @@ +package com.nowait.applicationadmin.statistic.service.impl; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.stereotype.Service; + +import com.nowait.applicationadmin.statistic.dto.PopularMenuDto; +import com.nowait.applicationadmin.statistic.service.PopularMenuRedisService; +import com.nowait.common.enums.Role; +import com.nowait.domainadminrdb.statistic.exception.StatisticViewUnauthorizedException; +import com.nowait.domaincorerdb.menu.entity.Menu; +import com.nowait.domaincorerdb.menu.entity.MenuImage; +import com.nowait.domaincorerdb.menu.repository.MenuImageRepository; +import com.nowait.domaincorerdb.menu.repository.MenuRepository; +import com.nowait.domaincorerdb.user.entity.MemberDetails; +import com.nowait.domaincorerdb.user.entity.User; +import com.nowait.domaincorerdb.user.exception.UserNotFoundException; +import com.nowait.domaincorerdb.user.repository.UserRepository; +import com.nowait.domaincoreredis.rank.service.MenuCounterService; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class PopularMenuRedisServiceImpl implements PopularMenuRedisService { + + private final MenuCounterService menuCounterService; + private final MenuRepository menuRepository; + private final UserRepository userRepository; + private final MenuImageRepository menuImageRepository; + + @Override + public List getTodayTop5(MemberDetails memberDetails) { + + User user = userRepository.findById(memberDetails.getId()).orElseThrow(UserNotFoundException::new); + Long storeId = user.getStoreId(); + + if (!Role.SUPER_ADMIN.equals(user.getRole())) { + if (storeId == null) throw new StatisticViewUnauthorizedException(); + } + + Set> tuples = menuCounterService.getTopMenus(storeId, 5); + + List menuIds = tuples.stream() + .map(tuple -> Long.parseLong(tuple.getValue())) + .toList(); + + Map menuIdToNameMap = menuRepository.findAllById(menuIds) + .stream() + .collect(Collectors.toMap( + Menu::getId, + Menu::getName + )); + + var firstImageByMenu = menuImageRepository.findByMenuIdInOrderByIdAsc(menuIds).stream() + .collect(Collectors.toMap( + menuImage -> menuImage.getMenu().getId(), + MenuImage::getImageUrl, + (existing, replacement) -> existing + )); + + + return tuples.stream() + .map(tuple -> { + Long menuId = Long.parseLong(tuple.getValue()); + String menuName = menuIdToNameMap.getOrDefault(menuId, "Unknown Menu"); + String imageUrl = firstImageByMenu.get(menuId); + long sold = tuple.getScore().longValue(); + + return new PopularMenuDto(menuId, menuName, sold, imageUrl); + }) + .toList(); + } +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/service/impl/RankingServiceImpl.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/service/impl/RankingServiceImpl.java new file mode 100644 index 00000000..82528132 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/statistic/service/impl/RankingServiceImpl.java @@ -0,0 +1,80 @@ +package com.nowait.applicationadmin.statistic.service.impl; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.nowait.applicationadmin.statistic.dto.StoreRankingDto; +import com.nowait.applicationadmin.statistic.service.RankingService; +import com.nowait.common.enums.Role; +import com.nowait.domainadminrdb.statistic.dto.StoreInfo; +import com.nowait.domainadminrdb.statistic.exception.StatisticViewUnauthorizedException; +import com.nowait.domainadminrdb.statistic.repository.StatisticCustomRepository; +import com.nowait.domaincorerdb.user.entity.MemberDetails; +import com.nowait.domaincorerdb.user.entity.User; +import com.nowait.domaincorerdb.user.exception.UserNotFoundException; +import com.nowait.domaincorerdb.user.repository.UserRepository; +import com.nowait.domaincoreredis.rank.dto.RankingEntry; +import com.nowait.domaincoreredis.rank.service.RankingQueryService; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class RankingServiceImpl implements RankingService { + + private final StatisticCustomRepository statisticCustomRepository; + private final UserRepository userRepository; + private final RankingQueryService rankingQuery; + + @Override + @Transactional(readOnly = true) + public List getStatisticsRankings(MemberDetails memberDetails) { + User user = userRepository.findById(memberDetails.getId()).orElseThrow(UserNotFoundException::new); + Long userStoreId = user.getStoreId(); + + if (!Role.SUPER_ADMIN.equals(user.getRole()) && !user.getStoreId().equals(userStoreId)) { + throw new StatisticViewUnauthorizedException(); + } + + // 1) Redis에서 Top4+내주점: storeId, totalSales, currentRank, delta + List entries = rankingQuery.getRankings(userStoreId, 5); + + // 2) 주문 건수 조회 + List storeIds = entries.stream() + .map(RankingEntry::getStoreId) + .toList(); + + Map orderCountMap = statisticCustomRepository.findOrderCountByStoreIds(storeIds); + + // 3) redis 에서 storeId 정보 가져오기 + List infos = statisticCustomRepository.findStoreInfoByIds(storeIds); + + // StoreInfo를 storeId로 매핑 + Map infoMap = infos.stream() + .collect(Collectors.toMap(StoreInfo::getStoreId, Function.identity())); + + // 4) 매핑 → 최종 DTO + return entries.stream() + .map(e -> { + StoreInfo info = infoMap.get(e.getStoreId()); + Integer orderCount = orderCountMap.getOrDefault(e.getStoreId(), 0); + return new StoreRankingDto( + e.getStoreId(), + info.getStoreName(), + info.getDepartmentId(), + info.getDepartmentName(), + e.getTotalSales(), + orderCount, + e.getCurrentRank(), + e.getDelta(), + info.getProfileUrl() + ); + }) + .collect(Collectors.toList()); + } +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/store/controller/StoreController.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/store/controller/StoreController.java new file mode 100644 index 00000000..d59795a9 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/store/controller/StoreController.java @@ -0,0 +1,109 @@ +package com.nowait.applicationadmin.store.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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.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.RestController; + +import com.nowait.applicationadmin.store.dto.StoreCreateRequest; +import com.nowait.applicationadmin.store.dto.StoreCreateResponse; +import com.nowait.applicationadmin.store.dto.StoreUpdateRequest; +import com.nowait.applicationadmin.store.service.StoreService; +import com.nowait.common.api.ApiUtils; +import com.nowait.domaincorerdb.user.entity.MemberDetails; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Tag(name = "Store API", description = "주점(Store) API") +@RestController +@RequestMapping("admin/stores") +@RequiredArgsConstructor +@Slf4j +public class StoreController { + + private final StoreService storeService; + + // 주점 생성 + @PostMapping("/create") + @Operation(summary = "주점 생성", description = "새로운 주점을 생성합니다.") + @ApiResponse(responseCode = "201", description = "주점 생성 성공") + public ResponseEntity createStore(@Valid @RequestBody StoreCreateRequest request) { + StoreCreateResponse response = storeService.createStore(request); + + return ResponseEntity + .status(HttpStatus.CREATED) + .body( + ApiUtils.success( + response + ) + ); + } + + @GetMapping("/{storeId}") + @Operation(summary = "주점 조회", description = "주점 ID로 주점을 조회합니다.") + @ApiResponse(responseCode = "200", description = "주점 조회 성공") + public ResponseEntity getStoreById(@PathVariable Long storeId, @AuthenticationPrincipal MemberDetails memberDetails) { + return ResponseEntity + .status(HttpStatus.OK) + .body( + ApiUtils.success( + storeService.getStoreByStoreId(storeId, memberDetails) + ) + ); + } + + @PatchMapping("/{storeId}") + @Operation(summary = "주점 정보 수정", description = "주점 ID로 주점 정보를 수정합니다.") + @ApiResponse(responseCode = "200", description = "주점 정보 수정 성공") + public ResponseEntity updateStore( + @PathVariable Long storeId, + @Valid @RequestBody StoreUpdateRequest request, + @AuthenticationPrincipal MemberDetails memberDetails + ) { + return ResponseEntity + .status(HttpStatus.OK) + .body( + ApiUtils.success( + storeService.updateStore(storeId, request, memberDetails) + ) + ); + } + + @DeleteMapping("/{storeId}") + @Operation(summary = "주점 삭제", description = "주점 ID로 주점을 삭제합니다.") + @ApiResponse(responseCode = "200", description = "주점 삭제 성공") + public ResponseEntity deleteStore(@PathVariable Long storeId, @AuthenticationPrincipal MemberDetails memberDetails) { + return ResponseEntity + .ok() + .body( + ApiUtils.success( + storeService.deleteStore(storeId,memberDetails) + ) + ); + } + + @PatchMapping("/toggle-active/{storeId}") + @Operation(summary = "주점 활성화/비활성화 토글", description = "주점 ID로 주점의 활성화 상태를 토글합니다.") + @ApiResponse(responseCode = "200", description = "주점 활성화/비활성화 토글 성공") + public ResponseEntity toggleActive(@PathVariable Long storeId) { + return ResponseEntity + .status(HttpStatus.OK) + .body( + ApiUtils.success( + storeService.toggleActive(storeId) + ) + ); + } +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/store/controller/StoreImageController.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/store/controller/StoreImageController.java new file mode 100644 index 00000000..d65bef08 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/store/controller/StoreImageController.java @@ -0,0 +1,113 @@ +package com.nowait.applicationadmin.store.controller; + +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +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 org.springframework.web.multipart.MultipartFile; + +import com.nowait.applicationadmin.store.dto.StoreImageUploadResponse; +import com.nowait.applicationadmin.store.service.StoreImageService; +import com.nowait.common.api.ApiUtils; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Store Image API", description = "주점 이미지 API") +@RestController +@RequestMapping("/admin/stores") +@RequiredArgsConstructor +public class StoreImageController { + + private final StoreImageService storeImageService; + + @PostMapping("/banner-images/{storeId}") + @Operation( + summary = "주점 이미지 업로드", + description = "주점에 이미지를 업로드합니다. 최대 10개의 이미지 파일을 업로드할 수 있습니다." + ) + @ApiResponse(responseCode = "201", description = "주점 이미지 업로드 성공") + public ResponseEntity uploadStoreImage( + @PathVariable Long storeId, + @RequestParam("files") List files + ) { + // TODO 관련 정책 확정되면 메서드로 분리 예정 + // 파일 크기 검증 + validateFiles(files); + + List response = storeImageService.saveAll(storeId, files); + return ResponseEntity + .status(HttpStatus.CREATED) + .body( + ApiUtils.success( + response + ) + ); + } + + @PostMapping("/profile-images/{storeId}") + @Operation( + summary = "주점 프로필 이미지 업로드", + description = "주점의 프로필 이미지를 업로드합니다. 단일 이미지 파일을 업로드할 수 있습니다." + ) + @ApiResponse(responseCode = "201", description = "주점 프로필 이미지 업로드 성공") + public ResponseEntity uploadStoreProfileImage( + @PathVariable Long storeId, + @RequestParam("file") MultipartFile file + ) { + validateFileSize(file); + + StoreImageUploadResponse response = storeImageService.saveProfileImage(storeId, file); + return ResponseEntity + .status(HttpStatus.CREATED) + .body( + ApiUtils.success( + response + ) + ); + } + + @DeleteMapping("/store-images/{storeImageId}") + @Operation( + summary = "주점 이미지 삭제", + description = "주점 이미지를 삭제합니다. 이미지 ID를 사용하여 특정 이미지를 삭제할 수 있습니다." + ) + @ApiResponse(responseCode = "200", description = "주점 이미지 삭제 성공") + public ResponseEntity deleteStoreImage(@PathVariable Long storeImageId) { + storeImageService.delete(storeImageId); + return ResponseEntity + .status(HttpStatus.OK) + .body( + ApiUtils + .success( + "Store image deleted successfully." + ) + ); + } + + private void validateFileSize(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new IllegalArgumentException("빈 파일은 업로드할 수 없습니다."); + } + if (file.getSize() > 10 * 1024 * 1024) { // 10MB 제한 + throw new IllegalArgumentException("파일 크기는 10MB를 초과할 수 없습니다."); + } + } + + private void validateFiles(List files) { + if (files.isEmpty() || files.size() > 10) { + throw new IllegalArgumentException("파일은 1개 이상 10개 이하로 업로드해 주세요."); + } + for (MultipartFile file : files) { + validateFileSize(file); + } + } +} diff --git a/api-admin/src/main/java/com/example/apiadmin/store/dto/StoreCreateRequest.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/store/dto/StoreCreateRequest.java similarity index 65% rename from api-admin/src/main/java/com/example/apiadmin/store/dto/StoreCreateRequest.java rename to nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/store/dto/StoreCreateRequest.java index 5fa790f0..5eff72bb 100644 --- a/api-admin/src/main/java/com/example/apiadmin/store/dto/StoreCreateRequest.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/store/dto/StoreCreateRequest.java @@ -1,6 +1,7 @@ -package com.example.apiadmin.store.dto; +package com.nowait.applicationadmin.store.dto; -import com.example.domainstore.entity.Store; +import com.nowait.common.token.TokenGenerator; +import com.nowait.domaincorerdb.store.entity.Store; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -25,10 +26,14 @@ public class StoreCreateRequest { public Store toEntity() { return Store.builder() + .publicCode(TokenGenerator.base62(12)) .departmentId(departmentId) .name(name) .location(location) .description(description) + .noticeTitle("제목을 입력해주세요.") + .noticeContent("내용을 입력해주세요.") + .openTime("00002359") .isActive(false) .deleted(false) .build(); diff --git a/api-admin/src/main/java/com/example/apiadmin/store/dto/StoreCreateResponse.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/store/dto/StoreCreateResponse.java similarity index 68% rename from api-admin/src/main/java/com/example/apiadmin/store/dto/StoreCreateResponse.java rename to nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/store/dto/StoreCreateResponse.java index f1c359ec..6dcaf79d 100644 --- a/api-admin/src/main/java/com/example/apiadmin/store/dto/StoreCreateResponse.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/store/dto/StoreCreateResponse.java @@ -1,8 +1,8 @@ -package com.example.apiadmin.store.dto; +package com.nowait.applicationadmin.store.dto; import java.time.LocalDateTime; -import com.example.domainstore.entity.Store; +import com.nowait.domaincorerdb.store.entity.Store; import lombok.AllArgsConstructor; import lombok.Builder; @@ -14,10 +14,14 @@ public class StoreCreateResponse { private Long storeId; + private String publicCode; private Long departmentId; private String name; private String location; private String description; + private String noticeTitle; + private String noticeContent; + private String openTime; private Boolean isActive; private Boolean deleted; private LocalDateTime createdAt; @@ -26,10 +30,14 @@ public static StoreCreateResponse fromEntity(Store store) { return StoreCreateResponse.builder() .createdAt(store.getCreatedAt()) .storeId(store.getStoreId()) + .publicCode(store.getPublicCode()) .departmentId(store.getDepartmentId()) .name(store.getName()) .location(store.getLocation()) .description(store.getDescription()) + .noticeTitle(store.getNoticeTitle()) + .noticeContent(store.getNoticeContent()) + .openTime(store.getOpenTime()) .isActive(store.getIsActive()) .deleted(store.getDeleted()) .build(); diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/store/dto/StoreDetailReadResponse.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/store/dto/StoreDetailReadResponse.java new file mode 100644 index 00000000..09f4d853 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/store/dto/StoreDetailReadResponse.java @@ -0,0 +1,60 @@ +package com.nowait.applicationadmin.store.dto; + +import java.time.LocalDateTime; +import java.util.List; + +import com.nowait.domaincorerdb.store.entity.ImageType; +import com.nowait.domaincorerdb.store.entity.Store; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Builder +public class StoreDetailReadResponse { + private Long storeId; + private Long departmentId; + private String departmentName; + private String name; + private String location; + private String description; + private String noticeTitle; + private String noticeContent; + private String openTime; + private StoreImageUploadResponse profileImage; + private List bannerImages; + private Boolean isActive; + private Boolean deleted; + private LocalDateTime createdAt; + + public static StoreDetailReadResponse fromEntity(Store store, List allImages, String departmentName) { + + StoreImageUploadResponse profile = allImages.stream() + .filter(image -> image.getImageType() == ImageType.PROFILE) + .findFirst() + .orElse(null); + + List banners = allImages.stream() + .filter(image -> image.getImageType() == ImageType.BANNER) + .toList(); + + return StoreDetailReadResponse.builder() + .storeId(store.getStoreId()) + .departmentId(store.getDepartmentId()) + .departmentName(departmentName) + .name(store.getName()) + .location(store.getLocation()) + .description(store.getDescription()) + .noticeTitle(store.getNoticeTitle()) + .noticeContent(store.getNoticeContent()) + .openTime(store.getOpenTime()) + .profileImage(profile) + .bannerImages(banners) + .isActive(store.getIsActive()) + .deleted(store.getDeleted()) + .createdAt(store.getCreatedAt()) + .build(); + } +} diff --git a/api-admin/src/main/java/com/example/apiadmin/store/dto/StoreImageUploadResponse.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/store/dto/StoreImageUploadResponse.java similarity index 60% rename from api-admin/src/main/java/com/example/apiadmin/store/dto/StoreImageUploadResponse.java rename to nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/store/dto/StoreImageUploadResponse.java index 998dd0b5..c5407e0f 100644 --- a/api-admin/src/main/java/com/example/apiadmin/store/dto/StoreImageUploadResponse.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/store/dto/StoreImageUploadResponse.java @@ -1,6 +1,7 @@ -package com.example.apiadmin.store.dto; +package com.nowait.applicationadmin.store.dto; -import com.example.domainstore.entity.StoreImage; +import com.nowait.domaincorerdb.store.entity.ImageType; +import com.nowait.domaincorerdb.store.entity.StoreImage; import lombok.Builder; import lombok.Getter; @@ -10,13 +11,13 @@ public class StoreImageUploadResponse { private final Long id; private final String imageUrl; - private final String type; + private final ImageType imageType; public static StoreImageUploadResponse fromEntity(StoreImage storeImage) { return StoreImageUploadResponse.builder() .id(storeImage.getId()) .imageUrl(storeImage.getImageUrl()) - .type(storeImage.getType()) + .imageType(storeImage.getImageType()) .build(); } } diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/store/dto/StoreReadDto.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/store/dto/StoreReadDto.java new file mode 100644 index 00000000..319a12a5 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/store/dto/StoreReadDto.java @@ -0,0 +1,58 @@ +package com.nowait.applicationadmin.store.dto; + +import java.time.LocalDateTime; +import java.util.List; + +import com.nowait.domaincorerdb.store.entity.ImageType; +import com.nowait.domaincorerdb.store.entity.Store; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Builder +public class StoreReadDto { + private Long storeId; + private Long departmentId; + private String name; + private String location; + private String description; + private String noticeTitle; + private String noticeContent; + private String openTime; + private StoreImageUploadResponse profileImage; + private List bannerImages; + private Boolean isActive; + private Boolean deleted; + private LocalDateTime createdAt; + + public static StoreReadDto fromEntity(Store store, List allImages) { + + StoreImageUploadResponse profile = allImages.stream() + .filter(image -> image.getImageType() == ImageType.PROFILE) + .findFirst() + .orElse(null); + + List banners = allImages.stream() + .filter(image -> image.getImageType() == ImageType.BANNER) + .toList(); + + return StoreReadDto.builder() + .createdAt(store.getCreatedAt()) + .storeId(store.getStoreId()) + .departmentId(store.getDepartmentId()) + .name(store.getName()) + .location(store.getLocation()) + .description(store.getDescription()) + .noticeTitle(store.getNoticeTitle()) + .noticeContent(store.getNoticeContent()) + .openTime(store.getOpenTime()) + .isActive(store.getIsActive()) + .deleted(store.getDeleted()) + .profileImage(profile) + .bannerImages(banners) + .build(); + } +} diff --git a/api-admin/src/main/java/com/example/apiadmin/store/dto/StoreUpdateRequest.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/store/dto/StoreUpdateRequest.java similarity index 68% rename from api-admin/src/main/java/com/example/apiadmin/store/dto/StoreUpdateRequest.java rename to nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/store/dto/StoreUpdateRequest.java index cd104115..82789857 100644 --- a/api-admin/src/main/java/com/example/apiadmin/store/dto/StoreUpdateRequest.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/store/dto/StoreUpdateRequest.java @@ -1,4 +1,4 @@ -package com.example.apiadmin.store.dto; +package com.nowait.applicationadmin.store.dto; import lombok.AllArgsConstructor; import lombok.Builder; @@ -13,5 +13,7 @@ public class StoreUpdateRequest { private String name; private String location; private String description; - private Boolean isActive; + private String noticeTitle; + private String noticeContent; + private String openTime; } diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/store/service/StoreImageService.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/store/service/StoreImageService.java new file mode 100644 index 00000000..1aa7b754 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/store/service/StoreImageService.java @@ -0,0 +1,114 @@ +package com.nowait.applicationadmin.store.service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import com.nowait.applicationadmin.store.dto.StoreImageUploadResponse; + +import com.nowait.domaincorerdb.store.entity.ImageType; +import com.nowait.domaincorerdb.store.entity.Store; +import com.nowait.domaincorerdb.store.entity.StoreImage; +import com.nowait.domaincorerdb.store.exception.StoreImageEmptyException; +import com.nowait.domaincorerdb.store.exception.StoreImageNotFoundException; +import com.nowait.domaincorerdb.store.exception.StoreNotFoundException; +import com.nowait.domaincorerdb.store.repository.StoreImageRepository; +import com.nowait.domaincorerdb.store.repository.StoreRepository; +import com.nowait.infraaws.aws.s3.S3Service; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class StoreImageService { + + private final StoreRepository storeRepository; + private final StoreImageRepository storeImageRepository; + private final S3Service s3Service; + + @Transactional + public List saveAll(Long storeId, List files) { + if (files == null || files.isEmpty()) + throw new StoreImageEmptyException(); + + String type = "banner"; + Store store = storeRepository.findById(storeId) + .orElseThrow(StoreNotFoundException::new); + + // 모든 파일을 비동기로 업로드 + List> uploadFutures = new ArrayList<>(); + for (MultipartFile file : files) { + uploadFutures.add(s3Service.upload(type, storeId, file)); + } + + // 모든 업로드 완료 대기 + List uploadResults; + try { + uploadResults = uploadFutures.stream() + .map(CompletableFuture::join) + .toList(); + } catch (Exception e) { + throw new RuntimeException("S3 업로드 실패", e); + } + + // DB 저장은 모든 S3 업로드 성공 후 수행 + List imageUploadResponses = new ArrayList<>(); + for (S3Service.S3UploadResult uploadResult : uploadResults) { + StoreImage storeImage = StoreImage.builder() + .store(store) + .imageUrl(uploadResult.resizedUrl()) + .fileKey(uploadResult.key()) + .imageType(ImageType.BANNER) + .build(); + + storeImageRepository.save(storeImage); + imageUploadResponses.add(StoreImageUploadResponse.fromEntity(storeImage)); + } + + return imageUploadResponses; + } + + @Transactional + public StoreImageUploadResponse saveProfileImage(Long storeId, MultipartFile file) { + + String type = "profile"; + Store store = storeRepository.findById(storeId) + .orElseThrow(StoreNotFoundException::new); + + Optional existingProfileImage = storeImageRepository.findByStoreStoreIdAndImageType(store.getStoreId(), + ImageType.PROFILE); + + existingProfileImage.ifPresent(profile -> { + s3Service.delete(profile.getFileKey()); + storeImageRepository.delete(profile); + }); + + S3Service.S3UploadResult uploadResult = s3Service.upload(type, storeId, file).join(); + + // StoreImage 엔티티 생성 및 저장 + StoreImage storeImage = StoreImage.builder() + .store(store) + .imageUrl(uploadResult.resizedUrl()) + .fileKey(uploadResult.key()) + .imageType(ImageType.PROFILE) + .build(); + + storeImageRepository.save(storeImage); + + return StoreImageUploadResponse.fromEntity(storeImage); + } + + @Transactional + public void delete(Long storeImageId) { + StoreImage storeImage = storeImageRepository.findById(storeImageId) + .orElseThrow(StoreImageNotFoundException::new); + + s3Service.delete(storeImage.getFileKey()); + storeImageRepository.delete(storeImage); + } +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/store/service/StoreService.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/store/service/StoreService.java new file mode 100644 index 00000000..2772d8b8 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/store/service/StoreService.java @@ -0,0 +1,21 @@ +package com.nowait.applicationadmin.store.service; + +import com.nowait.applicationadmin.store.dto.StoreCreateRequest; +import com.nowait.applicationadmin.store.dto.StoreCreateResponse; +import com.nowait.applicationadmin.store.dto.StoreDetailReadResponse; +import com.nowait.applicationadmin.store.dto.StoreReadDto; +import com.nowait.applicationadmin.store.dto.StoreUpdateRequest; +import com.nowait.domaincorerdb.user.entity.MemberDetails; + +public interface StoreService { + + StoreCreateResponse createStore(StoreCreateRequest request); + + StoreDetailReadResponse getStoreByStoreId(Long storeId, MemberDetails memberDetails); + + StoreReadDto updateStore(Long storeId, StoreUpdateRequest request, MemberDetails memberDetails); + + String deleteStore(Long storeId, MemberDetails memberDetails); + + Boolean toggleActive(Long storeId); +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/store/service/StoreServiceImpl.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/store/service/StoreServiceImpl.java new file mode 100644 index 00000000..acdf94d0 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/store/service/StoreServiceImpl.java @@ -0,0 +1,152 @@ +package com.nowait.applicationadmin.store.service; + +import java.util.List; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.nowait.applicationadmin.store.dto.StoreCreateRequest; +import com.nowait.applicationadmin.store.dto.StoreCreateResponse; +import com.nowait.applicationadmin.store.dto.StoreDetailReadResponse; +import com.nowait.applicationadmin.store.dto.StoreImageUploadResponse; +import com.nowait.applicationadmin.store.dto.StoreReadDto; +import com.nowait.applicationadmin.store.dto.StoreUpdateRequest; +import com.nowait.common.enums.Role; +import com.nowait.domaincorerdb.department.entity.Department; +import com.nowait.domaincorerdb.department.repository.DepartmentRepository; +import com.nowait.domaincorerdb.order.exception.OrderUpdateUnauthorizedException; +import com.nowait.domaincorerdb.order.exception.OrderViewUnauthorizedException; +import com.nowait.domaincorerdb.reservation.exception.ReservationUpdateUnauthorizedException; +import com.nowait.domaincorerdb.store.entity.Store; +import com.nowait.domaincorerdb.store.entity.StoreImage; +import com.nowait.domaincorerdb.store.exception.StoreDeleteUnauthorizedException; +import com.nowait.domaincorerdb.store.exception.StoreNotFoundException; +import com.nowait.domaincorerdb.store.exception.StoreParamEmptyException; +import com.nowait.domaincorerdb.store.exception.StoreUpdateUnauthorizedException; +import com.nowait.domaincorerdb.store.exception.StoreViewUnauthorizedException; +import com.nowait.domaincorerdb.store.repository.StoreImageRepository; +import com.nowait.domaincorerdb.store.repository.StoreRepository; +import com.nowait.domaincorerdb.user.entity.MemberDetails; +import com.nowait.domaincorerdb.user.entity.User; +import com.nowait.domaincorerdb.user.exception.UserNotFoundException; +import com.nowait.domaincorerdb.user.repository.UserRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class StoreServiceImpl implements StoreService { + + private final StoreRepository storeRepository; + private final StoreImageRepository storeImageRepository; + private final UserRepository userRepository; + private final DepartmentRepository departmentRepository; + + @Override + @Transactional + public StoreCreateResponse createStore(StoreCreateRequest request) { + if (request == null) throw new StoreParamEmptyException(); + + Store toSave = request.toEntity(); + + Store saved = storeRepository.save(toSave); + + return StoreCreateResponse.fromEntity(saved); + } + + @Override + @Transactional(readOnly = true) + public StoreDetailReadResponse getStoreByStoreId(Long storeId, MemberDetails memberDetails) { + if (storeId == null) throw new StoreParamEmptyException(); + User user = memberDetails.getUser(); + validateViewAuthorization(user, storeId); + + Store store = storeRepository.findByStoreIdAndDeletedFalse(storeId) + .orElseThrow(StoreNotFoundException::new); + + String departmentName = departmentRepository.findById(store.getDepartmentId()) + .map(Department::getName) + .orElse("Unknown Department"); + + List images = storeImageRepository.findByStore(store); + List imageDto = images.stream() + .map(StoreImageUploadResponse::fromEntity) + .toList(); + + return StoreDetailReadResponse.fromEntity(store, imageDto, departmentName); + } + + @Override + @Transactional + public StoreReadDto updateStore(Long storeId, StoreUpdateRequest request, MemberDetails memberDetails) { + if (storeId == null || request == null) throw new StoreParamEmptyException(); + + User user = memberDetails.getUser(); + validateUpdateAuthorization(user, storeId); + + Store store = storeRepository.findByStoreIdAndDeletedFalse(storeId) + .orElseThrow(StoreNotFoundException::new); + + store.updateInfo( + request.getName(), + request.getLocation(), + request.getDescription(), + request.getNoticeTitle(), + request.getNoticeContent(), + request.getOpenTime() + ); + + Store updatedStore = storeRepository.save(store); + + List images = storeImageRepository.findByStore(updatedStore); + List imageDto = images.stream() + .map(StoreImageUploadResponse::fromEntity) + .toList(); + + return StoreReadDto.fromEntity(updatedStore, imageDto); + } + + @Override + @Transactional + public String deleteStore(Long storeId, MemberDetails memberDetails) { + + if (storeId == null) { + throw new StoreParamEmptyException(); + } + + User user = memberDetails.getUser(); + validateUpdateAuthorization(user, storeId); + + Store store = storeRepository.findByStoreIdAndDeletedFalse(storeId) + .orElseThrow(StoreNotFoundException::new); + + store.markAsDeleted(); + storeRepository.save(store); + + return "Store ID " + storeId + " 삭제되었습니다."; + } + + @Transactional + public Boolean toggleActive(Long storeId) { + Store store = storeRepository.findById(storeId) + .orElseThrow(StoreNotFoundException::new); + + store.toggleActive(); + return store.getIsActive(); + } + + private void validateViewAuthorization(User user, Long storeId) { + if (!(Role.SUPER_ADMIN.equals(user.getRole()) + || (Role.MANAGER.equals(user.getRole()) && storeId.equals(user.getStoreId())))) { + throw new StoreViewUnauthorizedException(); + } + } + + private void validateUpdateAuthorization(User user, Long storeId) { + if (!(Role.SUPER_ADMIN.equals(user.getRole()) + || (Role.MANAGER.equals(user.getRole()) && storeId.equals(user.getStoreId())))) { + throw new StoreUpdateUnauthorizedException(); + } + } +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/storePayment/controller/StorePaymentController.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/storePayment/controller/StorePaymentController.java new file mode 100644 index 00000000..d8325258 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/storePayment/controller/StorePaymentController.java @@ -0,0 +1,91 @@ +package com.nowait.applicationadmin.storepayment.controller; + +import java.util.Optional; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +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.nowait.applicationadmin.storePayment.service.StorePaymentService; +import com.nowait.applicationadmin.storepayment.dto.StorePaymentCreateRequest; +import com.nowait.applicationadmin.storepayment.dto.StorePaymentCreateResponse; +import com.nowait.applicationadmin.storepayment.dto.StorePaymentReadDto; +import com.nowait.applicationadmin.storepayment.dto.StorePaymentUpdateRequest; +import com.nowait.common.api.ApiUtils; +import com.nowait.domaincorerdb.user.entity.MemberDetails; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Tag(name = "Store Payment API", description = "주점 결제 정보 API") +@RestController +@RequestMapping("admin/store-payments") +@RequiredArgsConstructor +@Slf4j +public class StorePaymentController { + + private final StorePaymentService storePaymentService; + + @PostMapping("/create") + @Operation(summary = "주점 결제 정보 연동 및 생성", description = "새로운 주점 결제 정보를 생성합니다.") + @ApiResponse(responseCode = "201", description = "주점 결제 정보 생성 성공") + public ResponseEntity createStorePayment(@Valid @RequestBody StorePaymentCreateRequest request, @AuthenticationPrincipal MemberDetails memberDetails) { + StorePaymentCreateResponse response = storePaymentService.createStorePayment(request, memberDetails); + + return ResponseEntity + .status(HttpStatus.CREATED) + .body( + ApiUtils.success( + response + ) + ); + } + + @GetMapping() + @Operation(summary = "주점 결제 정보 조회", description = "인증된 사용자의 주점 결제 정보를 조회합니다.") + @ApiResponse(responseCode = "200", description = "주점 결제 정보 조회 성공") + public ResponseEntity getStorePaymentByStoreId(@AuthenticationPrincipal MemberDetails memberDetails) { + Optional response = storePaymentService.getStorePaymentByStoreId(memberDetails); + + if (response.isPresent()) { + return ResponseEntity + .status(HttpStatus.OK) + .body( + ApiUtils.success( + response + ) + ); + } else { + return ResponseEntity + .status(HttpStatus.OK) + .body( + ApiUtils.success( + "해당 주점의 등록된 결제 정보가 존재하지 않습니다." + ) + ); + } + } + + @PatchMapping("/update") + @Operation(summary = "주점 결제 정보 수정", description = "주점 결제 정보를 수정합니다.") + @ApiResponse(responseCode = "200", description = "주점 결제 정보 수정 성공") + public ResponseEntity updateStorePayment(@RequestBody StorePaymentUpdateRequest request, @AuthenticationPrincipal MemberDetails memberDetails) { + return ResponseEntity + .status(HttpStatus.OK) + .body( + ApiUtils.success( + storePaymentService.updateStorePayment(request, memberDetails) + ) + ); + } +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/storePayment/dto/StorePaymentCreateRequest.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/storePayment/dto/StorePaymentCreateRequest.java new file mode 100644 index 00000000..3b6e7f4e --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/storePayment/dto/StorePaymentCreateRequest.java @@ -0,0 +1,29 @@ +package com.nowait.applicationadmin.storepayment.dto; + + +import com.nowait.domaincorerdb.storepayment.entity.StorePayment; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class StorePaymentCreateRequest { + + private String tossUrl; + private String kakaoPayUrl; + private String naverPayUrl; + private String accountNumber; + + public StorePayment toEntity(Long storeId) { + return StorePayment.builder() + .storeId(storeId) + .tossUrl(tossUrl) + .kakaoPayUrl(kakaoPayUrl) + .naverPayUrl(naverPayUrl) + .accountNumber(accountNumber) + .build(); + } +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/storePayment/dto/StorePaymentCreateResponse.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/storePayment/dto/StorePaymentCreateResponse.java new file mode 100644 index 00000000..2c2cc785 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/storePayment/dto/StorePaymentCreateResponse.java @@ -0,0 +1,35 @@ +package com.nowait.applicationadmin.storepayment.dto; + +import java.time.LocalDateTime; + +import com.nowait.domaincorerdb.storepayment.entity.StorePayment; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Builder +public class StorePaymentCreateResponse { + + private Long paymentMethodId; + private Long storeId; + private String tossUrl; + private String kakaoPayUrl; + private String naverPayUrl; + private String accountNumber; + private LocalDateTime createdAt; + + public static StorePaymentCreateResponse fromEntity(StorePayment storePayment) { + return StorePaymentCreateResponse.builder() + .paymentMethodId(storePayment.getPaymentMethodId()) + .storeId(storePayment.getStoreId()) + .tossUrl(storePayment.getTossUrl() != null ? storePayment.getTossUrl() : "") + .kakaoPayUrl(storePayment.getKakaoPayUrl() != null ? storePayment.getKakaoPayUrl() : "") + .naverPayUrl(storePayment.getNaverPayUrl() != null ? storePayment.getNaverPayUrl() : "") + .accountNumber(storePayment.getAccountNumber() != null ? storePayment.getAccountNumber() : "") + .createdAt(storePayment.getCreatedAt()) + .build(); + } +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/storePayment/dto/StorePaymentReadDto.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/storePayment/dto/StorePaymentReadDto.java new file mode 100644 index 00000000..bbd634e7 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/storePayment/dto/StorePaymentReadDto.java @@ -0,0 +1,34 @@ +package com.nowait.applicationadmin.storepayment.dto; + +import java.time.LocalDateTime; + +import com.nowait.domaincorerdb.storepayment.entity.StorePayment; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Builder +public class StorePaymentReadDto { + private Long paymentMethodId; + private Long storeId; + private String tossUrl; + private String kakaoPayUrl; + private String naverPayUrl; + private String accountNumber; + private LocalDateTime createdAt; + + public static StorePaymentReadDto fromEntity(StorePayment storePayment) { + return StorePaymentReadDto.builder() + .paymentMethodId(storePayment.getPaymentMethodId()) + .storeId(storePayment.getStoreId()) + .tossUrl(storePayment.getTossUrl()) + .kakaoPayUrl(storePayment.getKakaoPayUrl()) + .naverPayUrl(storePayment.getNaverPayUrl()) + .accountNumber(storePayment.getAccountNumber()) + .createdAt(storePayment.getCreatedAt()) + .build(); + } +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/storePayment/dto/StorePaymentUpdateRequest.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/storePayment/dto/StorePaymentUpdateRequest.java new file mode 100644 index 00000000..2c2d0220 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/storePayment/dto/StorePaymentUpdateRequest.java @@ -0,0 +1,17 @@ +package com.nowait.applicationadmin.storepayment.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class StorePaymentUpdateRequest { + private String tossUrl; + private String kakaoPayUrl; + private String naverPayUrl; + private String accountNumber; +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/storePayment/service/StorePaymentService.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/storePayment/service/StorePaymentService.java new file mode 100644 index 00000000..3429ba71 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/storePayment/service/StorePaymentService.java @@ -0,0 +1,16 @@ +package com.nowait.applicationadmin.storePayment.service; + +import java.util.Optional; + +import com.nowait.applicationadmin.storepayment.dto.StorePaymentCreateRequest; +import com.nowait.applicationadmin.storepayment.dto.StorePaymentCreateResponse; +import com.nowait.applicationadmin.storepayment.dto.StorePaymentReadDto; +import com.nowait.applicationadmin.storepayment.dto.StorePaymentUpdateRequest; +import com.nowait.domaincorerdb.user.entity.MemberDetails; + +public interface StorePaymentService { + + StorePaymentCreateResponse createStorePayment(StorePaymentCreateRequest request, MemberDetails memberDetails); + Optional getStorePaymentByStoreId(MemberDetails memberDetails); + StorePaymentReadDto updateStorePayment(StorePaymentUpdateRequest request, MemberDetails memberDetails); +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/storePayment/service/StorePaymentServiceImpl.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/storePayment/service/StorePaymentServiceImpl.java new file mode 100644 index 00000000..8e723f10 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/storePayment/service/StorePaymentServiceImpl.java @@ -0,0 +1,112 @@ +package com.nowait.applicationadmin.storepayment.service; + +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.nowait.applicationadmin.storePayment.service.StorePaymentService; +import com.nowait.applicationadmin.storepayment.dto.StorePaymentCreateRequest; +import com.nowait.applicationadmin.storepayment.dto.StorePaymentCreateResponse; +import com.nowait.applicationadmin.storepayment.dto.StorePaymentReadDto; +import com.nowait.applicationadmin.storepayment.dto.StorePaymentUpdateRequest; +import com.nowait.common.enums.Role; +import com.nowait.domaincorerdb.storepayment.entity.StorePayment; +import com.nowait.domaincorerdb.storepayment.exception.StorePaymentAlreadyExistsException; +import com.nowait.domaincorerdb.storepayment.exception.StorePaymentCreationUnauthorizedException; +import com.nowait.domaincorerdb.storepayment.exception.StorePaymentDeleteUnauthorizedException; +import com.nowait.domaincorerdb.storepayment.exception.StorePaymentNotFoundException; +import com.nowait.domaincorerdb.storepayment.exception.StorePaymentParamEmptyException; +import com.nowait.domaincorerdb.storepayment.exception.StorePaymentUpdateUnauthorizedException; +import com.nowait.domaincorerdb.storepayment.exception.StorePaymentViewUnauthorizedException; +import com.nowait.domaincorerdb.storepayment.repository.StorePaymentRepository; +import com.nowait.domaincorerdb.user.entity.MemberDetails; +import com.nowait.domaincorerdb.user.entity.User; +import com.nowait.domaincorerdb.user.repository.UserRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class StorePaymentServiceImpl implements StorePaymentService { + + private final StorePaymentRepository storePaymentRepository; + private final UserRepository userRepository; + + @Override + @Transactional + public StorePaymentCreateResponse createStorePayment(StorePaymentCreateRequest request, MemberDetails memberDetails) { + if (request == null) throw new StorePaymentParamEmptyException(); + + User user = memberDetails.getUser(); + Long storeId = user.getStoreId(); + if (storePaymentRepository.findByStoreId(storeId).isPresent()) { + throw new StorePaymentAlreadyExistsException(); + } + validateCreationAuthorization(user, storeId); + + StorePayment toSave = request.toEntity(storeId); + StorePayment saved = storePaymentRepository.save(toSave); + + return StorePaymentCreateResponse.fromEntity(saved); + } + + @Override + @Transactional(readOnly = true) + public Optional getStorePaymentByStoreId(MemberDetails memberDetails) { + if (memberDetails == null) throw new StorePaymentParamEmptyException(); + + User user = memberDetails.getUser(); + Long storeId = user.getStoreId(); + validateViewAuthorization(user, storeId); + + return storePaymentRepository.findByStoreId(storeId) + .map(StorePaymentReadDto::fromEntity); + } + + @Override + @Transactional + public StorePaymentReadDto updateStorePayment(StorePaymentUpdateRequest request, MemberDetails memberDetails) { + if (request == null) throw new StorePaymentParamEmptyException(); + + User user = memberDetails.getUser(); + Long storeId = user.getStoreId(); + validateUpdateAuthorization(user, storeId); + StorePayment storePayment = storePaymentRepository.findByStoreId(storeId) + .orElseThrow(StorePaymentNotFoundException::new); + + storePayment.updatePaymentMethodUrl( + request.getTossUrl(), + request.getKakaoPayUrl(), + request.getNaverPayUrl(), + request.getAccountNumber() + ); + storePaymentRepository.save(storePayment); + + return StorePaymentReadDto.fromEntity(storePayment); + } + + private void validateViewAuthorization(User user, Long storeId) { + if (!(Role.SUPER_ADMIN.equals(user.getRole()) || user.getStoreId().equals(storeId))) { + throw new StorePaymentViewUnauthorizedException(); + } + } + + private void validateCreationAuthorization(User user, Long storeId) { + if (!(Role.SUPER_ADMIN.equals(user.getRole()) || user.getStoreId().equals(storeId))) { + throw new StorePaymentCreationUnauthorizedException(); + } + } + + private void validateUpdateAuthorization(User user, Long storeId) { + if (!(Role.SUPER_ADMIN.equals(user.getRole()) || user.getStoreId().equals(storeId))) { + throw new StorePaymentUpdateUnauthorizedException(); + } + } + + private void validateDeleteAuthorization(User user, Long storeId) { + if (!(Role.SUPER_ADMIN.equals(user.getRole()) || user.getStoreId().equals(storeId))) { + throw new StorePaymentDeleteUnauthorizedException(); + } + } +} diff --git a/api-admin/src/main/java/com/example/apiadmin/token/controller/TokenController.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/token/controller/TokenController.java similarity index 58% rename from api-admin/src/main/java/com/example/apiadmin/token/controller/TokenController.java rename to nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/token/controller/TokenController.java index 0ad4e64f..3805508b 100644 --- a/api-admin/src/main/java/com/example/apiadmin/token/controller/TokenController.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/token/controller/TokenController.java @@ -1,21 +1,24 @@ -package com.example.apiadmin.token.controller; +package com.nowait.applicationadmin.token.controller; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CookieValue; 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.example.apiadmin.token.dto.AuthenticationResponse; -import com.example.apiadmin.token.dto.RefreshTokenRequest; -import com.example.apiadmin.token.service.TokenService; -import com.nowait.adminsecurity.auth.jwt.JwtUtil; +import com.nowait.applicationadmin.security.jwt.JwtUtil; +import com.nowait.applicationadmin.token.dto.AuthenticationResponse; +import com.nowait.applicationadmin.token.service.TokenService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +@Tag(name = "Token API", description = "토큰 API") @RestController @RequiredArgsConstructor @RequestMapping("/api/refresh-token") @@ -27,26 +30,29 @@ public class TokenController { private long accessTokenExpiration; @Value("${jwt.refresh-token-expiration-ms}") private long refreshTokenExpiration; + @PostMapping - public ResponseEntity refreshToken(@RequestBody RefreshTokenRequest request){ - String refreshToken = request.getRefreshToken(); + @Operation(summary = "리프레시 토큰", description = "리프레시 토큰을 사용하여 새로운 액세스 토큰과 리프레시 토큰을 발급합니다.") + @ApiResponse(responseCode = "200", description = "새로운 액세스 토큰과 리프레시 토큰 발급 성공") + public ResponseEntity refreshToken( + @CookieValue(value = "refreshToken", required = false) String refreshToken) { + + if (refreshToken == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Refresh token not found in cookies"); + } // 리프레시 토큰 검증 Long userId = jwtUtil.getUserId(refreshToken); String role = jwtUtil.getRole(refreshToken); - // 리프레시 토큰 유효성 검증 if (tokenService.validateToken(refreshToken, userId)){ - // 유효한 토큰이라면, 새로운 accessToken, refreshToken 생성 String newAccessToken = jwtUtil.createAccessToken("accessToken", userId, role, accessTokenExpiration); String newRefreshToken = jwtUtil.createRefreshToken("refreshToken", userId, refreshTokenExpiration); - // DB에 새로운 refreshToken으로 교체 tokenService.updateRefreshToken(userId, refreshToken, newRefreshToken); - AuthenticationResponse authenticationResponse = new AuthenticationResponse(newAccessToken, refreshToken); + AuthenticationResponse authenticationResponse = new AuthenticationResponse(newAccessToken, newRefreshToken); return ResponseEntity.ok().body(authenticationResponse); - } return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid or expired refresh token"); diff --git a/api-user/src/main/java/com/example/apiuser/token/dto/AuthenticationResponse.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/token/dto/AuthenticationResponse.java similarity index 90% rename from api-user/src/main/java/com/example/apiuser/token/dto/AuthenticationResponse.java rename to nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/token/dto/AuthenticationResponse.java index 71db8914..cb67851d 100644 --- a/api-user/src/main/java/com/example/apiuser/token/dto/AuthenticationResponse.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/token/dto/AuthenticationResponse.java @@ -1,4 +1,4 @@ -package com.example.apiuser.token.dto; +package com.nowait.applicationadmin.token.dto; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/api-user/src/main/java/com/example/apiuser/token/dto/RefreshTokenRequest.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/token/dto/RefreshTokenRequest.java similarity index 84% rename from api-user/src/main/java/com/example/apiuser/token/dto/RefreshTokenRequest.java rename to nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/token/dto/RefreshTokenRequest.java index 4ab8ae75..df7ed26f 100644 --- a/api-user/src/main/java/com/example/apiuser/token/dto/RefreshTokenRequest.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/token/dto/RefreshTokenRequest.java @@ -1,4 +1,4 @@ -package com.example.apiuser.token.dto; +package com.nowait.applicationadmin.token.dto; import jakarta.validation.constraints.NotBlank; import lombok.Getter; diff --git a/api-user/src/main/java/com/example/apiuser/token/service/TokenService.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/token/service/TokenService.java similarity index 63% rename from api-user/src/main/java/com/example/apiuser/token/service/TokenService.java rename to nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/token/service/TokenService.java index 56845238..44999a1c 100644 --- a/api-user/src/main/java/com/example/apiuser/token/service/TokenService.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/token/service/TokenService.java @@ -1,4 +1,4 @@ -package com.example.apiuser.token.service; +package com.nowait.applicationadmin.token.service; import java.time.LocalDateTime; import java.util.Optional; @@ -6,11 +6,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import com.example.domaintoken.entity.Token; -import com.example.domaintoken.repository.TokenRepository; -import com.nowait.auth.jwt.JwtUtil; -import com.nowait.exception.RefreshTokenNotFoundException; -import com.nowait.exception.TokenBadRequestException; +import com.nowait.applicationadmin.security.exception.RefreshTokenNotFoundException; +import com.nowait.applicationadmin.security.exception.TokenBadRequestException; +import com.nowait.applicationadmin.security.jwt.JwtUtil; +import com.nowait.domaincorerdb.token.entity.Token; +import com.nowait.domaincorerdb.token.repository.TokenRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -23,29 +23,29 @@ public class TokenService { private final JwtUtil jwtUtil; @Transactional - public Boolean validateToken(String token, Long userId){ + public boolean validateToken(String token, Long userId){ // DB에서 해당 userId와 일치하는 리프레시토큰을 찾는다. Optional savedToken = tokenRepository.findByUserId(userId); // DB에서 userId에 대응되는 리프레시토큰 없으면, 유효하지 않음 if (savedToken.isEmpty()){ - log.info("여기에 걸렸니 ? -- 1 "); + log.debug("DB에 현재 userId에 대응되는 리프레시 토큰이 없습니다"); return false; } // 리프레시 토큰이 DB에 저장된 토큰과 일치하는지 확인 if (!savedToken.get().getRefreshToken().equals(token)){ - log.info("여기에 걸렸니 ? -- 2 "); + log.debug("DB에 저장된 리프레시 토큰와 현재 전달받은 리프레시 토큰 일치하지 않습니다"); return false; } // 리프레시 토큰의 만료여부 확인 if(jwtUtil.isExpired(token)){ - log.info("여기에 걸렸니 ? -- 3 "); + log.debug("리프레시 토큰이 만료된 토큰입니다"); return false; // 만료된 토큰은 유효하지 않음 } - log.info("여기에 걸렸니 ? -- 4 "); + log.info("리프레시 토큰이 유효한 토큰입니다"); return true; // 모든 조건 만족 시, 유효한 토큰 } @@ -58,9 +58,6 @@ public void updateRefreshToken(Long userId, String oldRefreshToken, String newRe throw new TokenBadRequestException(); // 400 } - // 기존 토큰 삭제 및 새 토큰 저장 - tokenRepository.delete(token); - Token newToken = Token.toEntity(token.getUser(), newRefreshToken, LocalDateTime.now().plusDays(30)); - tokenRepository.save(newToken); + token.updateRefreshToken(newRefreshToken, LocalDateTime.now().plusDays(30)); } } diff --git a/api-admin/src/main/java/com/example/apiadmin/user/controller/UserController.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/user/controller/UserController.java similarity index 86% rename from api-admin/src/main/java/com/example/apiadmin/user/controller/UserController.java rename to nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/user/controller/UserController.java index 412e5a99..be794e84 100644 --- a/api-admin/src/main/java/com/example/apiadmin/user/controller/UserController.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/user/controller/UserController.java @@ -1,4 +1,4 @@ -package com.example.apiadmin.user.controller; +package com.nowait.applicationadmin.user.controller; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -7,10 +7,10 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import com.example.apiadmin.user.dto.ManagerLoginRequestDto; -import com.example.apiadmin.user.dto.ManagerSignupRequestDto; -import com.example.apiadmin.user.serivce.UserService; -import com.nowaiting.common.api.ApiUtils; +import com.nowait.applicationadmin.user.dto.ManagerLoginRequestDto; +import com.nowait.applicationadmin.user.dto.ManagerSignupRequestDto; +import com.nowait.applicationadmin.user.serivce.UserService; +import com.nowait.common.api.ApiUtils; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; diff --git a/api-admin/src/main/java/com/example/apiadmin/user/dto/ManagerLoginRequestDto.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/user/dto/ManagerLoginRequestDto.java similarity index 94% rename from api-admin/src/main/java/com/example/apiadmin/user/dto/ManagerLoginRequestDto.java rename to nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/user/dto/ManagerLoginRequestDto.java index d1f40771..e545bf6d 100644 --- a/api-admin/src/main/java/com/example/apiadmin/user/dto/ManagerLoginRequestDto.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/user/dto/ManagerLoginRequestDto.java @@ -1,4 +1,4 @@ -package com.example.apiadmin.user.dto; +package com.nowait.applicationadmin.user.dto; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; diff --git a/api-admin/src/main/java/com/example/apiadmin/user/dto/ManagerLoginResponseDto.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/user/dto/ManagerLoginResponseDto.java similarity index 82% rename from api-admin/src/main/java/com/example/apiadmin/user/dto/ManagerLoginResponseDto.java rename to nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/user/dto/ManagerLoginResponseDto.java index 4a36fb27..fd99b87e 100644 --- a/api-admin/src/main/java/com/example/apiadmin/user/dto/ManagerLoginResponseDto.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/user/dto/ManagerLoginResponseDto.java @@ -1,6 +1,6 @@ -package com.example.apiadmin.user.dto; +package com.nowait.applicationadmin.user.dto; -import com.nowaiting.user.entity.User; +import com.nowait.domaincorerdb.user.entity.User; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; @@ -17,6 +17,8 @@ public class ManagerLoginResponseDto { private String email; @Schema(description = "닉네임", example = "무한이") private String nickname; + @Schema(description = "주점 Id", example = "1") + private Long storeId; @Schema(description = "JWT TOKEN", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxNCIsImVtYWlsIjoiYXBpZG9jQGVtYWlsLmNvbSIsInJvbGVzIjpbIk1FTUJFUiJdLCJleHAiOjE3MTIyMjQ5NzV9.SQKtyHmqn2NKzHy4BX7_IgBePO_svEtmz1xbO9ToMz8") private String accessToken; @@ -25,6 +27,7 @@ public static ManagerLoginResponseDto fromEntity(User user,String token) { .userId(user.getId()) .email(user.getEmail()) .nickname(user.getNickname()) + .storeId(user.getStoreId()) .accessToken(token) .build(); } diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/user/dto/ManagerSignupRequestDto.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/user/dto/ManagerSignupRequestDto.java new file mode 100644 index 00000000..7cadbf2f --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/user/dto/ManagerSignupRequestDto.java @@ -0,0 +1,61 @@ +package com.nowait.applicationadmin.user.dto; + +import java.time.LocalDateTime; + +import com.nowait.common.enums.Role; +import com.nowait.common.enums.SocialType; +import com.nowait.domaincorerdb.user.entity.User; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class ManagerSignupRequestDto { + + @NotBlank + @Email(regexp = "^(?=.{1,64}@)[A-Za-z0-9_-]+(\\.[A-Za-z0-9_-]+)*@" + + "[^-][A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)*(\\.[A-Za-z]{2,})$") + @Schema(description = "이메일(예시)", example = "abc@gmail.com") + private String email; + + @NotBlank + @Pattern(regexp = "(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\\W)(?=\\S+$).{8,20}") + @Schema(description = "비밀번호(예시)", example = "1234568!@") + private String password; + + @NotBlank + @Pattern(regexp = "^[a-zA-Z가-힣0-9]{2,12}$", message = "2~12자 사이의 영문, 한글, 숫자만 입력 가능합니다.") + @Schema(description = "이름(예시)", example = "김노웻") + private String nickname; + + @Schema(description = "로그인타입", example = "LOCAL") + private String socialType; + + @Schema(description = "마케팅 수신 동의", example = "true") + private boolean isMarketingAgree; + + @Schema(description = "폰 번호 입력 여부", example = "true") + private boolean phoneEntered; + + public User toEntity() { + return User.builder() + .profileImage("no") + .email(email) + .phoneNumber("") + .password(password) + .nickname(nickname) + .socialType(SocialType.LOCAL) + .role(Role.MANAGER) + .isMarketingAgree(isMarketingAgree) + .phoneEntered(false) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + + } +} diff --git a/api-admin/src/main/java/com/example/apiadmin/user/dto/ManagerSignupResponseDto.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/user/dto/ManagerSignupResponseDto.java similarity index 85% rename from api-admin/src/main/java/com/example/apiadmin/user/dto/ManagerSignupResponseDto.java rename to nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/user/dto/ManagerSignupResponseDto.java index 9d311942..4c48e981 100644 --- a/api-admin/src/main/java/com/example/apiadmin/user/dto/ManagerSignupResponseDto.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/user/dto/ManagerSignupResponseDto.java @@ -1,7 +1,7 @@ -package com.example.apiadmin.user.dto; +package com.nowait.applicationadmin.user.dto; -import com.nowaiting.common.enums.Role; -import com.nowaiting.user.entity.User; +import com.nowait.common.enums.Role; +import com.nowait.domaincorerdb.user.entity.User; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; diff --git a/api-admin/src/main/java/com/example/apiadmin/user/dto/UserResponseDto.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/user/dto/UserResponseDto.java similarity index 87% rename from api-admin/src/main/java/com/example/apiadmin/user/dto/UserResponseDto.java rename to nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/user/dto/UserResponseDto.java index bbcdb8ea..d00601f2 100644 --- a/api-admin/src/main/java/com/example/apiadmin/user/dto/UserResponseDto.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/user/dto/UserResponseDto.java @@ -1,4 +1,4 @@ -package com.example.apiadmin.user.dto; +package com.nowait.applicationadmin.user.dto; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/user/serivce/UserService.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/user/serivce/UserService.java new file mode 100644 index 00000000..85b29b47 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/user/serivce/UserService.java @@ -0,0 +1,105 @@ +package com.nowait.applicationadmin.user.serivce; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.nowait.applicationadmin.security.jwt.JwtUtil; +import com.nowait.applicationadmin.user.dto.ManagerLoginRequestDto; +import com.nowait.applicationadmin.user.dto.ManagerLoginResponseDto; +import com.nowait.applicationadmin.user.dto.ManagerSignupRequestDto; +import com.nowait.applicationadmin.user.dto.ManagerSignupResponseDto; +import com.nowait.domaincorerdb.token.entity.Token; +import com.nowait.domaincorerdb.token.repository.TokenRepository; +import com.nowait.domaincorerdb.user.entity.MemberDetails; +import com.nowait.domaincorerdb.user.entity.User; +import com.nowait.domaincorerdb.user.repository.UserRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class UserService { + private final UserRepository userRepository; + private final TokenRepository tokenRepository; + private final PasswordEncoder passwordEncoder; + private final AuthenticationProvider authenticationProvider; + private final JwtUtil jwtUtil; + @Value("${jwt.access-token-expiration-ms}") + private long accessTokenExpiration; + + @Transactional + public ManagerSignupResponseDto signup(ManagerSignupRequestDto managerSignupRequestDto) { + validateEmailDuplicated(managerSignupRequestDto); + User user = managerSignupRequestDto.toEntity(); + user.encodePassword(passwordEncoder); + + return ManagerSignupResponseDto.fromEntity(userRepository.save(user)); + + } + private void validateEmailDuplicated(ManagerSignupRequestDto managerSignupRequestDto) { + userRepository.findByEmail(managerSignupRequestDto.getEmail()).ifPresent(member -> { + throw new IllegalArgumentException(); + } + ); + } + @Transactional + public ResponseEntity login(ManagerLoginRequestDto managerLoginRequestDto) { + Authentication authentication = authenticationProvider.authenticate( + new UsernamePasswordAuthenticationToken( + managerLoginRequestDto.getEmail(), + managerLoginRequestDto.getPassword() + ) + ); + MemberDetails memberDetails = (MemberDetails) authentication.getPrincipal(); + User user = userRepository.getReferenceById(memberDetails.getId()); + + long currentAccessTokenExpiration = accessTokenExpiration; + if (user.getRole() == com.nowait.common.enums.Role.SUPER_ADMIN) { + currentAccessTokenExpiration = 100L * 24 * 60 * 60 * 1000L; // 100일 + } + + String accessToken = jwtUtil.createAccessToken("accessToken", user.getId(), String.valueOf(user.getRole()), currentAccessTokenExpiration); + String refreshToken = jwtUtil.createRefreshToken("refreshToken", user.getId(), 30L * 24 * 60 * 60 * 1000L); + + // 기존 토큰 존재 확인 + Optional tokenOptional = tokenRepository.findByUserId(user.getId()); + if (tokenOptional.isPresent()) { + Token token = tokenOptional.get(); + token.updateRefreshToken(refreshToken, LocalDateTime.now().plusDays(30L)); // 엔티티에 update 메소드 구현 권장 + } else { + tokenRepository.save( + Token.builder() + .user(user) + .refreshToken(refreshToken) + .expiredDate(LocalDateTime.now().plusDays(30L)) + .build() + ); + } + ResponseCookie refreshTokenCookie = ResponseCookie.from("refreshToken", refreshToken) + .httpOnly(true) + .secure(true) // 운영환경에 맞게 + .path("/") + .maxAge(30L * 24 * 60 * 60) + .sameSite("Strict") + .build(); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()) + .body(ManagerLoginResponseDto.fromEntity(user, accessToken)); + + } + +} diff --git a/nowait-app-admin-api/src/test/java/com/nowait/applicationadmin/reservation/ReservationServiceTest.java b/nowait-app-admin-api/src/test/java/com/nowait/applicationadmin/reservation/ReservationServiceTest.java new file mode 100644 index 00000000..3c191177 --- /dev/null +++ b/nowait-app-admin-api/src/test/java/com/nowait/applicationadmin/reservation/ReservationServiceTest.java @@ -0,0 +1,298 @@ +package com.nowait.applicationadmin.reservation; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.stubbing.Answer; +import org.springframework.data.redis.core.DefaultTypedTuple; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import com.nowait.applicationadmin.reservation.dto.EntryStatusResponseDto; +import com.nowait.applicationadmin.reservation.dto.WaitingUserResponse; +import com.nowait.applicationadmin.reservation.service.ReservationService; +import com.nowait.common.enums.ReservationStatus; +import com.nowait.common.enums.Role; +import com.nowait.common.enums.SocialType; +import com.nowait.domaincorerdb.reservation.entity.Reservation; +import com.nowait.domaincorerdb.reservation.repository.ReservationRepository; +import com.nowait.domaincorerdb.store.entity.Store; +import com.nowait.domaincorerdb.store.repository.StoreRepository; +import com.nowait.domaincorerdb.user.entity.MemberDetails; +import com.nowait.domaincorerdb.user.entity.User; +import com.nowait.domaincorerdb.user.repository.UserRepository; +import com.nowait.domaincoreredis.reservation.repository.WaitingRedisRepository; + +public class ReservationServiceTest { + + @Mock + private ReservationRepository reservationRepository; + @Mock + private UserRepository userRepository; + @Mock + private WaitingRedisRepository waitingRedisRepository; + @Mock + private StoreRepository storeRepository; + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ZSetOperations zSetOps; + + + @InjectMocks + private ReservationService reservationService; + + private final Long storeId = 23L; + private final String userId = "10"; + private final AtomicLong seq = new AtomicLong(1000L); + private MemberDetails adminPrincipal; + private Store storeRef; + + private Answer stubSaveAssigningId() { + return inv -> { + Reservation r = inv.getArgument(0); + // 새 객체 만들어 ID 포함해서 반환 + return Reservation.builder() + .id(seq.incrementAndGet()) + .reservationNumber(r.getReservationNumber()) + .store(r.getStore()) + .user(r.getUser()) + .requestedAt(r.getRequestedAt()) + .updatedAt(r.getUpdatedAt()) + .status(r.getStatus()) + .partySize(r.getPartySize()) + .build(); + }; + } + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + + adminPrincipal = MemberDetails.builder() + .id(1L).email("admin@test.com").password("pw") + .authorities(List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))) + .build(); + + User adminUser = User.createUserWithId( + 1L, "admin@test.com", "admin", "img", + SocialType.LOCAL, Role.SUPER_ADMIN, storeId + ); + + given(userRepository.findById(1L)).willReturn(Optional.of(adminUser)); + + storeRef = Store.builder() + .storeId(storeId) + .departmentId(10L) + .name("S") + .location("L") + .description("D") + .noticeTitle("NT") + .noticeContent("NC") + .openTime("10001100") + .isActive(true) + .deleted(false) + .build(); + + given(storeRepository.getReferenceById(storeId)).willReturn(storeRef); + + given(redisTemplate.opsForZSet()).willReturn(zSetOps); + } + + @Test + @DisplayName("WAITING → CALLING") + void process_waiting_to_calling() { + // given + given(waitingRedisRepository.getReservationId(storeId, userId)).willReturn("R-001"); + given(waitingRedisRepository.getWaitingStatus(storeId, userId)).willReturn("WAITING"); + given(waitingRedisRepository.getWaitingPartySize(storeId, userId)).willReturn(3); + given(zSetOps.score("waiting:" + storeId, userId)).willReturn((double) System.currentTimeMillis()); + User userRef = User.createUserWithId(200L, "u@a", "neo", "img", SocialType.KAKAO, Role.USER, storeId); + given(userRepository.getReferenceById(anyLong())).willReturn(userRef); + + + // when + EntryStatusResponseDto dto = reservationService.processEntryStatus( + storeId, userId, adminPrincipal, ReservationStatus.CALLING); + + // then + assertThat(dto.getStatus()).isEqualTo("CALLING"); + assertThat(dto.getReservationNumber()).isEqualTo("R-001"); + then(waitingRedisRepository).should().setWaitingStatus(storeId, userId, "CALLING"); + then(waitingRedisRepository).should().setWaitingCalledAt(eq(storeId), eq(userId), anyLong()); + then(reservationRepository).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("WAITING → CONFIRMED : DB 저장 + Redis 삭제") + void process_waiting_to_confirmed() { + // given + given(waitingRedisRepository.getReservationId(storeId, userId)).willReturn("R-002"); + given(waitingRedisRepository.getWaitingStatus(storeId, userId)).willReturn("WAITING"); + given(waitingRedisRepository.getWaitingPartySize(storeId, userId)).willReturn(2); + given(zSetOps.score("waiting:" + storeId, userId)).willReturn((double) System.currentTimeMillis()); + given(userRepository.getReferenceById(anyLong())) + .willReturn(User.createUserWithId(200L, "u@a", "neo", "img", SocialType.KAKAO, Role.USER, storeId)); + + AtomicReference savedRef = new AtomicReference<>(); + given(reservationRepository.save(any(Reservation.class))).willAnswer(inv -> { + Reservation saved = stubSaveAssigningId().answer(inv); // ★ ID 있는 객체 + savedRef.set(saved); + return saved; + }); + + // when + EntryStatusResponseDto dto = reservationService.processEntryStatus( + storeId, userId, adminPrincipal, ReservationStatus.CONFIRMED); + + // then + then(waitingRedisRepository).should().deleteWaiting(storeId, userId); + assertThat(savedRef.get().getStatus()).isEqualTo(ReservationStatus.CONFIRMED); + assertThat(dto.getStatus()).isEqualTo("CONFIRMED"); + assertThat(dto.getReservationNumber()).isEqualTo("R-002"); + } + + + @Test + @DisplayName("CALLING → CONFIRMED : DB 저장 + Redis 삭제") + void process_calling_to_confirmed() { + // given + given(waitingRedisRepository.getReservationId(storeId, userId)).willReturn("R-003"); + given(waitingRedisRepository.getWaitingStatus(storeId, userId)).willReturn("CALLING"); + given(waitingRedisRepository.getWaitingPartySize(storeId, userId)).willReturn(4); + given(zSetOps.score("waiting:" + storeId, userId)).willReturn((double) System.currentTimeMillis()); + given(userRepository.getReferenceById(anyLong())) + .willReturn(User.createUserWithId(200L, "u@a", "neo", "img", SocialType.KAKAO, Role.USER, storeId)); + given(reservationRepository.save(any(Reservation.class))).willAnswer(stubSaveAssigningId()); // ★ + + // when + EntryStatusResponseDto dto = reservationService.processEntryStatus( + storeId, userId, adminPrincipal, ReservationStatus.CONFIRMED); + + // then + then(waitingRedisRepository).should().deleteWaiting(storeId, userId); + assertThat(dto.getStatus()).isEqualTo("CONFIRMED"); + } + + + @Test + @DisplayName("CANCELLED 상태에서 CALLING 시도 → 예외") + void process_cancelled_to_calling_illegal() { + // given + given(waitingRedisRepository.getWaitingStatus(storeId, userId)).willReturn("CANCELLED"); + + // when / then + assertThatThrownBy(() -> + reservationService.processEntryStatus(storeId, userId, adminPrincipal, ReservationStatus.CALLING) + ).isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("WAITING → CANCELLED : DB 저장 + Redis 삭제") + void process_waiting_to_cancelled() { + // given + given(waitingRedisRepository.getReservationId(storeId, userId)).willReturn("R-004"); + given(waitingRedisRepository.getWaitingStatus(storeId, userId)).willReturn("WAITING"); + given(waitingRedisRepository.getWaitingPartySize(storeId, userId)).willReturn(2); + given(zSetOps.score("waiting:" + storeId, userId)).willReturn((double) System.currentTimeMillis()); + given(userRepository.getReferenceById(anyLong())) + .willReturn(User.createUserWithId(200L, "u@a", "neo", "img", SocialType.KAKAO, Role.USER, storeId)); + given(reservationRepository.save(any(Reservation.class))).willAnswer(stubSaveAssigningId()); // ★ + + // when + EntryStatusResponseDto dto = reservationService.processEntryStatus( + storeId, userId, adminPrincipal, ReservationStatus.CANCELLED); + + // then + then(waitingRedisRepository).should().deleteWaiting(storeId, userId); + assertThat(dto.getStatus()).isEqualTo("CANCELLED"); + } + + + @Test + @DisplayName("이미 CANCELLED 기록 존재 시 → CONFIRMED로 전환 (DB 조회 분기)") + void process_from_cancelled_record_to_confirmed() { + // given + given(waitingRedisRepository.getWaitingStatus(storeId, userId)).willReturn("CANCELLED"); + Long uid = 200L; + + User userRef = User.createUserWithId(uid, "u@a", "neo", "img", SocialType.KAKAO, Role.USER, storeId); + Reservation cancelled = Reservation.builder() + .id(777L) + .reservationNumber("R-005") + .store(storeRef) + .user(userRef) + .requestedAt(LocalDateTime.now().minusMinutes(30)) + .updatedAt(LocalDateTime.now().minusMinutes(20)) + .status(ReservationStatus.CANCELLED) + .partySize(3) + .build(); + + given(reservationRepository.findFirstByStore_StoreIdAndUserIdAndStatusInAndRequestedAtBetweenOrderByRequestedAtDesc( + eq(storeId), eq(uid), eq(List.of(ReservationStatus.CANCELLED)), any(), any() + )).willReturn(Optional.of(cancelled)); + given(userRepository.getReferenceById(anyLong())).willReturn(userRef); + given(reservationRepository.save(any(Reservation.class))).willAnswer(stubSaveAssigningId()); // ★ + + // when + EntryStatusResponseDto dto = reservationService.processEntryStatus( + storeId, String.valueOf(uid), adminPrincipal, ReservationStatus.CONFIRMED); + + // then + then(reservationRepository).should().save(any(Reservation.class)); + assertThat(dto.getStatus()).isEqualTo("CONFIRMED"); + assertThat(dto.getReservationNumber()).isEqualTo("R-005"); + } + + + @Test + @DisplayName("getAllWaitingUserDetails : 파이프라인 매핑") + void get_all_waiting_pipeline_mapping() { + // given + long t1 = System.currentTimeMillis() - 1000; + long t2 = System.currentTimeMillis(); + var a = new DefaultTypedTuple<>("200", (double) t1); + var b = new DefaultTypedTuple<>("201", (double) t2); + given(waitingRedisRepository.getAllWaitingWithScore(storeId)).willReturn(List.of(a, b)); + + given(userRepository.findAllById(Arrays.asList(200L, 201L))) + .willReturn(List.of( + User.createUserWithId(200L, "u1@a", "neo", "img", SocialType.KAKAO, Role.USER, storeId), + User.createUserWithId(201L, "u2@a", "trinity", "img", SocialType.KAKAO, Role.USER, storeId) + )); + + List pipeline = new ArrayList<>(); + pipeline.add("3"); pipeline.add("WAITING"); pipeline.add("R-100"); pipeline.add(String.valueOf(t1)); pipeline.add((double) t1); + pipeline.add("2"); pipeline.add("CALLING"); pipeline.add("R-101"); pipeline.add(String.valueOf(t2)); pipeline.add((double) t2); + given(redisTemplate.executePipelined(any(RedisCallback.class))).willReturn(pipeline); + + // when + List list = reservationService.getAllWaitingUserDetails(storeId); + + // then + assertThat(list).hasSize(2); + assertThat(list.get(0).getUserId()).isEqualTo("200"); + assertThat(list.get(0).getUserName()).isEqualTo("neo"); + assertThat(list.get(0).getReservationNumber()).isEqualTo("R-100"); + assertThat(list.get(0).getStatus()).isEqualTo("WAITING"); + assertThat(list.get(1).getStatus()).isEqualTo("CALLING"); + assertThat(list.get(0).getCreatedAt()).isBefore(list.get(1).getCreatedAt()); + } +} diff --git a/nowait-app-admin-api/src/test/java/com/nowait/applicationadmin/reservation/WaitingDuplicateIT.java b/nowait-app-admin-api/src/test/java/com/nowait/applicationadmin/reservation/WaitingDuplicateIT.java new file mode 100644 index 00000000..f0c43b82 --- /dev/null +++ b/nowait-app-admin-api/src/test/java/com/nowait/applicationadmin/reservation/WaitingDuplicateIT.java @@ -0,0 +1,180 @@ +package com.nowait.applicationadmin.reservation; + +import static org.assertj.core.api.Assertions.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import com.nowait.domaincoreredis.common.util.RedisKeyUtils; + +@Testcontainers +public class WaitingDuplicateIT { + @Container + static GenericContainer redis = new GenericContainer<>("redis:7-alpine").withExposedPorts(6379); + + StringRedisTemplate tpl; + + Long storeId = 100L; + String zKey, stKey, partyKey, numKey, calledKey; + + @BeforeEach + void init() { + var cfg = new RedisStandaloneConfiguration("localhost", redis.getMappedPort(6379)); + var lf = new LettuceConnectionFactory(cfg); + lf.afterPropertiesSet(); + tpl = new StringRedisTemplate(lf); + + zKey = RedisKeyUtils.buildWaitingKeyPrefix() + storeId; // waiting:{storeId} + stKey = RedisKeyUtils.buildWaitingStatusKeyPrefix() + storeId; // waiting:status:{storeId} + partyKey = RedisKeyUtils.buildWaitingPartySizeKeyPrefix() + storeId; // waiting:party:{storeId} + numKey = RedisKeyUtils.buildReservationNumberKey(storeId); // reservation:number:{storeId} + calledKey = RedisKeyUtils.buildWaitingCalledAtKeyPrefix() + storeId; // waiting:calledAt:{storeId} + + // clean + tpl.delete(List.of(zKey, stKey, partyKey, numKey, calledKey)); + } + + // ===== Helper: 현재 목록 덤프 ===== + private List dump() { + Set ids = tpl.opsForZSet().range(zKey, 0, -1); + if (ids == null) return List.of(); + List out = new ArrayList<>(); + for (String uid : ids) { + String status = (String) tpl.opsForHash().get(stKey, uid); + String party = (String) tpl.opsForHash().get(partyKey, uid); + String num = (String) tpl.opsForHash().get(numKey, uid); + out.add(uid + "|" + status + "|" + party + "|" + num); + } + return out; + } + + // ====== 시나리오 A: ID 정규화 실패(공백 차이) ====== + @Test + @DisplayName("A) userId 공백/포맷 불일치 → 사실상 같은 팀이 두 줄로 보임") + void duplicate_by_userid_format() { + // given + String uid1 = "200"; + String uid2 = " 200"; // 앞 공백 (의도적인 비정규화) + long now = System.currentTimeMillis(); + + // when + tpl.opsForZSet().add(zKey, uid1, now); + tpl.opsForHash().put(stKey, uid1, "WAITING"); + tpl.opsForHash().put(partyKey, uid1, "3"); + tpl.opsForHash().put(numKey, uid1, "R-100"); + + tpl.opsForZSet().add(zKey, uid2, now + 1); // 공백 다른 멤버 → ZSET에선 “다른 사용자” + tpl.opsForHash().put(stKey, uid2, "WAITING"); + tpl.opsForHash().put(partyKey, uid2, "3"); + tpl.opsForHash().put(numKey, uid2, "R-100"); // 같은 예약번호까지 + + // then + List view = dump(); + assertThat(view).hasSize(2); // UI 관점엔 중복으로 보임 + // 방어 로직 없다면 동일 사용자로 인식될 수 있음 + } + + // ====== 시나리오 B: 예약번호 충돌(비원자 생성/할당 가정) ====== + @Test + @DisplayName("B) 예약번호 충돌 → 서로 다른 userId가 같은 reservationNumber로 노출") + void duplicate_by_reservation_number_collision() throws Exception { + // given + String u1 = "201"; + String u2 = "202"; + long t = System.currentTimeMillis(); + + // when (의도적으로 같은 번호 세팅) + tpl.opsForZSet().add(zKey, u1, t); + tpl.opsForHash().put(stKey, u1, "WAITING"); + tpl.opsForHash().put(partyKey, u1, "2"); + tpl.opsForHash().put(numKey, u1, "R-200"); + + tpl.opsForZSet().add(zKey, u2, t + 1); + tpl.opsForHash().put(stKey, u2, "WAITING"); + tpl.opsForHash().put(partyKey, u2, "2"); + tpl.opsForHash().put(numKey, u2, "R-200"); // 충돌 + + // then + List view = dump(); + assertThat(view).filteredOn(s -> s.endsWith("|R-200")).hasSize(2); // 같은 번호 2개 + // DB unique는 막지만 Redis 뷰에선 중복 그대로 노출됨 + } + + // ====== 시나리오 C: 부분 삭제 레이스(유령 조각) ====== + @Test + @DisplayName("C) 부분 삭제 실패 → 찌꺼기 남아 중복/유령 항목 노출") + void ghost_after_partial_delete() { + // given + String uid = "203"; + long t = System.currentTimeMillis(); + tpl.opsForZSet().add(zKey, uid, t); + tpl.opsForHash().put(stKey, uid, "WAITING"); + tpl.opsForHash().put(partyKey, uid, "4"); + tpl.opsForHash().put(numKey, uid, "R-300"); + + // when (의도적으로 해시만 지우고 ZSET은 남김 = 네 현재 deleteWaiting 순차 호출이 실패한 상황을 흉내) + tpl.opsForHash().delete(stKey, uid); + tpl.opsForHash().delete(partyKey, uid); + tpl.opsForHash().delete(numKey, uid); + // tpl.opsForZSet().remove(zKey, uid); // 실패했다고 가정 + + // then + var ids = tpl.opsForZSet().range(zKey, 0, -1); + assertThat(ids).contains(uid); // 큐엔 남아있음 + List view = dump(); + // status/party/number가 null → 조회/매핑에서 NPE or 비정상 라인 가능 + assertThat(view.get(0)).contains("null"); + } + + // ====== 시나리오 D(선택): 동시 등록 레이스 증폭 ====== + @Test + @DisplayName("D) 동시 등록 100개(같은 사용자/다른 포맷) → 중복 노출 가능성 확인") + void concurrent_register_format_mixture() throws Exception { + // given + ExecutorService es = Executors.newFixedThreadPool(16); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(100); + + // when + for (int i = 0; i < 100; i++) { + final int k = i; + es.submit(() -> { + try { + start.await(); + String uid = (k % 2 == 0) ? "200" : " 200"; // 포맷 섞기 + long score = System.currentTimeMillis() + k; + tpl.opsForZSet().add(zKey, uid, score); + tpl.opsForHash().put(stKey, uid, "WAITING"); + tpl.opsForHash().put(partyKey, uid, "3"); + tpl.opsForHash().put(numKey, uid, "R-BURST"); + } catch (Exception ignore) { + } finally { + done.countDown(); + } + }); + } + start.countDown(); + done.await(10, TimeUnit.SECONDS); + es.shutdown(); + + // then + var view = dump(); + // 공백/포맷이 다른 멤버가 둘 다 남아있어 2줄 이상일 가능성 + assertThat(view.stream().filter(s -> s.endsWith("|R-BURST")).count()).isGreaterThanOrEqualTo(2); + } +} diff --git a/nowait-app-admin-api/src/test/java/com/nowait/applicationadmin/reservation/WaitingRedisRepositoryTest.java b/nowait-app-admin-api/src/test/java/com/nowait/applicationadmin/reservation/WaitingRedisRepositoryTest.java new file mode 100644 index 00000000..3b399325 --- /dev/null +++ b/nowait-app-admin-api/src/test/java/com/nowait/applicationadmin/reservation/WaitingRedisRepositoryTest.java @@ -0,0 +1,75 @@ +package com.nowait.applicationadmin.reservation; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.data.redis.core.HashOperations; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; + +import com.nowait.domaincoreredis.reservation.repository.WaitingRedisRepository; + +public class WaitingRedisRepositoryTest { + @Mock + private StringRedisTemplate template; + @Mock private ZSetOperations zOps; + @Mock private HashOperations hOps; + + @InjectMocks + private WaitingRedisRepository repo; + + @BeforeEach + void init() { + MockitoAnnotations.openMocks(this); + given(template.opsForZSet()).willReturn(zOps); + given(template.opsForHash()).willReturn(hOps); + } + + @Test + @DisplayName("getAllWaitingWithScore : null-safe") + void get_all_waiting_with_score_null_safe() { + // given + given(zOps.rangeWithScores("waiting:100", 0, -1)).willReturn(null); + + // when + var res = repo.getAllWaitingWithScore(100L); + + // then + assertThat(res).isEmpty(); + } + + @Test + @DisplayName("getWaitingStatus : null-safe") + void get_waiting_status_null_safe() { + // given + given(hOps.get("waiting:status:100", "u")).willReturn(null); + + // when + var v = repo.getWaitingStatus(100L, "u"); + + // then + assertThat(v).isNull(); + } + + @Test + @DisplayName("deleteWaiting : 모든 관련 키 삭제") + void delete_waiting_remove_all_keys() { + // given (nothing) + + // when + repo.deleteWaiting(100L, "u"); + + // then + then(hOps).should().delete("waiting:status:100", "u"); + then(zOps).should().remove("waiting:100", "u"); + then(hOps).should().delete("waiting:party:100", "u"); + then(hOps).should().delete("reservation:number:100", "u"); + then(hOps).should().delete("waiting:calledAt:100", "u"); + } +} diff --git a/api-user/.gitattributes b/nowait-app-user-api/.gitattributes similarity index 100% rename from api-user/.gitattributes rename to nowait-app-user-api/.gitattributes diff --git a/api-user/.gitignore b/nowait-app-user-api/.gitignore similarity index 100% rename from api-user/.gitignore rename to nowait-app-user-api/.gitignore diff --git a/nowait-app-user-api/Dockerfile b/nowait-app-user-api/Dockerfile new file mode 100644 index 00000000..2fee4610 --- /dev/null +++ b/nowait-app-user-api/Dockerfile @@ -0,0 +1,9 @@ +FROM eclipse-temurin:21 +ARG JAR_FILE=./build/libs/*.jar +COPY ${JAR_FILE} nowait-app-user-api.jar + +ARG PROFILE=user + +ENV TZ=Asia/Seoul + +CMD ["java", "-Djava.security.egd=file:/dev/./urandom", "-Duser.timezone=Asia/Seoul", "-jar", "nowait-app-user-api.jar", "--spring.profiles.active=user"] diff --git a/nowait-app-user-api/build.gradle b/nowait-app-user-api/build.gradle new file mode 100644 index 00000000..ca819c48 --- /dev/null +++ b/nowait-app-user-api/build.gradle @@ -0,0 +1,87 @@ +plugins { + id 'java' +} + +jar { + enabled = false +} +bootJar { + enabled = true +} + +group = 'com.nowait' +version = '0.0.1-SNAPSHOT' + +repositories { + mavenCentral() + maven { url 'https://jitpack.io' } +} + +ext { + set('springCloudVersion', "2025.0.0") +} + +dependencyManagement { + imports { + mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" + } +} + +dependencies { + implementation project(":nowait-common") + implementation project(":nowait-domain:domain-core-rdb") + implementation project(":nowait-domain:domain-user-rdb") + implementation project(":nowait-domain:domain-redis") + implementation project(":nowait-infra") + + // Spring Boot Starter + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + + // SPRING SECURITY + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + + // jwt + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' + implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' + + // redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // SWAGGER + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' + + // DB + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + runtimeOnly 'com.mysql:mysql-connector-j' + + // 디스코드 웹훅 + implementation 'com.github.napstr:logback-discord-appender:1.0.0' + + //feign + implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' + implementation group: 'io.github.openfeign', name: 'feign-gson', version: '11.0' + + // 기타 필요 라이브러리 (예: Lombok) + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + // actuator + implementation 'org.springframework.boot:spring-boot-starter-actuator' + + // prometheus + implementation 'io.micrometer:micrometer-registry-prometheus' + + // test + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation platform("org.testcontainers:testcontainers-bom:1.20.1") + testImplementation "org.testcontainers:junit-jupiter" + testImplementation "org.testcontainers:testcontainers" + +} + +test { + useJUnitPlatform() +} diff --git a/api-user/gradle/wrapper/gradle-wrapper.jar b/nowait-app-user-api/gradle/wrapper/gradle-wrapper.jar similarity index 100% rename from api-user/gradle/wrapper/gradle-wrapper.jar rename to nowait-app-user-api/gradle/wrapper/gradle-wrapper.jar diff --git a/api-user/gradle/wrapper/gradle-wrapper.properties b/nowait-app-user-api/gradle/wrapper/gradle-wrapper.properties similarity index 100% rename from api-user/gradle/wrapper/gradle-wrapper.properties rename to nowait-app-user-api/gradle/wrapper/gradle-wrapper.properties diff --git a/nowait-app-user-api/src/main/java/com/nowait/ApiUserApplication.java b/nowait-app-user-api/src/main/java/com/nowait/ApiUserApplication.java new file mode 100644 index 00000000..2460ef23 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/ApiUserApplication.java @@ -0,0 +1,16 @@ +package com.nowait; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@EnableJpaAuditing +@EnableFeignClients +@SpringBootApplication +public class ApiUserApplication { + public static void main(String[] args) { + SpringApplication.run(ApiUserApplication.class, args); + } +} + diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/aws/controller/healthCheckController.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/aws/controller/healthCheckController.java new file mode 100644 index 00000000..79ae27c0 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/aws/controller/healthCheckController.java @@ -0,0 +1,28 @@ +package com.nowait.applicationuser.aws.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.nowait.common.api.ApiUtils; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("") +@RequiredArgsConstructor +public class healthCheckController { + @GetMapping("/health-check") + public ResponseEntity healthCheck() { + return ResponseEntity + .status(HttpStatus.OK) + .body( + ApiUtils + .success( + "정상 작동 중입니다." + ) + ); + } +} diff --git a/api-user/src/main/java/com/example/apiuser/bookmark/controller/BookmarkController.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/bookmark/controller/BookmarkController.java similarity index 74% rename from api-user/src/main/java/com/example/apiuser/bookmark/controller/BookmarkController.java rename to nowait-app-user-api/src/main/java/com/nowait/applicationuser/bookmark/controller/BookmarkController.java index 32c4132a..01fc1774 100644 --- a/api-user/src/main/java/com/example/apiuser/bookmark/controller/BookmarkController.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/bookmark/controller/BookmarkController.java @@ -1,4 +1,4 @@ -package com.example.apiuser.bookmark.controller; +package com.nowait.applicationuser.bookmark.controller; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -10,18 +10,19 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import com.example.apiuser.bookmark.dto.BookmarkCreateResponse; -import com.example.apiuser.bookmark.service.BookmarkService; -import com.nowait.auth.dto.CustomOAuth2User; -import com.nowaiting.common.api.ApiUtils; +import com.nowait.applicationuser.bookmark.dto.BookmarkCreateResponse; +import com.nowait.applicationuser.bookmark.service.BookmarkService; +import com.nowait.common.api.ApiUtils; +import com.nowait.domainuserrdb.oauth.dto.CustomOAuth2User; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; + @Tag(name = "Bookmark API", description = "북마크 API") @RestController -@RequestMapping("/bookmarks") +@RequestMapping("/v1/users/me/bookmarks") @RequiredArgsConstructor public class BookmarkController { private final BookmarkService bookmarkService; @@ -29,8 +30,9 @@ public class BookmarkController { @PostMapping("/{storeId}") @Operation(summary = "북마크 생성", description = "특정 주점에 대한 북마크 생성") @ApiResponse(responseCode = "201", description = "북마크 생성") - public ResponseEntity createBookmark(@PathVariable Long storeId,@AuthenticationPrincipal CustomOAuth2User customOAuth2User) { - BookmarkCreateResponse response = bookmarkService.createBookmark(storeId,customOAuth2User); + public ResponseEntity createBookmark(@PathVariable Long storeId, + @AuthenticationPrincipal CustomOAuth2User customOAuth2User) { + BookmarkCreateResponse response = bookmarkService.createBookmark(storeId, customOAuth2User); return ResponseEntity .status(HttpStatus.CREATED) @@ -40,6 +42,7 @@ public ResponseEntity createBookmark(@PathVariable Long storeId,@Authenticati ) ); } + @GetMapping @Operation(summary = "북마크 조회", description = "내가 북마크한 주점 조회") @ApiResponse(responseCode = "200", description = "북마크 조회") @@ -52,15 +55,18 @@ public ResponseEntity getAllBookmarks(@AuthenticationPrincipal CustomOAuth2Us ) ); } - @DeleteMapping("/{bookmarkId}") + + @DeleteMapping("/{storeId}") @Operation(summary = "북마크 삭제", description = "특정 주점에 대한 북마크 삭제") @ApiResponse(responseCode = "200", description = "북마크 삭제") - public ResponseEntity deleteBookmark(@PathVariable Long bookmarkId, @AuthenticationPrincipal CustomOAuth2User customOAuth2User) { + public ResponseEntity deleteBookmark(@PathVariable Long storeId, + @AuthenticationPrincipal CustomOAuth2User customOAuth2User) { + String response = bookmarkService.deleteBookmark(storeId, customOAuth2User); return ResponseEntity .ok() .body( ApiUtils.success( - bookmarkService.deleteBookmark(bookmarkId,customOAuth2User) + response ) ); } diff --git a/api-user/src/main/java/com/example/apiuser/bookmark/dto/BookmarkCreateResponse.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/bookmark/dto/BookmarkCreateResponse.java similarity index 81% rename from api-user/src/main/java/com/example/apiuser/bookmark/dto/BookmarkCreateResponse.java rename to nowait-app-user-api/src/main/java/com/nowait/applicationuser/bookmark/dto/BookmarkCreateResponse.java index 5e4a8f65..e596b75a 100644 --- a/api-user/src/main/java/com/example/apiuser/bookmark/dto/BookmarkCreateResponse.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/bookmark/dto/BookmarkCreateResponse.java @@ -1,6 +1,6 @@ -package com.example.apiuser.bookmark.dto; +package com.nowait.applicationuser.bookmark.dto; -import com.nowait.domainbookmark.entity.Bookmark; +import com.nowait.domainuserrdb.bookmark.entity.Bookmark; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/api-user/src/main/java/com/example/apiuser/bookmark/dto/BookmarkGetResponse.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/bookmark/dto/BookmarkGetResponse.java similarity index 81% rename from api-user/src/main/java/com/example/apiuser/bookmark/dto/BookmarkGetResponse.java rename to nowait-app-user-api/src/main/java/com/nowait/applicationuser/bookmark/dto/BookmarkGetResponse.java index 6f05db8e..a7f100de 100644 --- a/api-user/src/main/java/com/example/apiuser/bookmark/dto/BookmarkGetResponse.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/bookmark/dto/BookmarkGetResponse.java @@ -1,6 +1,6 @@ -package com.example.apiuser.bookmark.dto; +package com.nowait.applicationuser.bookmark.dto; -import com.nowait.domainbookmark.entity.Bookmark; +import com.nowait.domainuserrdb.bookmark.entity.Bookmark; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/bookmark/service/BookmarkService.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/bookmark/service/BookmarkService.java new file mode 100644 index 00000000..ed20348a --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/bookmark/service/BookmarkService.java @@ -0,0 +1,116 @@ +package com.nowait.applicationuser.bookmark.service; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.nowait.applicationuser.bookmark.dto.BookmarkCreateResponse; +import com.nowait.applicationuser.store.dto.StorePageReadResponse; +import com.nowait.applicationuser.store.service.StoreService; +import com.nowait.domaincorerdb.store.entity.Store; +import com.nowait.domaincorerdb.store.exception.StoreNotFoundException; +import com.nowait.domaincorerdb.store.repository.StoreRepository; +import com.nowait.domaincorerdb.user.entity.User; +import com.nowait.domaincorerdb.user.exception.UserNotFoundException; +import com.nowait.domaincorerdb.user.repository.UserRepository; +import com.nowait.domainuserrdb.bookmark.entity.Bookmark; +import com.nowait.domainuserrdb.bookmark.exception.AlreadyDeletedBookmarkException; +import com.nowait.domainuserrdb.bookmark.repository.BookmarkRepository; +import com.nowait.domainuserrdb.oauth.dto.CustomOAuth2User; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class BookmarkService { + private final BookmarkRepository bookmarkRepository; + private final StoreRepository storeRepository; + private final UserRepository userRepository; + private final StoreService storeService; + + @Transactional + public BookmarkCreateResponse createBookmark(Long storeId, CustomOAuth2User customOAuth2User) { + + parameterValidation(storeId, customOAuth2User); + Store store = storeRepository.findById(storeId) + .orElseThrow(StoreNotFoundException::new); + User user = userRepository.findById(customOAuth2User.getUserId()) + .orElseThrow(UserNotFoundException::new); + + Optional isBookmark = bookmarkRepository.findByUserAndStore(user, store); + + if (isBookmark.isPresent()) { + Bookmark bookmark = isBookmark.get(); + if (bookmark.isDeleted()) { + bookmark.restore(); + Bookmark restored = bookmarkRepository.save(bookmark); + return BookmarkCreateResponse.fromEntity(bookmarkRepository.save(restored)); + } else { + throw new IllegalArgumentException("already bookmarked"); + } + } + + Bookmark bookmark = Bookmark.builder() + .store(store) + .user(user) + .deleted(false) + .build(); + + return BookmarkCreateResponse.fromEntity(bookmarkRepository.save(bookmark)); + } + + @Transactional(readOnly = true) + public List getBookmarks(CustomOAuth2User customOAuth2User) { + User user = userRepository.findById(customOAuth2User.getUserId()) + .orElseThrow(UserNotFoundException::new); + + Collection allBookmarks = bookmarkRepository.findAllByUserAndDeletedFalse(user); + + List storeIds = bookmarkRepository.findAllByUserAndDeletedFalse(user) + .stream() + .map(Bookmark::getStore) + .map(Store::getStoreId) + .toList(); + + Map bookmarkMap = allBookmarks.stream() + .collect(Collectors.toMap( + b -> b.getStore().getStoreId(), + Bookmark::getId + )); + + return storeService.getAllStoresByPageAndDeparments(storeIds, bookmarkMap); + } + + @Transactional + public String deleteBookmark(Long storeId, CustomOAuth2User customOAuth2User) { + parameterValidation(storeId, customOAuth2User); + User user = customOAuth2User.getUser(); + Store store = storeRepository.findById(storeId) + .orElseThrow(StoreNotFoundException::new); + + Bookmark bookmark = bookmarkRepository.findByUserAndStoreAndDeletedFalse(user, store) + .orElseThrow(AlreadyDeletedBookmarkException::new); + + if (!Objects.equals(bookmark.getUser().getId(), customOAuth2User.getUserId())) { + throw new IllegalArgumentException("you can only delete your own bookmark"); + } + bookmark.softDelete(); + return "Bookmark ID " + storeId + " deleted."; + } + + private static void parameterValidation(Long storeId, CustomOAuth2User customOAuth2User) { + // 파라미터 유효성 검사 + if (storeId == null || storeId < 0) { + throw new IllegalArgumentException("storeId must be a positive number"); + } + if (customOAuth2User == null || customOAuth2User.getUserId() == null) { + throw new IllegalArgumentException("UserInfo is required"); + } + } +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/config/SwaggerConfig.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/config/SwaggerConfig.java new file mode 100644 index 00000000..c821b028 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/config/SwaggerConfig.java @@ -0,0 +1,29 @@ +package com.nowait.applicationuser.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; + +@Configuration +public class SwaggerConfig { + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .addSecurityItem(new SecurityRequirement().addList("JWT")) + .components(new Components() + .addSecuritySchemes("JWT", new SecurityScheme() + .name("Authorization") + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .in(SecurityScheme.In.HEADER))) + .info(new Info().title("NOWAIT API") + .description("NOWAIT API Specification") + .version("v0.0.1")); + } +} diff --git a/security-front/src/main/java/com/nowait/auth/config/CorsConfig.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/config/security/CorsConfig.java similarity index 75% rename from security-front/src/main/java/com/nowait/auth/config/CorsConfig.java rename to nowait-app-user-api/src/main/java/com/nowait/applicationuser/config/security/CorsConfig.java index 34b293b5..b0c5b9b6 100644 --- a/security-front/src/main/java/com/nowait/auth/config/CorsConfig.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/config/security/CorsConfig.java @@ -1,4 +1,4 @@ -package com.nowait.auth.config; +package com.nowait.applicationuser.config.security; import java.util.List; @@ -15,14 +15,15 @@ public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); config.setAllowCredentials(true); // 쿠키나 인증헤더 자격증명 허용 - config.setAllowedOrigins(List.of("http://localhost:3000", "http://localhost:8083")); // 허용할 출처 설정 + config.setAllowedOrigins(List.of("http://localhost:5173", "http://localhost:63342", "https://nowait.co.kr", "https://app.nowait.co.kr")); // 허용할 출처 설정 config.setAllowedMethods(List.of("GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS")); // 메서드 허용 config.setAllowedHeaders(List.of("*")); //클라이언트가 보낼 수 있는 헤더 config.setExposedHeaders(List.of("Authorization")); //클라이언트(브라우저)가 접근할 수 있는 헤더 지정 - // config.setAllowCredentials(true); // 쿠키 포함 허용 UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", config); //** 뜻은 모든 URL 경로에 적용한다는 의미 + source.registerCorsConfiguration("/**", config); // 모든 URL 경로에 적용한다는 의미 return source; } } + + diff --git a/security-front/src/main/java/com/nowait/auth/config/SecurityConfig.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/config/security/SecurityConfig.java similarity index 73% rename from security-front/src/main/java/com/nowait/auth/config/SecurityConfig.java rename to nowait-app-user-api/src/main/java/com/nowait/applicationuser/config/security/SecurityConfig.java index aa535f8c..2df7d8b0 100644 --- a/security-front/src/main/java/com/nowait/auth/config/SecurityConfig.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/config/security/SecurityConfig.java @@ -1,4 +1,4 @@ -package com.nowait.auth.config; +package com.nowait.applicationuser.config.security; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -14,11 +14,11 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.cors.CorsConfigurationSource; -import com.nowait.auth.jwt.JwtAuthorizationFilter; -import com.nowait.auth.jwt.JwtUtil; -import com.nowait.auth.oauth2.CustomOAuth2UserService; -import com.nowait.auth.oauth2.OAuth2LoginSuccessHandler; -import com.nowait.auth.service.CustomUserDetailService; +import com.nowait.applicationuser.oauth.service.CustomOAuth2UserService; +import com.nowait.applicationuser.oauth.oauth2.OAuth2LoginSuccessHandler; +import com.nowait.applicationuser.security.jwt.JwtAuthorizationFilter; +import com.nowait.applicationuser.security.jwt.JwtUtil; +import com.nowait.applicationuser.security.service.CustomUserDetailService; import lombok.RequiredArgsConstructor; @@ -27,13 +27,15 @@ @RequiredArgsConstructor public class SecurityConfig { private final CustomOAuth2UserService customOAuth2UserService; - private final OAuth2LoginSuccessHandler OAuth2LoginSuccessHandler; + private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; private final JwtUtil jwtUtil; private final CustomUserDetailService userDetailsService; + private final CorsConfigurationSource corsConfigurationSource; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http + .cors(cors -> cors.configurationSource(corsConfigurationSource)) // CSRF 방어 기능 비활성화 (jwt 토큰을 사용할 것이기에 필요없음) .csrf(AbstractHttpConfigurer::disable) // 시큐리티 폼 로그인 비활성화 @@ -46,29 +48,29 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .oauth2Login(oauth2 -> oauth2.userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint.userService(customOAuth2UserService) - ).successHandler(OAuth2LoginSuccessHandler) + ).successHandler(oAuth2LoginSuccessHandler) ) - // 세션 사용하지 않음 .sessionManagement(session -> - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) ) .authorizeHttpRequests(auth -> auth .requestMatchers( "/oauth2/authorization/kakao", // 카카오 로그인 요청 "/login/oauth2/code/**", // 카카오 인증 콜백 - "/api/refresh-token", // refresh token (토큰 갱신) - "orders/**", // 주문 관련 API - "/v1/menus/**", - "/v1/stores/**", - "/api/users/signup", - "/api/users/login", + "/v1/auth/token/refresh", // refresh token (토큰 갱신) + "/v1/auth/app/kakao/login", // 카카오 앱 로그인 + "/v1/stores/*/menus/**", // 모든 메뉴 조회 + "/v1/stores/*/tables/*/orders", // 주문 생성 및 조회 + "/v1/stores/*/payments/**", // 결제 관련 API "/swagger-ui/**", "/v3/api-docs/**", "/v3/api-docs.json", "/api-docs/**", "/swagger-resources/**", "/webjars/**", - "/demo-ui.html" + "/demo-ui.html", + "/nowait/management/**", + "/health-check" ) .permitAll() .anyRequest().authenticated() // 그외 요청은 허가된 사람만 인가 diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/exception/GlobalExceptionHandler.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..96a95d95 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/exception/GlobalExceptionHandler.java @@ -0,0 +1,317 @@ +package com.nowait.applicationuser.exception; + +import static com.nowait.common.exception.ErrorMessage.*; +import static org.springframework.http.HttpStatus.*; +import static org.springframework.http.HttpStatus.UNAUTHORIZED; + +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.validation.FieldError; +import org.springframework.validation.ObjectError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingRequestValueException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.multipart.MultipartException; + +import com.nowait.applicationuser.security.exception.ResourceNotFoundException; +import com.nowait.applicationuser.security.exception.UnauthorizedException; +import com.nowait.applicationuser.waiting.exception.WorkInProgressException; +import com.nowait.common.exception.ErrorMessage; +import com.nowait.common.exception.ErrorResponse; +import com.nowait.discord.service.DiscordAlarmService; +import com.nowait.domaincorerdb.department.exception.DepartmentNotFoundException; +import com.nowait.domaincorerdb.order.exception.DepositorNameTooLongException; +import com.nowait.domaincorerdb.order.exception.DuplicateOrderException; +import com.nowait.domaincorerdb.order.exception.OrderItemsEmptyException; +import com.nowait.domaincorerdb.order.exception.OrderParameterEmptyException; +import com.nowait.domaincorerdb.reservation.exception.AlreadyDeletedWaitingException; +import com.nowait.domaincorerdb.reservation.exception.DuplicateReservationException; +import com.nowait.domaincorerdb.reservation.exception.ReservationAddUnauthorizedException; +import com.nowait.domaincorerdb.reservation.exception.ReservationNotFoundException; +import com.nowait.domaincorerdb.reservation.exception.ReservationNumberIssueFailException; +import com.nowait.domaincorerdb.store.exception.StoreNotFoundException; +import com.nowait.domaincorerdb.store.exception.StoreWaitingDisabledException; +import com.nowait.domaincorerdb.storepayment.exception.StorePaymentNotFoundException; +import com.nowait.domaincorerdb.token.exception.BusinessException; +import com.nowait.domaincorerdb.user.exception.UserNotFoundException; +import com.nowait.domaincoreredis.reservation.exception.UserWaitingLimitExceededException; +import com.nowait.domainuserrdb.bookmark.exception.AlreadyDeletedBookmarkException; +import com.nowait.domainuserrdb.bookmark.exception.BookmarkNotFoundException; +import com.nowait.domainuserrdb.bookmark.exception.BookmarkOwnerMismatchException; +import com.nowait.domainuserrdb.bookmark.exception.DuplicateBookmarkException; + +import io.swagger.v3.oas.annotations.Hidden; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Hidden +@RequiredArgsConstructor +@RestControllerAdvice +public class GlobalExceptionHandler { + + private final DiscordAlarmService discordAlarmService; + + // Discord 알림 헬퍼 + private void alarm(Exception e, WebRequest request) { + discordAlarmService.sendDiscordUserAlarm(e, request); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(OAuth2AuthenticationException.class) + public ErrorResponse handlerOAuth2AuthenticationException(OAuth2AuthenticationException e, WebRequest request) { + alarm(e, request); + log.error("handleOAuth2AuthenticationException", e); + return new ErrorResponse("OAuth 인증 실패 : " + e.getMessage(), INVALID_INPUT_VALUE.getCode()); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(BusinessException.class) + public ErrorResponse handleBusinessException(BusinessException e, WebRequest request) { + alarm(e, request); + log.error("handleBusinessException", e); + return new ErrorResponse(e.getMessage(), e.getCode()); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(MethodArgumentNotValidException.class) + public ErrorResponse handleMethodArgumentNotValidException(MethodArgumentNotValidException e, WebRequest request) { + alarm(e, request); + log.error("handleMethodArgumentNotValidException", e); + Map errors = getErrors(e); + return new ErrorResponse(INVALID_INPUT_VALUE.getMessage(), INVALID_INPUT_VALUE.getCode(), errors); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(HttpMessageNotReadableException.class) + public ErrorResponse handleHttpMessageNotReadableException(HttpMessageNotReadableException e, WebRequest request) { + alarm(e, request); + log.error("handleHttpMessageNotReadableException", e); + return new ErrorResponse(INVALID_INPUT_VALUE.getMessage(), INVALID_INPUT_VALUE.getCode()); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(IllegalArgumentException.class) + public ErrorResponse handleIllegalArgumentException(IllegalArgumentException e, WebRequest request) { + alarm(e, request); + log.error("handleIllegalArgumentException", e); + return new ErrorResponse(e.getMessage(), INVALID_INPUT_VALUE.getCode()); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(MissingRequestValueException.class) + public ErrorResponse handleMissingRequestValueException(MissingRequestValueException e, WebRequest request) { + alarm(e, request); + log.error("handleMissingRequestValueException", e); + return new ErrorResponse(INVALID_INPUT_VALUE.getMessage(), INVALID_INPUT_VALUE.getCode()); + } + + @ResponseStatus(UNAUTHORIZED) + @ExceptionHandler(UnauthorizedException.class) + public ErrorResponse handleUnauthorizedException(UnauthorizedException e, WebRequest request) { + alarm(e, request); + log.error("handleUnauthorizedException", e); + return new ErrorResponse(e.getMessage(), e.getCode()); + } + + @ResponseStatus(NOT_FOUND) + @ExceptionHandler(ResourceNotFoundException.class) + public ErrorResponse handleResourceNotFoundException(ResourceNotFoundException e, WebRequest request) { + alarm(e, request); + log.error("handleResourceNotFoundException", e); + return new ErrorResponse(e.getMessage(), e.getCode()); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(MultipartException.class) + public ErrorResponse handleMultipartException(MultipartException e, WebRequest request) { + alarm(e, request); + log.error("handleMultipartException", e); + return new ErrorResponse(e.getMessage(), INVALID_INPUT_VALUE.getCode()); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(DuplicateBookmarkException.class) + public ErrorResponse handleDuplicateBookmarkException(DuplicateBookmarkException e, WebRequest request) { + alarm(e, request); + log.error("handleDuplicateBookmarkException", e); + return new ErrorResponse(e.getMessage(), ErrorMessage.DUPLICATE_BOOKMARK.getCode()); + } + + @ResponseStatus(FORBIDDEN) + @ExceptionHandler(BookmarkOwnerMismatchException.class) + public ErrorResponse bookmarkOwnerMismatchException(BookmarkOwnerMismatchException e, WebRequest request) { + alarm(e, request); + log.error("bookmarkOwnerMismatchException", e); + return new ErrorResponse(e.getMessage(), NOT_OWN_BOOKMARK.getCode()); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(BookmarkNotFoundException.class) + public ErrorResponse handleBookmarkNotFoundException( + BookmarkNotFoundException e, WebRequest request) { + alarm(e, request); + log.error("handleBookmarkNotFoundException", e); + return new ErrorResponse(e.getMessage(), NOT_FOUND_BOOKMARK.getCode()); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(AlreadyDeletedBookmarkException.class) + public ErrorResponse handleAlreadyDeleteBookmark( + AlreadyDeletedBookmarkException e, WebRequest request) { + alarm(e, request); + log.error("AlreadyDeletedBookmarkException", e); + return new ErrorResponse(e.getMessage(), ALREADY_DELETED_BOOKMARK.getCode()); + } + + @ResponseStatus(NOT_FOUND) + @ExceptionHandler(UserNotFoundException.class) + public ErrorResponse userNotFoundException(UserNotFoundException e, WebRequest request) { + alarm(e, request); + log.error("userNotFoundException", e); + return new ErrorResponse(e.getMessage(), NOT_FOUND_USER.getCode()); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(OrderParameterEmptyException.class) + public ErrorResponse orderParameterEmptyException(OrderParameterEmptyException e, WebRequest request) { + alarm(e, request); + log.error("orderParameterEmptyException", e); + return new ErrorResponse(e.getMessage(), ORDER_PARAMETER_EMPTY.getCode()); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(OrderItemsEmptyException.class) + public ErrorResponse orderItemsEmptyException(OrderItemsEmptyException e, WebRequest request) { + alarm(e, request); + log.error("orderItemsEmptyException", e); + return new ErrorResponse(e.getMessage(), ORDER_ITEMS_EMPTY.getCode()); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(DepositorNameTooLongException.class) + public ErrorResponse depositorNameTooLongException(DepositorNameTooLongException e, WebRequest request) { + alarm(e, request); + log.error("depositorNameTooLongException", e); + return new ErrorResponse(e.getMessage(), DEPOSITOR_NAME_TOO_LONG.getCode()); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(DuplicateOrderException.class) + public ErrorResponse duplicateOrderException(DuplicateOrderException e, WebRequest request) { + alarm(e, request); + log.error("duplicateOrderException", e); + return new ErrorResponse(e.getMessage(), ErrorMessage.DUPLICATE_ORDER.getCode()); + } + + @ResponseStatus(NOT_FOUND) + @ExceptionHandler(ReservationNotFoundException.class) + public ErrorResponse reservationNotFoundException(ReservationNotFoundException e, WebRequest request) { + alarm(e, request); + log.error("reservationNotFoundException", e); + return new ErrorResponse(e.getMessage(), NOTFOUND_RESERVATION.getCode()); + } + + @ResponseStatus(CONFLICT) + @ExceptionHandler(DuplicateReservationException.class) + public ErrorResponse duplicateReservationException(DuplicateReservationException e, WebRequest request) { + alarm(e, request); + log.error("duplicateReservationException", e); + return new ErrorResponse(e.getMessage(), DUPLICATE_RESERVATION.getCode()); + } + + @ResponseStatus(CONFLICT) + @ExceptionHandler(AlreadyDeletedWaitingException.class) + public ErrorResponse alreadyWaitingException(AlreadyDeletedWaitingException e, WebRequest request) { + alarm(e, request); + log.error("alreadyWaitingException", e); + return new ErrorResponse(e.getMessage(), ALREADY_DELETED_RESERVATION.getCode()); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(StoreWaitingDisabledException.class) + public ErrorResponse storeWaitingDisabledException(StoreWaitingDisabledException e, WebRequest request) { + alarm(e, request); + log.error("storeWaitingDisabledException", e); + return new ErrorResponse(e.getMessage(), STORE_WAITING_DISABLED.getCode()); + } + + @ResponseStatus(NOT_FOUND) + @ExceptionHandler(DepartmentNotFoundException.class) + public ErrorResponse handleDepartmentNotFoundException(DepartmentNotFoundException e, WebRequest request) { + alarm(e, request); + log.error("handleDepartmentNotFoundException", e); + return new ErrorResponse(e.getMessage(), DEPARTMENT_NOT_FOUND.getCode()); + } + + @ResponseStatus(NOT_FOUND) + @ExceptionHandler(StoreNotFoundException.class) + public ErrorResponse handleStoreNotFoundException(StoreNotFoundException e, WebRequest request) { + alarm(e, request); + log.error("handleStoreNotFoundException", e); + return new ErrorResponse(e.getMessage(), STORE_NOT_FOUND.getCode()); + } + + @ResponseStatus(NO_CONTENT) + @ExceptionHandler(StorePaymentNotFoundException.class) + public ErrorResponse handleStorePaymentNotFoundException(StorePaymentNotFoundException e, WebRequest request) { + alarm(e, request); + log.error("handleStorePaymentNotFoundException", e); + return new ErrorResponse(e.getMessage(), STORE_PAYMENT_NOT_FOUND.getCode()); + } + + @ResponseStatus(CONFLICT) + @ExceptionHandler(UserWaitingLimitExceededException.class) + public ErrorResponse handleUserWaitingLimitExceededException( + UserWaitingLimitExceededException e, WebRequest request) { + alarm(e, request); + log.error("handleUserWaitingLimitExceededException", e); + return new ErrorResponse(e.getMessage(), USER_WAITING_LIMIT_EXCEEDED.getCode()); + } + + @ResponseStatus(INTERNAL_SERVER_ERROR) + @ExceptionHandler(ReservationNumberIssueFailException.class) + public ErrorResponse handleReservationNumberIssueFailException( + ReservationNumberIssueFailException e, WebRequest request) { + alarm(e, request); + log.error("handleReservationNumberIssueFailException", e); + return new ErrorResponse(e.getMessage(), RESERVATION_NUMBER_ISSUE_FAIL.getCode()); + } + + @ResponseStatus(CONFLICT) + @ExceptionHandler(ReservationAddUnauthorizedException.class) + public ErrorResponse handleReservationAddUnauthorizedException( + ReservationAddUnauthorizedException e, WebRequest request) { + alarm(e, request); + log.error("handleReservationAddUnauthorizedException", e); + return new ErrorResponse(e.getMessage(), RESERVATION_ADD_UNAUTHORIZED.getCode()); + } + + @ResponseStatus(CONFLICT) + @ExceptionHandler(WorkInProgressException.class) + public ErrorResponse handleWorkInProgressException( + WorkInProgressException e, WebRequest request) { + alarm(e, request); + log.error("handleWorkInProgressException", e); + return new ErrorResponse(e.getMessage(), WORK_IN_PROGRESS.getCode()); + } + + // 공통 에러 Map 생성 + private static Map getErrors(MethodArgumentNotValidException e) { + return e.getBindingResult() + .getAllErrors() + .stream() + .filter(ObjectError.class::isInstance) + .collect(Collectors.toMap( + error -> error instanceof FieldError ? ((FieldError)error).getField() : error.getObjectName(), + ObjectError::getDefaultMessage, + (msg1, msg2) -> msg1 + ";" + msg2 + )); + } +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/menu/controller/MenuController.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/menu/controller/MenuController.java new file mode 100644 index 00000000..bd446191 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/menu/controller/MenuController.java @@ -0,0 +1,57 @@ +package com.nowait.applicationuser.menu.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.nowait.applicationuser.menu.service.MenuService; +import com.nowait.common.api.ApiUtils; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Tag(name = "Menu API", description = "메뉴 API") +@RestController +@RequestMapping("/v1/stores/{publicCode}/menus") +@RequiredArgsConstructor +@Slf4j +public class MenuController { + + private final MenuService menuService; + + @GetMapping + @Operation(summary = "가게의 모든 메뉴 조회", description = "특정 가게의 모든 메뉴를 조회합니다.") + @ApiResponse(responseCode = "200", description = "모든 메뉴를 조회 성공") + public ResponseEntity getMenusByStoreId(@PathVariable String publicCode) { + return ResponseEntity + .status(HttpStatus.OK) + .body( + ApiUtils.success( + menuService.getAllMenusByStoreId(publicCode) + ) + ); + } + + @GetMapping("/{menuId}") + @Operation( + summary = "메뉴 ID로 메뉴 조회", description = "특정 가게의 특정 메뉴를 ID로 조회합니다.") + @ApiResponse(responseCode = "200", description = "메뉴 조회 성공") + public ResponseEntity getMenuById( + @PathVariable String publicCode, + @PathVariable Long menuId + ) { + return ResponseEntity + .status(HttpStatus.OK) + .body( + ApiUtils.success( + menuService.getMenuById(publicCode, menuId) + ) + ); + } +} diff --git a/api-admin/src/main/java/com/example/apiadmin/menu/dto/MenuImageUploadResponse.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/menu/dto/MenuImageUploadResponse.java similarity index 78% rename from api-admin/src/main/java/com/example/apiadmin/menu/dto/MenuImageUploadResponse.java rename to nowait-app-user-api/src/main/java/com/nowait/applicationuser/menu/dto/MenuImageUploadResponse.java index 44db28d4..b8250451 100644 --- a/api-admin/src/main/java/com/example/apiadmin/menu/dto/MenuImageUploadResponse.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/menu/dto/MenuImageUploadResponse.java @@ -1,6 +1,6 @@ -package com.example.apiadmin.menu.dto; +package com.nowait.applicationuser.menu.dto; -import com.example.menu.entity.MenuImage; +import com.nowait.domaincorerdb.menu.entity.MenuImage; import lombok.Builder; import lombok.Getter; diff --git a/api-user/src/main/java/com/example/apiuser/menu/dto/MenuReadDto.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/menu/dto/MenuReadDto.java similarity index 70% rename from api-user/src/main/java/com/example/apiuser/menu/dto/MenuReadDto.java rename to nowait-app-user-api/src/main/java/com/nowait/applicationuser/menu/dto/MenuReadDto.java index 98dda1f2..ce8d6325 100644 --- a/api-user/src/main/java/com/example/apiuser/menu/dto/MenuReadDto.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/menu/dto/MenuReadDto.java @@ -1,8 +1,8 @@ -package com.example.apiuser.menu.dto; +package com.nowait.applicationuser.menu.dto; import java.util.List; -import com.example.menu.entity.Menu; +import com.nowait.domaincorerdb.menu.entity.Menu; import lombok.AllArgsConstructor; import lombok.Builder; @@ -17,6 +17,9 @@ public class MenuReadDto { private String name; private String description; private Integer price; + private Long sortOrder; + private Boolean isSoldOut; + private Boolean deleted; private List images; public static MenuReadDto fromEntity(Menu menu, List images) { @@ -26,6 +29,9 @@ public static MenuReadDto fromEntity(Menu menu, List im .name(menu.getName()) .description(menu.getDescription()) .price(menu.getPrice()) + .sortOrder(menu.getSortOrder()) + .isSoldOut(menu.getIsSoldOut()) + .deleted(menu.getDeleted()) .images(images) .build(); } diff --git a/api-user/src/main/java/com/example/apiuser/menu/dto/MenuReadResponse.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/menu/dto/MenuReadResponse.java similarity index 62% rename from api-user/src/main/java/com/example/apiuser/menu/dto/MenuReadResponse.java rename to nowait-app-user-api/src/main/java/com/nowait/applicationuser/menu/dto/MenuReadResponse.java index 755b243f..538479b0 100644 --- a/api-user/src/main/java/com/example/apiuser/menu/dto/MenuReadResponse.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/menu/dto/MenuReadResponse.java @@ -1,4 +1,4 @@ -package com.example.apiuser.menu.dto; +package com.nowait.applicationuser.menu.dto; import java.util.List; @@ -11,10 +11,12 @@ @Builder public class MenuReadResponse { + private String storeName; private List menuReadDto; - public static MenuReadResponse of(List menuReadDto) { + public static MenuReadResponse of(String storeName, List menuReadDto) { return MenuReadResponse.builder() + .storeName(storeName) .menuReadDto(menuReadDto) .build(); } diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/menu/repository/DistributedCachedMenuDao.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/menu/repository/DistributedCachedMenuDao.java new file mode 100644 index 00000000..133e9fa2 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/menu/repository/DistributedCachedMenuDao.java @@ -0,0 +1,42 @@ +package com.nowait.applicationuser.menu.repository; + +import java.time.Duration; +import java.util.List; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; +import org.springframework.stereotype.Component; + +import com.nowait.applicationuser.menu.dto.MenuImageUploadResponse; +import com.nowait.applicationuser.menu.dto.MenuReadDto; +import com.nowait.domaincorerdb.menu.repository.MenuRepository; +import com.nowait.domaincoreredis.common.cache.CacheInvalidationPublisher; +import com.nowait.domaincoreredis.common.cache.DistributedCachedDao; +import com.nowait.domaincoreredis.common.util.RedisKeyUtils; + +import io.micrometer.core.instrument.MeterRegistry; + +/** + * 주점별 메뉴 목록 캐시 (key: storeId) + * 메뉴/메뉴 이미지 변경은 admin-api 가 커밋 후 storeId 로 무효화 메시지를 발행한다 + */ +@Component +public class DistributedCachedMenuDao extends DistributedCachedDao> { + + private final MenuRepository menuRepository; + + // ttl 을 0s 로 주면 매 조회가 DB 로 간다 (캐시 끄기: 비교 측정, 장애 시 차단) + public DistributedCachedMenuDao(MenuRepository menuRepository, @Value("${nowait.cache.menu.ttl:1d}") Duration ttl, + RedisMessageListenerContainer container, CacheInvalidationPublisher publisher, MeterRegistry meterRegistry) { + super(RedisKeyUtils.buildMenuCacheChannel(), ttl, 1_000, container, publisher, meterRegistry); + this.menuRepository = menuRepository; + } + + @Override + protected List load(String storeId) { + return menuRepository.findAllWithImagesByStoreId(Long.valueOf(storeId)).entrySet().stream() + .map(e -> MenuReadDto.fromEntity(e.getKey(), + e.getValue().stream().map(MenuImageUploadResponse::fromEntity).toList())) + .toList(); + } +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/menu/service/MenuService.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/menu/service/MenuService.java new file mode 100644 index 00000000..3701598d --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/menu/service/MenuService.java @@ -0,0 +1,49 @@ +package com.nowait.applicationuser.menu.service; + +import org.springframework.stereotype.Service; + +import com.nowait.applicationuser.menu.dto.MenuReadDto; +import com.nowait.applicationuser.menu.dto.MenuReadResponse; +import com.nowait.applicationuser.menu.repository.DistributedCachedMenuDao; +import com.nowait.domaincorerdb.menu.exception.MenuNotFoundException; +import com.nowait.domaincorerdb.menu.exception.MenuParamEmptyException; +import com.nowait.domaincorerdb.store.entity.Store; +import com.nowait.domaincorerdb.store.exception.StoreNotFoundException; +import com.nowait.domaincorerdb.store.repository.StoreRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class MenuService { + + private final StoreRepository storeRepository; + private final DistributedCachedMenuDao menuDao; + + // @Transactional 을 걸지 않는다: 주점 조회로 잡힌 스냅샷 안에서 캐시 미스 로딩이 일어나면 + // 무효화 직후에도 커밋 전 메뉴가 TTL 동안 캐싱될 수 있다 + public MenuReadResponse getAllMenusByStoreId(String publicCode) { + if (publicCode == null) { + throw new MenuParamEmptyException(); + } + + Store store = storeRepository.findByPublicCodeAndDeletedFalse(publicCode) + .orElseThrow(StoreNotFoundException::new); + + return MenuReadResponse.of(store.getName(), menuDao.get(String.valueOf(store.getStoreId()))); + } + + public MenuReadDto getMenuById(String publicCode, Long menuId) { + if (publicCode == null || menuId == null) { + throw new MenuParamEmptyException(); + } + + Store store = storeRepository.findByPublicCodeAndDeletedFalse(publicCode) + .orElseThrow(StoreNotFoundException::new); + + return menuDao.get(String.valueOf(store.getStoreId())).stream() + .filter(menu -> menu.getMenuId().equals(menuId)) + .findFirst() + .orElseThrow(MenuNotFoundException::new); + } +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/oauth/controller/KakaoAppAuthController.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/oauth/controller/KakaoAppAuthController.java new file mode 100644 index 00000000..18dac6ae --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/oauth/controller/KakaoAppAuthController.java @@ -0,0 +1,35 @@ +package com.nowait.applicationuser.oauth.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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.nowait.applicationuser.oauth.dto.KakaoAppLoginRequest; +import com.nowait.applicationuser.oauth.dto.KakaoAppLoginResponse; +import com.nowait.applicationuser.oauth.service.KakaoAppLoginService; +import com.nowait.common.api.ApiUtils; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/v1/auth/app/kakao") +@RequiredArgsConstructor +public class KakaoAppAuthController { + + private final KakaoAppLoginService kakaoAppLoginService; + + @PostMapping("/login") + public ResponseEntity kakaoAppLogin(@RequestBody KakaoAppLoginRequest request) { + + KakaoAppLoginResponse response = kakaoAppLoginService.login(request); + + return ResponseEntity + .status(HttpStatus.OK) + .body( + ApiUtils.success(response) + ); + } +} diff --git a/security-front/src/main/java/com/nowait/auth/dto/KaKaoResponse.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/oauth/dto/KaKaoResponse.java similarity index 95% rename from security-front/src/main/java/com/nowait/auth/dto/KaKaoResponse.java rename to nowait-app-user-api/src/main/java/com/nowait/applicationuser/oauth/dto/KaKaoResponse.java index e745bd26..9456be8e 100644 --- a/security-front/src/main/java/com/nowait/auth/dto/KaKaoResponse.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/oauth/dto/KaKaoResponse.java @@ -1,4 +1,4 @@ -package com.nowait.auth.dto; +package com.nowait.applicationuser.oauth.dto; import java.util.Map; diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/oauth/dto/KakaoAppLoginRequest.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/oauth/dto/KakaoAppLoginRequest.java new file mode 100644 index 00000000..241b8604 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/oauth/dto/KakaoAppLoginRequest.java @@ -0,0 +1,13 @@ +package com.nowait.applicationuser.oauth.dto; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class KakaoAppLoginRequest { + private String kakaoAccessToken; +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/oauth/dto/KakaoAppLoginResponse.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/oauth/dto/KakaoAppLoginResponse.java new file mode 100644 index 00000000..4c6f36b5 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/oauth/dto/KakaoAppLoginResponse.java @@ -0,0 +1,22 @@ +package com.nowait.applicationuser.oauth.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class KakaoAppLoginResponse{ + private String accessToken; + private String refreshToken; + private Long userId; + private String email; + private String nickName; + private String profileImage; + private boolean phoneEntered; + private boolean marketingAgree; + private boolean isNewUser; +} diff --git a/security-front/src/main/java/com/nowait/auth/dto/OAuth2Response.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/oauth/dto/OAuth2Response.java similarity index 90% rename from security-front/src/main/java/com/nowait/auth/dto/OAuth2Response.java rename to nowait-app-user-api/src/main/java/com/nowait/applicationuser/oauth/dto/OAuth2Response.java index de987480..fb07ec78 100644 --- a/security-front/src/main/java/com/nowait/auth/dto/OAuth2Response.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/oauth/dto/OAuth2Response.java @@ -1,4 +1,4 @@ -package com.nowait.auth.dto; +package com.nowait.applicationuser.oauth.dto; public interface OAuth2Response { // 제공자 (ex. naver, kakao) diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/oauth/dto/OAuthUserResult.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/oauth/dto/OAuthUserResult.java new file mode 100644 index 00000000..01532eb5 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/oauth/dto/OAuthUserResult.java @@ -0,0 +1,13 @@ +package com.nowait.applicationuser.oauth.dto; + +import com.nowait.domaincorerdb.user.entity.User; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class OAuthUserResult { + private final User user; + private final boolean newUser; +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/oauth/oauth2/OAuth2LoginSuccessHandler.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/oauth/oauth2/OAuth2LoginSuccessHandler.java new file mode 100644 index 00000000..b741f9be --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/oauth/oauth2/OAuth2LoginSuccessHandler.java @@ -0,0 +1,58 @@ +package com.nowait.applicationuser.oauth.oauth2; + +import java.io.IOException; + +import org.springframework.http.ResponseCookie; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.nowait.applicationuser.token.dto.AuthenticationResponse; +import com.nowait.applicationuser.token.service.AuthTokenService; +import com.nowait.domaincorerdb.user.entity.User; +import com.nowait.domainuserrdb.oauth.dto.CustomOAuth2User; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +// 카카오 로그인 성공 시, 콜백 핸들러 +// 1. JWT 토큰 발급 +// - 이때, JWT payload는 보안상 최소한의 정보(userId, role)만 담겠다 +// 2. refreshToken만 DB에 저장 +// 3. JSON 응답으로, accessToken과 refreshToken 을 반환해준다. +@Component +@RequiredArgsConstructor +@Slf4j +public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + private final AuthTokenService authTokenService; + + @Override + @Transactional + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException { + + CustomOAuth2User customUserDetails = (CustomOAuth2User)authentication.getPrincipal(); + User user = customUserDetails.getUser(); + + AuthenticationResponse authenticationResponse = authTokenService.issueTokens(user); + + // 2. refreshToken을 HttpOnly 쿠키로 설정 (ResponseCookie로) + ResponseCookie refreshTokenCookie = ResponseCookie.from("refreshToken", authenticationResponse.getRefreshToken()) + .httpOnly(true) + .secure(false) // 운영환경에서는 true + .path("/") + .maxAge(30L * 24 * 60 * 60) // 30일 (초 단위) + .sameSite("Lax") + .build(); + + // 기존 방식 대신 ResponseCookie.toString()을 헤더로 추가 + response.setHeader("Set-Cookie", refreshTokenCookie.toString()); + + // 3. 프론트엔드로 리다이렉트 (accessToken만 쿼리로 전달) + String targetUrl = "https://app.nowait.co.kr/login/success?accessToken=" + authenticationResponse.getAccessToken(); + response.sendRedirect(targetUrl); + } +} diff --git a/security-front/src/main/java/com/nowait/auth/oauth2/CustomOAuth2UserService.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/oauth/service/CustomOAuth2UserService.java similarity index 54% rename from security-front/src/main/java/com/nowait/auth/oauth2/CustomOAuth2UserService.java rename to nowait-app-user-api/src/main/java/com/nowait/applicationuser/oauth/service/CustomOAuth2UserService.java index fbb16cfb..09b29e9a 100644 --- a/security-front/src/main/java/com/nowait/auth/oauth2/CustomOAuth2UserService.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/oauth/service/CustomOAuth2UserService.java @@ -1,6 +1,4 @@ -package com.nowait.auth.oauth2; - -import java.util.Optional; +package com.nowait.applicationuser.oauth.service; import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; @@ -8,13 +6,11 @@ import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; -import com.nowait.auth.dto.CustomOAuth2User; -import com.nowait.auth.dto.KaKaoResponse; -import com.nowait.auth.dto.OAuth2Response; -import com.nowaiting.common.enums.Role; -import com.nowaiting.common.enums.SocialType; -import com.nowaiting.user.entity.User; -import com.nowaiting.user.repository.UserRepository; +import com.nowait.applicationuser.oauth.dto.KaKaoResponse; +import com.nowait.applicationuser.oauth.dto.OAuth2Response; +import com.nowait.applicationuser.oauth.dto.OAuthUserResult; +import com.nowait.domaincorerdb.user.entity.User; +import com.nowait.domainuserrdb.oauth.dto.CustomOAuth2User; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -24,12 +20,13 @@ @RequiredArgsConstructor @Slf4j public class CustomOAuth2UserService extends DefaultOAuth2UserService { - private final UserRepository userRepository; + private final OAuthUserService oAuthUserService; @Override public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { OAuth2User oAuth2User = super.loadUser(userRequest); + // TODO : 해당 로그 필요한지 추후 확인 필요 log.info("CustomOAuth2UserService :: {}", oAuth2User); log.info("oAuthUser.getAttributes :: {}", oAuth2User.getAttributes()); @@ -43,28 +40,10 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic } // DB에 유저가 있는지 판단 - Optional foundUser = userRepository.findByEmail(oAuth2Response.getEmail()); - - // DB에 유저 없으면 - 회원가입 - if (foundUser.isEmpty()) { - - User user = User.builder() - .email(oAuth2Response.getEmail()) - .nickname(oAuth2Response.getNickName()) - .profileImage(oAuth2Response.getProfileImage()) - .socialType(SocialType.KAKAO) - .role(Role.USER) // 일반 유저 설정 - .build(); - - userRepository.save(user); + OAuthUserResult result = oAuthUserService.loadOrCreateUser(oAuth2Response); + User user = result.getUser(); + boolean newUser = result.isNewUser(); - return new CustomOAuth2User(user); - } else { - // DB에 유저 존재하면 - 로그인 진행 (이때 로그인 처리는 안하고, OAuth2LoginSuccessHandler에서 담당함) - User user = foundUser.get(); - - return new CustomOAuth2User(user); - } + return new CustomOAuth2User(user, newUser); } - } diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/oauth/service/KakaoAppLoginService.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/oauth/service/KakaoAppLoginService.java new file mode 100644 index 00000000..722dd223 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/oauth/service/KakaoAppLoginService.java @@ -0,0 +1,73 @@ +package com.nowait.applicationuser.oauth.service; + +import java.time.Instant; + +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +import com.nowait.applicationuser.oauth.dto.KakaoAppLoginRequest; +import com.nowait.applicationuser.oauth.dto.KakaoAppLoginResponse; +import com.nowait.applicationuser.token.dto.AuthenticationResponse; +import com.nowait.applicationuser.token.service.AuthTokenService; +import com.nowait.domaincorerdb.user.entity.User; +import com.nowait.domainuserrdb.oauth.dto.CustomOAuth2User; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class KakaoAppLoginService { + + private final CustomOAuth2UserService customOAuth2UserService; + private final ClientRegistrationRepository clientRegistrationRepository; + private final AuthTokenService authTokenService; + + public KakaoAppLoginResponse login(KakaoAppLoginRequest request) { + String kakaoAccessTokenValue = request.getKakaoAccessToken(); + + if (kakaoAccessTokenValue == null || kakaoAccessTokenValue.isEmpty()) { + throw new OAuth2AuthenticationException("Kakao Access Token is missing."); + } + + ClientRegistration kakaoRegistration = clientRegistrationRepository.findByRegistrationId("kakao"); + + if (kakaoRegistration == null) { + throw new OAuth2AuthenticationException("Kakao Client Registration not found."); + } + + OAuth2AccessToken kakaoAccessToken = new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, + kakaoAccessTokenValue, + Instant.now(), + null + ); + + OAuth2UserRequest userRequest = new OAuth2UserRequest( + kakaoRegistration, + kakaoAccessToken + ); + + OAuth2User oAuth2User = customOAuth2UserService.loadUser(userRequest); + CustomOAuth2User customUser = (CustomOAuth2User) oAuth2User; + User user = customUser.getUser(); + + AuthenticationResponse authenticationResponse = authTokenService.issueTokens(user); + + return KakaoAppLoginResponse.builder() + .accessToken(authenticationResponse.getAccessToken()) + .refreshToken(authenticationResponse.getRefreshToken()) + .userId(user.getId()) + .email(user.getEmail()) + .nickName(user.getNickname()) + .profileImage(user.getProfileImage()) + .phoneEntered(user.getPhoneEntered()) + .marketingAgree(user.getIsMarketingAgree()) + .isNewUser(customUser.isNewUser()) + .build(); + } +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/oauth/service/OAuthUserService.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/oauth/service/OAuthUserService.java new file mode 100644 index 00000000..96f5063f --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/oauth/service/OAuthUserService.java @@ -0,0 +1,49 @@ +package com.nowait.applicationuser.oauth.service; + +import java.time.LocalDateTime; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.nowait.applicationuser.oauth.dto.OAuth2Response; +import com.nowait.applicationuser.oauth.dto.OAuthUserResult; +import com.nowait.common.enums.Role; +import com.nowait.common.enums.SocialType; +import com.nowait.domaincorerdb.user.entity.User; +import com.nowait.domaincorerdb.user.repository.UserRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class OAuthUserService { + private final UserRepository userRepository; + + @Transactional + public OAuthUserResult loadOrCreateUser(OAuth2Response oAuth2Response) { + return userRepository.findByEmail(oAuth2Response.getEmail()) + .map(user -> new OAuthUserResult(user, false)) // 기존 유저 + .orElseGet(() -> { + User created = createUser(oAuth2Response); + return new OAuthUserResult(created, true); // 신규 유저 + }); + } + + private User createUser(OAuth2Response oAuth2Response) { + User user = User.builder() + .email(oAuth2Response.getEmail()) + .phoneNumber("") + .nickname(oAuth2Response.getNickName()) + .profileImage(oAuth2Response.getProfileImage()) + .socialType(SocialType.KAKAO) + .role(Role.USER) // 일반 유저 설정 + .storeId(0L) + .phoneEntered(false) + .isMarketingAgree(false) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + + return userRepository.save(user); + } +} diff --git a/api-user/src/main/java/com/example/apiuser/order/controller/OrderController.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/controller/OrderController.java similarity index 63% rename from api-user/src/main/java/com/example/apiuser/order/controller/OrderController.java rename to nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/controller/OrderController.java index 65238120..df3d2114 100644 --- a/api-user/src/main/java/com/example/apiuser/order/controller/OrderController.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/controller/OrderController.java @@ -1,4 +1,4 @@ -package com.example.apiuser.order.controller; +package com.nowait.applicationuser.order.controller; import java.util.List; @@ -11,37 +11,39 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import com.example.apiuser.order.dto.OrderCreateRequestDto; -import com.example.apiuser.order.dto.OrderCreateResponseDto; -import com.example.apiuser.order.dto.OrderItemListGetResponseDto; -import com.example.apiuser.order.service.OrderService; -import com.nowaiting.common.api.ApiUtils; +import com.nowait.applicationuser.order.dto.OrderCreateRequestDto; +import com.nowait.applicationuser.order.dto.OrderCreateResponseDto; +import com.nowait.applicationuser.order.dto.OrderResponseDto; +import com.nowait.applicationuser.order.service.OrderService; +import com.nowait.common.api.ApiUtils; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @Tag(name = "Order API", description = "주문 API") @RestController -@RequestMapping("/orders") +@RequestMapping("/v1/stores/{publicCode}/tables/{tableId}/orders") @RequiredArgsConstructor public class OrderController { private final OrderService orderService; - @PostMapping("/create/{storeId}/{tableId}") + @PostMapping @Operation(summary = "주문 생성", description = "특정 주점 - 특정 테이블에 대한 주문 생성") @ApiResponse(responseCode = "201", description = "주문 생성") public ResponseEntity createOrder( - @PathVariable Long storeId, + @PathVariable String publicCode, @PathVariable Long tableId, @RequestBody @Valid OrderCreateRequestDto orderCreateRequestDto, HttpSession session - ) { + ) { String sessionId = session.getId(); - OrderCreateResponseDto response = orderService.createOrder(storeId,tableId,orderCreateRequestDto,sessionId); + OrderCreateResponseDto response = orderService.createOrder(publicCode, tableId, orderCreateRequestDto, + sessionId); return ResponseEntity .status(HttpStatus.CREATED) .body( @@ -49,18 +51,21 @@ public ResponseEntity createOrder( ); } - @GetMapping("/items/{storeId}/{tableId}") + @GetMapping @Operation(summary = "테이블별 주문 아이템 조회", description = "비로그인(세션) 기준으로 테이블의 내 주문 목록만 조회") @ApiResponse(responseCode = "200", description = "주문 조회") public ResponseEntity getOrderItems( - @PathVariable Long storeId, + @PathVariable String publicCode, @PathVariable Long tableId, - HttpSession session + HttpServletRequest request ) { - // 세션ID 추출 (Spring이 세션 자동 관리) + HttpSession session = request.getSession(false); + if (session == null) { + // 프론트가 먼저 부트스트랩 안 했거나, 쿠키가 안 붙은 케이스 + return ResponseEntity.status(HttpStatus.OK).body(ApiUtils.success(List.of())); + } String sessionId = session.getId(); - - List orderItems = orderService.getOrderItems(storeId, tableId, sessionId); + List orderItems = orderService.getOrderItemsGroupByOrderId(publicCode, tableId, sessionId); return ResponseEntity. status(HttpStatus.OK) .body( diff --git a/api-user/src/main/java/com/example/apiuser/order/dto/CartItemDto.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/dto/CartItemDto.java similarity index 79% rename from api-user/src/main/java/com/example/apiuser/order/dto/CartItemDto.java rename to nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/dto/CartItemDto.java index 1b8fc52b..72b7b58e 100644 --- a/api-user/src/main/java/com/example/apiuser/order/dto/CartItemDto.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/dto/CartItemDto.java @@ -1,4 +1,4 @@ -package com.example.apiuser.order.dto; +package com.nowait.applicationuser.order.dto; import lombok.Builder; import lombok.Getter; diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/dto/OrderCreateRequestDto.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/dto/OrderCreateRequestDto.java new file mode 100644 index 00000000..89de6631 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/dto/OrderCreateRequestDto.java @@ -0,0 +1,27 @@ +package com.nowait.applicationuser.order.dto; + +import java.util.List; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Builder +public class OrderCreateRequestDto { + @NotBlank(message = "주문자 이름은 필수입니다") + @Size(max = 10, message = "주문자 이름은 10자 이하여야 합니다") + private final String depositorName; // 예약자 이름 + @NotEmpty(message = "주문 내역은 필수입니다") + private final List items; // 장바구니 항목 리스트 + @NotNull(message = "주문금액은 필수입니다") + @Positive(message = "주문금액은 양수여야 합니다") + private final int totalPrice; + +} diff --git a/api-user/src/main/java/com/example/apiuser/order/dto/OrderCreateResponseDto.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/dto/OrderCreateResponseDto.java similarity index 50% rename from api-user/src/main/java/com/example/apiuser/order/dto/OrderCreateResponseDto.java rename to nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/dto/OrderCreateResponseDto.java index 1293ac0f..f0f81e7f 100644 --- a/api-user/src/main/java/com/example/apiuser/order/dto/OrderCreateResponseDto.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/dto/OrderCreateResponseDto.java @@ -1,8 +1,11 @@ -package com.example.apiuser.order.dto; +package com.nowait.applicationuser.order.dto; +import java.time.LocalDateTime; import java.util.List; -import com.nowait.domainorder.entity.UserOrder; +import com.nowait.domaincorerdb.order.entity.OrderItem; +import com.nowait.domaincorerdb.order.entity.OrderStatus; +import com.nowait.domaincorerdb.order.entity.UserOrder; import lombok.AllArgsConstructor; import lombok.Builder; @@ -15,14 +18,24 @@ public class OrderCreateResponseDto { private Long orderId; // 주문 ID // 주문 상태 (예: "주문완료", "배송중" 등) private Long storeId; // 상점 ID private String storeName; // 상점 이름 + private String sessionId; + private String depositorName; private List orderItems; // 주문 항목 목록 + private OrderStatus status; + private Integer totalPrice; + private LocalDateTime createdAt; - public static OrderCreateResponseDto fromEntity(UserOrder order) { + public static OrderCreateResponseDto fromEntity(UserOrder order,List items) { return OrderCreateResponseDto.builder() .orderId(order.getId()) .storeId(order.getStore().getStoreId()) .storeName(order.getStore().getName()) - .orderItems(List.of()) + .sessionId(order.getSessionId()) + .depositorName(order.getDepositorName()) + .orderItems(items.stream().map(OrderItemResponseDTO::fromEntity).toList()) + .status(order.getStatus()) + .totalPrice(order.getTotalPrice()) + .createdAt(order.getCreatedAt()) .build(); } } diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/dto/OrderItemResponseDTO.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/dto/OrderItemResponseDTO.java new file mode 100644 index 00000000..f598fc96 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/dto/OrderItemResponseDTO.java @@ -0,0 +1,26 @@ +package com.nowait.applicationuser.order.dto; + +import com.nowait.domaincorerdb.order.entity.OrderItem; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class OrderItemResponseDTO { + private Long menuId; // 메뉴 ID + private String menuName; // 메뉴 이름 + private int quantity; // 수량 + + public static OrderItemResponseDTO fromEntity(OrderItem orderItem) { + return OrderItemResponseDTO.builder() + .menuId(orderItem.getMenu().getId()) + .menuName(orderItem.getMenu().getName()) + .quantity(orderItem.getQuantity()) + .build(); + } +} diff --git a/api-user/src/main/java/com/example/apiuser/order/dto/OrderItemListGetResponseDto.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/dto/OrderMenuDto.java similarity index 54% rename from api-user/src/main/java/com/example/apiuser/order/dto/OrderItemListGetResponseDto.java rename to nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/dto/OrderMenuDto.java index 02e24e3f..27ca1d62 100644 --- a/api-user/src/main/java/com/example/apiuser/order/dto/OrderItemListGetResponseDto.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/dto/OrderMenuDto.java @@ -1,6 +1,6 @@ -package com.example.apiuser.order.dto; +package com.nowait.applicationuser.order.dto; -import com.nowait.domainorder.entity.OrderItem; +import com.nowait.domaincorerdb.order.entity.OrderItem; import lombok.AllArgsConstructor; import lombok.Builder; @@ -11,19 +11,18 @@ @Builder @NoArgsConstructor @AllArgsConstructor -public class OrderItemListGetResponseDto { - private Long orderId; +public class OrderMenuDto { + private Long menuId; private String menuName; private Integer quantity; private Integer price; - public static OrderItemListGetResponseDto fromEntity(OrderItem orderItem) { - return OrderItemListGetResponseDto.builder() - .orderId(orderItem.getUserOrder().getId()) + public static OrderMenuDto fromEntity(OrderItem orderItem) { + return OrderMenuDto.builder() + .menuId(orderItem.getMenu().getId()) .menuName(orderItem.getMenu().getName()) .quantity(orderItem.getQuantity()) .price(orderItem.getMenu().getPrice()) .build(); - } } diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/dto/OrderResponseDto.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/dto/OrderResponseDto.java new file mode 100644 index 00000000..117c598e --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/dto/OrderResponseDto.java @@ -0,0 +1,23 @@ +package com.nowait.applicationuser.order.dto; + +import java.time.LocalDateTime; +import java.util.List; + +import com.nowait.domaincorerdb.order.entity.OrderStatus; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class OrderResponseDto { + private Long orderId; + private OrderStatus status; + private LocalDateTime createdAt; + private List items; + private Integer totalPrice; +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/service/OrderService.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/service/OrderService.java new file mode 100644 index 00000000..4f7f5b9e --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/service/OrderService.java @@ -0,0 +1,157 @@ +package com.nowait.applicationuser.order.service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.DigestUtils; + +import com.nowait.applicationuser.order.dto.CartItemDto; +import com.nowait.applicationuser.order.dto.OrderCreateRequestDto; +import com.nowait.applicationuser.order.dto.OrderCreateResponseDto; +import com.nowait.applicationuser.order.dto.OrderMenuDto; +import com.nowait.applicationuser.order.dto.OrderResponseDto; +import com.nowait.domaincorerdb.menu.entity.Menu; +import com.nowait.domaincorerdb.menu.exception.MenuNotFoundException; +import com.nowait.domaincorerdb.menu.repository.MenuRepository; +import com.nowait.domaincorerdb.order.entity.OrderItem; +import com.nowait.domaincorerdb.order.entity.OrderStatus; +import com.nowait.domaincorerdb.order.entity.UserOrder; +import com.nowait.domaincorerdb.order.exception.DepositorNameTooLongException; +import com.nowait.domaincorerdb.order.exception.DuplicateOrderException; +import com.nowait.domaincorerdb.order.exception.OrderItemsEmptyException; +import com.nowait.domaincorerdb.order.exception.OrderParameterEmptyException; +import com.nowait.domaincorerdb.order.repository.OrderItemRepository; +import com.nowait.domaincorerdb.order.repository.OrderRepository; +import com.nowait.domaincorerdb.store.entity.Store; +import com.nowait.domaincorerdb.store.exception.StoreNotFoundException; +import com.nowait.domaincorerdb.store.repository.StoreRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class OrderService { + private final OrderRepository orderRepository; + private final StoreRepository storeRepository; + private final MenuRepository menuRepository; + private final OrderItemRepository orderItemRepository; + + @Transactional + public OrderCreateResponseDto createOrder(String publicCode, Long tableId, + OrderCreateRequestDto orderCreateRequestDto, String sessionId) { + parameterValidation(publicCode, tableId, orderCreateRequestDto); + + // 💡 [중복 주문 방지] signature 생성 및 체크 + String signature = generateOrderSignature(publicCode, tableId, orderCreateRequestDto.getItems()); + checkDuplicateOrderSignature(signature); + + // 1. Store 조회 + Store store = storeRepository.findByPublicCodeAndDeletedFalse(publicCode) + .orElseThrow(StoreNotFoundException::new); + + // 2. UserOrder 생성 및 signature 저장 + UserOrder order = UserOrder.builder() + .tableId(tableId) + .store(store) + .signature(signature) // signature 저장 + .sessionId(sessionId) // sessionId 저장 + .depositorName(orderCreateRequestDto.getDepositorName()) + .status(OrderStatus.WAITING_FOR_PAYMENT) + .totalPrice(orderCreateRequestDto.getTotalPrice()) + + .build(); + UserOrder savedOrder = orderRepository.save(order); + + // 3. 메뉴 ID 리스트 수집 -> Map으로 캐싱 + List menuIds = orderCreateRequestDto.getItems().stream() + .map(CartItemDto::getMenuId) + .toList(); + + List menus = menuRepository.findAllById(menuIds); + Map menuMap = menus.stream() + .collect(Collectors.toMap(Menu::getId, Function.identity())); + + // 4. 각 장바구니 항목에 대해 OrderItem 생성 및 저장 + List orderItems = orderCreateRequestDto.getItems().stream() + .map(item -> { + Menu menu = Optional.ofNullable(menuMap.get(item.getMenuId())) + .orElseThrow(MenuNotFoundException::new); + return OrderItem.builder() + .userOrder(savedOrder) + .menu(menu) + .quantity(item.getQuantity()) + .build(); + }) + .collect(Collectors.toList()); + + orderItemRepository.saveAll(orderItems); + + // 5. 응답 반환 + return OrderCreateResponseDto.fromEntity(savedOrder, orderItems); + } + + @Transactional(readOnly = true) + public List getOrderItemsGroupByOrderId( + String publicCode, Long tableId, String sessionId) { + + List userOrders = orderRepository.findByStore_PublicCodeAndTableIdAndSessionId(publicCode, tableId, + sessionId); + + // orderId 기준으로 바로 변환 + return userOrders.stream() + .map(order -> OrderResponseDto.builder() + .orderId(order.getId()) + .status(order.getStatus()) + .createdAt(order.getCreatedAt()) + .items( + order.getOrderItems().stream() + .map(OrderMenuDto::fromEntity) + .toList() + ) + .totalPrice(order.getTotalPrice()) + .build()) + .toList(); + } + + private static void parameterValidation(String publicCode, Long tableId, + OrderCreateRequestDto orderCreateRequestDto) { + if (publicCode == null || tableId == null || orderCreateRequestDto == null) { + throw new OrderParameterEmptyException(); + } + if (orderCreateRequestDto.getItems() == null || orderCreateRequestDto.getItems().isEmpty()) { + throw new OrderItemsEmptyException(); + } + if (orderCreateRequestDto.getDepositorName() == null || orderCreateRequestDto.getDepositorName() + .trim() + .isEmpty()) { + throw new OrderParameterEmptyException(); + } + if (orderCreateRequestDto.getDepositorName().length() > 20) { + throw new DepositorNameTooLongException(); + } + } + + private String generateOrderSignature(String storeId, Long tableId, List items) { + String cartString = items.stream() + .sorted((a, b) -> a.getMenuId().compareTo(b.getMenuId())) // 메뉴 ID 기준 정렬 + .map(item -> item.getMenuId() + ":" + item.getQuantity()) + .collect(Collectors.joining(",")); + String raw = storeId + "-" + tableId + "-" + cartString; + return DigestUtils.md5DigestAsHex(raw.getBytes()); + } + + private void checkDuplicateOrderSignature(String signature) { + // 최근 2초 이내 동일 signature 주문이 있는지 검사 + LocalDateTime threshold = LocalDateTime.now().minusSeconds(2); + boolean exists = orderRepository.existsBySignatureAndCreatedAtAfter(signature, threshold); + if (exists) { + throw new DuplicateOrderException(); + } + } +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/controller/ReservationController.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/controller/ReservationController.java new file mode 100644 index 00000000..4e3d0701 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/controller/ReservationController.java @@ -0,0 +1,120 @@ +package com.nowait.applicationuser.reservation.controller; + +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.nowait.applicationuser.reservation.dto.MyWaitingQueueDto; +import com.nowait.applicationuser.reservation.dto.ReservationCreateRequestDto; +import com.nowait.applicationuser.reservation.dto.ReservationCreateResponseDto; +import com.nowait.applicationuser.reservation.dto.WaitingResponseDto; +import com.nowait.applicationuser.reservation.service.ReservationService; +import com.nowait.common.api.ApiUtils; +import com.nowait.domainuserrdb.oauth.dto.CustomOAuth2User; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Reservation API", description = "예약 API") +@RestController +@RequestMapping("/v1/users/me/waitings") +@RequiredArgsConstructor +public class ReservationController { + + private final ReservationService reservationService; + + // 성능 비교를 위해 남겨둔 레거시 웨이팅 등록 API + @PostMapping("/legacy/{storeId}") + @Operation(summary = "웨이팅 등록", description = "특정 주점에 대한 웨이팅 등록 레거시 현재는 사용하지 않음!!!!") + public ResponseEntity create( + @PathVariable Long storeId, + @AuthenticationPrincipal CustomOAuth2User customOAuth2User, + @RequestBody ReservationCreateRequestDto requestDto) { + ReservationCreateResponseDto response = reservationService.create(storeId, customOAuth2User, requestDto); + return ResponseEntity + .status(HttpStatus.CREATED) + .body( + ApiUtils.success( + response + ) + ); + } + + @PostMapping("/{storeId}") + @Operation(summary = "대기열 등록", description = "특정 주점에 대한 대기열 등록") + @ApiResponse(responseCode = "201", description = "대기열 등록") + public ResponseEntity createQueue( + @PathVariable Long storeId, + @AuthenticationPrincipal CustomOAuth2User customOAuth2User, + @RequestBody ReservationCreateRequestDto requestDto + ) { + WaitingResponseDto response = reservationService.registerWaiting(storeId, customOAuth2User, requestDto); + return ResponseEntity + .status(HttpStatus.CREATED) + .body( + ApiUtils.success( + response + ) + ); + } + + @GetMapping("/{storeId}") + @Operation(summary = "특정 주점의 본인 대기열 조회", description = "특정 주점에 대한 본인 대기열 조회") + @ApiResponse(responseCode = "200", description = "본인 대기열 조회") + public ResponseEntity getQueue( + @PathVariable Long storeId, + @AuthenticationPrincipal CustomOAuth2User customOAuth2User + ) { + WaitingResponseDto response = reservationService.myWaitingInfo(storeId, customOAuth2User); + return ResponseEntity + .ok() + .body( + ApiUtils.success( + response + ) + ); + } + + // TODO : 대기열 취소 시 예약 번호 사용하도록 변경 (현재는 주점 아이디로 처리 중) + @DeleteMapping("/{storeId}") + @Operation(summary = "내 대기열 취소", description = "특정 주점에 대한 대기열 취소") + @ApiResponse(responseCode = "200", description = "대기열 취소") + public ResponseEntity deleteQueue( + @PathVariable Long storeId, + @AuthenticationPrincipal CustomOAuth2User customOAuth2User + ) { + return ResponseEntity + .ok() + .body( + ApiUtils.success( + reservationService.cancelWaiting(storeId, customOAuth2User) + ) + ); + } + + @GetMapping + @Operation(summary = "내 모든 대기열 리스트 확인", description = "내가 신청한 모든 대기열 리스트 확인") + @ApiResponse(responseCode = "200", description = "대기열 리스트 조회") + public ResponseEntity getAllMyWaitings(@AuthenticationPrincipal CustomOAuth2User customOAuth2User) { + List response = reservationService.getAllMyWaitings(customOAuth2User); + return ResponseEntity + .ok() + .body( + ApiUtils.success( + response + ) + ); + } + +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/dto/MyWaitingQueueDto.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/dto/MyWaitingQueueDto.java new file mode 100644 index 00000000..0314d398 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/dto/MyWaitingQueueDto.java @@ -0,0 +1,43 @@ +package com.nowait.applicationuser.reservation.dto; + +import java.time.LocalDateTime; +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "내 대기 큐 정보 DTO") +public class MyWaitingQueueDto { + @Schema(description = "예약 ID", example = "1-20240720-0001") + private String reservationId; + @Schema(description = "주점 ID", example = "1") + private Long storeId; + @Schema(description = "주점 이름", example = "비어파티") + private String storeName; + @Schema(description = "학과 이름", example = "경영학과") + private String departmentName; + @Schema(description = "대기 순번", example = "1") + private Integer rank; + @Schema(description = "내 앞의 대기 팀 수", example = "3") + private Integer teamsAhead; + @Schema(description = "대기 인원(파티 사이즈)", example = "4") + private Integer partySize; // 파티 인원 + @Schema(description = "대기 상태", example = "WAITING") + private String status; + @Schema(description = "대기 등록 일시", example = "2024-07-20T18:00:00") + private LocalDateTime registeredAt; + @Schema(description = "주점 위치", example = "학생회관 1층 104호") + private String location; + @Schema(description = "프로필 이미지 URL", example = "https://cdn.gtable.com/profile/user1.jpg") + private String profileImageUrl; + @Schema(description = "배너 이미지 URL", example = "https://cdn.gtable.com/profile/user1.jpg") + private List bannerImageUrl; +} + diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/dto/MyWaitingStoreInfo.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/dto/MyWaitingStoreInfo.java new file mode 100644 index 00000000..eea3d273 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/dto/MyWaitingStoreInfo.java @@ -0,0 +1,13 @@ +package com.nowait.applicationuser.reservation.dto; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class MyWaitingStoreInfo { + private final Long storeId; + private final String storeName; + private final String departmentName; + private final String location; +} diff --git a/api-user/src/main/java/com/example/apiuser/reservation/dto/ReservationCreateRequestDto.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/dto/ReservationCreateRequestDto.java similarity index 72% rename from api-user/src/main/java/com/example/apiuser/reservation/dto/ReservationCreateRequestDto.java rename to nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/dto/ReservationCreateRequestDto.java index 03fbfb4f..c6871832 100644 --- a/api-user/src/main/java/com/example/apiuser/reservation/dto/ReservationCreateRequestDto.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/dto/ReservationCreateRequestDto.java @@ -1,4 +1,4 @@ -package com.example.apiuser.reservation.dto; +package com.nowait.applicationuser.reservation.dto; import lombok.Builder; import lombok.Getter; diff --git a/api-user/src/main/java/com/example/apiuser/reservation/dto/ReservationCreateResponseDto.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/dto/ReservationCreateResponseDto.java similarity index 84% rename from api-user/src/main/java/com/example/apiuser/reservation/dto/ReservationCreateResponseDto.java rename to nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/dto/ReservationCreateResponseDto.java index fa581589..0a07e466 100644 --- a/api-user/src/main/java/com/example/apiuser/reservation/dto/ReservationCreateResponseDto.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/dto/ReservationCreateResponseDto.java @@ -1,4 +1,4 @@ -package com.example.apiuser.reservation.dto; +package com.nowait.applicationuser.reservation.dto; import java.time.LocalDateTime; diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/dto/WaitingResponseDto.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/dto/WaitingResponseDto.java new file mode 100644 index 00000000..631c7771 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/dto/WaitingResponseDto.java @@ -0,0 +1,13 @@ +package com.nowait.applicationuser.reservation.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class WaitingResponseDto { + private final String reservationNumber; + private final int rank; + private final boolean reserved; // true면 예약, false면 대기 + private final int partySize; +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/dto/WaitingSnapshot.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/dto/WaitingSnapshot.java new file mode 100644 index 00000000..7f75bdbb --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/dto/WaitingSnapshot.java @@ -0,0 +1,13 @@ +package com.nowait.applicationuser.reservation.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class WaitingSnapshot { + private final Long rank; + private final Integer partySize; + private final String reservationId; + private final boolean isNew; +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/repository/WaitingUserRedisRepository.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/repository/WaitingUserRedisRepository.java new file mode 100644 index 00000000..bd340511 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/repository/WaitingUserRedisRepository.java @@ -0,0 +1,368 @@ +package com.nowait.applicationuser.reservation.repository; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Set; + +import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.ScanOptions; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Repository; + +import com.nowait.applicationuser.reservation.dto.WaitingSnapshot; +import com.nowait.domaincoreredis.common.util.RedisKeyUtils; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class WaitingUserRedisRepository { + private final StringRedisTemplate redisTemplate; + private static final String ADD_WAITING_LUA = """ + -- KEYS + -- 1: queueKey + -- 2: partyKey + -- 3: statusKey + -- 4: numberMapKey + -- 5: dailySeqKey + + -- ARGV + -- 1: userId + -- 2: timestamp (ms) + -- 3: partySize + -- 4: today (YYYYMMDD) + -- 5: storeId + -- 6: ttlMillis + + -- 1) ZADD NX + local added = redis.call('ZADD', KEYS[1], 'NX', ARGV[2], ARGV[1]) + + local isNew = 0 + local reservationId + + if added == 1 then + isNew = 1 + local seq = redis.call('INCR', KEYS[5]) + local seqStr = string.format('%04d', seq) + reservationId = ARGV[5] .. '-' .. ARGV[4] .. '-' .. seqStr + + redis.call('HSET', KEYS[4], ARGV[1], reservationId) + redis.call('HSET', KEYS[2], ARGV[1], ARGV[3]) + redis.call('HSET', KEYS[3], ARGV[1], 'WAITING') + else + reservationId = redis.call('HGET', KEYS[4], ARGV[1]) + end + + local rank = redis.call('ZRANK', KEYS[1], ARGV[1]) + + -- TTL: 키가 처음 생성된 경우만 + if redis.call('PTTL', KEYS[1]) == -1 then + for i = 1, #KEYS do + redis.call('PEXPIRE', KEYS[i], ARGV[6]) + end + end + + return {isNew, rank, ARGV[3], reservationId} + """; + + // 중복 등록 방지: 이미 있으면 추가X + // 특정 주점에 대한 예약 등록 + public String addToWaitingQueue(Long storeId, String userId, Integer partySize, long timestamp) { + String queueKey = RedisKeyUtils.buildWaitingKeyPrefix() + storeId; + String partyKey = RedisKeyUtils.buildWaitingPartySizeKeyPrefix() + storeId; + String statusKey = RedisKeyUtils.buildWaitingStatusKeyPrefix() + storeId; + String seqKey = RedisKeyUtils.buildReservationSeqKey(storeId); + String numberMapKey = RedisKeyUtils.buildReservationNumberKey(storeId); + String userMapKey = RedisKeyUtils.buildReservationUserKey(storeId); + + Boolean added = redisTemplate.opsForZSet().addIfAbsent(queueKey, userId, timestamp); + String reservationId; + + if (Boolean.TRUE.equals(added)) { + + reservationId = GenerateReservationNumber(seqKey, storeId); + + // 5) Hash에 저장 + redisTemplate.opsForHash().put(numberMapKey, userId, reservationId); + redisTemplate.opsForHash().put(userMapKey, reservationId, userId); + // 6) 기존 partySize, status, TTL 설정 + redisTemplate.opsForHash().put(partyKey, userId, partySize.toString()); + redisTemplate.opsForHash().put(statusKey, userId, "WAITING"); + + Duration ttl = calculateTTLUntilNext03AM(); + + redisTemplate.expire(queueKey, ttl); + redisTemplate.expire(partyKey, ttl); + redisTemplate.expire(statusKey, ttl); + redisTemplate.expire(seqKey, ttl); + redisTemplate.expire(numberMapKey, ttl); + } else { + Object stored = redisTemplate.opsForHash().get(numberMapKey, userId); + reservationId = stored != null ? stored.toString() : null; + } + + return reservationId; + } + + // 루아 스크립트 사용 + public WaitingSnapshot addToWaitingQueueLua( + Long storeId, + String userId, + Integer partySize, + long ts, + Duration ttl + ) { + String queueKey = RedisKeyUtils.buildWaitingKeyPrefix() + storeId; + String partyKey = RedisKeyUtils.buildWaitingPartySizeKeyPrefix() + storeId; + String statusKey = RedisKeyUtils.buildWaitingStatusKeyPrefix() + storeId; + String numberMapKey = RedisKeyUtils.buildReservationNumberKey(storeId); + + String today = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); + String dailySeqKey = RedisKeyUtils.buildReservationSeqKey(storeId) + ":" + today; + + List keys = List.of( + queueKey, + partyKey, + statusKey, + numberMapKey, + dailySeqKey + ); + + Object result = redisTemplate.execute( + new DefaultRedisScript<>(ADD_WAITING_LUA, List.class), + keys, + userId, + String.valueOf(ts), + String.valueOf(partySize), + today, + String.valueOf(storeId), + String.valueOf(ttl.toMillis()) + ); + + if (result == null) return null; + + @SuppressWarnings("unchecked") + List response = (List) result; + if (response == null || response.size() < 4) return null; + + boolean isNew = ((Long) response.get(0)) == 1L; + Long rank = response.get(1) == null ? null : (Long) response.get(1); + Integer ps = response.get(2) == null ? null : Integer.valueOf(response.get(2).toString()); + String reservationId = response.get(3) == null ? null : response.get(3).toString(); + + if (!isNew) { + // 중복 등록: Redis 재조회 없음 + return new WaitingSnapshot(rank, ps, reservationId, false); + } + + // 신규 등록 + return new WaitingSnapshot(rank, ps, reservationId, true); + } + + // 예약한 사람이 등록한 동반인원(partySize) 조회 + public Integer getPartySize(Long storeId, String userId) { + String partyKey = RedisKeyUtils.buildWaitingPartySizeKeyPrefix() + storeId; + Object value = redisTemplate.opsForHash().get(partyKey, userId); + return Integer.valueOf(value.toString()); + } + + // 예약자 대기순위 조회 + public Long getRank(Long storeId, String userId) { + String key = RedisKeyUtils.buildWaitingKeyPrefix() + storeId; + return redisTemplate.opsForZSet().rank(key, userId); + } + + // 예약 취소 + public boolean removeWaiting(Long storeId, String userId) { + String key = RedisKeyUtils.buildWaitingKeyPrefix() + storeId; + String partyKey = RedisKeyUtils.buildWaitingPartySizeKeyPrefix() + storeId; + String statusKey = RedisKeyUtils.buildWaitingStatusKeyPrefix() + storeId; + String reservationNumberKey = RedisKeyUtils.buildReservationNumberKey(storeId); + + redisTemplate.opsForZSet().remove(key, userId); + redisTemplate.opsForHash().delete(partyKey, userId); + redisTemplate.opsForHash().delete(statusKey, userId); + redisTemplate.opsForHash().delete(reservationNumberKey, userId); + + return true; + } + + // 예약 등록 시간 + public Long getWaitingTimestamp(Long storeId, String userId) { + String key = RedisKeyUtils.buildWaitingKeyPrefix() + storeId; + Double score = redisTemplate.opsForZSet().score(key, userId); + return score == null ? null : score.longValue(); + } + + // 사용자가 대기중인 전체 매장 목록 조회 + public List getUserWaitingStoreIds(String userId) { + String prefix = RedisKeyUtils.buildWaitingKeyPrefix(); + String pattern = prefix + "*"; + + // 1) SCAN 으로 모든 키 수집 + Set matchingKeys = new HashSet<>(); + ScanOptions options = ScanOptions.scanOptions() + .match(pattern) + .count(500) // 한 번에 스캔할 예상 키 개수 힌트 (조정 가능) + .build(); + + // 2) Cursor 로 비차단 스캔 + try (Cursor cursor = + redisTemplate.getConnectionFactory() + .getConnection() + .scan(options)) { + while (cursor.hasNext()) { + String key = new String(cursor.next(), StandardCharsets.UTF_8); + matchingKeys.add(key); + } + } + + if (matchingKeys.isEmpty()) { + return Collections.emptyList(); + } + + // 2) ZSet 타입 키만 필터링 + List zsetKeys = matchingKeys.stream() + .filter(key -> "zset".equals(redisTemplate.type(key).code())) + .toList(); + + if (zsetKeys.isEmpty()) { + return Collections.emptyList(); + } + + // 3) 파이프라인으로 zRank 한 번에 조회 + List pipelineResults = redisTemplate.executePipelined( + (RedisCallback)conn -> { + byte[] uid = redisTemplate.getStringSerializer().serialize(userId); + for (String key : zsetKeys) { + byte[] rawKey = redisTemplate.getStringSerializer().serialize(key); + conn.zRank(rawKey, uid); + } + return null; + } + ); + + // 4) zsetKeys 로 루프를 돌며 결과 매핑 + List result = new ArrayList<>(); + Iterator it = pipelineResults.iterator(); + for (String key : zsetKeys) { + Object rankObj = it.next(); // pipelineResults 개수와 정확히 매칭 + if (rankObj != null) { + long storeId = Long.parseLong(key.substring(prefix.length())); + result.add(storeId); + } + } + return result; + } + + // 상태값 조회 + public String getWaitingStatus(Long storeId, String userId) { + String statusKey = RedisKeyUtils.buildWaitingStatusKeyPrefix() + storeId; + Object value = redisTemplate.opsForHash().get(statusKey, userId); + return value == null ? null : value.toString(); + } + + // 사용자가 해당 스토어 대기열에 있는지 여부 반환 + public boolean isUserWaiting(Long storeId, String userId) { + return getRank(storeId, userId) != null; + } + + // ReservationNumber 조회 + public String getReservationId(Long storeId, String userId) { + String numberMapKey = RedisKeyUtils.buildReservationNumberKey(storeId); + Object val = redisTemplate.opsForHash().get(numberMapKey, userId); + + return val != null ? val.toString() : null; + } + + // 6) TTL 계산: 다음날 03:00 까지 남은 시간 + public Duration calculateTTLUntilNext03AM() { + // 6-1) Asia/Seoul 기준으로 오늘 자정(내일 00:00) 구하기 + ZoneId zone = ZoneId.of("Asia/Seoul"); + LocalDateTime now = LocalDateTime.now(zone); + LocalDateTime midnight = now.toLocalDate().plusDays(1).atTime(3, 0); + + // 6-2) TTL 남은 초 계산 + long secondsUntilMidnight = now.until(midnight, ChronoUnit.SECONDS); + + return Duration.ofSeconds(secondsUntilMidnight); + } + + // 예약 번호 생성 + public String GenerateReservationNumber(String seqKey, Long storeId) { + // 2) 일일 시퀀스: 날짜별로 초기화하려면 + String today = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); // YYYYMMDD + String dailySeqKey = seqKey + ":" + today; // ex. reservation:seq:5:20250728 + + // atomic increment + Long seq = redisTemplate.opsForValue().increment(dailySeqKey, 1); + + // 3) 4자리 0패딩 + String seqStr = String.format("%04d", seq); + + // 4) 최종 ID 조합 + String reservationId = storeId + "-" + today + "-" + seqStr; + + return reservationId; + } + + public WaitingSnapshot getWaitingSnapshot(Long storeId, String userId) { + String queueKey = RedisKeyUtils.buildWaitingKeyPrefix() + storeId; + String partyKey = RedisKeyUtils.buildWaitingPartySizeKeyPrefix() + storeId; + String numberMapKey = RedisKeyUtils.buildReservationNumberKey(storeId); + + List results = redisTemplate.executePipelined((RedisCallback) conn -> { + byte[] qk = redisTemplate.getStringSerializer().serialize(queueKey); + byte[] pk = redisTemplate.getStringSerializer().serialize(partyKey); + byte[] nk = redisTemplate.getStringSerializer().serialize(numberMapKey); + byte[] uid = redisTemplate.getStringSerializer().serialize(userId); + + conn.zRank(qk, uid); + conn.hGet(pk, uid); + conn.hGet(nk, uid); + return null; + }); + + if (results == null || results.size() < 3) { + return new WaitingSnapshot(null, null, null, false); + } + + // 1) rank + Long rank = (results.get(0) instanceof Long r) ? r : null; + + // 2) partySize + Integer partySize = null; + Object psObj = results.get(1); + if (psObj instanceof String s) { + partySize = Integer.valueOf(s); + } else if (psObj instanceof byte[] b) { + String deserialized = redisTemplate.getStringSerializer().deserialize(b); + partySize = deserialized != null ? Integer.valueOf(deserialized) : null; + } + + // 3) reservationId + String reservationId = null; + Object ridObj = results.get(2); + if (ridObj instanceof String s) { + reservationId = s; + } else if (ridObj instanceof byte[] b) { + reservationId = redisTemplate.getStringSerializer().deserialize(b); + } + + return new WaitingSnapshot(rank, partySize, reservationId, false); + } +} + + diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/service/ReservationService.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/service/ReservationService.java new file mode 100644 index 00000000..57ca9cb5 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/reservation/service/ReservationService.java @@ -0,0 +1,420 @@ +package com.nowait.applicationuser.reservation.service; + +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.nowait.applicationuser.reservation.dto.MyWaitingQueueDto; +import com.nowait.applicationuser.reservation.dto.ReservationCreateRequestDto; +import com.nowait.applicationuser.reservation.dto.ReservationCreateResponseDto; +import com.nowait.applicationuser.reservation.dto.WaitingResponseDto; +import com.nowait.applicationuser.reservation.dto.WaitingSnapshot; +import com.nowait.applicationuser.reservation.repository.WaitingUserRedisRepository; +import com.nowait.common.enums.ReservationStatus; +import com.nowait.common.enums.Role; +import com.nowait.domaincorerdb.department.entity.Department; +import com.nowait.domaincorerdb.department.repository.DepartmentRepository; +import com.nowait.domaincorerdb.reservation.entity.Reservation; +import com.nowait.domaincorerdb.reservation.exception.DuplicateReservationException; +import com.nowait.domaincorerdb.reservation.exception.ReservationAddUnauthorizedException; +import com.nowait.domaincorerdb.reservation.exception.ReservationNotFoundException; +import com.nowait.domaincorerdb.reservation.exception.ReservationNumberIssueFailException; +import com.nowait.domaincorerdb.reservation.repository.ReservationRepository; +import com.nowait.domaincorerdb.store.entity.ImageType; +import com.nowait.domaincorerdb.store.entity.Store; +import com.nowait.domaincorerdb.store.entity.StoreImage; +import com.nowait.domaincorerdb.store.exception.StoreNotFoundException; +import com.nowait.domaincorerdb.store.exception.StoreWaitingDisabledException; +import com.nowait.domaincorerdb.store.repository.StoreImageRepository; +import com.nowait.domaincorerdb.store.repository.StoreRepository; +import com.nowait.domaincorerdb.user.entity.User; +import com.nowait.domaincorerdb.user.exception.UserNotFoundException; +import com.nowait.domaincorerdb.user.repository.UserRepository; +import com.nowait.domaincoreredis.common.util.RedisKeyUtils; +import com.nowait.domaincoreredis.reservation.exception.UserWaitingLimitExceededException; +import com.nowait.domaincoreredis.reservation.repository.WaitingPermitLuaRepository; +import com.nowait.domainuserrdb.oauth.dto.CustomOAuth2User; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ReservationService { + + private final ReservationRepository reservationRepository; + private final StoreRepository storeRepository; + private final UserRepository userRepository; + private final WaitingUserRedisRepository waitingUserRedisRepository; + private final DepartmentRepository departmentRepository; + private final StoreImageRepository storeImageRepository; + private final WaitingPermitLuaRepository waitingPermitLuaRepository; + private final RedisTemplate redisTemplate; + private static final int USER_LIMIT = 3; + private static final long LEASE_MS = 30_000; // 20~60초 권장 + + /** + * 웨이팅 등록 기존 로직 + */ + // public WaitingResponseDto registerWaiting( + // Long storeId, CustomOAuth2User customOAuth2User, ReservationCreateRequestDto requestDto + // ) { + // // Store 유효성 검증 추가 + // Store store = storeRepository.findById(storeId) + // .orElseThrow(StoreNotFoundException::new); + // if (Boolean.FALSE.equals(store.getIsActive())) + // throw new StoreWaitingDisabledException(); + // + // // User Role 검증 추가 + // User user = userRepository.findById(customOAuth2User.getUserId()) + // .orElseThrow(UserNotFoundException::new); + // if (user.getRole() == Role.MANAGER) { + // throw new IllegalArgumentException("Manager cannot register waiting"); + // } + // + // String userId = customOAuth2User.getUserId().toString(); + // long timestamp = System.currentTimeMillis(); + // + // // 예약 신청 유저 큐(queue)에 추가 + // String reservationId = waitingUserRedisRepository.addToWaitingQueue(storeId, userId, requestDto.getPartySize(), + // timestamp); + // if (reservationId == null) { + // throw new IllegalStateException("예약 번호 발급 실패"); + // } + // + // // 신규 등록/기존 등록 관계없이 내 순번, 전체 인원 반환 + // Long rank = waitingUserRedisRepository.getRank(storeId, userId); + // return WaitingResponseDto.builder() + // .reservationNumber(reservationId) + // .rank(rank == null ? -1 : rank.intValue() + 1) + // .partySize(requestDto.getPartySize() == null ? 0 : requestDto.getPartySize()) + // .build(); + // } + public WaitingResponseDto registerWaiting(Long storeId, CustomOAuth2User principal, + ReservationCreateRequestDto dto) { + + // 0) 스토어/유저 검증 복구 + Store store = storeRepository.findById(storeId).orElseThrow(StoreNotFoundException::new); + if (Boolean.FALSE.equals(store.getIsActive())) + throw new StoreWaitingDisabledException(); + + User user = userRepository.findById(principal.getUserId()).orElseThrow(UserNotFoundException::new); + if (user.getRole() == Role.MANAGER) + throw new ReservationAddUnauthorizedException(); + + // (기존 유효성 검사 동일) + String userId = user.getId().toString(); + Duration ttlTo3am = waitingUserRedisRepository.calculateTTLUntilNext03AM(); + + // 1) 임대 획득 + String token = UUID.randomUUID().toString(); + int attempts = 0; + while (true) { + boolean ok = waitingPermitLuaRepository.acquireLease(userId, token, System.currentTimeMillis(), LEASE_MS, + USER_LIMIT, ttlTo3am); + if (ok) + break; + if (++attempts >= 3) + throw new UserWaitingLimitExceededException(); + try { + Thread.sleep((long)(5 * Math.pow(3, attempts - 1))); + } catch (InterruptedException ignored) { + } + } + + // 2) 임대 획득 후 중복 체크 + WaitingSnapshot existingSnapshot = waitingUserRedisRepository.getWaitingSnapshot(storeId, userId); + if (existingSnapshot != null && existingSnapshot.getRank() != null) { + waitingPermitLuaRepository.releaseLease(userId, token); + throw new DuplicateReservationException(); + } + + WaitingSnapshot snapshot = null; + try { + // 2) 스토어 큐 등록(기존 메서드 그대로) + long ts = System.currentTimeMillis(); + + snapshot = waitingUserRedisRepository.addToWaitingQueueLua( + storeId, userId, dto.getPartySize(), ts, ttlTo3am + ); + + if (snapshot == null || snapshot.getReservationId() == null) + throw new ReservationNumberIssueFailException(); + + + if (snapshot.isNew()) { + waitingPermitLuaRepository.finalizeActive( + userId, + token, + String.valueOf(storeId), + snapshot.getReservationId(), + ttlTo3am + ); + } else { + // Lua 스크립트 중복 감지 케이스 + waitingPermitLuaRepository.releaseLease(userId, token); + throw new DuplicateReservationException(); + } + + // 3) 확정(holding → active) + WaitingSnapshot after = waitingUserRedisRepository.getWaitingSnapshot(storeId, userId); + if (after == null) + throw new ReservationNumberIssueFailException(); + + // 4) 응답 + return WaitingResponseDto.builder() + .reservationNumber(after.getReservationId()) + .rank(after.getRank() == null ? -1 : after.getRank().intValue() + 1) + .partySize(after.getPartySize() == null ? 0 : after.getPartySize()) + .build(); + + } catch (RuntimeException e) { + // 실패 시 임대 반납 + if (snapshot == null || (snapshot.isNew())) { + waitingPermitLuaRepository.releaseLease(userId, token); + } + throw e; + } + } + + public WaitingResponseDto myWaitingInfo(Long storeId, CustomOAuth2User customOAuth2User) { + String userId = customOAuth2User.getUserId().toString(); + // 입력 검증 추가 + if (storeId == null || userId.trim().isEmpty()) { + throw new IllegalArgumentException("Invalid storeId or userId"); + } + + Long rank = waitingUserRedisRepository.getRank(storeId, userId); + Integer partySize = waitingUserRedisRepository.getPartySize(storeId, userId); + String reservationId = waitingUserRedisRepository.getReservationId(storeId, userId); + + if (reservationId == null) { + throw new ReservationNotFoundException(); + } + + return WaitingResponseDto.builder() + .reservationNumber(reservationId) + .rank(rank == null ? -1 : rank.intValue() + 1) + .partySize(partySize == null ? 0 : partySize) + .build(); + } + + public boolean cancelWaiting(Long storeId, CustomOAuth2User customOAuth2User) { + String userId = customOAuth2User.getUserId().toString(); + if (storeId == null) { + throw new StoreNotFoundException(); + } + + if (userId.trim().isEmpty()) { + throw new UserNotFoundException(); + } + + String reservationNumber = waitingUserRedisRepository.getReservationId(storeId, userId); + if (reservationNumber == null) { + throw new ReservationNotFoundException(); + } + Integer partySize = waitingUserRedisRepository.getPartySize(storeId, userId); + Long ts = waitingUserRedisRepository.getWaitingTimestamp(storeId, userId); + + + // 대기열에서 제거 및 결과 반환 + if (reservationRepository.existsReservationByReservationNumber(reservationNumber)) { + Reservation reservation = Reservation.builder() + .reservationNumber(reservationNumber) + .partySize(partySize) + .status(ReservationStatus.CANCELLED) + .store(storeRepository.getReferenceById(storeId)) + .user(userRepository.getReferenceById(Long.parseLong(userId))) + .updatedAt(LocalDateTime.now()) + .requestedAt(ts != null ? LocalDateTime.ofInstant(Instant.ofEpochMilli(ts), ZoneId.of("Asia/Seoul")) + : LocalDateTime.now()) + .build(); + + reservationRepository.save(reservation); + } + + waitingUserRedisRepository.removeWaiting(storeId, userId); + waitingPermitLuaRepository.removeActiveMember(userId, String.valueOf(storeId), reservationNumber); + + return true; + } + + //TODO 성능 개선 필요 + public List getAllMyWaitings(CustomOAuth2User customOAuth2User) { + String userId = customOAuth2User.getUserId().toString(); + + Set members = waitingPermitLuaRepository.getActiveMembers(userId); + if (members.isEmpty()) + return Collections.emptyList(); + + Map activeResIdByStore = members.stream() + .map(m -> m.split(":", 2)) + .filter(a -> a.length == 2) + .collect(Collectors.toMap(a -> Long.parseLong(a[0]), a -> a[1], (a, b) -> a)); + + // 1) 현재 SCAN 기반으로 얻어온 storeId 리스트 + // List storeIds = waitingUserRedisRepository.getUserWaitingStoreIds(userId); + // if (storeIds.isEmpty()) + // return Collections.emptyList(); + List storeIds = members.stream() + .map(m -> Long.parseLong(m.substring(0, m.indexOf(':')))) + .distinct() + .toList(); + + // 2) Store, Department 배치 조회 + List stores = storeRepository.findAllWithDepartmentByStoreIdIn(storeIds); + Map storeMap = stores.stream() + .collect(Collectors.toMap(Store::getStoreId, Function.identity())); + + // 3) Department 이름 조회 (departmentId 기준) + Set deptIds = stores.stream() + .map(Store::getDepartmentId) + .collect(Collectors.toSet()); + Map deptNameMap = departmentRepository.findAllById(deptIds).stream() + .collect(Collectors.toMap(Department::getId, Department::getName)); + + // 4) StoreImage 조회 (프로필 + 배너) + List neededTypes = List.of(ImageType.PROFILE, ImageType.BANNER); + List images = storeImageRepository.findAllByStore_StoreIdInAndImageTypeIn(storeIds, neededTypes); + + Map profileMap = images.stream() + .filter(img -> img.getImageType() == ImageType.PROFILE) + .collect(Collectors.toMap( + img -> img.getStore().getStoreId(), + StoreImage::getImageUrl, + (first, second) -> first + )); + Map> bannerMap = images.stream() + .filter(img -> img.getImageType() == ImageType.BANNER) + .collect(Collectors.groupingBy( + img -> img.getStore().getStoreId(), + Collectors.mapping(StoreImage::getImageUrl, Collectors.toList()) + )); + + List pipelineResults = redisTemplate.executePipelined((RedisCallback)conn -> { + byte[] uid = redisTemplate.getStringSerializer().serialize(userId); + for (Long storeId : storeIds) { + String qk = RedisKeyUtils.buildWaitingKeyPrefix() + storeId; + String pk = RedisKeyUtils.buildWaitingPartySizeKeyPrefix() + storeId; + String sk = RedisKeyUtils.buildWaitingStatusKeyPrefix() + storeId; + String nk = RedisKeyUtils.buildReservationNumberKey(storeId); + conn.zRank(qk.getBytes(), uid); // 순번 (0-based) + conn.hGet(pk.getBytes(), uid); // partySize (String) + conn.zScore(qk.getBytes(), uid); // timestamp (Double) + conn.hGet(sk.getBytes(), uid); // status (String) + conn.hGet(nk.getBytes(), uid); // reservationId (String) + } + return null; + }); + + // 4) 결과 매핑 + List result = new ArrayList<>(storeIds.size()); + Iterator it = pipelineResults.iterator(); + for (Long storeId : storeIds) { + Store store = storeMap.get(storeId); + Long rankObj = (Long)it.next(); // null 가능 + String partyStr = (String)it.next(); + Double tsScore = (Double)it.next(); + String status = (String)it.next(); + String reservationId = (String)it.next(); + + + String activeReservationId = activeResIdByStore.get(storeId); + // 유령 감지: 큐에 없음/번호 불일치/번호 null + boolean zombie = (rankObj == null) || (reservationId == null) || + (activeReservationId != null && !activeReservationId.equals(reservationId)); + if (zombie) { + try { + String toRemove = (activeReservationId != null) ? activeReservationId + : (reservationId != null ? reservationId : null); + if (toRemove != null) { + waitingPermitLuaRepository.removeActiveMember(userId, String.valueOf(storeId), toRemove); + } + } catch (Exception ignore) {} + continue; // 목록에서 제외 + } + + + int rank = (rankObj != null ? rankObj.intValue() + 1 : 0); + int teamsAhead = (rankObj != null ? rankObj.intValue() : 0); + int partySize = (partyStr != null ? Integer.parseInt(partyStr) : 0); + LocalDateTime registeredAt = tsScore != null + ? Instant.ofEpochMilli(tsScore.longValue()) + .atZone(ZoneId.of("Asia/Seoul")) + .toLocalDateTime() + : null; + + result.add(MyWaitingQueueDto.builder() + .reservationId(reservationId) + .storeId(storeId) + .storeName(store.getName()) + .departmentName(deptNameMap.get(store.getDepartmentId())) + .location(store.getLocation()) + .profileImageUrl(profileMap.getOrDefault(storeId, "")) + .bannerImageUrl(bannerMap.getOrDefault(storeId, Collections.emptyList())) + .rank(rank) + .teamsAhead(teamsAhead) + .partySize(partySize) + .status(status) + .registeredAt(registeredAt) + .build() + ); + } + + return result; + } + + @Transactional + public ReservationCreateResponseDto create(Long storeId, CustomOAuth2User customOAuth2User, + ReservationCreateRequestDto requestDto) { + Store store = storeRepository.findById(storeId) + .orElseThrow(StoreNotFoundException::new); + User user = userRepository.findById(customOAuth2User.getUserId()) + .orElseThrow(UserNotFoundException::new); + // store 웨이팅 비활성화 여부 + if (Boolean.FALSE.equals(store.getIsActive())) + throw new StoreWaitingDisabledException(); + // 중복 예약 존재 여부 확인 + boolean hasOngoingReservation = reservationRepository.existsByUserAndStoreAndStatusIn( + user, + store, + List.of(ReservationStatus.WAITING, ReservationStatus.CALLING) + ); + if (hasOngoingReservation) { + throw new DuplicateReservationException(); + } + Reservation reservation = Reservation.builder() + .store(store) + .user(user) + .requestedAt(LocalDateTime.now()) + .status(ReservationStatus.WAITING) + .partySize(requestDto.getPartySize()) + .build(); + + Reservation saved = reservationRepository.save(reservation); + + return ReservationCreateResponseDto.builder() + .id(saved.getId()) + .storeId(saved.getStore().getStoreId()) + .userId(saved.getUser().getId()) + .requestedAt(saved.getRequestedAt()) + .status(saved.getStatus().name()) + .partySize(saved.getPartySize()) + .build(); + } +} + diff --git a/security-admin/src/main/java/com/nowait/adminsecurity/exception/RefreshTokenNotFoundException.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/security/exception/RefreshTokenNotFoundException.java similarity index 62% rename from security-admin/src/main/java/com/nowait/adminsecurity/exception/RefreshTokenNotFoundException.java rename to nowait-app-user-api/src/main/java/com/nowait/applicationuser/security/exception/RefreshTokenNotFoundException.java index e02f5114..bae58c83 100644 --- a/security-admin/src/main/java/com/nowait/adminsecurity/exception/RefreshTokenNotFoundException.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/security/exception/RefreshTokenNotFoundException.java @@ -1,6 +1,6 @@ -package com.nowait.adminsecurity.exception; +package com.nowait.applicationuser.security.exception; -import com.nowaiting.common.exception.ErrorMessage; +import com.nowait.common.exception.ErrorMessage; public class RefreshTokenNotFoundException extends ResourceNotFoundException { diff --git a/security-admin/src/main/java/com/nowait/adminsecurity/exception/ResourceNotFoundException.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/security/exception/ResourceNotFoundException.java similarity index 75% rename from security-admin/src/main/java/com/nowait/adminsecurity/exception/ResourceNotFoundException.java rename to nowait-app-user-api/src/main/java/com/nowait/applicationuser/security/exception/ResourceNotFoundException.java index 90b43f35..5615e137 100644 --- a/security-admin/src/main/java/com/nowait/adminsecurity/exception/ResourceNotFoundException.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/security/exception/ResourceNotFoundException.java @@ -1,6 +1,6 @@ -package com.nowait.adminsecurity.exception; +package com.nowait.applicationuser.security.exception; -import com.nowaiting.common.exception.ErrorMessage; +import com.nowait.common.exception.ErrorMessage; public abstract class ResourceNotFoundException extends RuntimeException { private final ErrorMessage errorMessage; diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/security/exception/TokenBadRequestException.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/security/exception/TokenBadRequestException.java new file mode 100644 index 00000000..7c2faeaa --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/security/exception/TokenBadRequestException.java @@ -0,0 +1,11 @@ +package com.nowait.applicationuser.security.exception; + +import com.nowait.common.exception.ErrorMessage; +import com.nowait.domaincorerdb.token.exception.BusinessException; + +public class TokenBadRequestException extends BusinessException { + public TokenBadRequestException() { + super(ErrorMessage.DOES_NOT_MATCH_REFRESH_TOKEN); + } + +} diff --git a/security-admin/src/main/java/com/nowait/adminsecurity/exception/UnauthorizedException.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/security/exception/UnauthorizedException.java similarity index 80% rename from security-admin/src/main/java/com/nowait/adminsecurity/exception/UnauthorizedException.java rename to nowait-app-user-api/src/main/java/com/nowait/applicationuser/security/exception/UnauthorizedException.java index 558e1e2a..1ebd7bcc 100644 --- a/security-admin/src/main/java/com/nowait/adminsecurity/exception/UnauthorizedException.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/security/exception/UnauthorizedException.java @@ -1,6 +1,6 @@ -package com.nowait.adminsecurity.exception; +package com.nowait.applicationuser.security.exception; -import com.nowaiting.common.exception.ErrorMessage; +import com.nowait.common.exception.ErrorMessage; public class UnauthorizedException extends RuntimeException { private final ErrorMessage errorMessage; diff --git a/security-front/src/main/java/com/nowait/auth/jwt/JwtAuthorizationFilter.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/security/jwt/JwtAuthorizationFilter.java similarity index 70% rename from security-front/src/main/java/com/nowait/auth/jwt/JwtAuthorizationFilter.java rename to nowait-app-user-api/src/main/java/com/nowait/applicationuser/security/jwt/JwtAuthorizationFilter.java index d55f9a5f..ca599ce8 100644 --- a/security-front/src/main/java/com/nowait/auth/jwt/JwtAuthorizationFilter.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/security/jwt/JwtAuthorizationFilter.java @@ -1,4 +1,4 @@ -package com.nowait.auth.jwt; +package com.nowait.applicationuser.security.jwt; import java.io.IOException; @@ -6,11 +6,10 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.OncePerRequestFilter; -import com.nowait.auth.dto.CustomOAuth2User; -import com.nowaiting.common.enums.Role; -import com.nowaiting.common.enums.SocialType; -import com.nowaiting.user.entity.User; - +import com.nowait.common.enums.Role; +import com.nowait.common.enums.SocialType; +import com.nowait.domaincorerdb.user.entity.User; +import com.nowait.domainuserrdb.oauth.dto.CustomOAuth2User; import io.jsonwebtoken.ExpiredJwtException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -36,7 +35,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse if (header == null || !header.startsWith("Bearer ")) { filterChain.doFilter(request, response); - log.info("JwtAuthorizationFilter 1 "); + log.debug("JwtAuthorizationFilter: Authorization 헤더가 없거나 Bearer 토큰 형식이 아님. JWT 인증 필터를 건너뜁니다. [header={}] ", header); return; } @@ -52,7 +51,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().print("access token expired"); - log.info("JwtAuthorizationFilter 2 "); + log.warn("JwtAuthorizationFilter: 만료된 AccessToken 입니다. 토큰 인증 거부, URI: {}", request.getRequestURI()); return; } @@ -64,7 +63,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().print("invalid access token"); - log.info("JwtAuthorizationFilter 3 "); + log.warn("JwtAuthorizationFilter: 잘못된 토큰 유형(accessToken 아님)으로 인증 요청. URI: {}, tokenCategory: {}", request.getRequestURI(), tokenCategory); return; } @@ -72,8 +71,15 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse Long userId = jwtUtil.getUserId(accessToken); String roleString = jwtUtil.getRole(accessToken); + if (userId == null || roleString == null) { + log.warn("JwtAuthorizationFilter: JWT에서 userId 또는 role 추출 실패. 토큰: {}", accessToken); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().print("invalid token"); + return; + } + User user = User.createUserWithId(userId, "sampleEmail", "sampleNickname", "sampleProfileImg" - , SocialType.KAKAO, Role.fromString(roleString)); + , SocialType.KAKAO, Role.fromString(roleString),5000L); CustomOAuth2User customOAuth2User = new CustomOAuth2User(user); @@ -84,7 +90,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse // 생성한 인증 정보를 SecurityContext에 설정 SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken); - log.info("JwtAuthorizationFilter 4 "); + log.info("JwtAuthorizationFilter: 인증 성공. userId={}, role={}, URI={}", userId, roleString, request.getRequestURI()); filterChain.doFilter(request, response); diff --git a/security-admin/src/main/java/com/nowait/adminsecurity/auth/jwt/JwtUtil.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/security/jwt/JwtUtil.java similarity index 90% rename from security-admin/src/main/java/com/nowait/adminsecurity/auth/jwt/JwtUtil.java rename to nowait-app-user-api/src/main/java/com/nowait/applicationuser/security/jwt/JwtUtil.java index 18dd2e02..3df48aab 100644 --- a/security-admin/src/main/java/com/nowait/adminsecurity/auth/jwt/JwtUtil.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/security/jwt/JwtUtil.java @@ -1,4 +1,4 @@ -package com.nowait.adminsecurity.auth.jwt; +package com.nowait.applicationuser.security.jwt; import java.nio.charset.StandardCharsets; import java.util.Date; @@ -23,11 +23,14 @@ public JwtUtil(@Value("${jwt.secret}") String secret) { ); } - public String createAccessToken(String tokenCategory, Long userId, String role, Long expiredMs) { + public String createAccessToken(String tokenCategory, Long userId, String role, boolean phoneEntered, + boolean marketingAgree, Long expiredMs) { return Jwts.builder() .claim("tokenCategory", tokenCategory) // accessToken .claim("userId", userId) .claim("role", role) + .claim("phoneEntered", phoneEntered) + .claim("marketingAgree", marketingAgree) .issuedAt(new Date(System.currentTimeMillis())) .expiration(new Date(System.currentTimeMillis() + expiredMs)) .signWith(secretKey) diff --git a/security-front/src/main/java/com/nowait/auth/service/CustomUserDetailService.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/security/service/CustomUserDetailService.java similarity index 78% rename from security-front/src/main/java/com/nowait/auth/service/CustomUserDetailService.java rename to nowait-app-user-api/src/main/java/com/nowait/applicationuser/security/service/CustomUserDetailService.java index 53550a56..d6445229 100644 --- a/security-front/src/main/java/com/nowait/auth/service/CustomUserDetailService.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/security/service/CustomUserDetailService.java @@ -1,13 +1,13 @@ -package com.nowait.auth.service; +package com.nowait.applicationuser.security.service; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Component; -import com.nowaiting.user.entity.MemberDetails; -import com.nowaiting.user.entity.User; -import com.nowaiting.user.repository.UserRepository; +import com.nowait.domaincorerdb.user.entity.MemberDetails; +import com.nowait.domaincorerdb.user.entity.User; +import com.nowait.domaincorerdb.user.repository.UserRepository; import lombok.RequiredArgsConstructor; diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/store/controller/StoreController.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/store/controller/StoreController.java new file mode 100644 index 00000000..2d085022 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/store/controller/StoreController.java @@ -0,0 +1,87 @@ +package com.nowait.applicationuser.store.controller; + +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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.nowait.applicationuser.store.service.StoreService; +import com.nowait.common.api.ApiUtils; +import com.nowait.domainuserrdb.oauth.dto.CustomOAuth2User; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Tag(name = "Store API", description = "주점(Store) API") +@RestController +@RequestMapping("/v1/stores") +@RequiredArgsConstructor +@Slf4j +public class StoreController { + + private final StoreService storeService; + + + @GetMapping + @Operation(summary = "모든 주점 페이지네이션 조회", description = "모든 주점을 페이지네이션으로 조회합니다.") + @ApiResponse(responseCode = "200", description = "모든 주점 페이지네이션 조회 성공") + public ResponseEntity getAllStoresByPageAndDepartments(Pageable pageable, @AuthenticationPrincipal CustomOAuth2User customOAuth2User) { + return ResponseEntity + .ok() + .body( + ApiUtils.success( + storeService.getAllStoresByPageAndDeparments(pageable, customOAuth2User) + ) + ); + } + + @GetMapping("/{publicCode}") + @Operation(summary = "주점 ID로 주점 상세 조회", description = "특정 주점을 ID로 조회합니다.") + @ApiResponse(responseCode = "200", description = "주점 상세 조회 성공") + public ResponseEntity getStoreById(@PathVariable String publicCode, @AuthenticationPrincipal CustomOAuth2User customOAuth2User) { + return ResponseEntity + .status(HttpStatus.OK) + .body( + ApiUtils.success( + storeService.getStoreByPublicCode(publicCode, customOAuth2User) + ) + ); + } + + @GetMapping("/search") + @Operation(summary = "주점 이름으로 주점 검색", description = "주점 이름을 기준으로 주점을 검색합니다.") + @ApiResponse(responseCode = "200", description = "주점 검색 성공") + public ResponseEntity searchStores(@RequestParam("keyword") String keyword) { + return ResponseEntity + .ok() + .body( + ApiUtils.success( + storeService.searchByKeywordNative(keyword) + ) + ); + } + + @GetMapping("/waiting-count") + @Operation(summary = "예약 많은순/적은순 주점 리스트 조회", description = "desc(대기 많은순) , asc(대기 적은순)") + @ApiResponse(responseCode = "200", description = "주점 대기순 정렬") + public ResponseEntity getStoreWaitingList( + @RequestParam(defaultValue = "desc") String order) { + boolean desc = !"asc".equalsIgnoreCase(order); // 기본: 대기 많은 순 + + return ResponseEntity + .ok() + .body( + ApiUtils.success( + storeService.getStoresByWaitingCount(desc) + ) + ); + } +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/store/dto/StoreDepartmentReadResponse.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/store/dto/StoreDepartmentReadResponse.java new file mode 100644 index 00000000..7047140e --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/store/dto/StoreDepartmentReadResponse.java @@ -0,0 +1,23 @@ +package com.nowait.applicationuser.store.dto; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Builder +public class StoreDepartmentReadResponse { + + private List storePageReadResponses; + private boolean hasNext; + + public static StoreDepartmentReadResponse of(List storePageReadResponses, boolean hasNext) { + return StoreDepartmentReadResponse.builder() + .storePageReadResponses(storePageReadResponses) + .hasNext(hasNext) + .build(); + } +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/store/dto/StoreDetailReadResponse.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/store/dto/StoreDetailReadResponse.java new file mode 100644 index 00000000..191eded0 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/store/dto/StoreDetailReadResponse.java @@ -0,0 +1,72 @@ +package com.nowait.applicationuser.store.dto; + +import java.time.LocalDateTime; +import java.util.List; + +import com.nowait.domaincorerdb.store.entity.ImageType; +import com.nowait.domaincorerdb.store.entity.Store; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Builder +public class StoreDetailReadResponse { + private Long storeId; + private String publicCode; + private Long bookmarkId; + private Boolean isBookmark; + private Long waitingCount; + private Boolean isWaiting; + private Long departmentId; + private String departmentName; + private String name; + private String location; + private String description; + private String noticeTitle; + private String noticeContent; + private String openTime; + private StoreImageUploadResponse profileImage; + private List bannerImages; + private Boolean isActive; + private Boolean deleted; + private LocalDateTime createdAt; + + public static StoreDetailReadResponse fromEntity( + Store store, Long bookmarkId, List allImages, String departmentName, Long waitingCount, Boolean isBookmark, Boolean isWaiting + ) { + + StoreImageUploadResponse profile = allImages.stream() + .filter(image -> image.getImageType() == ImageType.PROFILE) + .findFirst() + .orElse(null); + + List banners = allImages.stream() + .filter(image -> image.getImageType() == ImageType.BANNER) + .toList(); + + return StoreDetailReadResponse.builder() + .storeId(store.getStoreId()) + .publicCode(store.getPublicCode()) + .bookmarkId(bookmarkId) + .isBookmark(isBookmark) + .waitingCount(waitingCount) + .isWaiting(isWaiting) + .departmentId(store.getDepartmentId()) + .departmentName(departmentName) + .name(store.getName()) + .location(store.getLocation()) + .description(store.getDescription()) + .noticeTitle(store.getNoticeTitle()) + .noticeContent(store.getNoticeContent()) + .openTime(store.getOpenTime()) + .profileImage(profile) + .bannerImages(banners) + .isActive(store.getIsActive()) + .deleted(store.getDeleted()) + .createdAt(store.getCreatedAt()) + .build(); + } +} diff --git a/api-user/src/main/java/com/example/apiuser/store/dto/StoreImageUploadResponse.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/store/dto/StoreImageUploadResponse.java similarity index 53% rename from api-user/src/main/java/com/example/apiuser/store/dto/StoreImageUploadResponse.java rename to nowait-app-user-api/src/main/java/com/nowait/applicationuser/store/dto/StoreImageUploadResponse.java index 40cb984d..39dbf2a0 100644 --- a/api-user/src/main/java/com/example/apiuser/store/dto/StoreImageUploadResponse.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/store/dto/StoreImageUploadResponse.java @@ -1,6 +1,7 @@ -package com.example.apiuser.store.dto; +package com.nowait.applicationuser.store.dto; -import com.example.domainstore.entity.StoreImage; +import com.nowait.domaincorerdb.store.entity.ImageType; +import com.nowait.domaincorerdb.store.entity.StoreImage; import lombok.Builder; import lombok.Getter; @@ -9,14 +10,16 @@ @Builder public class StoreImageUploadResponse { private final Long id; + private final Long storeId; private final String imageUrl; - private final String type; + private final ImageType imageType; public static StoreImageUploadResponse fromEntity(StoreImage storeImage) { return StoreImageUploadResponse.builder() .id(storeImage.getId()) + .storeId(storeImage.getStore().getStoreId()) .imageUrl(storeImage.getImageUrl()) - .type(storeImage.getType()) + .imageType(storeImage.getImageType()) .build(); } } diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/store/dto/StorePageReadResponse.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/store/dto/StorePageReadResponse.java new file mode 100644 index 00000000..4bc28d85 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/store/dto/StorePageReadResponse.java @@ -0,0 +1,68 @@ +package com.nowait.applicationuser.store.dto; + +import java.time.LocalDateTime; +import java.util.List; + +import com.nowait.domaincorerdb.store.entity.ImageType; +import com.nowait.domaincorerdb.store.entity.Store; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Builder +public class StorePageReadResponse { + private Long storeId; + private String publicCode; + private Long bookmarkId; + private Boolean isBookmark; + private Long waitingCount; + private Long departmentId; + private String departmentName; + private String name; + private String location; + private String description; + private String noticeTitle; + private String noticeContent; + private String openTime; + private StoreImageUploadResponse profileImage; + private List bannerImages; + private Boolean isActive; + private Boolean deleted; + private LocalDateTime createdAt; + + public static StorePageReadResponse fromEntity(Store store, Long bookmarkId, List allImages, String departmentName, Long waitingCount, Boolean isBookmark) { + + StoreImageUploadResponse profile = allImages.stream() + .filter(image -> image.getImageType() == ImageType.PROFILE) + .findFirst() + .orElse(null); + + List banners = allImages.stream() + .filter(image -> image.getImageType() == ImageType.BANNER) + .toList(); + + return StorePageReadResponse.builder() + .storeId(store.getStoreId()) + .publicCode(store.getPublicCode()) + .bookmarkId(bookmarkId) + .isBookmark(isBookmark) + .waitingCount(waitingCount) + .departmentId(store.getDepartmentId()) + .departmentName(departmentName) + .name(store.getName()) + .location(store.getLocation()) + .description(store.getDescription()) + .noticeTitle(store.getNoticeTitle()) + .noticeContent(store.getNoticeContent()) + .openTime(store.getOpenTime()) + .profileImage(profile) + .bannerImages(banners) + .isActive(store.getIsActive()) + .deleted(store.getDeleted()) + .createdAt(store.getCreatedAt()) + .build(); + } +} diff --git a/api-admin/src/main/java/com/example/apiadmin/store/dto/StoreReadDto.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/store/dto/StoreReadDto.java similarity index 56% rename from api-admin/src/main/java/com/example/apiadmin/store/dto/StoreReadDto.java rename to nowait-app-user-api/src/main/java/com/nowait/applicationuser/store/dto/StoreReadDto.java index 0a0148c8..ddc78a70 100644 --- a/api-admin/src/main/java/com/example/apiadmin/store/dto/StoreReadDto.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/store/dto/StoreReadDto.java @@ -1,9 +1,10 @@ -package com.example.apiadmin.store.dto; +package com.nowait.applicationuser.store.dto; import java.time.LocalDateTime; import java.util.List; -import com.example.domainstore.entity.Store; +import com.nowait.domaincorerdb.store.entity.ImageType; +import com.nowait.domaincorerdb.store.entity.Store; import lombok.AllArgsConstructor; import lombok.Builder; @@ -18,12 +19,23 @@ public class StoreReadDto { private String name; private String location; private String description; - private List images; + private StoreImageUploadResponse profileImage; + private List bannerImages; private Boolean isActive; private Boolean deleted; private LocalDateTime createdAt; - public static StoreReadDto fromEntity(Store store, List images) { + public static StoreReadDto fromEntity(Store store, List allImages) { + + StoreImageUploadResponse profile = allImages.stream() + .filter(image -> image.getImageType() == ImageType.PROFILE) + .findFirst() + .orElse(null); + + List banners = allImages.stream() + .filter(image -> image.getImageType() == ImageType.BANNER) + .toList(); + return StoreReadDto.builder() .createdAt(store.getCreatedAt()) .storeId(store.getStoreId()) @@ -33,7 +45,8 @@ public static StoreReadDto fromEntity(Store store, List profileImage, String departmentName, Long waitingCount) { + + StoreImageUploadResponse profile = profileImage.stream() + .filter(image -> image.getImageType() == ImageType.PROFILE) + .findFirst() + .orElse(null); + + return StoreSearchResponse.builder() + .storeId(store.getStoreId()) + .publicCode(store.getPublicCode()) + .waitingCount(waitingCount) + .departmentId(store.getDepartmentId()) + .departmentName(departmentName) + .name(store.getName()) + .openTime(store.getOpenTime()) + .profileImage(profile) + .isActive(store.getIsActive()) + .deleted(store.getDeleted()) + .createdAt(store.getCreatedAt()) + .build(); + } +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/store/dto/StoreWaitingInfo.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/store/dto/StoreWaitingInfo.java new file mode 100644 index 00000000..97a75241 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/store/dto/StoreWaitingInfo.java @@ -0,0 +1,28 @@ +package com.nowait.applicationuser.store.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Schema(description = "주점 대기 현황 정보 DTO") +public class StoreWaitingInfo { + @Schema(description = "배너 첫번째 이미지", example = "https://cdn.gtable.com/profile/user1.jpg") + private String bannerImageUrl; + + @Schema(description = "학과 이름", example = "경영학과") + private String departmentName; + + @Schema(description = "주점 ID", example = "1") + private String storeId; + + @Schema(description = "주점 publicCode", example = "ad2ndkald24d") + private String publicCode; + + @Schema(description = "주점명", example = "노웨이팅 치킨") + private String storeName; + + @Schema(description = "대기 인원 수", example = "12") + private Long waitingCount; +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/store/service/StoreService.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/store/service/StoreService.java new file mode 100644 index 00000000..7f7d8bd6 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/store/service/StoreService.java @@ -0,0 +1,28 @@ +package com.nowait.applicationuser.store.service; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.springframework.data.domain.Pageable; + +import com.nowait.applicationuser.store.dto.StoreDepartmentReadResponse; +import com.nowait.applicationuser.store.dto.StoreDetailReadResponse; +import com.nowait.applicationuser.store.dto.StorePageReadResponse; +import com.nowait.applicationuser.store.dto.StoreSearchResponse; +import com.nowait.applicationuser.store.dto.StoreWaitingInfo; +import com.nowait.domaincorerdb.user.entity.MemberDetails; +import com.nowait.domainuserrdb.oauth.dto.CustomOAuth2User; + +public interface StoreService { + + StoreDepartmentReadResponse getAllStoresByPageAndDeparments(Pageable pageable, CustomOAuth2User customOAuth2User); + + StoreDetailReadResponse getStoreByPublicCode(String publicCode, CustomOAuth2User customOAuth2User); + + List searchByKeywordNative(String name); + + List getStoresByWaitingCount(boolean desc); + + List getAllStoresByPageAndDeparments(List storeIds, Map bookmarkMap); +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/store/service/StoreServiceImpl.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/store/service/StoreServiceImpl.java new file mode 100644 index 00000000..12e352b7 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/store/service/StoreServiceImpl.java @@ -0,0 +1,375 @@ +package com.nowait.applicationuser.store.service; + +import java.awt.print.Book; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.redis.connection.DataType; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.connection.RedisConnectionFactory; +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 org.springframework.util.StringUtils; + +import com.nowait.applicationuser.reservation.repository.WaitingUserRedisRepository; +import com.nowait.applicationuser.store.dto.StoreDepartmentReadResponse; +import com.nowait.applicationuser.store.dto.StoreDetailReadResponse; +import com.nowait.applicationuser.store.dto.StoreImageUploadResponse; +import com.nowait.applicationuser.store.dto.StorePageReadResponse; +import com.nowait.applicationuser.store.dto.StoreSearchResponse; +import com.nowait.applicationuser.store.dto.StoreWaitingInfo; +import com.nowait.domaincorerdb.department.entity.Department; +import com.nowait.domaincorerdb.department.repository.DepartmentRepository; +import com.nowait.domaincorerdb.store.entity.ImageType; +import com.nowait.domaincorerdb.store.entity.Store; +import com.nowait.domaincorerdb.store.entity.StoreImage; +import com.nowait.domaincorerdb.store.exception.StoreNotFoundException; +import com.nowait.domaincorerdb.store.exception.StoreParamEmptyException; +import com.nowait.domaincorerdb.store.repository.StoreImageRepository; +import com.nowait.domaincorerdb.store.repository.StoreRepository; +import com.nowait.domaincorerdb.user.entity.User; +import com.nowait.domaincorerdb.user.exception.UserNotFoundException; +import com.nowait.domaincorerdb.user.repository.UserRepository; +import com.nowait.domaincoreredis.common.util.RedisKeyUtils; +import com.nowait.domainuserrdb.bookmark.entity.Bookmark; +import com.nowait.domainuserrdb.bookmark.exception.BookmarkNotFoundException; +import com.nowait.domainuserrdb.bookmark.repository.BookmarkRepository; +import com.nowait.domainuserrdb.oauth.dto.CustomOAuth2User; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class StoreServiceImpl implements StoreService { + + private final StoreRepository storeRepository; + private final StoreImageRepository storeImageRepository; + private final DepartmentRepository departmentRepository; + private final StringRedisTemplate redisTemplate; + private final BookmarkRepository bookmarkRepository; + private final WaitingUserRedisRepository waitingRepo; + + @Override + @Transactional(readOnly = true) + public StoreDepartmentReadResponse getAllStoresByPageAndDeparments(Pageable pageable, + CustomOAuth2User customOAuth2User) { + + User user = customOAuth2User.getUser(); + + // 1) 페이징된 Store 스냅샷 조회 + Slice slice = storeRepository.findAllByDeletedFalseOrderByStoreIdAsc(pageable); + List stores = slice.getContent(); + + // 2) 각 StoreId / Department ID 추출 + List storeIds = stores.stream() + .map(Store::getStoreId) + .toList(); + List deptIds = stores.stream() + .map(Store::getDepartmentId) + .distinct() + .toList(); + + // 2-1) Redis에서 각 Store의 웨이팅 사이즈 조회 + Map waitingSizeMap = storeIds.stream() + .collect(Collectors.toMap( + Function.identity(), + storeId -> { + String key = "waiting:" + storeId; + try { + return redisTemplate.opsForZSet().zCard(key); + } catch (Exception e) { + return 0L; // Redis 접근 실패 시 0으로 처리 + } + + } + )); + + // 3) 각 StoreId에 해당하는 이미지 조회 + List allImages = storeImageRepository.findByStore_StoreIdIn(storeIds); + Map> imageMap = allImages.stream() + .map(StoreImageUploadResponse::fromEntity) + .collect(Collectors.groupingBy( + StoreImageUploadResponse::getStoreId + )); + + // 4) 각 DepartmentId에 해당하는 이름 조회 + List allDepts = departmentRepository.findAllById(deptIds); + Map deptNameMap = allDepts.stream() + .collect(Collectors.toMap( + Department::getId, + Department::getName + )); + + // 5) 북마크 조회 + Collection allBookmarks = bookmarkRepository.findAllByUserAndDeletedFalse(user); + Map bookmarkIdMap = allBookmarks.stream() + .collect(Collectors.toMap( + b -> b.getStore().getStoreId(), + Bookmark::getId + )); + + // 5) Dto 매핑 + List content = stores.stream() + .map(store -> { + Long bookmarkId = bookmarkIdMap.get(store.getStoreId()); + boolean isBookmark = bookmarkId != null; + List imgs = imageMap.getOrDefault(store.getStoreId(), List.of()); + String departmentName = deptNameMap.getOrDefault(store.getDepartmentId(), "Unknown Department"); + Long waitingCount = waitingSizeMap.getOrDefault(store.getStoreId(), 0L); + + return StorePageReadResponse.fromEntity(store, bookmarkId, imgs, departmentName, waitingCount, isBookmark); + }) + .toList(); + + boolean hasNext = slice.hasNext(); + + return StoreDepartmentReadResponse.of(content, hasNext); + } + + @Override + @Transactional(readOnly = true) + public StoreDetailReadResponse getStoreByPublicCode(String publicCode, CustomOAuth2User customOAuth2User) { + + if (!StringUtils.hasText(publicCode)) + throw new StoreParamEmptyException(); + User user = customOAuth2User.getUser(); + + Store store = storeRepository.findByPublicCodeAndDeletedFalse(publicCode) + .orElseThrow(StoreNotFoundException::new); + + Long storeId = store.getStoreId();; + + String departmentName = departmentRepository.findById(store.getDepartmentId()) + .map(Department::getName) + .orElse("Unknown Department"); + + Optional bookmark = bookmarkRepository.findByUserAndStoreAndDeletedFalse(user, store); + boolean isBookmark = bookmark.isPresent(); + Long bookmarkId = isBookmark ? bookmark.get().getId() : null; + + // 2-1) Redis에서 각 Store의 웨이팅 사이즈 조회 + String key = "waiting:" + storeId; + long waitingSize = redisTemplate.opsForZSet().zCard(key); + boolean userWaiting = waitingRepo.isUserWaiting(storeId, String.valueOf(customOAuth2User.getUserId())); + + List images = storeImageRepository.findByStore(store); + List imageDto = images.stream() + .map(StoreImageUploadResponse::fromEntity) + .toList(); + + return StoreDetailReadResponse.fromEntity(store, bookmarkId, imageDto, departmentName, waitingSize, isBookmark, + userWaiting); + } + + @Override + public List searchByKeywordNative(String keyword) { + if (keyword == null || keyword.isBlank()) { + throw new StoreParamEmptyException(); + } + + // 1) 페이징된 Store 스냅샷 조회 + // 풀텍스트인덱스 사용 + List stores = storeRepository.searchByKeywordNative(keyword); + + // 2) 각 StoreId / Department ID 추출 + List storeIds = stores.stream() + .map(Store::getStoreId) + .toList(); + List deptIds = stores.stream() + .map(Store::getDepartmentId) + .distinct() + .toList(); + + // 2-1) Redis에서 각 Store의 웨이팅 사이즈 조회 + Map waitingSizeMap = storeIds.stream() + .collect(Collectors.toMap( + Function.identity(), + storeId -> { + String key = "waiting:" + storeId; + try { + return redisTemplate.opsForZSet().zCard(key); + } catch (Exception e) { + return 0L; // Redis 접근 실패 시 0으로 처리 + } + } + )); + + // 3) 각 StoreId에 해당하는 이미지 조회 + List allImages = storeImageRepository.findByStore_StoreIdIn(storeIds); + Map> imageMap = allImages.stream() + .map(StoreImageUploadResponse::fromEntity) + .collect(Collectors.groupingBy( + StoreImageUploadResponse::getStoreId + )); + + // 4) 각 DepartmentId에 해당하는 이름 조회 + List allDepts = departmentRepository.findAllById(deptIds); + Map deptNameMap = allDepts.stream() + .collect(Collectors.toMap( + Department::getId, + Department::getName + )); + + // 5) Dto 매핑 + return stores.stream() + .map(store -> { + List imgs = imageMap + .getOrDefault(store.getStoreId(), List.of()); + String departmentName = deptNameMap + .getOrDefault(store.getDepartmentId(), "Unknown Department"); + Long waitingCount = + waitingSizeMap.getOrDefault(store.getStoreId(), 0L); + return StoreSearchResponse.fromEntity(store, imgs, departmentName, waitingCount); + }) + .toList(); + } + + // 주점 대기 리스트 반환 (많은 순/적은 순) + @Transactional(readOnly = true) + public List getStoresByWaitingCount(boolean desc) { + final String PREFIX = RedisKeyUtils.buildWaitingKeyPrefix(); // 예: "waiting:" + final String UNKNOWN_STORE_NAME = "UNKNOWN"; + + RedisConnection connection = getSafeConnection(); // 안전하게 Redis 커넥션 획득 + + ScanOptions options = ScanOptions.scanOptions() + .match(PREFIX + "[0-9]*") // 숫자로 끝나는 waiting key만 매칭 + .count(1000) + .build(); + + List result = new ArrayList<>(); + + // 반드시 try-with-resources로 Cursor 닫아줘야 함 + try (Cursor cursor = connection.scan(options)) { + while (cursor.hasNext()) { + String key = new String(cursor.next(), StandardCharsets.UTF_8); + + // zset 타입인지 확인 + DataType type = redisTemplate.type(key); + if (type == null || !"zset".equals(type.code())) + continue; + + Long count = redisTemplate.opsForZSet().zCard(key); + String storeId = key.replace(PREFIX, ""); + + // DB에서 storeName 조회 + String storeName = storeRepository.findById(Long.valueOf(storeId)) + .map(Store::getName) + .orElse(UNKNOWN_STORE_NAME); + + Store store = storeRepository.findById(Long.valueOf(storeId)) + .orElseThrow(StoreNotFoundException::new); + Department department = departmentRepository.getReferenceById(store.getDepartmentId()); + List storeImageList = storeImageRepository.findByStoreAndImageType(store, ImageType.BANNER); + + String imageUrl = storeImageList.isEmpty() ? null : storeImageList.get(0).getImageUrl(); + + result.add(new StoreWaitingInfo( + imageUrl, + department.getName(), + storeId, + store.getPublicCode(), + storeName, + count != null ? count : 0 + )); + } + } + + // 정렬 로직: 대기 인원 기준 desc/asc + Comparator comparator = Comparator.comparing(StoreWaitingInfo::getWaitingCount); + if (desc) + comparator = comparator.reversed(); + result.sort(comparator); + + return result.stream() + .limit(5) + .toList(); + } + + private RedisConnection getSafeConnection() { + RedisConnectionFactory factory = redisTemplate.getConnectionFactory(); + if (factory == null) { + throw new IllegalStateException("RedisConnectionFactory is not configured"); + } + return factory.getConnection(); + } + + @Override + @Transactional(readOnly = true) + public List getAllStoresByPageAndDeparments(List storeIds, + Map bookmarkMap) { + // 1) 페이징된 Store 스냅샷 조회 + List stores = storeRepository.findAllByStoreIdInOrderByStoreIdAsc(storeIds); + + // 2) 각 StoreId / Department ID 추출 + List deptIds = stores.stream() + .map(Store::getDepartmentId) + .distinct() + .toList(); + + // 2-1) Redis에서 각 Store의 웨이팅 사이즈 조회 + Map waitingSizeMap = storeIds.stream() + .collect(Collectors.toMap( + Function.identity(), + storeId -> { + String key = "waiting:" + storeId; + try { + return redisTemplate.opsForZSet().zCard(key); + } catch (Exception e) { + return 0L; // Redis 접근 실패 시 0으로 처리 + } + + } + )); + + // 3) 각 StoreId에 해당하는 이미지 조회 + List allImages = storeImageRepository.findByStore_StoreIdIn(storeIds); + Map> imageMap = allImages.stream() + .map(StoreImageUploadResponse::fromEntity) + .collect(Collectors.groupingBy( + StoreImageUploadResponse::getStoreId + )); + + // 4) 각 DepartmentId에 해당하는 이름 조회 + List allDepts = departmentRepository.findAllById(deptIds); + Map deptNameMap = allDepts.stream() + .collect(Collectors.toMap( + Department::getId, + Department::getName + )); + + // 5) Dto 매핑 + List content = stores.stream() + .map(store -> { + Long bookmarkId = bookmarkMap.get(store.getStoreId()); + boolean isBookmark = bookmarkId != null; + + List imgs = imageMap + .getOrDefault(store.getStoreId(), List.of()); + String departmentName = deptNameMap + .getOrDefault(store.getDepartmentId(), "Unknown Department"); + Long waitingCount = + waitingSizeMap.getOrDefault(store.getStoreId(), 0L); + + return StorePageReadResponse.fromEntity(store, bookmarkId, imgs, departmentName, waitingCount, + isBookmark); + }) + .toList(); + + return content; + } +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/storepayment/controller/StorePaymentController.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/storepayment/controller/StorePaymentController.java new file mode 100644 index 00000000..cd1d8da0 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/storepayment/controller/StorePaymentController.java @@ -0,0 +1,49 @@ +package com.nowait.applicationuser.storepayment.controller; + +import java.util.Optional; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.nowait.applicationuser.storepayment.dto.StorePaymentReadDto; +import com.nowait.applicationuser.storepayment.service.StorePaymentService; +import com.nowait.common.api.ApiUtils; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Tag(name = "Store Payment API", description = "주점 결제 정보 API") +@RestController +@RequestMapping("/v1/stores/{publicCode}/payments") +@RequiredArgsConstructor +@Slf4j +public class StorePaymentController { + + private final StorePaymentService storePaymentService; + + @GetMapping + @Operation(summary = "주점 결제 정보 조회", description = "주점 ID로 주점 결제 정보를 조회합니다.") + @ApiResponse(responseCode = "200", description = "주점 결제 정보 조회 성공") + @ApiResponse(responseCode = "204", description = "결제 정보 없음") + public ResponseEntity getStorePaymentByStoreId(@PathVariable String publicCode) { + + return storePaymentService.getStorePaymentByStoreId(publicCode) + .map(response -> ResponseEntity + .status(HttpStatus.OK) + .body( + ApiUtils.success( + response + ) + )) + .orElseGet(() -> ResponseEntity + .status(HttpStatus.NO_CONTENT).build() + ); + } +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/storepayment/dto/StorePaymentReadDto.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/storepayment/dto/StorePaymentReadDto.java new file mode 100644 index 00000000..c96e01c2 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/storepayment/dto/StorePaymentReadDto.java @@ -0,0 +1,34 @@ +package com.nowait.applicationuser.storepayment.dto; + +import java.time.LocalDateTime; + +import com.nowait.domaincorerdb.storepayment.entity.StorePayment; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Builder +public class StorePaymentReadDto { + private Long paymentMethodId; + private Long storeId; + private String tossUrl; + private String kakaoPayUrl; + private String naverPayUrl; + private String accountNumber; + private LocalDateTime createdAt; + + public static StorePaymentReadDto fromEntity(StorePayment storePayment) { + return StorePaymentReadDto.builder() + .paymentMethodId(storePayment.getPaymentMethodId()) + .storeId(storePayment.getStoreId()) + .tossUrl(storePayment.getTossUrl()) + .kakaoPayUrl(storePayment.getKakaoPayUrl()) + .naverPayUrl(storePayment.getNaverPayUrl()) + .accountNumber(storePayment.getAccountNumber()) + .createdAt(storePayment.getCreatedAt()) + .build(); + } +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/storepayment/service/StorePaymentService.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/storepayment/service/StorePaymentService.java new file mode 100644 index 00000000..100b6dac --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/storepayment/service/StorePaymentService.java @@ -0,0 +1,9 @@ +package com.nowait.applicationuser.storepayment.service; + +import java.util.Optional; + +import com.nowait.applicationuser.storepayment.dto.StorePaymentReadDto; + +public interface StorePaymentService { + Optional getStorePaymentByStoreId(String publicCode); +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/storepayment/service/StorePaymentServiceImpl.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/storepayment/service/StorePaymentServiceImpl.java new file mode 100644 index 00000000..c4c9c295 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/storepayment/service/StorePaymentServiceImpl.java @@ -0,0 +1,32 @@ +package com.nowait.applicationuser.storepayment.service; + +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.nowait.applicationuser.storepayment.dto.StorePaymentReadDto; +import com.nowait.domaincorerdb.store.entity.Store; +import com.nowait.domaincorerdb.store.exception.StoreNotFoundException; +import com.nowait.domaincorerdb.store.repository.StoreRepository; +import com.nowait.domaincorerdb.storepayment.exception.StorePaymentParamEmptyException; +import com.nowait.domaincorerdb.storepayment.repository.StorePaymentRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class StorePaymentServiceImpl implements StorePaymentService { + + private final StorePaymentRepository storePaymentRepository; + private final StoreRepository storeRepository; + + @Override + @Transactional(readOnly = true) + public Optional getStorePaymentByStoreId(String publicCode) { + if (publicCode == null) throw new StorePaymentParamEmptyException(); + Store store = storeRepository.findByPublicCodeAndDeletedFalse(publicCode) + .orElseThrow(StoreNotFoundException::new); + return storePaymentRepository.findByStoreId(store.getStoreId()) + .map(StorePaymentReadDto::fromEntity); + } +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/token/controller/TokenController.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/token/controller/TokenController.java new file mode 100644 index 00000000..211c9330 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/token/controller/TokenController.java @@ -0,0 +1,81 @@ +package com.nowait.applicationuser.token.controller; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.nowait.applicationuser.security.jwt.JwtUtil; +import com.nowait.applicationuser.token.dto.AuthenticationResponse; +import com.nowait.applicationuser.token.service.AuthTokenService; +import com.nowait.domaincorerdb.user.entity.User; +import com.nowait.domaincorerdb.user.exception.UserNotFoundException; +import com.nowait.domaincorerdb.user.repository.UserRepository; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1/auth/token/refresh") +@Slf4j +public class TokenController { + private final JwtUtil jwtUtil; + private final AuthTokenService tokenService; + private final UserRepository userRepository; + @Value("${jwt.access-token-expiration-ms}") + private long accessTokenExpiration; + @Value("${jwt.refresh-token-expiration-ms}") + private long refreshTokenExpiration; + + @PostMapping + @Operation(summary = "리프레시 토큰", description = "리프레시 토큰을 사용하여 새로운 액세스 토큰과 리프레시 토큰을 발급합니다.") + @ApiResponse(responseCode = "200", description = "새로운 액세스 토큰과 리프레시 토큰 발급 성공") + public ResponseEntity refreshToken( + @CookieValue(value = "refreshToken", required = false) String refreshToken) { + + if (refreshToken == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Refresh token not found in cookies"); + } + + // 리프레시 토큰 검증 + Long userId; + String role; + try { + userId = jwtUtil.getUserId(refreshToken); + role = jwtUtil.getRole(refreshToken); + } catch (RuntimeException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid or expired refresh token"); + } + + User user = userRepository.findById(userId).orElseThrow(UserNotFoundException::new); + + if (tokenService.validateToken(refreshToken, userId)) { + String newAccessToken = jwtUtil.createAccessToken( + "accessToken", + userId, + role, + Boolean.TRUE.equals(user.getPhoneEntered()), + Boolean.TRUE.equals(user.getIsMarketingAgree()), + accessTokenExpiration + ); + String newRefreshToken = jwtUtil.createRefreshToken( + "refreshToken", + userId, + refreshTokenExpiration + ); + + tokenService.updateRefreshToken(userId, refreshToken, newRefreshToken); + + AuthenticationResponse authenticationResponse = new AuthenticationResponse(newAccessToken, newRefreshToken); + return ResponseEntity.ok().body(authenticationResponse); + } + + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid or expired refresh token"); + } +} diff --git a/api-admin/src/main/java/com/example/apiadmin/token/dto/AuthenticationResponse.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/token/dto/AuthenticationResponse.java similarity index 90% rename from api-admin/src/main/java/com/example/apiadmin/token/dto/AuthenticationResponse.java rename to nowait-app-user-api/src/main/java/com/nowait/applicationuser/token/dto/AuthenticationResponse.java index 170a3166..5a1a3c39 100644 --- a/api-admin/src/main/java/com/example/apiadmin/token/dto/AuthenticationResponse.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/token/dto/AuthenticationResponse.java @@ -1,4 +1,4 @@ -package com.example.apiadmin.token.dto; +package com.nowait.applicationuser.token.dto; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/api-admin/src/main/java/com/example/apiadmin/token/dto/RefreshTokenRequest.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/token/dto/RefreshTokenRequest.java similarity index 73% rename from api-admin/src/main/java/com/example/apiadmin/token/dto/RefreshTokenRequest.java rename to nowait-app-user-api/src/main/java/com/nowait/applicationuser/token/dto/RefreshTokenRequest.java index aa97db17..ac4b9c5c 100644 --- a/api-admin/src/main/java/com/example/apiadmin/token/dto/RefreshTokenRequest.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/token/dto/RefreshTokenRequest.java @@ -1,9 +1,10 @@ -package com.example.apiadmin.token.dto; +package com.nowait.applicationuser.token.dto; import jakarta.validation.constraints.NotBlank; import lombok.Getter; import lombok.NoArgsConstructor; +// TODO : 사용하는 DTO인지 확인 필요 @Getter @NoArgsConstructor public class RefreshTokenRequest { diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/token/service/AuthTokenService.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/token/service/AuthTokenService.java new file mode 100644 index 00000000..dc5f3f61 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/token/service/AuthTokenService.java @@ -0,0 +1,115 @@ +package com.nowait.applicationuser.token.service; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.nowait.applicationuser.security.exception.RefreshTokenNotFoundException; +import com.nowait.applicationuser.security.exception.TokenBadRequestException; +import com.nowait.applicationuser.security.jwt.JwtUtil; +import com.nowait.applicationuser.token.dto.AuthenticationResponse; +import com.nowait.domaincorerdb.token.entity.Token; +import com.nowait.domaincorerdb.token.repository.TokenRepository; +import com.nowait.domaincorerdb.user.entity.User; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AuthTokenService { + + private final JwtUtil jwtUtil; + private final TokenRepository tokenRepository; + + @Transactional + public boolean validateToken(String token, Long userId){ + // DB에서 해당 userId와 일치하는 리프레시토큰을 찾는다. + Optional savedToken = tokenRepository.findByUserId(userId); + + // DB에서 userId에 대응되는 리프레시토큰 없으면, 유효하지 않음 + if (savedToken.isEmpty()){ + log.debug("DB에 현재 userId에 대응되는 리프레시 토큰이 없습니다"); + return false; + } + + // 리프레시 토큰이 DB에 저장된 토큰과 일치하는지 확인 + if (!savedToken.get().getRefreshToken().equals(token)){ + log.debug("DB에 저장된 리프레시 토큰와 현재 전달받은 리프레시 토큰 일치하지 않습니다"); + return false; + } + + // 리프레시 토큰의 만료여부 확인 + if(jwtUtil.isExpired(token)){ + log.debug("리프레시 토큰이 만료된 토큰입니다"); + return false; // 만료된 토큰은 유효하지 않음 + } + + log.info("리프레시 토큰이 유효한 토큰입니다"); + return true; // 모든 조건 만족 시, 유효한 토큰 + } + + @Transactional + public AuthenticationResponse issueTokens(User user) { + Long userId = user.getId(); + String role = user.getRole().name(); + + String accessToken = jwtUtil.createAccessToken( + "accessToken", + userId, + role, + Boolean.TRUE.equals(user.getPhoneEntered()), + Boolean.TRUE.equals(user.getIsMarketingAgree()), + 60 * 60 * 2000L + ); // 2시간 + + String refreshToken = jwtUtil.createRefreshToken( + "refreshToken", + userId, + 30L * 24 * 60 * 60 * 1000L + ); // 30일 + + newUpdateRefreshToken(user, refreshToken); + return new AuthenticationResponse(accessToken, refreshToken); + } + + @Transactional + public void newUpdateRefreshToken(User user, String refreshToken) { + Optional tokenOptional = tokenRepository.findByUserId(user.getId()); + if (tokenOptional.isPresent()) { + Token token = tokenOptional.get(); + token.updateRefreshToken(refreshToken, LocalDateTime.now().plusDays(30)); + } else { + Token token = Token.toEntity(user, refreshToken, LocalDateTime.now().plusDays(30)); + tokenRepository.save(token); + } + } + + @Transactional + public void updateRefreshToken(Long userId, String oldRefreshToken, String newRefreshToken){ + Token token = tokenRepository.findByUserId(userId) + .orElseThrow(RefreshTokenNotFoundException::new); // 404 + + if (!token.getRefreshToken().equals(oldRefreshToken)){ + throw new TokenBadRequestException(); // 400 + } + + // 기존 토큰 삭제 및 새 토큰 저장 + token.updateRefreshToken(newRefreshToken, LocalDateTime.now().plusDays(30)); + } + + @Getter + public static class TokenResult { + private final String accessToken; + private final String refreshToken; + + public TokenResult(String accessToken, String refreshToken) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + } + } +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/user/controller/UserController.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/user/controller/UserController.java new file mode 100644 index 00000000..79f0906b --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/user/controller/UserController.java @@ -0,0 +1,41 @@ +package com.nowait.applicationuser.user.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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.nowait.applicationuser.token.dto.AuthenticationResponse; +import com.nowait.applicationuser.user.dto.UserUpdateRequest; +import com.nowait.applicationuser.user.service.UserService; +import com.nowait.common.api.ApiUtils; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/v1/users/me") +@RequiredArgsConstructor +public class UserController { + + private final UserService userService; + + // TODO : Pathch로 변경 고려 및 토큰 전달 방식 변경 고려 (SecurityContext) + @PutMapping("/optional") + public ResponseEntity putOptional( + @Valid @RequestBody UserUpdateRequest request) { + + AuthenticationResponse authenticationResponse = userService.putOptional(request.phoneNumber(), + Boolean.TRUE.equals(request.consent()), request.accessToken()); + + return ResponseEntity + .status(HttpStatus.OK) + .body( + ApiUtils.success( + authenticationResponse + ) + ); + } +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/user/dto/UserUpdateRequest.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/user/dto/UserUpdateRequest.java new file mode 100644 index 00000000..37d385d5 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/user/dto/UserUpdateRequest.java @@ -0,0 +1,11 @@ +package com.nowait.applicationuser.user.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record UserUpdateRequest( + @NotBlank + @Pattern(regexp = "^010\\d{4}\\d{4}$", message = "휴대폰 번호는 01000000000 형식이어야 합니다.") + String phoneNumber, + boolean consent, + String accessToken) { } diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/user/service/UserService.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/user/service/UserService.java new file mode 100644 index 00000000..5820b224 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/user/service/UserService.java @@ -0,0 +1,41 @@ +package com.nowait.applicationuser.user.service; + +import java.time.LocalDateTime; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.nowait.applicationuser.security.jwt.JwtUtil; +import com.nowait.applicationuser.token.dto.AuthenticationResponse; +import com.nowait.applicationuser.token.service.AuthTokenService; +import com.nowait.domaincorerdb.user.entity.User; +import com.nowait.domaincorerdb.user.exception.UserNotFoundException; +import com.nowait.domaincorerdb.user.repository.UserRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + private final AuthTokenService authTokenService; + private final JwtUtil jwtUtil; + + @Transactional + public AuthenticationResponse putOptional(String phoneNumber, boolean consent, String accessToken) { + + Long userId = jwtUtil.getUserId(accessToken); + + User user = userRepository.findById(userId).orElseThrow(UserNotFoundException::new); + + if (userRepository.existsByPhoneNumberAndIdNot(phoneNumber, userId)) { + throw new IllegalArgumentException("이미 사용 중인 휴대폰 번호입니다."); + } + + user.setPhoneNumberAndMarkEntered(phoneNumber, LocalDateTime.now()); + user.setIsMarketingAgree(consent, LocalDateTime.now()); + + return authTokenService.issueTokens(user); + } +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/controller/WaitingController.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/controller/WaitingController.java new file mode 100644 index 00000000..52c3bc63 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/controller/WaitingController.java @@ -0,0 +1,125 @@ +package com.nowait.applicationuser.waiting.controller; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.nowait.applicationuser.waiting.dto.CancelWaitingRequest; +import com.nowait.applicationuser.waiting.dto.CancelWaitingResponse; +import com.nowait.applicationuser.waiting.dto.GetMyWaitingInfoResponse; +import com.nowait.applicationuser.waiting.dto.GetWaitingSizeResponse; +import com.nowait.applicationuser.waiting.dto.RegisterWaitingRequest; +import com.nowait.applicationuser.waiting.dto.RegisterWaitingResponse; +import com.nowait.applicationuser.waiting.service.WaitingService; +import com.nowait.common.api.ApiUtils; +import com.nowait.domainuserrdb.oauth.dto.CustomOAuth2User; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Waiting API", description = "예약 API -리팩토링 중-") +@RestController +@RequestMapping("/v2/users/me/waitings") +@RequiredArgsConstructor +public class WaitingController { + private final WaitingService waitingService; + + /** + * 대기열 리팩토링용 API + */ + @GetMapping() + @Operation(summary = "대기열 리팩토링용 API", description = "전체 대기 목록 조회") + public ResponseEntity getMyWaitingInfo( + @AuthenticationPrincipal CustomOAuth2User customOAuth2User + ) { + List response = waitingService.getMyWaitingInfo( + customOAuth2User + ); + + return ResponseEntity + .ok() + .body( + ApiUtils.success( + response + ) + ); + } + + @PostMapping("/{publicCode}") + @Operation(summary = "대기열 리팩토링용 API", description = "대기열 리팩토링용 API") + public ResponseEntity registerWaiting( + @AuthenticationPrincipal CustomOAuth2User customOAuth2User, + @PathVariable String publicCode, + @RequestBody RegisterWaitingRequest request, + HttpServletRequest httpServletRequest + ) { + RegisterWaitingResponse registerWaitingResponse = waitingService.registerWaiting( + customOAuth2User, + publicCode, + request, + httpServletRequest + ); + + return ResponseEntity + .ok() + .body( + ApiUtils.success( + registerWaitingResponse + ) + ); + } + + @DeleteMapping("/{publicCode}") + @Operation(summary = "대기열 리팩토링용 API", description = "대기 취소") + public ResponseEntity cancelWaiting( + @AuthenticationPrincipal CustomOAuth2User customOAuth2User, + @PathVariable String publicCode, + @RequestBody CancelWaitingRequest request, + HttpServletRequest httpServletRequest + ) { + CancelWaitingResponse cancelWaitingResponse = waitingService.cancelWaiting( + customOAuth2User, + publicCode, + request, + httpServletRequest + ); + + return ResponseEntity + .ok() + .body( + ApiUtils.success( + cancelWaitingResponse + ) + ); + } + + @GetMapping("/{publicCode}/waiting-count") + @Operation(summary = "대기열 리팩토링용 API", description = "대기 인원수 조회") + public ResponseEntity getWaitingCount( + @AuthenticationPrincipal CustomOAuth2User customOAuth2User, + @PathVariable String publicCode + ) { + GetWaitingSizeResponse response = waitingService.getWaitingCount( + customOAuth2User, + publicCode + ); + + return ResponseEntity + .ok() + .body( + ApiUtils.success( + response + ) + ); + } +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/CancelWaitingRequest.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/CancelWaitingRequest.java new file mode 100644 index 00000000..f39200f4 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/CancelWaitingRequest.java @@ -0,0 +1,12 @@ +package com.nowait.applicationuser.waiting.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class CancelWaitingRequest { + private String waitingNumber; +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/CancelWaitingResponse.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/CancelWaitingResponse.java new file mode 100644 index 00000000..dbd77037 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/CancelWaitingResponse.java @@ -0,0 +1,18 @@ +package com.nowait.applicationuser.waiting.dto; + +import java.time.LocalDateTime; + +import com.nowait.common.enums.ReservationStatus; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class CancelWaitingResponse { + private String waitingNumber; + private Long storeId; + private ReservationStatus reservationStatus; + private LocalDateTime canceledAt; + private String message; +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/GetMyWaitingInfoResponse.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/GetMyWaitingInfoResponse.java new file mode 100644 index 00000000..d7eade38 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/GetMyWaitingInfoResponse.java @@ -0,0 +1,24 @@ +package com.nowait.applicationuser.waiting.dto; + +import java.time.LocalDateTime; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class GetMyWaitingInfoResponse { + private String waitingNumber; + private String publicCode; + private Long storeId; + private String storeName; + private String departmentName; + private Integer rank; + private Integer teamsAhead; + private Integer partySize; + private String status; + private LocalDateTime registeredAt; + private String location; + private String profileImageUrl; + private String bannerImageUrl; +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/GetWaitingSizeResponse.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/GetWaitingSizeResponse.java new file mode 100644 index 00000000..17d654ff --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/GetWaitingSizeResponse.java @@ -0,0 +1,13 @@ +package com.nowait.applicationuser.waiting.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class GetWaitingSizeResponse { + private Long storeId; + private String storeName; + private String departmentName; + private Long waitingCount; +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/IdempotencyResponse.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/IdempotencyResponse.java new file mode 100644 index 00000000..00ccb5c9 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/IdempotencyResponse.java @@ -0,0 +1,13 @@ +package com.nowait.applicationuser.waiting.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class IdempotencyResponse { + private String state; + private Object response; +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/RegisterWaitingRequest.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/RegisterWaitingRequest.java new file mode 100644 index 00000000..df823445 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/RegisterWaitingRequest.java @@ -0,0 +1,12 @@ +package com.nowait.applicationuser.waiting.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class RegisterWaitingRequest { + private Integer partySize; +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/RegisterWaitingResponse.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/RegisterWaitingResponse.java new file mode 100644 index 00000000..52e940ba --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/RegisterWaitingResponse.java @@ -0,0 +1,11 @@ +package com.nowait.applicationuser.waiting.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class RegisterWaitingResponse { + private final String waitingNumber; + private final int partySize; +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/WaitingCancelIdempotencyValue.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/WaitingCancelIdempotencyValue.java new file mode 100644 index 00000000..92bb2346 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/WaitingCancelIdempotencyValue.java @@ -0,0 +1,13 @@ +package com.nowait.applicationuser.waiting.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class WaitingCancelIdempotencyValue { + private String state; + private CancelWaitingResponse response; +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/WaitingIdempotencyValue.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/WaitingIdempotencyValue.java new file mode 100644 index 00000000..7ccbc99f --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/dto/WaitingIdempotencyValue.java @@ -0,0 +1,13 @@ +package com.nowait.applicationuser.waiting.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class WaitingIdempotencyValue { + private String state; + private RegisterWaitingResponse response; +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/event/AddWaitingRegisterEvent.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/event/AddWaitingRegisterEvent.java new file mode 100644 index 00000000..35716590 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/event/AddWaitingRegisterEvent.java @@ -0,0 +1,18 @@ +package com.nowait.applicationuser.waiting.event; + +import java.time.LocalDateTime; + +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class AddWaitingRegisterEvent { + @NotNull + private final Long storeId; + @NotNull + private final Long userId; + @NotNull + private final LocalDateTime timestamp; +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/event/listener/AddWaitingRegisterListener.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/event/listener/AddWaitingRegisterListener.java new file mode 100644 index 00000000..f5aa4aff --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/event/listener/AddWaitingRegisterListener.java @@ -0,0 +1,27 @@ +package com.nowait.applicationuser.waiting.event.listener; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import com.nowait.applicationuser.waiting.event.AddWaitingRegisterEvent; +import com.nowait.domaincoreredis.reservation.repository.WaitingRedisRepository; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class AddWaitingRegisterListener { + + private final WaitingRedisRepository waitingRedisRepository; + + @Async + @TransactionalEventListener( + classes = AddWaitingRegisterEvent.class, + phase = TransactionPhase.AFTER_COMMIT + ) + public void onAddWaitingRegister(AddWaitingRegisterEvent event) { + waitingRedisRepository.addWaiting(event.getStoreId(), event.getUserId(), event.getTimestamp()); + } +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/exception/WorkInProgressException.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/exception/WorkInProgressException.java new file mode 100644 index 00000000..8ea38bcd --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/exception/WorkInProgressException.java @@ -0,0 +1,7 @@ +package com.nowait.applicationuser.waiting.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class WorkInProgressException extends RuntimeException { + public WorkInProgressException() { super(ErrorMessage.WORK_IN_PROGRESS.getMessage()); } +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/redis/WaitingIdempotencyRepository.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/redis/WaitingIdempotencyRepository.java new file mode 100644 index 00000000..2d191f2c --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/redis/WaitingIdempotencyRepository.java @@ -0,0 +1,92 @@ +package com.nowait.applicationuser.waiting.redis; + +import java.time.Duration; +import java.util.Optional; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nowait.applicationuser.waiting.dto.IdempotencyResponse; +import com.nowait.applicationuser.waiting.dto.WaitingCancelIdempotencyValue; +import com.nowait.applicationuser.waiting.dto.WaitingIdempotencyValue; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Repository +@RequiredArgsConstructor +@Slf4j +public class WaitingIdempotencyRepository { + + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + private static final Duration TTL = Duration.ofMinutes(10); + + // 멱등키 조회 메서드 + public Optional findByRegisterKey(String key) { + String idempotencyValue = redisTemplate.opsForValue().get(key); + + if (idempotencyValue == null) { + return Optional.empty(); + } + + try { + log.info("Idempotency value found in Redis for key {}: {}", key, idempotencyValue); + return Optional.of(objectMapper.readValue(idempotencyValue, WaitingIdempotencyValue.class)); + } catch (Exception e) { + throw new IllegalArgumentException("Failed to deserialize value from Redis", e); + } + } + + // 멱등키 조회 메서드 + public WaitingCancelIdempotencyValue findByCancelKey(String key) { + String idempotencyValue = redisTemplate.opsForValue().get(key); + + if (idempotencyValue == null) { + return null; + } + + try { + log.info("Idempotency value found in Redis for key {}: {}", key, idempotencyValue); + return objectMapper.readValue(idempotencyValue, WaitingCancelIdempotencyValue.class); + } catch (Exception e) { + throw new IllegalArgumentException("Failed to deserialize value from Redis", e); + } + } + + + // 멱등키 저장 메서드 + public void saveIdempotencyResponse(String key, Object response) { + IdempotencyResponse idempotencyResponse = new IdempotencyResponse( + "COMPLETED", + response + ); + + try { + String jsonValue = objectMapper.writeValueAsString(idempotencyResponse); + redisTemplate.opsForValue().set(key, jsonValue, TTL); + } catch (Exception e) { + throw new IllegalArgumentException("Failed to serialize value for Redis", e); + } + } + + public void saveIdempotencyInProgress(String key) { + IdempotencyResponse idempotencyResponse = new IdempotencyResponse( + "IN-PROGRESS", + null + ); + + try { + String jsonValue = objectMapper.writeValueAsString(idempotencyResponse); + redisTemplate.opsForValue().set(key, jsonValue, TTL); + } catch (Exception e) { + throw new IllegalArgumentException("Failed to serialize value for Redis", e); + } + } + + public void deleteByRegisterKey(String key) { + redisTemplate.delete(key); + } +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/IdempotencyService.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/IdempotencyService.java new file mode 100644 index 00000000..039d6bbc --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/IdempotencyService.java @@ -0,0 +1,58 @@ +package com.nowait.applicationuser.waiting.service; + +import java.util.Optional; + +import org.springframework.stereotype.Service; + +import com.nowait.applicationuser.waiting.dto.WaitingCancelIdempotencyValue; +import com.nowait.applicationuser.waiting.dto.WaitingIdempotencyValue; +import com.nowait.applicationuser.waiting.redis.WaitingIdempotencyRepository; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class IdempotencyService { + + private final WaitingIdempotencyRepository waitingIdempotencyRepository; + + // 멱등키 검증 메서드 + // TODO : 공통 멱등키 검증 메서드로 리팩토링 필요!!!!!!!!! + public Optional validateIdempotency(HttpServletRequest httpServletRequest) { + String idempotentKey = httpServletRequest.getHeader("Idempotency-Key"); + + return waitingIdempotencyRepository.findByRegisterKey(idempotentKey); + } + + public WaitingCancelIdempotencyValue validateIdempotencyCancel(HttpServletRequest httpServletRequest) { + String idempotentKey = httpServletRequest.getHeader("Idempotency-Key"); + + return waitingIdempotencyRepository.findByCancelKey(idempotentKey); + } + + // 멱등키 응답 저장 메서드 + public void saveIdempotencyResponse(String idempotentKey, Object response) { + if (idempotentKey != null && !idempotentKey.isBlank()) { + waitingIdempotencyRepository.saveIdempotencyResponse(idempotentKey, response); + } + } + + // 멱등키 PROCESSING 상태로 최초 저장 + public void saveIdempotencyKeyInProgress(String idempotentKey) { + if (idempotentKey != null && !idempotentKey.isBlank()) { + log.info("Saving idempotency key in progress: {}", idempotentKey); + waitingIdempotencyRepository.saveIdempotencyInProgress(idempotentKey); + } + } + + public void rollbackIdempotencyKey(String idempotentKey) { + if (idempotentKey != null && !idempotentKey.isBlank()) { + log.info("Rolling back idempotency key: {}", idempotentKey); + waitingIdempotencyRepository.deleteByRegisterKey(idempotentKey); + } + } + +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.java new file mode 100644 index 00000000..37cdbcdf --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/waiting/service/WaitingService.java @@ -0,0 +1,246 @@ +package com.nowait.applicationuser.waiting.service; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Optional; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.nowait.applicationuser.waiting.dto.CancelWaitingRequest; +import com.nowait.applicationuser.waiting.dto.CancelWaitingResponse; +import com.nowait.applicationuser.waiting.dto.GetMyWaitingInfoResponse; +import com.nowait.applicationuser.waiting.dto.GetWaitingSizeResponse; +import com.nowait.applicationuser.waiting.dto.RegisterWaitingRequest; +import com.nowait.applicationuser.waiting.dto.RegisterWaitingResponse; +import com.nowait.applicationuser.waiting.dto.WaitingCancelIdempotencyValue; +import com.nowait.applicationuser.waiting.dto.WaitingIdempotencyValue; +import com.nowait.applicationuser.waiting.event.AddWaitingRegisterEvent; +import com.nowait.applicationuser.waiting.exception.WorkInProgressException; +import com.nowait.common.enums.ReservationStatus; +import com.nowait.domaincorerdb.department.entity.Department; +import com.nowait.domaincorerdb.department.exception.DepartmentNotFoundException; +import com.nowait.domaincorerdb.department.repository.DepartmentRepository; +import com.nowait.domaincorerdb.reservation.dto.GetMyWaitingBaseDto; +import com.nowait.domaincorerdb.reservation.entity.Reservation; +import com.nowait.domaincorerdb.reservation.exception.ReservationNotFoundException; +import com.nowait.domaincorerdb.reservation.repository.ReservationRepository; +import com.nowait.domaincorerdb.store.entity.Store; +import com.nowait.domaincorerdb.store.exception.StoreNotFoundException; +import com.nowait.domaincorerdb.store.repository.StoreRepository; +import com.nowait.domaincorerdb.user.entity.User; +import com.nowait.domaincorerdb.user.exception.UserNotFoundException; +import com.nowait.domaincorerdb.user.repository.UserRepository; +import com.nowait.domaincoreredis.common.util.RedisKeyUtils; +import com.nowait.domaincoreredis.reservation.repository.WaitingRedisRepository; +import com.nowait.domainuserrdb.oauth.dto.CustomOAuth2User; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class WaitingService { + + private final IdempotencyService idempotencyService; + private final ReservationRepository reservationRepository; + private final WaitingRedisRepository waitingRedisRepository; + private final StoreRepository storeRepository; + private final UserRepository userRepository; + private final DepartmentRepository departmentRepository; + private final ApplicationEventPublisher eventPublisher; + + /** + * 최초 대기 등록 + */ + // 대기열 리팩토링 서비스 메서드 + @Transactional + public RegisterWaitingResponse registerWaiting(CustomOAuth2User oAuth2User, String publicCode, RegisterWaitingRequest waitingRequest, HttpServletRequest httpServletRequest) { + + // TODO 멱등키 동시성 처리 로직 고려 필요 (분산락 등) + Optional idempotencyResponse = idempotencyService.validateIdempotency(httpServletRequest); + if (idempotencyResponse.isPresent() && idempotencyResponse.getClass().equals("IN-PROGRESS")) { + throw new WorkInProgressException(); + } else if (idempotencyResponse.isPresent() && idempotencyResponse.getClass().equals("COMPLETED")) { + log.info("Idempotent request detected. Returning existing response."); + return idempotencyResponse.get().getResponse(); + } else { + // TODO : DB 저장 실패 시 롤백 처리 필요 + idempotencyService.saveIdempotencyKeyInProgress(httpServletRequest.getHeader("Idempotency-Key")); + } + + // TODO 유저 및 주점 존재 검증은 공통으로 많이 쓰이니 AOP로 빼는게 좋을 듯 + Store store = storeRepository.findByPublicCodeAndDeletedFalse(publicCode) + .orElseThrow(StoreNotFoundException::new); + + User user = userRepository.findById(oAuth2User.getUserId()) + .orElseThrow(UserNotFoundException::new); + + // 일일 가능 웨이팅 최대 개수 초과 검증 + // TODO race condition 발생 가능성 점검 필요, DB 저장 로직 실패 시 롤백 처리 필요 + waitingRedisRepository.incrementAndCheckWaitingLimit(user.getId(), 3L); + + // 웨이팅 고유 번호 생성 - YYYYMMDD-storeId-sequence number 일련 번호 + Long storeId = store.getStoreId(); + LocalDateTime timestamp = LocalDateTime.now(); + String waitingNumber = generateWaitingNumber(storeId, timestamp); + + // DB에 상태 값 저장 + Reservation reservation = Reservation.builder() + .reservationNumber(waitingNumber) + .store(store) + .user(user) + .status(ReservationStatus.WAITING) + .partySize(waitingRequest.getPartySize()) + .requestedAt(timestamp) + .updatedAt(timestamp) + .build(); + + reservationRepository.save(reservation); + + // Redis 대기열 추가 이벤트 발행 + eventPublisher.publishEvent( + new AddWaitingRegisterEvent( + storeId, + user.getId(), + timestamp + ) + ); + + RegisterWaitingResponse response = RegisterWaitingResponse.builder() + .waitingNumber(waitingNumber) + .partySize(waitingRequest.getPartySize()) + .build(); + + // TODO 멱등키 응답 실패 시 어떻게 처리할 지 점검 필요 + idempotencyService.saveIdempotencyResponse(httpServletRequest.getHeader("Idempotency-Key"), response); + + return response; + } + + @Transactional + public CancelWaitingResponse cancelWaiting(CustomOAuth2User oAuth2User, String publicCode, CancelWaitingRequest request, HttpServletRequest httpServletRequest) { + // TODO 멱등키 동시성 처리 로직 고려 필요 (분산락 등) + WaitingCancelIdempotencyValue idempotencyResponse = idempotencyService.validateIdempotencyCancel(httpServletRequest); + if (idempotencyResponse != null && idempotencyResponse.getState().equals("IN-PROGRESS")) { + throw new WorkInProgressException(); + } else if (idempotencyResponse != null && idempotencyResponse.getState().equals("COMPLETED")) { + log.info("Idempotent request detected. Returning existing response."); + return idempotencyResponse.getResponse(); + } else { + idempotencyService.saveIdempotencyKeyInProgress(httpServletRequest.getHeader("Idempotency-Key")); + } + + Store store = storeRepository.findByPublicCodeAndDeletedFalse(publicCode).orElseThrow(StoreNotFoundException::new); + Long storeId = store.getStoreId(); + + User user = userRepository.findById(oAuth2User.getUserId()) + .orElseThrow(UserNotFoundException::new); + + // DB 웨이팅 상태 취소 처리 + Reservation reservation; + try { + reservation = reservationRepository.findReservationByReservationNumber(request.getWaitingNumber()) + .orElseThrow(ReservationNotFoundException::new); + + reservation.markAsCancelled(LocalDateTime.now()); + + // Redis 대기열 취소 이벤트 발행 + waitingRedisRepository.removeWaiting(storeId, user.getId()); + } catch (RuntimeException e) { + // 롤백 처리 + idempotencyService.rollbackIdempotencyKey(httpServletRequest.getHeader("Idempotency-Key")); + throw e; + } + + CancelWaitingResponse response = CancelWaitingResponse.builder() + .waitingNumber(reservation.getReservationNumber()) + .storeId(storeId) + .reservationStatus(reservation.getStatus()) + .canceledAt(reservation.getUpdatedAt()) + .message("대기 취소가 완료되었습니다.") + .build(); + + // 멱등키가 있다면 멱등 응답 저장 + idempotencyService.saveIdempotencyResponse(httpServletRequest.getHeader("Idempotency-Key"), response); + + return response; + } + + // 웨이팅 목록 조회 + public List getMyWaitingInfo(CustomOAuth2User oAuth2User) { + User user = userRepository.findById(oAuth2User.getUserId()) + .orElseThrow(UserNotFoundException::new); + + Long userId = user.getId(); + + List waitingInfoList = reservationRepository.findMyWaitingInfo(userId); + + return waitingInfoList.stream() + .map(dto -> { + Long storeId = dto.getStoreId(); + Long rank = waitingRedisRepository.getWaitingCount(storeId); + + return GetMyWaitingInfoResponse.builder() + .waitingNumber(dto.getWaitingNumber()) + .publicCode(dto.getPublicCode()) + .storeId(dto.getStoreId()) + .storeName(dto.getStoreName()) + .departmentName(dto.getDepartmentName()) + .rank(rank.intValue()) + .teamsAhead(rank.intValue() - 1) + .partySize(dto.getPartySize()) + .status(dto.getStatus().name()) + .registeredAt(dto.getRegisteredAt()) + .location(dto.getLocation()) + .profileImageUrl(dto.getProfileImageUrl()) + .bannerImageUrl(dto.getBannerImageUrl()) + .build(); + }) + .toList(); + } + + // 현재 대기 인원 수 조회 + public GetWaitingSizeResponse getWaitingCount(CustomOAuth2User oAuth2User, String publicCode) { + + Store store = storeRepository.findByPublicCodeAndDeletedFalse(publicCode) + .orElseThrow(StoreNotFoundException::new); + + User user = userRepository.findById(oAuth2User.getUserId()) + .orElseThrow(UserNotFoundException::new); + + Department department = departmentRepository.findById(store.getDepartmentId()) + .orElseThrow(DepartmentNotFoundException::new); + + Long storeId = store.getStoreId(); + + Long waitingCount = waitingRedisRepository.getWaitingCount(storeId); + + return GetWaitingSizeResponse.builder() + .storeId(storeId) + .storeName(store.getName()) + .departmentName(department.getName()) + .waitingCount(waitingCount) + .build(); + } + + private String generateWaitingNumber(Long storeId, LocalDateTime timestamp) { + // 1) 키 접두사 - 날짜 + String today = timestamp.format(DateTimeFormatter.BASIC_ISO_DATE); // YYYYMMDD + + // atomic increment + // TODO 웨이팅 실패 시 카운터 롤백 처리 필요 + String dailySeqKey = RedisKeyUtils.buildWaitingSeqKey(storeId) + ":" + today; // ex. waiting:sequence:{storeId}:{today} + Long seqNum = waitingRedisRepository.incrementDailySequence(dailySeqKey); + + // 3) 4자리 0패딩 + String seqStr = String.format("%04d", seqNum); + + // 4) 최종 ID 조합 + return today + "-" + storeId + "-" + seqStr; + } +} diff --git a/nowait-app-user-api/src/test/java/com/nowait/applicationuser/reservation/service/ReservationServiceConcurrencyTest.java b/nowait-app-user-api/src/test/java/com/nowait/applicationuser/reservation/service/ReservationServiceConcurrencyTest.java new file mode 100644 index 00000000..bb24d0cd --- /dev/null +++ b/nowait-app-user-api/src/test/java/com/nowait/applicationuser/reservation/service/ReservationServiceConcurrencyTest.java @@ -0,0 +1,188 @@ +// package com.nowait.applicationuser.reservation.service; +// +// import static org.junit.jupiter.api.Assertions.*; +// import static org.mockito.ArgumentMatchers.anyLong; +// import static org.mockito.Mockito.*; +// +// import java.util.ArrayList; +// import java.util.Collections; +// import java.util.HashSet; +// import java.util.List; +// import java.util.Optional; +// import java.util.Set; +// import java.util.concurrent.CountDownLatch; +// import java.util.concurrent.ExecutorService; +// import java.util.concurrent.Executors; +// import java.util.concurrent.TimeUnit; +// +// import org.junit.jupiter.api.BeforeAll; +// import org.junit.jupiter.api.BeforeEach; +// import org.junit.jupiter.api.DisplayName; +// import org.junit.jupiter.api.Test; +// import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +// import org.springframework.data.redis.core.StringRedisTemplate; +// import org.testcontainers.junit.jupiter.Container; +// import org.testcontainers.junit.jupiter.Testcontainers; +// +// import com.nowait.applicationuser.reservation.dto.ReservationCreateRequestDto; +// import com.nowait.applicationuser.reservation.dto.WaitingResponseDto; +// import com.nowait.applicationuser.reservation.repository.WaitingUserRedisRepository; +// import com.nowait.common.enums.Role; +// import com.nowait.domaincoreredis.common.util.RedisKeyUtils; +// import com.nowait.domaincorerdb.department.repository.DepartmentRepository; +// import com.nowait.domaincorerdb.reservation.repository.ReservationRepository; +// import com.nowait.domaincorerdb.store.repository.StoreImageRepository; +// import com.nowait.domaincorerdb.store.repository.StoreRepository; +// import com.nowait.domaincorerdb.user.repository.UserRepository; +// import com.nowait.domaincorerdb.store.entity.Store; +// import com.nowait.domaincorerdb.user.entity.User; +// import com.nowait.domaincoreredis.reservation.repository.WaitingPermitLuaRepository; +// import com.nowait.domainuserrdb.oauth.dto.CustomOAuth2User; +// import com.redis.testcontainers.RedisContainer; +// +// @Testcontainers +// class ReservationServiceConcurrencyTest { +// +// @Container +// static RedisContainer redis = new RedisContainer("redis:6.2.6"); +// +// static StringRedisTemplate redisTemplate; +// static WaitingUserRedisRepository waitingRepo; +// static ReservationService reservationService; +// +// // Mockito로 대체할 의존성들 +// static ReservationRepository reservationRepo; +// static StoreRepository storeRepo; +// static UserRepository userRepo; +// static DepartmentRepository deptRepo; +// static StoreImageRepository storeImageRepo; +// static WaitingPermitLuaRepository waitingPermitRepo; +// +// private static final Long STORE_ID = 100L; +// private static final int THREAD_COUNT = 50; +// +// @BeforeAll +// static void setupAll() { +// // 1) RedisTemplate 초기화 +// LettuceConnectionFactory factory = new LettuceConnectionFactory( +// redis.getHost(), +// redis.getFirstMappedPort() +// ); +// factory.afterPropertiesSet(); +// redisTemplate = new StringRedisTemplate(factory); +// +// // 2) 실제 Redis 리포지토리 객체 생성 +// waitingRepo = new WaitingUserRedisRepository(redisTemplate); +// waitingPermitRepo = new WaitingPermitLuaRepository(redisTemplate); +// +// // 3) Mockito mock 인스턴스 생성 +// reservationRepo = mock(ReservationRepository.class); +// storeRepo = mock(StoreRepository.class); +// userRepo = mock(UserRepository.class); +// deptRepo = mock(DepartmentRepository.class); +// storeImageRepo = mock(StoreImageRepository.class); +// +// // 4) store/user 유효성 검증 스텁 +// Store mockStore = mock(Store.class); +// when(mockStore.getIsActive()).thenReturn(true); +// when(storeRepo.findById(anyLong())).thenReturn(Optional.of(mockStore)); +// +// when(userRepo.findById(anyLong())).thenAnswer(inv -> { +// Long uid = inv.getArgument(0, Long.class); +// User u = mock(User.class); +// when(u.getRole()).thenReturn(Role.USER); +// when(u.getId()).thenReturn(uid); +// return Optional.of(u); +// }); +// +// // 5) ReservationService 실체 생성 +// reservationService = new ReservationService( +// reservationRepo, +// storeRepo, +// userRepo, +// waitingRepo, +// deptRepo, +// storeImageRepo, +// waitingPermitRepo, +// redisTemplate +// ); +// } +// +// @BeforeEach +// void clearRedis() { +// // 매 테스트마다 Redis 키 초기화 +// redisTemplate.delete(redisTemplate.keys(RedisKeyUtils.buildWaitingKeyPrefix() + "*")); +// redisTemplate.delete(redisTemplate.keys(RedisKeyUtils.buildWaitingPartySizeKeyPrefix() + "*")); +// redisTemplate.delete(redisTemplate.keys(RedisKeyUtils.buildWaitingStatusKeyPrefix() + "*")); +// redisTemplate.delete(redisTemplate.keys(RedisKeyUtils.buildReservationSeqKey(STORE_ID) + ":*")); +// redisTemplate.delete(redisTemplate.keys(RedisKeyUtils.buildReservationNumberKey(STORE_ID) + ":*")); +// redisTemplate.delete(redisTemplate.keys("waiting:user:*")); +// redisTemplate.delete(RedisKeyUtils.buildReservationNumberKey(STORE_ID)); +// redisTemplate.delete(redisTemplate.keys(RedisKeyUtils.buildReservationSeqKey(STORE_ID) + ":*")); +// } +// +// @Test +// @DisplayName("동시 50명 대기 등록 테스트 (Given–When–Then)") +// void concurrentRegisterWaiting() throws InterruptedException { +// // --- Given --- +// // 50개 스레드를 준비하고, 동시에 시작/종료를 제어할 CountDownLatch +// ExecutorService exec = Executors.newFixedThreadPool(THREAD_COUNT); +// CountDownLatch startLatch = new CountDownLatch(1); +// CountDownLatch finishLatch = new CountDownLatch(THREAD_COUNT); +// // 결과를 수집할 스레드 안전 리스트 +// List responses = Collections.synchronizedList(new ArrayList<>()); +// List errors = Collections.synchronizedList(new ArrayList<>()); +// +// // THREAD_COUNT개의 작업을 스레드풀에 제출 +// for (int i = 0; i < THREAD_COUNT; i++) { +// final long uid = 1000 + i; +// exec.submit(() -> { +// try { +// // 모든 스레드가 startLatch.await() 대기 중 +// startLatch.await(); +// +// // 각 스레드마다 OAuth2User mocking +// CustomOAuth2User user = mock(CustomOAuth2User.class); +// when(user.getUserId()).thenReturn(uid); +// +// // 실제 호출 +// ReservationCreateRequestDto dto = ReservationCreateRequestDto.builder() +// .partySize(2) +// .build(); +// WaitingResponseDto responseDto = reservationService.registerWaiting(STORE_ID, user, dto); +// responses.add(responseDto); +// } catch (Throwable t) { +// errors.add(t); +// } finally { +// // 작업 완료 신고 +// finishLatch.countDown(); +// } +// }); +// } +// +// // --- When --- +// // 모든 스레드를 동시에 시작 +// startLatch.countDown(); +// // 최대 15초 대기 +// boolean completedInTime = finishLatch.await(15, TimeUnit.SECONDS); +// +// // --- Then --- +// // 1) 모든 스레드가 제시간에 완료되었는가? +// assertTrue(completedInTime, "스레드가 제시간에 완료되지 않았습니다."); +// assertTrue(errors.isEmpty(), "스레드 예외 발생: " + errors); +// // 2) 반환된 DTO 개수가 50개인가? +// assertEquals(THREAD_COUNT, responses.size(), "전체 응답 수 불일치"); +// +// // 3) 예약번호가 모두 유니크한가? +// Set reservationIds = new HashSet<>(); +// for (WaitingResponseDto r : responses) { +// assertNotNull(r.getReservationNumber(), "예약번호가 null 입니다"); +// reservationIds.add(r.getReservationNumber()); +// } +// assertEquals(THREAD_COUNT, reservationIds.size(), "예약번호가 중복되었습니다"); +// +// // 스레드풀 정리 +// exec.shutdown(); +// } +// +// } diff --git a/nowait-app-user-api/src/test/java/com/nowait/applicationuser/reservation/service/ReservationServiceTest.java b/nowait-app-user-api/src/test/java/com/nowait/applicationuser/reservation/service/ReservationServiceTest.java new file mode 100644 index 00000000..74ef5038 --- /dev/null +++ b/nowait-app-user-api/src/test/java/com/nowait/applicationuser/reservation/service/ReservationServiceTest.java @@ -0,0 +1,366 @@ +package com.nowait.applicationuser.reservation.service; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; + +import com.nowait.applicationuser.reservation.dto.MyWaitingQueueDto; +import com.nowait.applicationuser.reservation.dto.ReservationCreateRequestDto; +import com.nowait.applicationuser.reservation.dto.ReservationCreateResponseDto; +import com.nowait.applicationuser.reservation.dto.WaitingResponseDto; +import com.nowait.applicationuser.reservation.repository.WaitingUserRedisRepository; +import com.nowait.common.enums.ReservationStatus; +import com.nowait.common.enums.Role; +import com.nowait.domaincorerdb.department.repository.DepartmentRepository; +import com.nowait.domaincorerdb.reservation.entity.Reservation; +import com.nowait.domaincorerdb.reservation.exception.DuplicateReservationException; +import com.nowait.domaincorerdb.reservation.repository.ReservationRepository; +import com.nowait.domaincorerdb.store.entity.Store; +import com.nowait.domaincorerdb.store.exception.StoreNotFoundException; +import com.nowait.domaincorerdb.store.exception.StoreWaitingDisabledException; +import com.nowait.domaincorerdb.store.repository.StoreImageRepository; +import com.nowait.domaincorerdb.store.repository.StoreRepository; +import com.nowait.domaincorerdb.user.entity.User; +import com.nowait.domaincorerdb.user.exception.UserNotFoundException; +import com.nowait.domaincorerdb.user.repository.UserRepository; +import com.nowait.domaincoreredis.reservation.exception.UserWaitingLimitExceededException; +import com.nowait.domaincoreredis.reservation.repository.WaitingPermitLuaRepository; +import com.nowait.domainuserrdb.oauth.dto.CustomOAuth2User; + +@ExtendWith(MockitoExtension.class) +public class ReservationServiceTest { + + @Mock private StoreRepository storeRepository; + @Mock private UserRepository userRepository; + @Mock private WaitingUserRedisRepository waitingRepo; + @Mock private ReservationRepository reservationRepository; + @Mock private WaitingPermitLuaRepository waitingPermitLuaRepository; + @Mock private DepartmentRepository departmentRepository; + @Mock private StoreImageRepository storeImageRepository; + @Mock private RedisTemplate redisTemplate; + + @InjectMocks private ReservationService service; + + + @Test + @DisplayName("registerWaiting: 스토어 없음 예외") + void registerWaiting_StoreNotFound() { + // Given + Long storeId = 10L; + when(storeRepository.findById(storeId)).thenReturn(Optional.empty()); + + // When/Then + assertThrows(StoreNotFoundException.class, + () -> service.registerWaiting(storeId, mock(CustomOAuth2User.class), null) + ); + } + + @Test + @DisplayName("myWaitingInfo: 정상 조회") + void myWaitingInfo_Success() { + // Given + Long storeId = 5L; + CustomOAuth2User user = mock(CustomOAuth2User.class); + when(user.getUserId()).thenReturn(20L); + when(waitingRepo.getRank(storeId, "20")).thenReturn(0L); + when(waitingRepo.getPartySize(storeId, "20")).thenReturn(3); + when(waitingRepo.getReservationId(storeId, "20")).thenReturn("5-20250804-0001"); + + // When + WaitingResponseDto info = service.myWaitingInfo(storeId, user); + + // Then + assertNotNull(info); + assertEquals("5-20250804-0001", info.getReservationNumber()); + assertEquals(1, info.getRank()); + assertEquals(3, info.getPartySize()); + } + + @Test + @DisplayName("cancelWaiting: 성공 시 true 반환 및 DB 저장 호출") + void cancelWaiting_Success() { + // Given + Long storeId = 7L; + CustomOAuth2User user = mock(CustomOAuth2User.class); + when(user.getUserId()).thenReturn(30L); + when(waitingRepo.getReservationId(storeId, "30")).thenReturn("RID-1"); + when(waitingRepo.getPartySize(storeId, "30")).thenReturn(4); + when(waitingRepo.getWaitingTimestamp(storeId, "30")).thenReturn(Instant.now().toEpochMilli()); + when(waitingRepo.removeWaiting(storeId, "30")).thenReturn(true); + + // When + boolean removed = service.cancelWaiting(storeId, user); + + // Then + assertTrue(removed); + verify(reservationRepository).save(any(Reservation.class)); + } + + @Test + @DisplayName("getAllMyWaitings: active가 비어 있으면 빈 리스트 반환") + void getAllMyWaitings_Empty_GWT() { + // Given + CustomOAuth2User principal = mock(CustomOAuth2User.class); + when(principal.getUserId()).thenReturn(100L); + + when(waitingPermitLuaRepository.getActiveMembers("100")) + .thenReturn(Collections.emptySet()); + + // When + List result = service.getAllMyWaitings(principal); + + // Then + assertThat(result).isEmpty(); + + // 불필요한 스텁/호출이 없도록 보장 + verify(waitingPermitLuaRepository).getActiveMembers("100"); + verifyNoMoreInteractions(waitingPermitLuaRepository); + // storeRepository 등은 호출되지 않아야 함 + verifyNoInteractions(storeRepository, departmentRepository, storeImageRepository, waitingRepo); + } + + + @Test + @DisplayName("create(DB): 성공 시 DTO 반환") + void create_Success() { + // Given + Long storeId = 2L; + CustomOAuth2User user = mock(CustomOAuth2User.class); + when(user.getUserId()).thenReturn(50L); + ReservationCreateRequestDto dto = ReservationCreateRequestDto.builder().partySize(5).build(); + Store store = mock(Store.class); + when(storeRepository.findById(storeId)).thenReturn(Optional.of(store)); + when(store.getIsActive()).thenReturn(true); + User domainUser = mock(User.class); + when(userRepository.findById(50L)).thenReturn(Optional.of(domainUser)); + when(reservationRepository.existsByUserAndStoreAndStatusIn(eq(domainUser), eq(store), anyList())) + .thenReturn(false); + Reservation saved = Reservation.builder() + .id(99L) + .store(store) + .user(domainUser) + .requestedAt(LocalDateTime.now()) + .status(ReservationStatus.WAITING) + .partySize(5) + .build(); + when(reservationRepository.save(any(Reservation.class))).thenReturn(saved); + + // When + ReservationCreateResponseDto res = service.create(storeId, user, dto); + + // Then + assertNotNull(res); + assertEquals(99L, res.getId()); + assertEquals(5, res.getPartySize()); + } + + @Test + @DisplayName("create(DB): 중복 예약 예외") + void create_DuplicateException() { + // Given + Long storeId = 2L; + CustomOAuth2User user = mock(CustomOAuth2User.class); + when(user.getUserId()).thenReturn(50L); + ReservationCreateRequestDto dto = ReservationCreateRequestDto.builder().partySize(1).build(); + Store store = mock(Store.class); + when(storeRepository.findById(storeId)).thenReturn(Optional.of(store)); + when(store.getIsActive()).thenReturn(true); + User domainUser = mock(User.class); + when(userRepository.findById(50L)).thenReturn(Optional.of(domainUser)); + when(reservationRepository.existsByUserAndStoreAndStatusIn(eq(domainUser), eq(store), anyList())) + .thenReturn(true); + + // When/Then + assertThrows(DuplicateReservationException.class, + () -> service.create(storeId, user, dto) + ); + } + + @Test + @DisplayName("create(DB): 사용자 없음 예외") + void create_UserNotFound() { + // Given + Long storeId = 2L; + CustomOAuth2User user = mock(CustomOAuth2User.class); + when(user.getUserId()).thenReturn(50L); + when(storeRepository.findById(storeId)).thenReturn(Optional.of(mock(Store.class))); + when(userRepository.findById(50L)).thenReturn(Optional.empty()); + + // When/Then + assertThrows(UserNotFoundException.class, + () -> service.create(storeId, user, ReservationCreateRequestDto.builder().partySize(1).build()) + ); + } + + @Test + @DisplayName("create(DB): 스토어 없음 예외") + void create_StoreNotFound() { + // Given + Long storeId = 3L; + when(storeRepository.findById(storeId)).thenReturn(Optional.empty()); + + // When/Then + assertThrows(StoreNotFoundException.class, + () -> service.create(storeId, mock(CustomOAuth2User.class), ReservationCreateRequestDto.builder().partySize(1).build()) + ); + } + + @Test + @DisplayName("create(DB): 스토어 비활성화 예외") + void create_StoreDisabledException() { + // Given + Long storeId = 4L; + CustomOAuth2User user = mock(CustomOAuth2User.class); + when(user.getUserId()).thenReturn(60L); + + // 1) 사용자 조회 스텁 추가 + User domainUser = mock(User.class); + when(userRepository.findById(60L)).thenReturn(Optional.of(domainUser)); + + // 2) 스토어 조회 및 비활성화 스텁 + Store store = mock(Store.class); + when(storeRepository.findById(storeId)).thenReturn(Optional.of(store)); + when(store.getIsActive()).thenReturn(false); + + // When / Then + assertThrows(StoreWaitingDisabledException.class, + () -> service.create( + storeId, + user, + ReservationCreateRequestDto.builder().partySize(2).build() + ) + ); + } + + @Test + @DisplayName("registerWaiting: 성공 시 WaitingResponseDto 반환") + void registerWaiting_Success_GWT() { + // Given + Long storeId = 10L; + CustomOAuth2User principal = mock(CustomOAuth2User.class); + when(principal.getUserId()).thenReturn(100L); + + ReservationCreateRequestDto dto = ReservationCreateRequestDto.builder().partySize(2).build(); + + Store store = mock(Store.class); + when(storeRepository.findById(storeId)).thenReturn(Optional.of(store)); + when(store.getIsActive()).thenReturn(true); + + User domainUser = mock(User.class); + when(userRepository.findById(100L)).thenReturn(Optional.of(domainUser)); + when(domainUser.getRole()).thenReturn(Role.USER); + // 서비스가 user.getId()를 쓰는 경우 필수 + when(domainUser.getId()).thenReturn(100L); + + when(waitingRepo.calculateTTLUntilNext03AM()).thenReturn(Duration.ofHours(1)); + when(waitingRepo.isUserWaiting(storeId, "100")).thenReturn(false); + when(waitingPermitLuaRepository.acquireLease(eq("100"), anyString(), anyLong(), anyLong(), eq(3), any())) + .thenReturn(true); + + when(waitingRepo.addToWaitingQueue(eq(storeId), eq("100"), eq(2), anyLong())) + .thenReturn("10-20250804-0001"); + when(waitingRepo.getRank(storeId, "100")).thenReturn(4L); + + // When + WaitingResponseDto result = service.registerWaiting(storeId, principal, dto); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getReservationNumber()).isEqualTo("10-20250804-0001"); + assertThat(result.getRank()).isEqualTo(5); + assertThat(result.getPartySize()).isEqualTo(2); + + verify(waitingPermitLuaRepository).acquireLease(eq("100"), anyString(), anyLong(), anyLong(), eq(3), any()); + verify(waitingRepo).addToWaitingQueue(eq(storeId), eq("100"), eq(2), anyLong()); + verify(waitingPermitLuaRepository).finalizeActive(eq("100"), anyString(), eq(String.valueOf(storeId)), eq("10-20250804-0001"), any()); + verifyNoMoreInteractions(waitingPermitLuaRepository, waitingRepo); + } + + + @Test + @DisplayName("registerWaiting: 동일 매장 재요청 시 기존 정보 반환(임대 미소비)") + void registerWaiting_Duplicate_SameStore_GWT() { + // Given + Long storeId = 10L; + CustomOAuth2User principal = mock(CustomOAuth2User.class); + when(principal.getUserId()).thenReturn(100L); + + ReservationCreateRequestDto dto = ReservationCreateRequestDto.builder().partySize(2).build(); + + Store store = mock(Store.class); + when(storeRepository.findById(storeId)).thenReturn(Optional.of(store)); + when(store.getIsActive()).thenReturn(true); + + User domainUser = mock(User.class); + when(userRepository.findById(100L)).thenReturn(Optional.of(domainUser)); + when(domainUser.getRole()).thenReturn(Role.USER); + when(domainUser.getId()).thenReturn(100L); + + // 이미 해당 매장에 있는 상태 + when(waitingRepo.isUserWaiting(storeId, "100")).thenReturn(true); + when(waitingRepo.getRank(storeId, "100")).thenReturn(4L); + when(waitingRepo.getPartySize(storeId, "100")).thenReturn(2); + when(waitingRepo.getReservationId(storeId, "100")).thenReturn("10-20250804-0001"); + + // When + WaitingResponseDto result = service.registerWaiting(storeId, principal, dto); + + // Then + assertThat(result.getReservationNumber()).isEqualTo("10-20250804-0001"); + assertThat(result.getRank()).isEqualTo(5); + assertThat(result.getPartySize()).isEqualTo(2); + + verify(waitingPermitLuaRepository, never()).acquireLease(any(), any(), anyLong(), anyLong(), anyInt(), any()); + verify(waitingRepo, never()).addToWaitingQueue(anyLong(), anyString(), anyInt(), anyLong()); + } + + + @Test + @DisplayName("registerWaiting: 유저 한도(3개) 초과 시 예외") + void registerWaiting_LimitExceeded_GWT() { + // Given + Long storeId = 40L; + CustomOAuth2User principal = mock(CustomOAuth2User.class); + when(principal.getUserId()).thenReturn(100L); + + ReservationCreateRequestDto dto = ReservationCreateRequestDto.builder().partySize(2).build(); + + Store store = mock(Store.class); + when(storeRepository.findById(storeId)).thenReturn(Optional.of(store)); + when(store.getIsActive()).thenReturn(true); + + User domainUser = mock(User.class); + when(userRepository.findById(100L)).thenReturn(Optional.of(domainUser)); + when(domainUser.getRole()).thenReturn(Role.USER); + when(domainUser.getId()).thenReturn(100L); + + when(waitingRepo.calculateTTLUntilNext03AM()).thenReturn(Duration.ofHours(1)); + when(waitingRepo.isUserWaiting(storeId, "100")).thenReturn(false); + // 임대 3회 모두 실패하도록 + when(waitingPermitLuaRepository.acquireLease(eq("100"), anyString(), anyLong(), anyLong(), eq(3), any())) + .thenReturn(false); + + // When & Then + assertThatThrownBy(() -> service.registerWaiting(storeId, principal, dto)) + .isInstanceOf(UserWaitingLimitExceededException.class) + .hasMessageContaining("유저당 웨이팅 가능 개수"); + + verify(waitingRepo, never()).addToWaitingQueue(anyLong(), anyString(), anyInt(), anyLong()); + verify(waitingPermitLuaRepository, atLeastOnce()).acquireLease(eq("100"), anyString(), anyLong(), anyLong(), eq(3), any()); + } +} diff --git a/nowait-app-user-api/src/test/java/com/nowait/applicationuser/reservation/service/WaitingPermitLuaRepositoryTest.java b/nowait-app-user-api/src/test/java/com/nowait/applicationuser/reservation/service/WaitingPermitLuaRepositoryTest.java new file mode 100644 index 00000000..ae32ab4b --- /dev/null +++ b/nowait-app-user-api/src/test/java/com/nowait/applicationuser/reservation/service/WaitingPermitLuaRepositoryTest.java @@ -0,0 +1,104 @@ +package com.nowait.applicationuser.reservation.service; + +import static org.assertj.core.api.Assertions.*; + +import java.time.Duration; +import java.util.Set; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import com.nowait.domaincoreredis.common.util.RedisKeyUtils; +import com.nowait.domaincoreredis.reservation.repository.WaitingPermitLuaRepository; + +@Testcontainers +public class WaitingPermitLuaRepositoryTest { + + @Container + static GenericContainer redis = new GenericContainer<>("redis:7.2-alpine").withExposedPorts(6379); + + static StringRedisTemplate template; + static WaitingPermitLuaRepository permitRepo; + + final String userId = "U1"; + final Duration ttlTo3am = Duration.ofHours(6); // 테스트용 + final long leaseMs = 30_000; + + @BeforeAll + static void setupAll() { + var factory = new LettuceConnectionFactory(redis.getHost(), redis.getMappedPort(6379)); + factory.afterPropertiesSet(); + template = new StringRedisTemplate(factory); + template.afterPropertiesSet(); + permitRepo = new WaitingPermitLuaRepository(template); + } + + @BeforeEach + void flush() { + template.getConnectionFactory().getConnection().serverCommands().flushAll(); + } + + @Test + void acquireLease_underLimit_succeedsUpTo3() { + String hk = RedisKeyUtils.buildUserHoldingKey(userId); + String ak = RedisKeyUtils.buildUserActiveKey(userId); + long now = System.currentTimeMillis(); + + // 1,2,3번째 임대 성공 + assertThat(permitRepo.acquireLease(userId, "t1", now, leaseMs, 3, ttlTo3am)).isTrue(); + assertThat(permitRepo.acquireLease(userId, "t2", now, leaseMs, 3, ttlTo3am)).isTrue(); + assertThat(permitRepo.acquireLease(userId, "t3", now, leaseMs, 3, ttlTo3am)).isTrue(); + + // 4번째는 실패 + assertThat(permitRepo.acquireLease(userId, "t4", now, leaseMs, 3, ttlTo3am)).isFalse(); + + // holding 3개 확인 + Long holding = template.opsForZSet().zCard(hk); + Long active = template.opsForSet().size(ak); + assertThat(holding).isEqualTo(3L); + assertThat(active).isEqualTo(0L); + } + + @Test + void finalize_movesFromHoldingToActive_totalCountMaintained() { + long now = System.currentTimeMillis(); + // 두 개 임대 획득 + assertThat(permitRepo.acquireLease(userId, "t1", now, leaseMs, 3, ttlTo3am)).isTrue(); + assertThat(permitRepo.acquireLease(userId, "t2", now, leaseMs, 3, ttlTo3am)).isTrue(); + + // t1 확정 + permitRepo.finalizeActive(userId, "t1", "10", "10-20250101-0001", ttlTo3am); + + String hk = RedisKeyUtils.buildUserHoldingKey(userId); + String ak = RedisKeyUtils.buildUserActiveKey(userId); + + assertThat(template.opsForZSet().zCard(hk)).isEqualTo(1L); // t2만 holding + assertThat(template.opsForSet().size(ak)).isEqualTo(1L); // active 1 + assertThat(template.opsForSet().isMember(ak, "10:10-20250101-0001")).isTrue(); + } + + @Test + void releaseLease_removesFromHolding() { + long now = System.currentTimeMillis(); + assertThat(permitRepo.acquireLease(userId, "t1", now, leaseMs, 3, ttlTo3am)).isTrue(); + permitRepo.releaseLease(userId, "t1"); + + String hk = RedisKeyUtils.buildUserHoldingKey(userId); + assertThat(template.opsForZSet().zCard(hk)).isEqualTo(0L); + } + + @Test + void getActiveMembers_returnsSetMembers() { + long now = System.currentTimeMillis(); + permitRepo.acquireLease(userId, "t1", now, leaseMs, 3, ttlTo3am); + permitRepo.finalizeActive(userId, "t1", "77", "77-20250101-0001", ttlTo3am); + Set ms = permitRepo.getActiveMembers(userId); + assertThat(ms).containsExactly("77:77-20250101-0001"); + } +} diff --git a/nowait-app-user-api/src/test/java/com/nowait/applicationuser/waiting/service/WaitingServiceTest.java b/nowait-app-user-api/src/test/java/com/nowait/applicationuser/waiting/service/WaitingServiceTest.java new file mode 100644 index 00000000..afbd23b5 --- /dev/null +++ b/nowait-app-user-api/src/test/java/com/nowait/applicationuser/waiting/service/WaitingServiceTest.java @@ -0,0 +1,358 @@ +package com.nowait.applicationuser.waiting.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.Optional; + + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import com.nowait.applicationuser.waiting.dto.RegisterWaitingRequest; +import com.nowait.applicationuser.waiting.dto.RegisterWaitingResponse; +import com.nowait.applicationuser.waiting.dto.WaitingIdempotencyValue; +import com.nowait.applicationuser.waiting.event.AddWaitingRegisterEvent; +import com.nowait.applicationuser.waiting.redis.WaitingIdempotencyRepository; +import com.nowait.domaincorerdb.reservation.entity.Reservation; +import com.nowait.domaincorerdb.reservation.exception.DuplicateReservationException; +import com.nowait.domaincorerdb.reservation.repository.ReservationRepository; +import com.nowait.domaincorerdb.store.entity.Store; +import com.nowait.domaincorerdb.store.exception.StoreNotFoundException; +import com.nowait.domaincorerdb.store.repository.StoreRepository; +import com.nowait.domaincorerdb.user.entity.User; +import com.nowait.domaincorerdb.user.exception.UserNotFoundException; +import com.nowait.domaincorerdb.user.repository.UserRepository; +import com.nowait.domaincoreredis.reservation.exception.UserWaitingLimitExceededException; +import com.nowait.domaincoreredis.reservation.repository.WaitingRedisRepository; +import com.nowait.domainuserrdb.oauth.dto.CustomOAuth2User; + +import jakarta.servlet.http.HttpServletRequest; + +@ExtendWith(MockitoExtension.class) +class WaitingServiceTest { + + @InjectMocks + private WaitingService waitingService; + @InjectMocks + private IdempotencyService idempotencyService; + @Mock + private ApplicationEventPublisher eventPublisher; + @Mock + private StoreRepository storeRepository; + @Mock + private UserRepository userRepository; + @Mock + private WaitingRedisRepository waitingRedisRepository; + @Mock + private ReservationRepository reservationRepository; + @Mock + private WaitingIdempotencyRepository waitingIdempotencyRepository; + @Mock + private HttpServletRequest httpServletRequest; + @Mock + private CustomOAuth2User customOAuth2User; + + private static final String IDEMPOTENCY_KEY = "550e8400-e29b-41d4-a716-446655440000"; + + @BeforeEach + void setUp() { + when(httpServletRequest.getHeader("Idempotency-Key")).thenReturn(IDEMPOTENCY_KEY); + } + + @Test + @DisplayName("멱등키 있는 경우 미리 저장된 응답이 반환 되는지 테스트") + void registerWaiting_idempotentKeyExists() { + // given + RegisterWaitingRequest request = new RegisterWaitingRequest(4); + + RegisterWaitingResponse idempotentResponse = RegisterWaitingResponse.builder() + .waitingNumber("20260201-2-0001") + .partySize(4) + .build(); + + when(waitingIdempotencyRepository.findByRegisterKey(IDEMPOTENCY_KEY)) + .thenReturn(Optional.of(new WaitingIdempotencyValue( + "COMPLETED", + idempotentResponse + ))); + + // when + RegisterWaitingResponse response = waitingService.registerWaiting( + customOAuth2User, + "ZiVXAD1vVr5b", + request, + httpServletRequest + ); + + // then + assertThat(response).isSameAs(idempotentResponse); + + verify(storeRepository, never()).findByPublicCodeAndDeletedFalse(anyString()); + verify(userRepository, never()).findById(anyLong()); + verify(waitingRedisRepository, never()).incrementAndCheckWaitingLimit(anyLong(), anyLong()); + verify(reservationRepository, never()).save(any(Reservation.class)); + verify(eventPublisher, never()).publishEvent(any()); + verify(waitingIdempotencyRepository, never()).saveIdempotencyResponse(anyString(), any(RegisterWaitingResponse.class)); + } + + @Test + @DisplayName("멱등키가 In-PROGRESS 상태이면 DuplicateReservationException 발생") + void registerWaiting_idempotentKeyInProgress() { + // given + RegisterWaitingRequest request = new RegisterWaitingRequest(4); + + when(waitingIdempotencyRepository.findByRegisterKey(IDEMPOTENCY_KEY)) + .thenReturn(Optional.of(new WaitingIdempotencyValue( + "IN-PROGRESS", + null + ))); + + // when & then + assertThatThrownBy(() -> waitingService.registerWaiting( + customOAuth2User, + "ZiVXAD1vVr5b", + request, + httpServletRequest + )).isInstanceOf(DuplicateReservationException.class); + + verify(storeRepository, never()).findByPublicCodeAndDeletedFalse(anyString()); + verify(userRepository, never()).findById(anyLong()); + verify(waitingRedisRepository, never()).incrementAndCheckWaitingLimit(anyLong(), anyLong()); + verify(reservationRepository, never()).save(any(Reservation.class)); + verify(eventPublisher, never()).publishEvent(any()); + verify(waitingIdempotencyRepository, never()).saveIdempotencyResponse(anyString(), + any(RegisterWaitingResponse.class)); + } + + @Test + @DisplayName("Idempotency-Key가 blank이면 멱등 로직을 타지 않는다") + void registerWaiting_idempotentKeyNotExists() { + // given + CustomOAuth2User customOAuth2User = mock(CustomOAuth2User.class); + RegisterWaitingRequest request = new RegisterWaitingRequest(4); + + when(httpServletRequest.getHeader("Idempotency-Key")).thenReturn(" "); + + Long userId = 10L; + String publicCode = "ZiVXAD1vVr5b"; + + Store store = Store.builder().publicCode(publicCode).build(); + User user = User.builder().id(userId).build(); + + when(storeRepository.findByPublicCodeAndDeletedFalse(publicCode)).thenReturn(java.util.Optional.of(store)); + when(customOAuth2User.getUserId()).thenReturn(userId); + when(userRepository.findById(userId)).thenReturn(java.util.Optional.of(user)); + + doNothing() + .when(waitingRedisRepository) + .incrementAndCheckWaitingLimit(userId, 3L); + + when(waitingRedisRepository.incrementDailySequence(anyString())).thenReturn(1L); + + // when + RegisterWaitingResponse response = waitingService.registerWaiting( + customOAuth2User, + publicCode, + request, + httpServletRequest + ); + + // then + assertThat(response).isNotNull(); + assertThat(response.getPartySize()).isEqualTo(4); + assertThat(response.getWaitingNumber()).isNotBlank(); + + verify(waitingRedisRepository).incrementAndCheckWaitingLimit(userId, 3L); + verify(reservationRepository).save(any(Reservation.class)); + verify(eventPublisher).publishEvent(any(AddWaitingRegisterEvent.class)); + } + + @Test + @DisplayName("웨이팅 정상 등록 시 DB 저장, 이벤트 발생, 멱등 응답 저장 수행") + void registerWaiting_success() { + // given + CustomOAuth2User customOAuth2User = mock(CustomOAuth2User.class); + RegisterWaitingRequest request = new RegisterWaitingRequest(4); + + when(httpServletRequest.getHeader("Idempotency-Key")).thenReturn(IDEMPOTENCY_KEY); + when(waitingIdempotencyRepository.findByRegisterKey(IDEMPOTENCY_KEY)) + .thenReturn(Optional.empty()); + + Long userId = 10L; + String publicCode = "ZiVXAD1vVr5b"; + + Store store = Store.builder().publicCode(publicCode).build(); + User user = User.builder().id(userId).build(); + + when(storeRepository.findByPublicCodeAndDeletedFalse(publicCode)).thenReturn(java.util.Optional.of(store)); + when(customOAuth2User.getUserId()).thenReturn(10L); + when(userRepository.findById(userId)).thenReturn(java.util.Optional.of(user)); + + doNothing() + .when(waitingRedisRepository) + .incrementAndCheckWaitingLimit(userId, 3L); + + when(waitingRedisRepository.incrementDailySequence(anyString())).thenReturn(1L); + + // when + RegisterWaitingResponse response = waitingService.registerWaiting( + customOAuth2User, + publicCode, + request, + httpServletRequest + ); + + // then + assertThat(response).isNotNull(); + assertThat(response.getPartySize()).isEqualTo(4); + assertThat(response.getWaitingNumber()).isNotBlank(); + + verify(waitingRedisRepository).incrementAndCheckWaitingLimit(userId, 3L); + verify(reservationRepository).save(any(Reservation.class)); + verify(eventPublisher).publishEvent(any(AddWaitingRegisterEvent.class)); + verify(waitingIdempotencyRepository).saveIdempotencyResponse(anyString(), any(RegisterWaitingResponse.class)); + } + + @Test + @DisplayName("DB 저장 중 예외 발생 시 이벤트 발행 및 멱등 저장이 수행되지 않음") + void registerWaiting_dbSaveException() { + // given + when(waitingIdempotencyRepository.findByRegisterKey(IDEMPOTENCY_KEY)) + .thenReturn(Optional.empty()); + RegisterWaitingRequest request = new RegisterWaitingRequest(4); + + Store store = Store.builder().publicCode("ZiVXAD1vVr5b").build(); + User user = User.builder().id(10L).build(); + + when(storeRepository.findByPublicCodeAndDeletedFalse("ZiVXAD1vVr5b")) + .thenReturn(Optional.of(store)); + when(customOAuth2User.getUserId()).thenReturn(10L); + when(userRepository.findById(10L)) + .thenReturn(Optional.of(user)); + + doNothing().when(waitingRedisRepository).incrementAndCheckWaitingLimit(10L, 3L); + + when(waitingRedisRepository.incrementDailySequence(anyString())).thenReturn(1L); + + doThrow(new RuntimeException("DB 저장 실패")) + .when(reservationRepository) + .save(any(Reservation.class)); + + + // when & then + assertThatThrownBy(() -> waitingService.registerWaiting( + customOAuth2User, + "ZiVXAD1vVr5b", + request, + httpServletRequest + )).isInstanceOf(RuntimeException.class); + + verify(eventPublisher, never()).publishEvent(any(AddWaitingRegisterEvent.class)); + verify(waitingIdempotencyRepository, never()).saveIdempotencyResponse(anyString(), any(RegisterWaitingResponse.class)); + } + + @Test + @DisplayName("웨이팅 개수 제한 초과 시 예외 발생 테스트") + void registerWaiting_exceedWaitingLimit() { + // given + CustomOAuth2User customOAuth2User = mock(CustomOAuth2User.class); + RegisterWaitingRequest request = new RegisterWaitingRequest(4); + + when(httpServletRequest.getHeader("Idempotency-Key")).thenReturn(IDEMPOTENCY_KEY); + when(waitingIdempotencyRepository.findByRegisterKey(IDEMPOTENCY_KEY)) + .thenReturn(Optional.empty()); + + + Long userId = 10L; + String publicCode = "ZiVXAD1vVr5b"; + + Store store = Store.builder().publicCode(publicCode).build(); + User user = User.builder().id(userId).build(); + + // when + when(storeRepository.findByPublicCodeAndDeletedFalse(publicCode)).thenReturn(java.util.Optional.of(store)); + when(customOAuth2User.getUserId()).thenReturn(10L); + when(userRepository.findById(userId)).thenReturn(java.util.Optional.of(user)); + + doThrow(new UserWaitingLimitExceededException()) + .when(waitingRedisRepository) + .incrementAndCheckWaitingLimit(10L, 3L); + + // then + assertThatThrownBy(() -> waitingService.registerWaiting( + customOAuth2User, + "ZiVXAD1vVr5b", + request, + httpServletRequest + )).isInstanceOf(UserWaitingLimitExceededException.class); + + verify(reservationRepository, never()).save(any(Reservation.class)); + verify(eventPublisher, never()).publishEvent(any()); + verify(waitingIdempotencyRepository, never()).saveIdempotencyResponse(anyString(), any(RegisterWaitingResponse.class)); + verify(waitingRedisRepository).incrementAndCheckWaitingLimit(10L, 3L); + } + + @Test + @DisplayName("존재하지 않는 publicCode이면 StoreNotFoundException 발생") + void registerWaiting_storeNotFound() { + // given + when(waitingIdempotencyRepository.findByRegisterKey(IDEMPOTENCY_KEY)) + .thenReturn(Optional.empty()); + RegisterWaitingRequest request = new RegisterWaitingRequest(4); + + // when + when(storeRepository.findByPublicCodeAndDeletedFalse("invalidCode")) + .thenReturn(Optional.empty()); + + // then + assertThatThrownBy(() -> waitingService.registerWaiting( + customOAuth2User, + "invalidCode", + request, + httpServletRequest + )).isInstanceOf(StoreNotFoundException.class); + + verify(userRepository, never()).findById(anyLong()); + verify(waitingRedisRepository, never()).incrementAndCheckWaitingLimit(anyLong(), anyLong()); + verify(reservationRepository, never()).save(any(Reservation.class)); + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + @DisplayName("존재하지 않는 userId이면 UserNotFoundException 발생") + void registerWaiting_userNotFound() { + // given + when(waitingIdempotencyRepository.findByRegisterKey(IDEMPOTENCY_KEY)) + .thenReturn(Optional.empty()); + RegisterWaitingRequest request = new RegisterWaitingRequest(4); + String publicCode = "ZiVXAD1vVr5b"; + + Store store = Store.builder().publicCode(publicCode).build(); + + // when + when(storeRepository.findByPublicCodeAndDeletedFalse(publicCode)) + .thenReturn(Optional.of(store)); + when(customOAuth2User.getUserId()).thenReturn(10L); + when(userRepository.findById(10L)) + .thenReturn(Optional.empty()); + + // then + assertThatThrownBy(() -> waitingService.registerWaiting( + customOAuth2User, + publicCode, + request, + httpServletRequest + )).isInstanceOf(UserNotFoundException.class); + + verify(waitingRedisRepository, never()).incrementAndCheckWaitingLimit(anyLong(), anyLong()); + verify(reservationRepository, never()).save(any(Reservation.class)); + verify(eventPublisher, never()).publishEvent(any()); + } +} diff --git a/application-config/.gitattributes b/nowait-common/.gitattributes similarity index 100% rename from application-config/.gitattributes rename to nowait-common/.gitattributes diff --git a/application-config/.gitignore b/nowait-common/.gitignore similarity index 100% rename from application-config/.gitignore rename to nowait-common/.gitignore diff --git a/nowait-common/Dockerfile b/nowait-common/Dockerfile new file mode 100644 index 00000000..da287cab --- /dev/null +++ b/nowait-common/Dockerfile @@ -0,0 +1,6 @@ +FROM eclipse-temurin:21 + +WORKDIR /app +COPY build/libs/*.jar nowait-common.jar + +CMD ["java", "-jar", "nowait-common.jar"] diff --git a/nowait-common/build.gradle b/nowait-common/build.gradle new file mode 100644 index 00000000..c2f76e1b --- /dev/null +++ b/nowait-common/build.gradle @@ -0,0 +1,12 @@ +jar { + enabled = true +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +dependencies { +} diff --git a/common/src/main/java/com/nowaiting/common/api/ApiError.java b/nowait-common/src/main/java/com/nowait/common/api/ApiError.java similarity index 89% rename from common/src/main/java/com/nowaiting/common/api/ApiError.java rename to nowait-common/src/main/java/com/nowait/common/api/ApiError.java index b5cbca81..08f3e598 100644 --- a/common/src/main/java/com/nowaiting/common/api/ApiError.java +++ b/nowait-common/src/main/java/com/nowait/common/api/ApiError.java @@ -1,6 +1,6 @@ -package com.nowaiting.common.api; +package com.nowait.common.api; -import com.nowaiting.common.enums.HttpStatusCode; +import com.nowait.common.enums.HttpStatusCode; /** * API 에러 정보 diff --git a/common/src/main/java/com/nowaiting/common/api/ApiResult.java b/nowait-common/src/main/java/com/nowait/common/api/ApiResult.java similarity index 79% rename from common/src/main/java/com/nowaiting/common/api/ApiResult.java rename to nowait-common/src/main/java/com/nowait/common/api/ApiResult.java index 82e9a161..17247f42 100644 --- a/common/src/main/java/com/nowaiting/common/api/ApiResult.java +++ b/nowait-common/src/main/java/com/nowait/common/api/ApiResult.java @@ -1,4 +1,4 @@ -package com.nowaiting.common.api; +package com.nowait.common.api; public class ApiResult { private final boolean success; @@ -19,7 +19,5 @@ public T getResponse() { return response; } - public ApiError getError() { - return error; - } + // public ApiError getError() { return success ? null : error; } } diff --git a/common/src/main/java/com/nowaiting/common/api/ApiUtils.java b/nowait-common/src/main/java/com/nowait/common/api/ApiUtils.java similarity index 85% rename from common/src/main/java/com/nowaiting/common/api/ApiUtils.java rename to nowait-common/src/main/java/com/nowait/common/api/ApiUtils.java index 6cd010d3..1517f253 100644 --- a/common/src/main/java/com/nowaiting/common/api/ApiUtils.java +++ b/nowait-common/src/main/java/com/nowait/common/api/ApiUtils.java @@ -1,7 +1,7 @@ // common/api/ApiUtils.java -package com.nowaiting.common.api; +package com.nowait.common.api; -import com.nowaiting.common.enums.HttpStatusCode; +import com.nowait.common.enums.HttpStatusCode; public class ApiUtils { public static ApiResult success(T response) { diff --git a/common/src/main/java/com/nowaiting/common/enums/HttpStatusCode.java b/nowait-common/src/main/java/com/nowait/common/enums/HttpStatusCode.java similarity index 88% rename from common/src/main/java/com/nowaiting/common/enums/HttpStatusCode.java rename to nowait-common/src/main/java/com/nowait/common/enums/HttpStatusCode.java index a22fe922..5d96e2eb 100644 --- a/common/src/main/java/com/nowaiting/common/enums/HttpStatusCode.java +++ b/nowait-common/src/main/java/com/nowait/common/enums/HttpStatusCode.java @@ -1,4 +1,4 @@ -package com.nowaiting.common.enums; +package com.nowait.common.enums; public enum HttpStatusCode { OK(200), diff --git a/nowait-common/src/main/java/com/nowait/common/enums/ReservationStatus.java b/nowait-common/src/main/java/com/nowait/common/enums/ReservationStatus.java new file mode 100644 index 00000000..8ede6dd2 --- /dev/null +++ b/nowait-common/src/main/java/com/nowait/common/enums/ReservationStatus.java @@ -0,0 +1,5 @@ +package com.nowait.common.enums; + +public enum ReservationStatus { + WAITING, CALLING, CONFIRMED, CANCELLED +} diff --git a/common/src/main/java/com/nowaiting/common/enums/Role.java b/nowait-common/src/main/java/com/nowait/common/enums/Role.java similarity index 87% rename from common/src/main/java/com/nowaiting/common/enums/Role.java rename to nowait-common/src/main/java/com/nowait/common/enums/Role.java index c0a87c2e..ddbd6911 100644 --- a/common/src/main/java/com/nowaiting/common/enums/Role.java +++ b/nowait-common/src/main/java/com/nowait/common/enums/Role.java @@ -1,8 +1,9 @@ -package com.nowaiting.common.enums; +package com.nowait.common.enums; public enum Role { USER("USER"), - MANAGER("MANAGER"); + MANAGER("MANAGER"), + SUPER_ADMIN("SUPER_ADMIN"); private final String name; diff --git a/common/src/main/java/com/nowaiting/common/enums/SocialType.java b/nowait-common/src/main/java/com/nowait/common/enums/SocialType.java similarity index 62% rename from common/src/main/java/com/nowaiting/common/enums/SocialType.java rename to nowait-common/src/main/java/com/nowait/common/enums/SocialType.java index a853e826..1e46a7c6 100644 --- a/common/src/main/java/com/nowaiting/common/enums/SocialType.java +++ b/nowait-common/src/main/java/com/nowait/common/enums/SocialType.java @@ -1,4 +1,4 @@ -package com.nowaiting.common.enums; +package com.nowait.common.enums; public enum SocialType { KAKAO, NAVER, GOOGLE, LOCAL diff --git a/nowait-common/src/main/java/com/nowait/common/exception/ErrorMessage.java b/nowait-common/src/main/java/com/nowait/common/exception/ErrorMessage.java new file mode 100644 index 00000000..6aa6267e --- /dev/null +++ b/nowait-common/src/main/java/com/nowait/common/exception/ErrorMessage.java @@ -0,0 +1,121 @@ +package com.nowait.common.exception; + +public enum ErrorMessage { + // global + INVALID_INPUT_VALUE("입력값이 올바르지 않습니다.", "global001"), + + // auth + UNAUTHORIZED("권한이 없습니다", "auth001"), + + // token + REFRESH_TOKEN_NOT_FOUND("기존 리프레시 토큰을 찾을 수 없습니다.", "token001"), + DOES_NOT_MATCH_REFRESH_TOKEN("기존 리프레시 토큰이 일치하지 않습니다.", "token002"), + + // user + NOT_FOUND_USER("저장된 사용자 정보가 없습니다.", "user001"), + + //order + ORDER_PARAMETER_EMPTY("주문 생성 시 파라미터 정보가 없습니다.", "order001"), + ORDER_ITEMS_EMPTY("주문 항목이 없습니다.", "order002"), + DUPLICATE_ORDER("동일한 주문이 접수되었습니다.", "order003"), + DEPOSITOR_NAME_TOO_LONG("주문자명은 10자 이내 글자열입니다.", "order004"), + ORDER_VIEW_UNAUTHORIZED("주문 보기 권한이 없습니다.(슈퍼계정 or 주점 관리자만 가능)", "order005"), + ORDER_NOT_FOUND("해당 주문을 찾을 수 없습니다.", "order006"), + ORDER_UPDATE_UNAUTHORIZED("주문 수정 권한이 없습니다.(슈퍼계정 or 주점 관리자만 가능)", "order007"), + ORDER_ALREADY_CANCELLED("이미 취소된 주문입니다.", "order008"), + INVALID_ORDER_STATUS_TRANSITION("유효하지 않은 주문 상태 변경입니다. (현재: %s, 요청: %s)", "order009"), + + //reservation + NOTFOUND_RESERVATION("저장된 예약 정보가 없습니다.", "reservation001"), + RESERVATION_VIEW_UNAUTHORIZED("예약 보기 권한이 없습니다.(슈퍼계정 or 주점 관리자만 가능)", "reservation002"), + RESERVATION_UPDATE_UNAUTHORIZED("예약 수정 권한이 없습니다.(슈퍼계정 or 주점 관리자만 가능)", "reservation003"), + DUPLICATE_RESERVATION("이미 대기 중인 예약이 존재합니다.", "reservation004"), + USER_WAITING_LIMIT_EXCEEDED("유저당 웨이팅 가능 개수(3개)를 초과했습니다.", "reservation005"), + RESERVATION_NUMBER_ISSUE_FAIL("예약 번호 발급에 실패했습니다.", "reservation006"), + RESERVATION_ADD_UNAUTHORIZED("MANAGER는 예약 대기를 할 수 없습니다.", "reservation007"), + INVALID_RESERVATION_STATUS_TRANSITION("유효하지 않은 예약 상태 변경입니다. (현재: %s, 요청: %s)", "reservation008"), + RESERVATION_ALREADY_CONFIRMED("이미 확정된 예약입니다.", "reservation009"), + RESERVATION_ALREADY_CANCELLED("이미 취소된 예약입니다.", "reservation010"), + UNSUPPORTED_RESERVATION_STATUS("지원하지 않는 예약 상태입니다: %s", "reservation011"), + INVALID_RESERVATION_PARAMETER("잘못된 예약 요청 파라미터입니다. (%s)", "reservation012"), + ALREADY_DELETED_RESERVATION("이미 삭제된 예약입니다.", "reservation013"), + + // redis + RESERVATION_DATA_INCONSISTENCY("예약 데이터가 Redis와 DB 간 일치하지 않습니다. (%s)", "redis001"), + + // bookmark + DUPLICATE_BOOKMARK("이미 북마크한 주점입니다.", "bookmark001"), + NOT_OWN_BOOKMARK("해당 주점은 다른 사용자가 북마크한 주점입니다.", "bookmark002"), + NOT_FOUND_BOOKMARK("북마크를 찾을 수 없습니다", "bookmark003"), + ALREADY_DELETED_BOOKMARK("이미 삭제된 북마크입니다.", "bookmark004"), + + // menu + MENU_PARAMETER_EMPTY("메뉴 생성 시 파라미터 정보가 없습니다.", "menu001"), + MENU_NOT_FOUND("해당 메뉴를 찾을 수 없습니다.", "menu002"), + MENU_CREATION_UNAUTHORIZED("메뉴 생성 권한이 없습니다.(슈퍼계정 or 주점 관리자만 가능)", "menu003"), + MENU_VIEW_UNAUTHORIZED("메뉴 보기 권한이 없습니다.(슈퍼계정 or 주점 관리자만 가능)", "menu004"), + MENU_UPDATE_UNAUTHORIZED("메뉴 수정 권한이 없습니다.(슈퍼계정 or 주점 관리자만 가능)", "menu005"), + MENU_DELETE_UNAUTHORIZED("메뉴 삭제 권한이 없습니다.(슈퍼계정 or 주점 관리자만 가능)", "menu006"), + MENU_INVALID_SORT_ORDER("잘못된 정렬 순서가 포함되어 있습니다. 정렬 순서는 0 이상의 정수여야 합니다.", "menu007"), + MENU_DUPLICATE_ID("중복된 메뉴 ID가 포함되어 있습니다.", "menu008"), + MENU_CROSS_STORE_CONFLICT("서로 다른 매장의 메뉴가 포함되어 있습니다.", "menu009"), + MENU_ALREADY_DELETED("이미 삭제된 메뉴입니다.", "menu010"), + MENU_TOGGLE_UNAUTHORIZED("메뉴 품절 상태 변경 권한이 없습니다.", "menu011"), + + // store + STORE_PARAMETER_EMPTY("주점 생성 시 파라미터 정보가 없습니다.", "store001"), + STORE_NOT_FOUND("해당 주점을 찾을 수 없습니다.", "store002"), + STORE_VIEW_UNAUTHORIZED("주점 보기 권한이 없습니다.(슈퍼계정 or 주점 관리자만 가능)", "store003"), + STORE_UPDATE_UNAUTHORIZED("주점 수정 권한이 없습니다.(슈퍼계정 or 주점 관리자만 가능)", "store004"), + STORE_DELETE_UNAUTHORIZED("주점 삭제 권한이 없습니다.(슈퍼계정 or 주점 관리자만 가능)", "store005"), + STORE_WAITING_DISABLED("해당 주점은 대기 비활성화된 주점입니다.", "store006"), + + // storePayment + STORE_PAYMENT_PARAMETER_EMPTY("주점 결제 생성 시 파라미터 정보가 없습니다.", "storePayment001"), + STORE_PAYMENT_NOT_FOUND("해당 주점 결제 정보를 찾을 수 없습니다.", "storePayment002"), + STORE_PAYMENT_VIEW_UNAUTHORIZED("주점 결제 정보 보기 권한이 없습니다.(슈퍼계정 or 주점 관리자만 가능)", "storePayment003"), + STORE_PAYMENT_CREATION_UNAUTHORIZED("주점 결제 정보 생성 권한이 없습니다.(슈퍼계정 or 주점 관리자만 가능)", "storePayment004"), + STORE_PAYMENT_UPDATE_UNAUTHORIZED("주점 결제 정보 수정 권한이 없습니다.(슈퍼계정 or 주점 관리자만 가능)", "storePayment005"), + STORE_PAYMENT_DELETE_UNAUTHORIZED("주점 결제 정보 삭제 권한이 없습니다.(슈퍼계정 or 주점 관리자만 가능)", "storePayment006"), + STORE_PAYMENT_ALREADY_EXISTS("이미 존재하는 주점 결제 정보입니다.", "storePayment007"), + + // department + DEPARTMENT_NOT_FOUND("해당 학과/전공을 찾을 수 없습니다.", "department001"), + + // Statistics + STATISTIC_VIEW_UNAUTHORIZED("통계 보기 권한이 없습니다.(슈퍼계정 or 주점 관리자만 가능)", "statistics001"), + MENU_COUNTER_UPDATE("메뉴 카운터 업데이트 실패", "statistics002"), + + // image + IMAGE_FILE_EMPTY("업로드 된 이미지 파일이 없습니다.", "image001"), + IMAGE_FILE_NOT_FOUND("DB에 해당 이미지 메타데이터가 존재하지 않습니다.", "image002"), + + // search + SEARCH_PARAMETER_EMPTY("검색어가 비어있습니다.", "search001"), + + // idempotency + WORK_IN_PROGRESS("해당 요청이 이미 처리 중입니다. 잠시 후 다시 시도해주세요.", "idempotency001"), + + // common + UNEXPECTED_ERROR("예상하지 못한 오류가 발생했습니다.", "common999"); + + private final String message; + private final String code; + + ErrorMessage(String message, String code) { + this.message = message; + this.code = code; + } + + public String getMessage() { + return message; + } + + public String getCode() { + return code; + } + + public String format(Object... args) { + return String.format(message, args); + } +} diff --git a/common/src/main/java/com/nowaiting/common/exception/ErrorResponse.java b/nowait-common/src/main/java/com/nowait/common/exception/ErrorResponse.java similarity index 94% rename from common/src/main/java/com/nowaiting/common/exception/ErrorResponse.java rename to nowait-common/src/main/java/com/nowait/common/exception/ErrorResponse.java index 935069e7..1e0f70a7 100644 --- a/common/src/main/java/com/nowaiting/common/exception/ErrorResponse.java +++ b/nowait-common/src/main/java/com/nowait/common/exception/ErrorResponse.java @@ -1,4 +1,4 @@ -package com.nowaiting.common.exception; +package com.nowait.common.exception; import java.util.HashMap; import java.util.Map; diff --git a/nowait-common/src/main/java/com/nowait/common/token/TokenGenerator.java b/nowait-common/src/main/java/com/nowait/common/token/TokenGenerator.java new file mode 100644 index 00000000..2b84840b --- /dev/null +++ b/nowait-common/src/main/java/com/nowait/common/token/TokenGenerator.java @@ -0,0 +1,17 @@ +package com.nowait.common.token; + +import java.security.SecureRandom; + +public final class TokenGenerator { + private static final SecureRandom RNG = new SecureRandom(); + private static final char[] ALPHABET = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".toCharArray(); + + // 12~16 권장 + public static String base62(int len) { + char[] out = new char[len]; + for (int i = 0; i < len; i++) out[i] = ALPHABET[RNG.nextInt(ALPHABET.length)]; + return new String(out); + } + private TokenGenerator() {} +} diff --git a/common/.gitignore b/nowait-domain/.gitignore similarity index 100% rename from common/.gitignore rename to nowait-domain/.gitignore diff --git a/domain-user/build.gradle b/nowait-domain/build.gradle similarity index 85% rename from domain-user/build.gradle rename to nowait-domain/build.gradle index 3825b4d1..9ce9a13f 100644 --- a/domain-user/build.gradle +++ b/nowait-domain/build.gradle @@ -2,6 +2,11 @@ plugins { id 'java-library' } +jar { + enabled = true +} + + group = 'com.nowaiting' version = rootProject.version @@ -17,18 +22,18 @@ repositories { dependencies { // 순수 common 코드 - implementation project(':common') - // BaseTimeEntity - implementation project(':domain-base') + implementation project(':nowait-common') + // JPA API api 'org.springframework.boot:spring-boot-starter-data-jpa' api 'jakarta.persistence:jakarta.persistence-api:3.1.0' // SPRING SECURITY api 'org.springframework.boot:spring-boot-starter-security' - api 'org.springframework.boot:spring-boot-starter-test' // Lombok (optional) compileOnly 'org.projectlombok:lombok:1.18.26' annotationProcessor 'org.projectlombok:lombok:1.18.26' + // SWAGGER + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/domain-base/.gitignore b/nowait-domain/domain-admin-rdb/.gitignore similarity index 100% rename from domain-base/.gitignore rename to nowait-domain/domain-admin-rdb/.gitignore diff --git a/nowait-domain/domain-admin-rdb/build.gradle b/nowait-domain/domain-admin-rdb/build.gradle new file mode 100644 index 00000000..21a07f1f --- /dev/null +++ b/nowait-domain/domain-admin-rdb/build.gradle @@ -0,0 +1,53 @@ +plugins { + id 'java-library' +} + +jar { + enabled = true +} + + +group = 'com.nowait' +version = rootProject.version + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation project(':nowait-common') + implementation project(':nowait-domain:domain-core-rdb') + + api 'org.springframework.boot:spring-boot-starter-data-jpa' + api 'jakarta.persistence:jakarta.persistence-api:3.1.0' + + // SPRING SECURITY + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + implementation 'org.springframework.boot:spring-boot-starter-validation' + + //QueryDsl + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" + + // SWAGGER + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' + + compileOnly 'org.projectlombok:lombok:1.18.26' + annotationProcessor 'org.projectlombok:lombok:1.18.26' + + // Jackson + api 'com.fasterxml.jackson.core:jackson-databind:2.15.2' + api 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.15.2' + + testImplementation 'org.junit.jupiter:junit-jupiter' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' +} diff --git a/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/cancelOrder/entity/CancelOrder.java b/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/cancelOrder/entity/CancelOrder.java new file mode 100644 index 00000000..7ee01640 --- /dev/null +++ b/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/cancelOrder/entity/CancelOrder.java @@ -0,0 +1,52 @@ +package com.nowait.domainadminrdb.cancelOrder.entity; + +import java.time.Instant; + +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.PrePersist; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "cancel_orders") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class CancelOrder { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long orderId; + + @Column(name = "store_id", nullable = false) + private Long storeId; + + @Column(nullable = false, length = 256) + private String orderSignature; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 30) + private CancelReason reason; + + @Column(nullable = false) + private Instant cancelAt; + + @PrePersist + void prePersist() { + if (cancelAt == null) cancelAt = Instant.now(); + } +} diff --git a/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/cancelOrder/entity/CancelReason.java b/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/cancelOrder/entity/CancelReason.java new file mode 100644 index 00000000..66245b32 --- /dev/null +++ b/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/cancelOrder/entity/CancelReason.java @@ -0,0 +1,21 @@ +package com.nowait.domainadminrdb.cancelOrder.entity; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +@Schema(description = "주문 상태 Enum") +public enum CancelReason { + + @Schema(description = "단순 취소") + SIMPLE_CANCEL("단순 취소"), + @Schema(description = "메뉴 품절") + SOLD_OUT("메뉴 품절"), + @Schema(description = "기타") + ETC("기타"); + + private final String description; +} + diff --git a/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/cancelOrder/repository/CancelOrderRepository.java b/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/cancelOrder/repository/CancelOrderRepository.java new file mode 100644 index 00000000..790a9a3a --- /dev/null +++ b/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/cancelOrder/repository/CancelOrderRepository.java @@ -0,0 +1,10 @@ +package com.nowait.domainadminrdb.cancelOrder.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.nowait.domainadminrdb.cancelOrder.entity.CancelOrder; + +@Repository +public interface CancelOrderRepository extends JpaRepository { +} diff --git a/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/dto/OrderSalesSumDetail.java b/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/dto/OrderSalesSumDetail.java new file mode 100644 index 00000000..e516f403 --- /dev/null +++ b/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/dto/OrderSalesSumDetail.java @@ -0,0 +1,29 @@ +package com.nowait.domainadminrdb.statistic.dto; + +import java.time.LocalDate; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class OrderSalesSumDetail { + private Long storeId; + private Integer todaySalesSum; + private Integer yesterdaySalesSum; + private Integer cumulativeSalesBeforeYesterday; + private LocalDate date; + + public OrderSalesSumDetail(Long storeId, Integer todaySalesSum, Integer yesterdaySalesSum, + Integer cumulativeSalesBeforeYesterday, LocalDate date) { + this.storeId = storeId; + this.todaySalesSum = todaySalesSum; + this.yesterdaySalesSum = yesterdaySalesSum; + this.cumulativeSalesBeforeYesterday = cumulativeSalesBeforeYesterday; + this.date = date; + } + + public boolean isAllZero() { + return todaySalesSum == 0 && yesterdaySalesSum == 0 && cumulativeSalesBeforeYesterday == 0; + } +} diff --git a/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/dto/StoreInfo.java b/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/dto/StoreInfo.java new file mode 100644 index 00000000..c50981b7 --- /dev/null +++ b/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/dto/StoreInfo.java @@ -0,0 +1,20 @@ +package com.nowait.domainadminrdb.statistic.dto; + +import lombok.Getter; + +@Getter +public class StoreInfo { + private final Long storeId; + private final String storeName; + private final Long departmentId; + private final String departmentName; + private final String profileUrl; + + public StoreInfo(Long storeId, String storeName, Long departmentId, String departmentName, String profileUrl) { + this.storeId = storeId; + this.storeName = storeName; + this.departmentId = departmentId; + this.departmentName = departmentName; + this.profileUrl = profileUrl; + } +} diff --git a/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/dto/StoreSales.java b/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/dto/StoreSales.java new file mode 100644 index 00000000..442eacc8 --- /dev/null +++ b/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/dto/StoreSales.java @@ -0,0 +1,14 @@ +package com.nowait.domainadminrdb.statistic.dto; + +import lombok.Getter; + +@Getter +public class StoreSales { + private final Long storeId; + private final Integer totalSales; + + public StoreSales(Long storeId, Integer totalSales) { + this.storeId = storeId; + this.totalSales = totalSales; + } +} diff --git a/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/dto/TopSalesStoresDetail.java b/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/dto/TopSalesStoresDetail.java new file mode 100644 index 00000000..156e0249 --- /dev/null +++ b/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/dto/TopSalesStoresDetail.java @@ -0,0 +1,25 @@ +package com.nowait.domainadminrdb.statistic.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class TopSalesStoresDetail { + private Long storeId; + private String storeName; + private Long departmentId; + private String departmentName; + private Integer totalSales; + private Long storeRank; + + public TopSalesStoresDetail(Long storeId, String storeName, Long departmentId, String departmentName, Integer totalSales, + Long storeRank) { + this.storeId = storeId; + this.storeName = storeName; + this.departmentId = departmentId; + this.departmentName = departmentName; + this.totalSales = totalSales; + this.storeRank = storeRank; + } +} diff --git a/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/exception/StatisticViewUnauthorizedException.java b/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/exception/StatisticViewUnauthorizedException.java new file mode 100644 index 00000000..0757f643 --- /dev/null +++ b/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/exception/StatisticViewUnauthorizedException.java @@ -0,0 +1,9 @@ +package com.nowait.domainadminrdb.statistic.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class StatisticViewUnauthorizedException extends RuntimeException { + public StatisticViewUnauthorizedException() { + super(ErrorMessage.STATISTIC_VIEW_UNAUTHORIZED.getMessage()); + } +} diff --git a/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/repository/StatisticCustomRepository.java b/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/repository/StatisticCustomRepository.java new file mode 100644 index 00000000..4bb96013 --- /dev/null +++ b/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/repository/StatisticCustomRepository.java @@ -0,0 +1,24 @@ +package com.nowait.domainadminrdb.statistic.repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +import com.nowait.domainadminrdb.statistic.dto.OrderSalesSumDetail; +import com.nowait.domainadminrdb.statistic.dto.StoreInfo; +import com.nowait.domainadminrdb.statistic.dto.StoreSales; +import com.nowait.domainadminrdb.statistic.dto.TopSalesStoresDetail; + +public interface StatisticCustomRepository { + + OrderSalesSumDetail findSalesSumByStoreId(Long storeId, LocalDate date); + + List getTop4PlusMine(Long storeId); + + Map findOrderCountByStoreIds(List storeIds); + + // redis 사용하는 부분 + List findTotalSales(); + + List findStoreInfoByIds(List storeIds); +} diff --git a/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/repository/StatisticCustomRepositoryImpl.java b/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/repository/StatisticCustomRepositoryImpl.java new file mode 100644 index 00000000..560d0abc --- /dev/null +++ b/nowait-domain/domain-admin-rdb/src/main/java/com/nowait/domainadminrdb/statistic/repository/StatisticCustomRepositoryImpl.java @@ -0,0 +1,339 @@ +package com.nowait.domainadminrdb.statistic.repository; + +import static com.nowait.domaincorerdb.order.entity.OrderStatus.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Repository; + +import com.nowait.domainadminrdb.statistic.dto.OrderSalesSumDetail; +import com.nowait.domainadminrdb.statistic.dto.StoreInfo; +import com.nowait.domainadminrdb.statistic.dto.StoreSales; +import com.nowait.domainadminrdb.statistic.dto.TopSalesStoresDetail; +import com.nowait.domaincorerdb.department.entity.QDepartment; +import com.nowait.domaincorerdb.order.entity.QUserOrder; +import com.nowait.domaincorerdb.store.entity.ImageType; +import com.nowait.domaincorerdb.store.entity.QStore; +import com.nowait.domaincorerdb.store.entity.QStoreImage; +import com.querydsl.core.Tuple; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.extern.slf4j.Slf4j; + +@Repository +@Slf4j +public class StatisticCustomRepositoryImpl implements StatisticCustomRepository { + + private final JPAQueryFactory queryFactory; + + public StatisticCustomRepositoryImpl(JPAQueryFactory queryFactory) { + this.queryFactory = queryFactory; + } + + private static final QUserOrder u = QUserOrder.userOrder; + private static final QStore s = QStore.store; + private static final QDepartment d = QDepartment.department; + private static final QStoreImage si = QStoreImage.storeImage; + + @Override + public OrderSalesSumDetail findSalesSumByStoreId(Long storeId, LocalDate date) { + // 1) 시작, 끝 날짜 설정 + LocalDateTime start = date.atStartOfDay(); // 해당 날짜의 자정 + LocalDateTime end = date.plusDays(1).atStartOfDay(); + + LocalDateTime yesterDayStart = start.minusDays(1); // 어제 00:00 + + // 2) target 날짜 해당하는 매출 합산 + Integer targetSum = queryFactory + .select(u.totalPrice.sum()) + .from(u) + .where( + u.store.storeId.eq(storeId), + u.createdAt.goe(start), + u.createdAt.lt(end), + u.status.eq(COOKED) + ) + .fetchOne(); + + // 3. 어제 매출 합산 + Integer yesterdaySum = queryFactory + .select(u.totalPrice.sum()) + .from(u) + .where( + u.store.storeId.eq(storeId), + u.createdAt.goe(yesterDayStart), + u.createdAt.lt(start), + u.status.eq(COOKED) + ) + .fetchOne(); + + Integer cumulativeSalesBeforeYesterday = queryFactory + .select(u.totalPrice.sum()) + .from(u) + .where( + u.store.storeId.eq(storeId), + u.createdAt.lt(start), + u.status.eq(COOKED) + ) + .fetchOne(); + + // null 방어 처리 + if (targetSum == null) + targetSum = 0; + if (yesterdaySum == null) + yesterdaySum = 0; + if (cumulativeSalesBeforeYesterday == null) + cumulativeSalesBeforeYesterday = 0; + + // 4. 응답 객체 생성 + return new OrderSalesSumDetail(storeId, targetSum, yesterdaySum, cumulativeSalesBeforeYesterday, date); + } + + @Override + public List getTop4PlusMine(Long userStoreId) { + LocalDate today = LocalDate.now(); + LocalDateTime todayStart = today.atStartOfDay(); + LocalDateTime todayEnd = today.plusDays(1).atStartOfDay(); // 내일 00:00:00 + + // 1. 전체 주점 및 순위 맵 + List allStores = getAllStores(todayStart, todayEnd); + Map rankMap = getRankMap(allStores); + + // 2. 내 주점의 순위가 5위 이하인지 확인 + Integer userRank = rankMap.get(userStoreId); + + // 3. 상위 5개 주점 조회 (내 주점 포함 여부는 나중에 확인) + List result = getTopNStores(allStores, rankMap, 5); + Set already = result.stream() + .map(TopSalesStoresDetail::getStoreId) + .collect(Collectors.toSet()); + + // 4. 내 주점 추가 (내 주점이 포함되지 않으면 추가) + TopSalesStoresDetail myStore = getMyStoreDetail(allStores, rankMap, userStoreId); + if (myStore == null) + myStore = getZeroSalesStore(userStoreId); + + if (myStore != null && !already.contains(userStoreId)) { + // 내 주점이 5위 이하로 밀린 경우 + if (userRank > 5) { + result = result.stream() + .sorted(Comparator.comparing(TopSalesStoresDetail::getStoreRank, + Comparator.nullsLast(Comparator.naturalOrder()))) + .limit(4) + .collect(Collectors.toList()); + // 5위 자리에 내 주점 추가하고 나머지 4개만 보여줌 + result.add(myStore); + } + } + + // 5. 결과가 6개가 되지 않도록 자르고 5개로 제한 + result = result.stream() + .sorted(Comparator.comparing(TopSalesStoresDetail::getStoreRank, + Comparator.nullsLast(Comparator.naturalOrder()))) + .collect(Collectors.toList()); + + return result; + } + + @Override + public Map findOrderCountByStoreIds(List storeIds) { + LocalDate today = LocalDate.now(); + LocalDateTime start = today.atStartOfDay(); + LocalDateTime end = today.plusDays(1).atStartOfDay(); + + List rows = queryFactory + .select(u.store.storeId, u.count()) + .from(u) + .where( + u.store.storeId.in(storeIds), + u.createdAt.goe(start), + u.createdAt.lt(end), + u.status.eq(COOKED) + ) + .groupBy(u.store.storeId) + .fetch(); + + // Tuple → Map + return rows.stream() + .collect(Collectors.toMap( + t -> t.get(u.store.storeId), + t -> t.get(u.count()).intValue() + )); + } + + private List getAllStores(LocalDateTime todayStart, LocalDateTime todayEnd) { + return queryFactory + .select(u.store.storeId, u.store.name, u.store.departmentId, u.totalPrice.sum()) + .from(u) + .where( + u.createdAt.goe(todayStart), + u.createdAt.lt(todayEnd), + u.status.eq(COOKED) + ) + .groupBy(u.store.storeId, u.store.name, u.store.departmentId) + .orderBy(u.totalPrice.sum().desc()) + .fetch(); + } + + private Map getRankMap(List allStores) { + Map rankMap = new HashMap<>(); + int rank = 1; + for (Tuple t : allStores) { + rankMap.put(t.get(u.store.storeId), rank++); + } + return rankMap; + } + + private List getTopNStores(List allStores, Map rankMap, int n) { + // 1. departmentId 목록을 추출 + Set departmentIds = allStores.stream() + .map(t -> t.get(u.store.departmentId)) + .collect(Collectors.toSet()); + + // 2. departmentId에 대한 학과 이름을 한번에 가져오기 + Map departmentNameMap = getDepartmentNames(departmentIds); + + List list = new ArrayList<>(); + for (int i = 0; i < Math.min(n, allStores.size()); i++) { + Tuple t = allStores.get(i); + Long storeId = t.get(u.store.storeId); + String storeName = t.get(u.store.name); + Long departmentId = t.get(u.store.departmentId); + + // 학과 이름 가져오기 + String departmentName = departmentNameMap.getOrDefault(departmentId, "Unknown"); + + list.add(new TopSalesStoresDetail( + storeId, + storeName, + t.get(u.store.departmentId), + departmentName, + t.get(u.totalPrice.sum()), + rankMap.get(storeId).longValue() + )); + } + return list; + } + + private TopSalesStoresDetail getMyStoreDetail(List allStores, Map rankMap, Long userStoreId) { + Tuple myStore = allStores.stream() + .filter(t -> t.get(u.store.storeId).equals(userStoreId)) + .findFirst() + .orElse(null); + + if (myStore == null) + return null; + + Long departmentId = myStore.get(u.store.departmentId); + + // departmentId에 대한 학과 이름을 한번에 가져오기 + Map departmentNameMap = getDepartmentNames(Set.of(departmentId)); + String departmentName = departmentNameMap.getOrDefault(departmentId, "Unknown"); + + Long storeId = myStore.get(u.store.storeId); + return new TopSalesStoresDetail( + storeId, + myStore.get(u.store.name), + myStore.get(u.store.departmentId), + departmentName, + myStore.get(u.totalPrice.sum()), + rankMap.get(storeId).longValue() + ); + } + + private TopSalesStoresDetail getZeroSalesStore(Long userStoreId) { + Tuple zeroTuple = queryFactory + .select(s.storeId, s.name, s.departmentId) + .from(s) + .where(s.storeId.eq(userStoreId)) + .fetchOne(); + + if (zeroTuple == null) + return null; + + Long departmentId = zeroTuple.get(u.store.departmentId); + // departmentId에 대한 학과 이름을 한번에 가져오기 + Map departmentNameMap = getDepartmentNames(Set.of(departmentId)); + String departmentName = departmentNameMap.getOrDefault(departmentId, "Unknown"); + + return new TopSalesStoresDetail( + zeroTuple.get(s.storeId), + zeroTuple.get(s.name), + zeroTuple.get(s.departmentId), + departmentName, + 0, + null + ); + } + + // 한 번에 학과 이름을 가져오는 메서드 + private Map getDepartmentNames(Set departmentIds) { + List departmentTuples = queryFactory + .select(d.id, d.name) + .from(d) + .where(d.id.in(departmentIds)) + .fetch(); + + Map departmentNameMap = new HashMap<>(); + for (Tuple t : departmentTuples) { + departmentNameMap.put(t.get(d.id), t.get(d.name)); + } + return departmentNameMap; + } + + // redis 사용하는 부분 + @Override + public List findTotalSales() { + // 오늘 자정 + LocalDate today = LocalDate.now(); + LocalDateTime todayStart = today.atStartOfDay(); + LocalDateTime todayEnd = today.plusDays(1).atStartOfDay(); // 내일 00:00:00 + + List rows = queryFactory + .select(u.store.storeId, u.totalPrice.sum().coalesce(0)) + .from(u) + .where( + u.createdAt.goe(todayStart), + u.createdAt.lt(todayEnd), + u.status.eq(COOKED) + ) + .groupBy(u.store.storeId) + .fetch(); + + return rows.stream() + .map(t -> new StoreSales( + t.get(u.store.storeId), + t.get(u.totalPrice.sum().coalesce(0)) + )) + .collect(Collectors.toList()); + } + + @Override + public List findStoreInfoByIds(List storeIds) { + List tuples = queryFactory + .select(s.storeId, s.name, s.departmentId, d.name, si.imageUrl.coalesce("")) + .from(s) + .join(d).on(s.departmentId.eq(d.id)) + .leftJoin(si).on(s.storeId.eq(si.store.storeId).and(si.imageType.eq(ImageType.PROFILE))) + .where(s.storeId.in(storeIds)) + .fetch(); + + return tuples.stream() + .map(t -> new StoreInfo( + t.get(s.storeId), + t.get(s.name), + t.get(s.departmentId), + t.get(d.name), + t.get(si.imageUrl.coalesce("")) + )) + .collect(Collectors.toList()); + } +} diff --git a/domain-bookmark/.gitignore b/nowait-domain/domain-core-rdb/.gitignore similarity index 100% rename from domain-bookmark/.gitignore rename to nowait-domain/domain-core-rdb/.gitignore diff --git a/nowait-domain/domain-core-rdb/build.gradle b/nowait-domain/domain-core-rdb/build.gradle new file mode 100644 index 00000000..d277ef0c --- /dev/null +++ b/nowait-domain/domain-core-rdb/build.gradle @@ -0,0 +1,55 @@ +plugins { + id 'java-library' +} + +jar { + enabled = true +} + + +group = 'com.nowait' +version = rootProject.version + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation project(':nowait-common') + + api 'org.springframework.boot:spring-boot-starter-data-jpa' + api 'jakarta.persistence:jakarta.persistence-api:3.1.0' + + // SPRING SECURITY + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + implementation 'org.springframework.boot:spring-boot-starter-validation' + + + // SWAGGER + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' + + compileOnly 'org.projectlombok:lombok:1.18.26' + annotationProcessor 'org.projectlombok:lombok:1.18.26' + + //QueryDsl + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" + + // Jackson + api 'com.fasterxml.jackson.core:jackson-databind:2.15.2' + api 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.15.2' + + testImplementation 'org.junit.jupiter:junit-jupiter' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testRuntimeOnly 'com.h2database:h2' +} diff --git a/domain-base/src/main/java/com/nowait/base/BaseTimeEntity.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/base/entity/BaseTimeEntity.java similarity index 95% rename from domain-base/src/main/java/com/nowait/base/BaseTimeEntity.java rename to nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/base/entity/BaseTimeEntity.java index 85b3a15d..1d5ce5e9 100644 --- a/domain-base/src/main/java/com/nowait/base/BaseTimeEntity.java +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/base/entity/BaseTimeEntity.java @@ -1,4 +1,4 @@ -package com.nowait.base; +package com.nowait.domaincorerdb.base.entity; import java.time.LocalDateTime; diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/config/QueryDslConfig.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/config/QueryDslConfig.java new file mode 100644 index 00000000..f75d497b --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/config/QueryDslConfig.java @@ -0,0 +1,20 @@ +package com.nowait.domaincorerdb.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.querydsl.jpa.impl.JPAQueryFactory; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; + +@Configuration +public class QueryDslConfig { + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/department/entity/Department.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/department/entity/Department.java new file mode 100644 index 00000000..51c54577 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/department/entity/Department.java @@ -0,0 +1,24 @@ +package com.nowait.domaincorerdb.department.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "departments") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Department { + + @Id + private Long id; + + @Column(nullable = false) + private String name; +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/department/exception/DepartmentNotFoundException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/department/exception/DepartmentNotFoundException.java new file mode 100644 index 00000000..55718143 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/department/exception/DepartmentNotFoundException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.department.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class DepartmentNotFoundException extends RuntimeException { + public DepartmentNotFoundException() { + super(ErrorMessage.DEPARTMENT_NOT_FOUND.getMessage()); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/department/repository/DepartmentRepository.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/department/repository/DepartmentRepository.java new file mode 100644 index 00000000..023d2409 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/department/repository/DepartmentRepository.java @@ -0,0 +1,10 @@ +package com.nowait.domaincorerdb.department.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.nowait.domaincorerdb.department.entity.Department; + +@Repository +public interface DepartmentRepository extends JpaRepository { +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/entity/Menu.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/entity/Menu.java new file mode 100644 index 00000000..e4f3d6ab --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/entity/Menu.java @@ -0,0 +1,85 @@ +package com.nowait.domaincorerdb.menu.entity; + +import java.time.LocalDateTime; + +import com.nowait.domaincorerdb.base.entity.BaseTimeEntity; + +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.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Entity +// 주점별 메뉴 조회(WHERE store_id = ? AND deleted = ? ORDER BY sort_order)가 전체 스캔·filesort 없이 끝나게 한다 +@Table(name = "menus", indexes = @Index(name = "idx_menus_store_id_deleted_sort_order", + columnList = "store_id, deleted, sort_order")) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@SuperBuilder +public class Menu extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long storeId; + + @Column(nullable = true) + private String adminDisplayName; + + @Column(nullable = false) + private String name; + + @Column(nullable = true) + private String description; + + @Column(nullable = true) + private Integer price; + + @Column(nullable = false) + private Boolean isSoldOut; + + @Column(nullable = false) + private Long sortOrder; + + @Column(nullable = false) + private Boolean deleted; + + + public Menu(LocalDateTime createdAt, Long id, Long storeId, String adminDisplayName, String name, String description, Integer price, Boolean isSoldOut, Boolean deleted) { + super(createdAt); + this.id = id; + this.storeId = storeId; + this.adminDisplayName = adminDisplayName; + this.name = name; + this.description = description; + this.price = price; + this.isSoldOut = isSoldOut != null ? isSoldOut : false; + this.deleted = deleted != null ? deleted : false; + } + + public void updateInfo(String adminDisplayName, String name, String description, Integer price) { + if (adminDisplayName != null) this.adminDisplayName = adminDisplayName; + if (name != null) this.name = name; + if (description != null) this.description = description; + if (price != null) this.price = price; + } + + public void markAsDeleted() { this.deleted = true; } + + public void toggleSoldOut() { this.isSoldOut = !this.isSoldOut; } + + public void updateSortOrder(Long sortOrder) { + this.sortOrder = sortOrder; + } +} diff --git a/domain-menu/src/main/java/com/example/menu/entity/MenuImage.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/entity/MenuImage.java similarity index 85% rename from domain-menu/src/main/java/com/example/menu/entity/MenuImage.java rename to nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/entity/MenuImage.java index 6269ad43..cdeb0fbf 100644 --- a/domain-menu/src/main/java/com/example/menu/entity/MenuImage.java +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/entity/MenuImage.java @@ -1,6 +1,6 @@ -package com.example.menu.entity; +package com.nowait.domaincorerdb.menu.entity; -import com.nowait.base.BaseTimeEntity; +import com.nowait.domaincorerdb.base.entity.BaseTimeEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -28,7 +28,7 @@ public class MenuImage extends BaseTimeEntity { private Long id; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "menu_id") + @JoinColumn(name = "menu_id", nullable = false) private Menu menu; @Column(nullable = false, length = 500) diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuAlreadyDeletedException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuAlreadyDeletedException.java new file mode 100644 index 00000000..0a2202a3 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuAlreadyDeletedException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.menu.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class MenuAlreadyDeletedException extends RuntimeException { + public MenuAlreadyDeletedException() { + super(ErrorMessage.MENU_ALREADY_DELETED.getMessage()); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuCreationUnauthorizedException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuCreationUnauthorizedException.java new file mode 100644 index 00000000..7d68bb9b --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuCreationUnauthorizedException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.menu.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class MenuCreationUnauthorizedException extends RuntimeException { + public MenuCreationUnauthorizedException() { + super(ErrorMessage.MENU_CREATION_UNAUTHORIZED.getMessage()); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuCrossStoreConflictException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuCrossStoreConflictException.java new file mode 100644 index 00000000..70caeae1 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuCrossStoreConflictException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.menu.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class MenuCrossStoreConflictException extends RuntimeException { + public MenuCrossStoreConflictException() { + super(ErrorMessage.MENU_CROSS_STORE_CONFLICT.getMessage()); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuDeleteUnauthorizedException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuDeleteUnauthorizedException.java new file mode 100644 index 00000000..5da343f7 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuDeleteUnauthorizedException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.menu.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class MenuDeleteUnauthorizedException extends RuntimeException { + public MenuDeleteUnauthorizedException() { + super(ErrorMessage.MENU_DELETE_UNAUTHORIZED.getMessage()); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuDuplicateIdException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuDuplicateIdException.java new file mode 100644 index 00000000..761595ac --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuDuplicateIdException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.menu.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class MenuDuplicateIdException extends RuntimeException { + public MenuDuplicateIdException() { + super(ErrorMessage.MENU_DUPLICATE_ID.getMessage()); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuImageEmptyException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuImageEmptyException.java new file mode 100644 index 00000000..e7a82c8d --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuImageEmptyException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.menu.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class MenuImageEmptyException extends RuntimeException { + public MenuImageEmptyException() { + super(ErrorMessage.IMAGE_FILE_EMPTY.getMessage()); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuImageNotFoundException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuImageNotFoundException.java new file mode 100644 index 00000000..9791b7fe --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuImageNotFoundException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.menu.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class MenuImageNotFoundException extends RuntimeException { + public MenuImageNotFoundException() { + super(ErrorMessage.IMAGE_FILE_NOT_FOUND.getMessage()); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuInvalidSortOrderException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuInvalidSortOrderException.java new file mode 100644 index 00000000..55570c3a --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuInvalidSortOrderException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.menu.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class MenuInvalidSortOrderException extends RuntimeException { + public MenuInvalidSortOrderException() { + super(ErrorMessage.MENU_INVALID_SORT_ORDER.getMessage()); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuNotFoundException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuNotFoundException.java new file mode 100644 index 00000000..5753e00c --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuNotFoundException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.menu.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class MenuNotFoundException extends RuntimeException { + public MenuNotFoundException() { + super(ErrorMessage.MENU_NOT_FOUND.getMessage()); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuParamEmptyException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuParamEmptyException.java new file mode 100644 index 00000000..e052d339 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuParamEmptyException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.menu.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class MenuParamEmptyException extends RuntimeException { + public MenuParamEmptyException() { + super(ErrorMessage.MENU_PARAMETER_EMPTY.getMessage()); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuToggleUnauthorizedException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuToggleUnauthorizedException.java new file mode 100644 index 00000000..9dea0004 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuToggleUnauthorizedException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.menu.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class MenuToggleUnauthorizedException extends RuntimeException { + public MenuToggleUnauthorizedException() { + super(ErrorMessage.MENU_TOGGLE_UNAUTHORIZED.getMessage()); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuUpdateUnauthorizedException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuUpdateUnauthorizedException.java new file mode 100644 index 00000000..6cb4eeff --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuUpdateUnauthorizedException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.menu.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class MenuUpdateUnauthorizedException extends RuntimeException { + public MenuUpdateUnauthorizedException() { + super(ErrorMessage.MENU_UPDATE_UNAUTHORIZED.getMessage()); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuViewUnauthorizedException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuViewUnauthorizedException.java new file mode 100644 index 00000000..24e0f28b --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/exception/MenuViewUnauthorizedException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.menu.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class MenuViewUnauthorizedException extends RuntimeException { + public MenuViewUnauthorizedException() { + super(ErrorMessage.MENU_VIEW_UNAUTHORIZED.getMessage()); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/repository/MenuImageRepository.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/repository/MenuImageRepository.java new file mode 100644 index 00000000..94a6649c --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/repository/MenuImageRepository.java @@ -0,0 +1,17 @@ +package com.nowait.domaincorerdb.menu.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.nowait.domaincorerdb.menu.entity.Menu; +import com.nowait.domaincorerdb.menu.entity.MenuImage; + +@Repository +public interface MenuImageRepository extends JpaRepository { + List findByMenu(Menu menu); + Optional findByMenuId(Long menuId); + List findByMenuIdInOrderByIdAsc(List menuIds); +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/repository/MenuRepository.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/repository/MenuRepository.java new file mode 100644 index 00000000..8e07ebae --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/repository/MenuRepository.java @@ -0,0 +1,45 @@ +package com.nowait.domaincorerdb.menu.repository; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import com.nowait.domaincorerdb.menu.entity.Menu; +import com.nowait.domaincorerdb.menu.entity.MenuImage; + +@Repository +public interface MenuRepository extends JpaRepository { + List findAllByStoreIdAndDeletedFalse(Long storeId); + List findAllByStoreIdAndDeletedFalseOrderBySortOrder(Long storeId); + Optional findByStoreIdAndIdAndDeletedFalse(Long storeId, Long menuId); + Optional findByIdAndDeletedFalse(Long menuId); + + // 이미지가 아직 없는 메뉴도 보여야 하므로 LEFT JOIN (행: [Menu, MenuImage 또는 null]) + @Query(value = """ + SELECT m, i + FROM Menu m + LEFT JOIN MenuImage i ON i.menu = m + WHERE m.storeId = :storeId + AND m.deleted = false + ORDER BY m.sortOrder, i.id + """) + List findAllWithImageRowsByStoreId(@Param("storeId") Long storeId); + + /** + * 주점의 메뉴와 이미지를 쿼리 한 번으로 가져온다 (메뉴마다 이미지를 따로 조회하던 N+1 대체) + * sortOrder 순서를 유지하고, 이미지가 없는 메뉴는 빈 목록이다 + */ + default Map> findAllWithImagesByStoreId(Long storeId) { + return findAllWithImageRowsByStoreId(storeId).stream() + .collect(Collectors.groupingBy(row -> (Menu)row[0], LinkedHashMap::new, + Collectors.flatMapping(row -> Stream.ofNullable((MenuImage)row[1]), Collectors.toList()))); + } +} diff --git a/domain-order/src/main/java/com/nowait/domainorder/entity/OrderItem.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/entity/OrderItem.java similarity index 84% rename from domain-order/src/main/java/com/nowait/domainorder/entity/OrderItem.java rename to nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/entity/OrderItem.java index 064fc166..e23f943c 100644 --- a/domain-order/src/main/java/com/nowait/domainorder/entity/OrderItem.java +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/entity/OrderItem.java @@ -1,7 +1,7 @@ -package com.nowait.domainorder.entity; +package com.nowait.domaincorerdb.order.entity; -import com.example.menu.entity.Menu; -import com.nowait.base.BaseTimeEntity; +import com.nowait.domaincorerdb.base.entity.BaseTimeEntity; +import com.nowait.domaincorerdb.menu.entity.Menu; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/entity/OrderStatus.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/entity/OrderStatus.java new file mode 100644 index 00000000..7e022a5b --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/entity/OrderStatus.java @@ -0,0 +1,26 @@ +package com.nowait.domaincorerdb.order.entity; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +// TODO 자바 코드로 변경 후 common 모듈로 이동 +@Getter +@RequiredArgsConstructor +@Schema(description = "주문 상태 Enum") +public enum OrderStatus { + @Schema(description = "입금대기") + WAITING_FOR_PAYMENT("입금대기"), + + @Schema(description = "조리중") + COOKING("조리중"), + + @Schema(description = "조리완료") + COOKED("조리완료"), + + @Schema(description = "주문취소") + CANCELLED("주문취소"); + + private final String description; +} + diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/entity/UserOrder.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/entity/UserOrder.java new file mode 100644 index 00000000..c26ac87e --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/entity/UserOrder.java @@ -0,0 +1,88 @@ +package com.nowait.domaincorerdb.order.entity; + +import java.util.ArrayList; +import java.util.List; + +import com.nowait.domaincorerdb.base.entity.BaseTimeEntity; +import com.nowait.domaincorerdb.order.exception.InvalidOrderStatusTransitionException; +import com.nowait.domaincorerdb.order.exception.OrderAlreadyCancelledException; +import com.nowait.domaincorerdb.store.entity.Store; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class UserOrder extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long tableId; + + @Column(length = 64) + private String signature; + + // Store와 연관관계 (N:1) + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "store_id") + private Store store; + + @OneToMany(mappedBy = "userOrder", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List orderItems = new ArrayList<>(); + + private String sessionId; + + @Column(length = 10) // 예약자 이름 길이 제한 + private String depositorName; + + @Builder.Default + @Enumerated(value = EnumType.STRING) + private OrderStatus status = OrderStatus.WAITING_FOR_PAYMENT; + + @Column(nullable = false) + private Integer totalPrice; + + public void updateStatus(OrderStatus newStatus) { + if (!isValidTransition(this.status, newStatus)) { + throw new InvalidOrderStatusTransitionException(this.status, newStatus); + } + this.status = newStatus; + } + + public void cancelOrder() { + if (this.status == OrderStatus.CANCELLED) { + throw new OrderAlreadyCancelledException(); + } + this.status = OrderStatus.CANCELLED; + } + + private boolean isValidTransition(OrderStatus current, OrderStatus target) { + return switch (current) { + case WAITING_FOR_PAYMENT, COOKED -> target == OrderStatus.COOKING || target == OrderStatus.CANCELLED; + case COOKING -> target == OrderStatus.COOKED || target == OrderStatus.CANCELLED; + case CANCELLED -> false; + }; + } + +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/exception/DepositorNameTooLongException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/exception/DepositorNameTooLongException.java new file mode 100644 index 00000000..2767804f --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/exception/DepositorNameTooLongException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.order.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class DepositorNameTooLongException extends RuntimeException { + public DepositorNameTooLongException() { + super(ErrorMessage.DEPOSITOR_NAME_TOO_LONG.getMessage()); + } +} diff --git a/domain-order/src/main/java/com/nowait/domainorder/exception/DuplicateOrderException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/exception/DuplicateOrderException.java similarity index 61% rename from domain-order/src/main/java/com/nowait/domainorder/exception/DuplicateOrderException.java rename to nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/exception/DuplicateOrderException.java index a3e816cd..c9311def 100644 --- a/domain-order/src/main/java/com/nowait/domainorder/exception/DuplicateOrderException.java +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/exception/DuplicateOrderException.java @@ -1,6 +1,6 @@ -package com.nowait.domainorder.exception; +package com.nowait.domaincorerdb.order.exception; -import com.nowaiting.common.exception.ErrorMessage; +import com.nowait.common.exception.ErrorMessage; public class DuplicateOrderException extends RuntimeException { public DuplicateOrderException() { diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/exception/InvalidOrderStatusTransitionException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/exception/InvalidOrderStatusTransitionException.java new file mode 100644 index 00000000..571c9b26 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/exception/InvalidOrderStatusTransitionException.java @@ -0,0 +1,10 @@ +package com.nowait.domaincorerdb.order.exception; + +import com.nowait.common.exception.ErrorMessage; +import com.nowait.domaincorerdb.order.entity.OrderStatus; + +public class InvalidOrderStatusTransitionException extends RuntimeException { + public InvalidOrderStatusTransitionException(OrderStatus current, OrderStatus target) { + super(ErrorMessage.INVALID_ORDER_STATUS_TRANSITION.format(current, target)); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/exception/OrderAlreadyCancelledException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/exception/OrderAlreadyCancelledException.java new file mode 100644 index 00000000..2d650670 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/exception/OrderAlreadyCancelledException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.order.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class OrderAlreadyCancelledException extends RuntimeException { + public OrderAlreadyCancelledException() { + super(ErrorMessage.ORDER_ALREADY_CANCELLED.getMessage()); + } +} diff --git a/domain-order/src/main/java/com/nowait/domainorder/exception/OrderItemsEmptyException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/exception/OrderItemsEmptyException.java similarity index 62% rename from domain-order/src/main/java/com/nowait/domainorder/exception/OrderItemsEmptyException.java rename to nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/exception/OrderItemsEmptyException.java index 70614798..e3f6f730 100644 --- a/domain-order/src/main/java/com/nowait/domainorder/exception/OrderItemsEmptyException.java +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/exception/OrderItemsEmptyException.java @@ -1,6 +1,6 @@ -package com.nowait.domainorder.exception; +package com.nowait.domaincorerdb.order.exception; -import com.nowaiting.common.exception.ErrorMessage; +import com.nowait.common.exception.ErrorMessage; public class OrderItemsEmptyException extends RuntimeException { public OrderItemsEmptyException() { diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/exception/OrderNotFoundException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/exception/OrderNotFoundException.java new file mode 100644 index 00000000..935927bd --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/exception/OrderNotFoundException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.order.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class OrderNotFoundException extends RuntimeException { + public OrderNotFoundException() { + super(ErrorMessage.ORDER_NOT_FOUND.getMessage()); + } +} diff --git a/domain-order/src/main/java/com/nowait/domainorder/exception/OrderParameterEmptyException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/exception/OrderParameterEmptyException.java similarity index 63% rename from domain-order/src/main/java/com/nowait/domainorder/exception/OrderParameterEmptyException.java rename to nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/exception/OrderParameterEmptyException.java index d3b6736c..b50ccece 100644 --- a/domain-order/src/main/java/com/nowait/domainorder/exception/OrderParameterEmptyException.java +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/exception/OrderParameterEmptyException.java @@ -1,6 +1,6 @@ -package com.nowait.domainorder.exception; +package com.nowait.domaincorerdb.order.exception; -import com.nowaiting.common.exception.ErrorMessage; +import com.nowait.common.exception.ErrorMessage; public class OrderParameterEmptyException extends RuntimeException { public OrderParameterEmptyException() { diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/exception/OrderUpdateUnauthorizedException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/exception/OrderUpdateUnauthorizedException.java new file mode 100644 index 00000000..9b9e8833 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/exception/OrderUpdateUnauthorizedException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.order.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class OrderUpdateUnauthorizedException extends RuntimeException { + public OrderUpdateUnauthorizedException() { + super(ErrorMessage.ORDER_UPDATE_UNAUTHORIZED.getMessage()); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/exception/OrderViewUnauthorizedException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/exception/OrderViewUnauthorizedException.java new file mode 100644 index 00000000..b623d5fa --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/exception/OrderViewUnauthorizedException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.order.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class OrderViewUnauthorizedException extends RuntimeException { + public OrderViewUnauthorizedException() { + super(ErrorMessage.ORDER_VIEW_UNAUTHORIZED.getMessage()); + } +} diff --git a/domain-order/src/main/java/com/nowait/domainorder/repository/OrderItemRepository.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/repository/OrderItemRepository.java similarity index 65% rename from domain-order/src/main/java/com/nowait/domainorder/repository/OrderItemRepository.java rename to nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/repository/OrderItemRepository.java index 949b4e78..b7cbc6cf 100644 --- a/domain-order/src/main/java/com/nowait/domainorder/repository/OrderItemRepository.java +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/repository/OrderItemRepository.java @@ -1,9 +1,9 @@ -package com.nowait.domainorder.repository; +package com.nowait.domaincorerdb.order.repository; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; -import com.nowait.domainorder.entity.OrderItem; +import com.nowait.domaincorerdb.order.entity.OrderItem; @Repository public interface OrderItemRepository extends JpaRepository { diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/repository/OrderRepository.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/repository/OrderRepository.java new file mode 100644 index 00000000..05da4d75 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/repository/OrderRepository.java @@ -0,0 +1,21 @@ +package com.nowait.domaincorerdb.order.repository; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.nowait.domaincorerdb.order.entity.UserOrder; + +@Repository +public interface OrderRepository extends JpaRepository { + boolean existsBySignatureAndCreatedAtAfter(String signature, LocalDateTime createdAt); + + @EntityGraph(attributePaths = {"store", "orderItems", "orderItems.menu"}) + List findByStore_PublicCodeAndTableIdAndSessionId(String publicCode, Long tableId, String sessionId); + + @EntityGraph(attributePaths = {"orderItems", "orderItems.menu"}) + List findAllByStore_StoreIdAndCreatedAtBetween(Long storeId, LocalDateTime startDateTime, LocalDateTime endDateTime); +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/dto/GetMyWaitingBaseDto.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/dto/GetMyWaitingBaseDto.java new file mode 100644 index 00000000..b677df74 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/dto/GetMyWaitingBaseDto.java @@ -0,0 +1,52 @@ +package com.nowait.domaincorerdb.reservation.dto; + +import java.time.LocalDateTime; +import java.util.List; + +import com.nowait.common.enums.ReservationStatus; +import com.querydsl.core.annotations.QueryProjection; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Getter +public class GetMyWaitingBaseDto { + private final String waitingNumber; + private final String publicCode; + private final Long storeId; + private final String storeName; + private final String departmentName; + private final Integer partySize; + private final ReservationStatus status; + private final LocalDateTime registeredAt; + private final String location; + private final String profileImageUrl; + private final String bannerImageUrl; + + @QueryProjection + public GetMyWaitingBaseDto( + String waitingNumber, + String publicCode, + Long storeId, + String storeName, + String departmentName, + Integer partySize, + ReservationStatus status, + LocalDateTime registeredAt, + String location, + String profileImageUrl, + String bannerImageUrl + ) { + this.waitingNumber = waitingNumber; + this.publicCode = publicCode; + this.storeId = storeId; + this.storeName = storeName; + this.departmentName = departmentName; + this.partySize = partySize; + this.status = status; + this.registeredAt = registeredAt; + this.location = location; + this.profileImageUrl = profileImageUrl == null ? "" : profileImageUrl; + this.bannerImageUrl = bannerImageUrl == null ? "" : bannerImageUrl; + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/entity/Reservation.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/entity/Reservation.java new file mode 100644 index 00000000..ab4efa20 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/entity/Reservation.java @@ -0,0 +1,106 @@ +package com.nowait.domaincorerdb.reservation.entity; + +import java.time.LocalDateTime; + +import com.nowait.common.enums.ReservationStatus; +import com.nowait.domaincorerdb.reservation.exception.AlreadyDeletedWaitingException; +import com.nowait.domaincorerdb.reservation.exception.InvalidReservationStatusTransitionException; +import com.nowait.domaincorerdb.reservation.exception.ReservationAlreadyCancelledException; +import com.nowait.domaincorerdb.reservation.exception.ReservationAlreadyConfirmedException; +import com.nowait.domaincorerdb.store.entity.Store; +import com.nowait.domaincorerdb.user.entity.User; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "reservation") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class Reservation { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "reservation_number", nullable = true, length = 50) + private String reservationNumber; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "store_id") + private Store store; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id") + private User user; + + @Column(name = "requested_at", nullable = false) + private LocalDateTime requestedAt; + + @Column(name = "updated_at", nullable = true) + private LocalDateTime updatedAt; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ReservationStatus status; + + @Column(name = "party_size", nullable = false) + private Integer partySize; + + public void markUpdated(LocalDateTime updatedAt, ReservationStatus newStatus) { + if (this.status == newStatus) { + switch (newStatus) { + case CONFIRMED -> throw new ReservationAlreadyConfirmedException(); + case CANCELLED -> throw new ReservationAlreadyCancelledException(); + default -> {} + } + } + + if (!isValidTransition(this.status, newStatus)) { + throw new InvalidReservationStatusTransitionException(this.status, newStatus); + } + this.status = newStatus; + this.updatedAt = updatedAt; + } + + public void markAsCancelled(LocalDateTime updatedAt) { + if (this.status == ReservationStatus.CANCELLED) { + throw new AlreadyDeletedWaitingException(); + } + + if (!isValidTransition(this.status, ReservationStatus.CANCELLED)) { + throw new InvalidReservationStatusTransitionException(this.status, ReservationStatus.CANCELLED); + } + + this.status = ReservationStatus.CANCELLED; + this.updatedAt = updatedAt; + } + + private boolean isValidTransition(ReservationStatus current, ReservationStatus target) { + return switch (current) { + case WAITING -> target == ReservationStatus.CALLING + || target == ReservationStatus.CONFIRMED + || target == ReservationStatus.CANCELLED; + case CALLING -> target == ReservationStatus.CONFIRMED + || target == ReservationStatus.CANCELLED; + case CONFIRMED -> target == ReservationStatus.CANCELLED; + case CANCELLED -> false; + }; + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/AlreadyDeletedWaitingException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/AlreadyDeletedWaitingException.java new file mode 100644 index 00000000..8e7a0357 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/AlreadyDeletedWaitingException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.reservation.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class AlreadyDeletedWaitingException extends RuntimeException { + public AlreadyDeletedWaitingException() { + super(ErrorMessage.ALREADY_DELETED_RESERVATION.getMessage()); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/DuplicateReservationException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/DuplicateReservationException.java new file mode 100644 index 00000000..588a0a49 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/DuplicateReservationException.java @@ -0,0 +1,10 @@ +package com.nowait.domaincorerdb.reservation.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class DuplicateReservationException extends RuntimeException { + public DuplicateReservationException() { + super(ErrorMessage.DUPLICATE_RESERVATION.getMessage()); + } +} + diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/InvalidReservationParameterException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/InvalidReservationParameterException.java new file mode 100644 index 00000000..2db8dd4a --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/InvalidReservationParameterException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.reservation.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class InvalidReservationParameterException extends RuntimeException { + public InvalidReservationParameterException(String details) { + super(ErrorMessage.INVALID_RESERVATION_PARAMETER.format(details)); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/InvalidReservationStatusTransitionException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/InvalidReservationStatusTransitionException.java new file mode 100644 index 00000000..cc6fe02c --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/InvalidReservationStatusTransitionException.java @@ -0,0 +1,10 @@ +package com.nowait.domaincorerdb.reservation.exception; + +import com.nowait.common.enums.ReservationStatus; +import com.nowait.common.exception.ErrorMessage; + +public class InvalidReservationStatusTransitionException extends RuntimeException { + public InvalidReservationStatusTransitionException(ReservationStatus current, ReservationStatus target) { + super(ErrorMessage.INVALID_RESERVATION_STATUS_TRANSITION.format(current, target)); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/ReservationAddUnauthorizedException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/ReservationAddUnauthorizedException.java new file mode 100644 index 00000000..2f506793 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/ReservationAddUnauthorizedException.java @@ -0,0 +1,10 @@ +package com.nowait.domaincorerdb.reservation.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class ReservationAddUnauthorizedException extends RuntimeException { + public ReservationAddUnauthorizedException() { + super(ErrorMessage.RESERVATION_ADD_UNAUTHORIZED.getMessage()); + } +} + diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/ReservationAlreadyCancelledException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/ReservationAlreadyCancelledException.java new file mode 100644 index 00000000..1d2a7666 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/ReservationAlreadyCancelledException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.reservation.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class ReservationAlreadyCancelledException extends RuntimeException { + public ReservationAlreadyCancelledException() { + super(ErrorMessage.RESERVATION_ALREADY_CANCELLED.getMessage()); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/ReservationAlreadyConfirmedException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/ReservationAlreadyConfirmedException.java new file mode 100644 index 00000000..724b1678 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/ReservationAlreadyConfirmedException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.reservation.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class ReservationAlreadyConfirmedException extends RuntimeException { + public ReservationAlreadyConfirmedException() { + super(ErrorMessage.RESERVATION_ALREADY_CONFIRMED.getMessage()); + } +} diff --git a/api-user/src/main/java/com/example/apiuser/reservation/exception/ReservationNotFoundException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/ReservationNotFoundException.java similarity index 62% rename from api-user/src/main/java/com/example/apiuser/reservation/exception/ReservationNotFoundException.java rename to nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/ReservationNotFoundException.java index 37dc86eb..4ce91721 100644 --- a/api-user/src/main/java/com/example/apiuser/reservation/exception/ReservationNotFoundException.java +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/ReservationNotFoundException.java @@ -1,9 +1,10 @@ -package com.example.apiuser.reservation.exception; +package com.nowait.domaincorerdb.reservation.exception; -import com.nowaiting.common.exception.ErrorMessage; +import com.nowait.common.exception.ErrorMessage; public class ReservationNotFoundException extends RuntimeException { public ReservationNotFoundException() { super(ErrorMessage.NOTFOUND_RESERVATION.getMessage()); } } + diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/ReservationNumberIssueFailException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/ReservationNumberIssueFailException.java new file mode 100644 index 00000000..30cf81c2 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/ReservationNumberIssueFailException.java @@ -0,0 +1,10 @@ +package com.nowait.domaincorerdb.reservation.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class ReservationNumberIssueFailException extends RuntimeException { + public ReservationNumberIssueFailException() { + super(ErrorMessage.RESERVATION_NUMBER_ISSUE_FAIL.getMessage()); + } +} + diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/ReservationUpdateUnauthorizedException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/ReservationUpdateUnauthorizedException.java new file mode 100644 index 00000000..f2af519b --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/ReservationUpdateUnauthorizedException.java @@ -0,0 +1,10 @@ +package com.nowait.domaincorerdb.reservation.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class ReservationUpdateUnauthorizedException extends RuntimeException { + public ReservationUpdateUnauthorizedException() { + super(ErrorMessage.RESERVATION_UPDATE_UNAUTHORIZED.getMessage()); + } +} + diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/ReservationViewUnauthorizedException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/ReservationViewUnauthorizedException.java new file mode 100644 index 00000000..e5ff5220 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/ReservationViewUnauthorizedException.java @@ -0,0 +1,10 @@ +package com.nowait.domaincorerdb.reservation.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class ReservationViewUnauthorizedException extends RuntimeException { + public ReservationViewUnauthorizedException() { + super(ErrorMessage.RESERVATION_VIEW_UNAUTHORIZED.getMessage()); + } +} + diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/UnsupportedReservationStatusException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/UnsupportedReservationStatusException.java new file mode 100644 index 00000000..98295756 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/exception/UnsupportedReservationStatusException.java @@ -0,0 +1,10 @@ +package com.nowait.domaincorerdb.reservation.exception; + +import com.nowait.common.enums.ReservationStatus; +import com.nowait.common.exception.ErrorMessage; + +public class UnsupportedReservationStatusException extends RuntimeException { + public UnsupportedReservationStatusException(ReservationStatus status) { + super(ErrorMessage.UNSUPPORTED_RESERVATION_STATUS.format(status)); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/repository/ReservationCustomRepository.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/repository/ReservationCustomRepository.java new file mode 100644 index 00000000..eb4014a6 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/repository/ReservationCustomRepository.java @@ -0,0 +1,12 @@ +package com.nowait.domaincorerdb.reservation.repository; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.nowait.domaincorerdb.reservation.dto.GetMyWaitingBaseDto; + +@Repository +public interface ReservationCustomRepository { + List findMyWaitingInfo(Long userId); +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/repository/ReservationCustomRepositoryImpl.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/repository/ReservationCustomRepositoryImpl.java new file mode 100644 index 00000000..9571a062 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/repository/ReservationCustomRepositoryImpl.java @@ -0,0 +1,88 @@ +package com.nowait.domaincorerdb.reservation.repository; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.nowait.common.enums.ReservationStatus; +import com.nowait.domaincorerdb.department.entity.QDepartment; +import com.nowait.domaincorerdb.reservation.dto.GetMyWaitingBaseDto; +import com.nowait.domaincorerdb.reservation.dto.QGetMyWaitingBaseDto; +import com.nowait.domaincorerdb.reservation.entity.QReservation; +import com.nowait.domaincorerdb.store.entity.ImageType; +import com.nowait.domaincorerdb.store.entity.QStore; +import com.nowait.domaincorerdb.store.entity.QStoreImage; +import com.nowait.domaincorerdb.user.entity.QUser; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class ReservationCustomRepositoryImpl implements ReservationCustomRepository { + + private final JPAQueryFactory queryFactory; + + private final QReservation reservation = QReservation.reservation; + private final QStore store = QStore.store; + private final QDepartment department = QDepartment.department; + private final QUser user = QUser.user; + private final QStoreImage storeImage = QStoreImage.storeImage; + + + @Override + public List findMyWaitingInfo(Long userId) { + + LocalDateTime startOfDay = LocalDate.now().atStartOfDay(); + LocalDateTime endOfDay = LocalDate.now().atTime(LocalTime.MAX); + + QStoreImage subStoreImage = new QStoreImage("subStoreImage"); + + return queryFactory + .select(new QGetMyWaitingBaseDto( + reservation.reservationNumber, + store.publicCode, + store.storeId, + store.name, + department.name, + reservation.partySize, + reservation.status, + reservation.requestedAt, + store.location, + storeImage.imageUrl, + + JPAExpressions + .select(subStoreImage.imageUrl) + .from(subStoreImage) + .where( + subStoreImage.store.storeId.eq(store.storeId), + subStoreImage.imageType.eq(ImageType.BANNER) + ) + .limit(1) + )) + .from(reservation) + .join(reservation.store, store) + .join(reservation.user, user) + .leftJoin(department) + .on(store.departmentId.eq(department.id)) + .leftJoin(storeImage) + .on( + storeImage.store.storeId.eq(store.storeId), + storeImage.imageType.eq(ImageType.PROFILE) + ) + .where( + reservation.user.id.eq(userId), + reservation.status.in( + ReservationStatus.WAITING, + ReservationStatus.CALLING + ), + reservation.requestedAt.between(startOfDay, endOfDay), + store.deleted.isFalse() + ) + .fetch(); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/repository/ReservationRepository.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/repository/ReservationRepository.java new file mode 100644 index 00000000..15bff08b --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/reservation/repository/ReservationRepository.java @@ -0,0 +1,31 @@ +package com.nowait.domaincorerdb.reservation.repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.nowait.common.enums.ReservationStatus; +import com.nowait.domaincorerdb.reservation.entity.Reservation; +import com.nowait.domaincorerdb.store.entity.Store; +import com.nowait.domaincorerdb.user.entity.User; + +@Repository +public interface ReservationRepository extends JpaRepository, ReservationCustomRepository { + List findAllByStore_StoreIdOrderByRequestedAtAsc(Long storeId); + + boolean existsByUserAndStoreAndStatusIn(User user, Store store, List statuses); + + boolean existsReservationByReservationNumber(String reservationNumber); + + Optional findFirstByStore_StoreIdAndUserIdAndStatusInAndRequestedAtBetweenOrderByRequestedAtDesc( + Long storeId, Long userId, List statuses, LocalDateTime start, LocalDateTime end); + + List findAllByStore_StoreIdAndStatusInAndRequestedAtBetween( + Long storeId, List statuses, LocalDateTime start, LocalDateTime end); + + // 웨이팅 취소 처리 + Optional findReservationByReservationNumber(String reservationNumber); +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/entity/ImageType.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/entity/ImageType.java new file mode 100644 index 00000000..b7564e93 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/entity/ImageType.java @@ -0,0 +1,21 @@ +package com.nowait.domaincorerdb.store.entity; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +@Schema(description = "이미지 타입 Enum") +public enum ImageType { + @Schema(description = "주점 프로필 사진") + PROFILE("프로필 사진"), + + @Schema(description = "주점 배너 사진") + BANNER("배너 사진"), + + @Schema(description = "주점 배너 사진") + NONE("이미지 없음"); + + private final String description; +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/entity/Store.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/entity/Store.java new file mode 100644 index 00000000..109c9c45 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/entity/Store.java @@ -0,0 +1,101 @@ +package com.nowait.domaincorerdb.store.entity; + +import java.time.LocalDateTime; + +import com.nowait.domaincorerdb.base.entity.BaseTimeEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Entity +@Table(name = "stores") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@SuperBuilder +public class Store extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long storeId; + + @Column(nullable = false, unique = true, updatable = false, length = 12) + private String publicCode; + + @Column(name = "department_id", nullable = false) + private Long departmentId; + + @Column(nullable = false, length = 200) + private String name; + + @Column(nullable = true, length = 200) + private String location; + + @Column(nullable = true, length = 250) + private String description; + + @Column(nullable = true, length = 200) + private String noticeTitle; + + @Column(nullable = true, length = 500) + private String noticeContent; + + @Column(nullable = true, length = 200) + private String openTime; + + @Column(name = "is_active", nullable = false) + private Boolean isActive; + + @Column(nullable = false) + private Boolean deleted; + + public Store(LocalDateTime createdAt, Long storeId, Long departmentId, String name, String location, + String description, String noticeTitle, String noticeContent, String openTime, Boolean isActive, + Boolean deleted) { + super(createdAt); + this.storeId = storeId; + this.departmentId = departmentId; + this.name = name; + this.location = location; + this.description = description; + this.noticeTitle = noticeTitle; + this.noticeContent = noticeContent; + this.openTime = openTime; + this.isActive = isActive; + this.deleted = deleted; + } + + public void updateInfo(String name, String location, String description, String noticeTitle, String notice, + String openTime) { + if (name != null) + this.name = name; + if (location != null) + this.location = location; + if (description != null) + this.description = description; + if (noticeTitle != null) + this.noticeTitle = noticeTitle; + if (notice != null) + this.noticeContent = notice; + if (openTime != null) + this.openTime = openTime; + } + + public void markAsDeleted() { + this.deleted = true; + } + + public void toggleActive() { + this.isActive = !this.isActive; + } + +} diff --git a/domain-store/src/main/java/com/example/domainstore/entity/StoreImage.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/entity/StoreImage.java similarity index 73% rename from domain-store/src/main/java/com/example/domainstore/entity/StoreImage.java rename to nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/entity/StoreImage.java index 0ced904e..a132c4ce 100644 --- a/domain-store/src/main/java/com/example/domainstore/entity/StoreImage.java +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/entity/StoreImage.java @@ -1,9 +1,11 @@ -package com.example.domainstore.entity; +package com.nowait.domaincorerdb.store.entity; -import com.nowait.base.BaseTimeEntity; +import com.nowait.domaincorerdb.base.entity.BaseTimeEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -28,7 +30,7 @@ public class StoreImage extends BaseTimeEntity { private Long id; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "store_id") + @JoinColumn(name = "store_id", nullable = false) private Store store; @Column(nullable = false, length = 500) @@ -37,6 +39,7 @@ public class StoreImage extends BaseTimeEntity { @Column(nullable = false, length = 500) private String fileKey; - @Column(length = 20) - private String type; + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private ImageType imageType; } diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/exception/StoreDeleteUnauthorizedException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/exception/StoreDeleteUnauthorizedException.java new file mode 100644 index 00000000..9b92d807 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/exception/StoreDeleteUnauthorizedException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.store.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class StoreDeleteUnauthorizedException extends RuntimeException { + public StoreDeleteUnauthorizedException() { + super(ErrorMessage.STORE_DELETE_UNAUTHORIZED.getMessage()); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/exception/StoreImageEmptyException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/exception/StoreImageEmptyException.java new file mode 100644 index 00000000..174cb8d0 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/exception/StoreImageEmptyException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.store.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class StoreImageEmptyException extends RuntimeException { + public StoreImageEmptyException() { + super(ErrorMessage.IMAGE_FILE_EMPTY.getMessage()); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/exception/StoreImageNotFoundException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/exception/StoreImageNotFoundException.java new file mode 100644 index 00000000..f1b49fd1 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/exception/StoreImageNotFoundException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.store.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class StoreImageNotFoundException extends RuntimeException { + public StoreImageNotFoundException() { + super(ErrorMessage.IMAGE_FILE_NOT_FOUND.getMessage()); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/exception/StoreNotFoundException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/exception/StoreNotFoundException.java new file mode 100644 index 00000000..ded420e0 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/exception/StoreNotFoundException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.store.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class StoreNotFoundException extends RuntimeException { + public StoreNotFoundException() { + super(ErrorMessage.STORE_NOT_FOUND.getMessage()); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/exception/StoreParamEmptyException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/exception/StoreParamEmptyException.java new file mode 100644 index 00000000..c33bc1bb --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/exception/StoreParamEmptyException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.store.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class StoreParamEmptyException extends RuntimeException { + public StoreParamEmptyException() { + super(ErrorMessage.STORE_PARAMETER_EMPTY.getMessage()); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/exception/StoreUpdateUnauthorizedException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/exception/StoreUpdateUnauthorizedException.java new file mode 100644 index 00000000..a77d8379 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/exception/StoreUpdateUnauthorizedException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.store.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class StoreUpdateUnauthorizedException extends RuntimeException { + public StoreUpdateUnauthorizedException() { + super(ErrorMessage.STORE_UPDATE_UNAUTHORIZED.getMessage()); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/exception/StoreViewUnauthorizedException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/exception/StoreViewUnauthorizedException.java new file mode 100644 index 00000000..ee996974 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/exception/StoreViewUnauthorizedException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.store.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class StoreViewUnauthorizedException extends RuntimeException { + public StoreViewUnauthorizedException() { + super(ErrorMessage.STORE_VIEW_UNAUTHORIZED.getMessage()); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/exception/StoreWaitingDisabledException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/exception/StoreWaitingDisabledException.java new file mode 100644 index 00000000..cf1d2c49 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/exception/StoreWaitingDisabledException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.store.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class StoreWaitingDisabledException extends RuntimeException { + public StoreWaitingDisabledException() { + super(ErrorMessage.STORE_WAITING_DISABLED.getMessage()); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/repository/StoreCustomRepository.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/repository/StoreCustomRepository.java new file mode 100644 index 00000000..4d6a1f2a --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/repository/StoreCustomRepository.java @@ -0,0 +1,4 @@ +package com.nowait.domaincorerdb.store.repository; + +public interface StoreCustomRepository { +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/repository/StoreCustomRepositoryImpl.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/repository/StoreCustomRepositoryImpl.java new file mode 100644 index 00000000..82820c1d --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/repository/StoreCustomRepositoryImpl.java @@ -0,0 +1,17 @@ +package com.nowait.domaincorerdb.store.repository; + +import org.springframework.stereotype.Repository; + +import com.nowait.domaincorerdb.store.entity.QStore; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class StoreCustomRepositoryImpl implements StoreCustomRepository { + + private final JPAQueryFactory queryFactory; + + private final QStore store = QStore.store; +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/repository/StoreImageRepository.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/repository/StoreImageRepository.java new file mode 100644 index 00000000..1b495a7e --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/repository/StoreImageRepository.java @@ -0,0 +1,26 @@ +package com.nowait.domaincorerdb.store.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.nowait.domaincorerdb.store.entity.ImageType; +import com.nowait.domaincorerdb.store.entity.Store; +import com.nowait.domaincorerdb.store.entity.StoreImage; + +@Repository +public interface StoreImageRepository extends JpaRepository { + + List findByStore(Store store); + + List findByStore_StoreIdIn(List storeIds); + + Optional findByStoreStoreIdAndImageType(Long storeId, ImageType imageType); + + List findByStoreAndImageType(Store store, ImageType imageType); + + List findAllByStore_StoreIdInAndImageTypeIn(List storeIds, List imageTypes); + +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/repository/StoreRepository.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/repository/StoreRepository.java new file mode 100644 index 00000000..83d7d324 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/store/repository/StoreRepository.java @@ -0,0 +1,48 @@ +package com.nowait.domaincorerdb.store.repository; + + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import com.nowait.domaincorerdb.store.entity.Store; + +@Repository +public interface StoreRepository extends JpaRepository, StoreCustomRepository { + + Optional findByStoreIdAndDeletedFalse(Long storeId); + + Optional findByPublicCodeAndDeletedFalse(String publicCode); + + Slice findAllByDeletedFalseOrderByStoreIdAsc(Pageable pageable); + + @Query(value = """ + SELECT DISTINCT s.* + FROM stores s + LEFT JOIN departments d ON s.department_id = d.id + WHERE s.deleted = false + AND ( + MATCH(s.name) AGAINST(:kw) + OR MATCH(d.name) AGAINST(:kw) + ) + """, + nativeQuery = true) + List searchByKeywordNative(@Param("kw") String booleanKeyword); + + List findAllByStoreIdInOrderByStoreIdAsc(List storeIds); + + @Query(value = """ + SELECT s + FROM Store s + LEFT JOIN Department d ON s.departmentId = d.id + WHERE s.deleted = false + AND s.storeId IN :ids + """) + List findAllWithDepartmentByStoreIdIn(@Param("ids") List ids); +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/storePayment/entity/StorePayment.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/storePayment/entity/StorePayment.java new file mode 100644 index 00000000..aebd8b6a --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/storePayment/entity/StorePayment.java @@ -0,0 +1,63 @@ +package com.nowait.domaincorerdb.storepayment.entity; + +import java.time.LocalDateTime; + +import com.nowait.domaincorerdb.base.entity.BaseTimeEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Entity +@Table(name = "store_payments") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@SuperBuilder +public class StorePayment extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long paymentMethodId; + + @Column(name = "store_id", nullable = false) + private Long storeId; + + @Column(name = "toss_url",length = 500) + private String tossUrl; + + @Column(name = "kakao_pay_url", nullable = true, length = 500) + private String kakaoPayUrl; + + @Column(name = "naver_pay_url", nullable = true, length = 500) + private String naverPayUrl; + + @Column(name = "account_number", nullable = true, length = 45) + private String accountNumber; + + public StorePayment(LocalDateTime createdAt, Long paymentMethodId, Long storeId, String tossUrl, String kakaoPayUrl, String naverPayUrl, String accountNumber) { + super(createdAt); + this.paymentMethodId = paymentMethodId; + this.storeId = storeId; + this.tossUrl = tossUrl; + this.kakaoPayUrl = kakaoPayUrl; + this.naverPayUrl = naverPayUrl; + this.accountNumber = accountNumber; + } + + public void updatePaymentMethodUrl(String tossUrl, String kakaoPayUrl, String naverPayUrl, String accountNumber) { + if (tossUrl != null) this.tossUrl = tossUrl; + if (kakaoPayUrl != null) this.kakaoPayUrl = kakaoPayUrl; + if (naverPayUrl != null) this.naverPayUrl = naverPayUrl; + if (accountNumber != null) this.accountNumber = accountNumber; + } +} + diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/storePayment/exception/StorePaymentAlreadyExistsException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/storePayment/exception/StorePaymentAlreadyExistsException.java new file mode 100644 index 00000000..609ba342 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/storePayment/exception/StorePaymentAlreadyExistsException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.storepayment.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class StorePaymentAlreadyExistsException extends RuntimeException { + public StorePaymentAlreadyExistsException() { + super(ErrorMessage.STORE_PAYMENT_ALREADY_EXISTS.getMessage()); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/storePayment/exception/StorePaymentCreationUnauthorizedException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/storePayment/exception/StorePaymentCreationUnauthorizedException.java new file mode 100644 index 00000000..ad458a24 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/storePayment/exception/StorePaymentCreationUnauthorizedException.java @@ -0,0 +1,10 @@ +package com.nowait.domaincorerdb.storepayment.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class StorePaymentCreationUnauthorizedException extends RuntimeException { + + public StorePaymentCreationUnauthorizedException() { + super(ErrorMessage.STORE_PAYMENT_CREATION_UNAUTHORIZED.getMessage()); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/storePayment/exception/StorePaymentDeleteUnauthorizedException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/storePayment/exception/StorePaymentDeleteUnauthorizedException.java new file mode 100644 index 00000000..c65f5e55 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/storePayment/exception/StorePaymentDeleteUnauthorizedException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.storepayment.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class StorePaymentDeleteUnauthorizedException extends RuntimeException{ + public StorePaymentDeleteUnauthorizedException() { + super(ErrorMessage.STORE_PAYMENT_DELETE_UNAUTHORIZED.getMessage()); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/storePayment/exception/StorePaymentNotFoundException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/storePayment/exception/StorePaymentNotFoundException.java new file mode 100644 index 00000000..f384e132 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/storePayment/exception/StorePaymentNotFoundException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.storepayment.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class StorePaymentNotFoundException extends RuntimeException { + public StorePaymentNotFoundException() { + super(ErrorMessage.STORE_PAYMENT_NOT_FOUND.getMessage()); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/storePayment/exception/StorePaymentParamEmptyException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/storePayment/exception/StorePaymentParamEmptyException.java new file mode 100644 index 00000000..83b62dba --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/storePayment/exception/StorePaymentParamEmptyException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.storepayment.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class StorePaymentParamEmptyException extends RuntimeException { + public StorePaymentParamEmptyException() { + super(ErrorMessage.STORE_PAYMENT_PARAMETER_EMPTY.getMessage()); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/storePayment/exception/StorePaymentUpdateUnauthorizedException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/storePayment/exception/StorePaymentUpdateUnauthorizedException.java new file mode 100644 index 00000000..32d1c85a --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/storePayment/exception/StorePaymentUpdateUnauthorizedException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.storepayment.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class StorePaymentUpdateUnauthorizedException extends RuntimeException { + public StorePaymentUpdateUnauthorizedException() { + super(ErrorMessage.STORE_PAYMENT_UPDATE_UNAUTHORIZED.getMessage()); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/storePayment/exception/StorePaymentViewUnauthorizedException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/storePayment/exception/StorePaymentViewUnauthorizedException.java new file mode 100644 index 00000000..cec829ec --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/storePayment/exception/StorePaymentViewUnauthorizedException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.storepayment.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class StorePaymentViewUnauthorizedException extends RuntimeException { + public StorePaymentViewUnauthorizedException() { + super(ErrorMessage.STORE_PAYMENT_VIEW_UNAUTHORIZED.getMessage()); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/storePayment/repository/StorePaymentRepository.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/storePayment/repository/StorePaymentRepository.java new file mode 100644 index 00000000..3a92bc09 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/storePayment/repository/StorePaymentRepository.java @@ -0,0 +1,13 @@ +package com.nowait.domaincorerdb.storepayment.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.nowait.domaincorerdb.storepayment.entity.StorePayment; + +@Repository +public interface StorePaymentRepository extends JpaRepository { + Optional findByStoreId(Long storeId); +} diff --git a/domain-token/src/main/java/com/example/domaintoken/entity/Token.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/token/entity/Token.java similarity index 76% rename from domain-token/src/main/java/com/example/domaintoken/entity/Token.java rename to nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/token/entity/Token.java index d337bdd5..29206c5f 100644 --- a/domain-token/src/main/java/com/example/domaintoken/entity/Token.java +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/token/entity/Token.java @@ -1,8 +1,8 @@ -package com.example.domaintoken.entity; +package com.nowait.domaincorerdb.token.entity; import java.time.LocalDateTime; -import com.nowaiting.user.entity.User; +import com.nowait.domaincorerdb.user.entity.User; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -23,11 +23,12 @@ @Table(name = "token") @Getter public class Token { - @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) private Long tokenId; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", nullable = false) + @JoinColumn(name = "user_id", nullable = false,unique = true) private User user; @Column @@ -51,4 +52,9 @@ public static Token toEntity(User user, String refreshToken, LocalDateTime expir .build(); } + public void updateRefreshToken(String refreshToken, LocalDateTime expiredDate) { + this.refreshToken = refreshToken; + this.expiredDate = expiredDate; + } + } diff --git a/domain-token/src/main/java/com/example/domaintoken/exception/BusinessException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/token/exception/BusinessException.java similarity index 75% rename from domain-token/src/main/java/com/example/domaintoken/exception/BusinessException.java rename to nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/token/exception/BusinessException.java index 05dc5c67..86886330 100644 --- a/domain-token/src/main/java/com/example/domaintoken/exception/BusinessException.java +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/token/exception/BusinessException.java @@ -1,6 +1,6 @@ -package com.example.domaintoken.exception; +package com.nowait.domaincorerdb.token.exception; -import com.nowaiting.common.exception.ErrorMessage; +import com.nowait.common.exception.ErrorMessage; public abstract class BusinessException extends RuntimeException { private final ErrorMessage errorMessage; diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/token/repository/TokenRepository.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/token/repository/TokenRepository.java new file mode 100644 index 00000000..4cc7b9ec --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/token/repository/TokenRepository.java @@ -0,0 +1,17 @@ +package com.nowait.domaincorerdb.token.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.nowait.domaincorerdb.token.entity.Token; +import com.nowait.domaincorerdb.user.entity.User; + +@Repository +public interface TokenRepository extends JpaRepository { + Optional findByUserId(Long userId); + + Optional findByUser(User user); + +} diff --git a/domain-user/src/main/java/com/nowaiting/user/entity/MemberDetails.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/user/entity/MemberDetails.java similarity index 90% rename from domain-user/src/main/java/com/nowaiting/user/entity/MemberDetails.java rename to nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/user/entity/MemberDetails.java index c2c512f1..9a50fabd 100644 --- a/domain-user/src/main/java/com/nowaiting/user/entity/MemberDetails.java +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/user/entity/MemberDetails.java @@ -1,4 +1,4 @@ -package com.nowaiting.user.entity; +package com.nowait.domaincorerdb.user.entity; import java.util.Collection; import java.util.List; @@ -16,6 +16,7 @@ public class MemberDetails implements UserDetails { private final String email; private final String password; private final Collection authorities; + private final User user; public static MemberDetails create(User user) { return MemberDetails.builder() @@ -23,9 +24,14 @@ public static MemberDetails create(User user) { .email(user.getEmail()) .password(user.getPassword()) .authorities(List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().name()))) + .user(user) .build(); } + public User getUser() { + return user; + } + public Long getId() { return id; } @Override diff --git a/domain-user/src/main/java/com/nowaiting/user/entity/User.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/user/entity/User.java similarity index 54% rename from domain-user/src/main/java/com/nowaiting/user/entity/User.java rename to nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/user/entity/User.java index 164d86a4..bac239a2 100644 --- a/domain-user/src/main/java/com/nowaiting/user/entity/User.java +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/user/entity/User.java @@ -1,9 +1,13 @@ -package com.nowaiting.user.entity; +package com.nowait.domaincorerdb.user.entity; +import java.time.LocalDateTime; + +import org.springframework.data.annotation.LastModifiedDate; import org.springframework.security.crypto.password.PasswordEncoder; -import com.nowaiting.common.enums.Role; -import com.nowaiting.common.enums.SocialType; +import com.nowait.common.enums.Role; +import com.nowait.common.enums.SocialType; +import com.nowait.domaincorerdb.base.entity.BaseTimeEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -14,21 +18,25 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import lombok.AccessLevel; -import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) @Table(name = "users") @Getter -public class User { +@SuperBuilder +public class User extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) // 자동 생성 private Long id; @Column(nullable = false, unique = true) private String email; // 카카오 이메일 + @Column(nullable = true) + private String phoneNumber; // 사용자 전화번호 + @Column(nullable = false) private String password; // 관리자 패스워드 @@ -44,23 +52,43 @@ public class User { @Enumerated(EnumType.STRING) private Role role; - @Builder - public User(String email,String password, String nickname, String profileImage, SocialType socialType, Role role){ + @Column(nullable = false) + private Boolean isMarketingAgree = false; + + @Column(nullable = false) + private Boolean phoneEntered = false; + + private Long storeId; + + @LastModifiedDate + @Column(nullable = false) + private LocalDateTime updatedAt; + + + public User(LocalDateTime createdAt, String email,String password, String nickname, String profileImage, SocialType socialType, + Role role, Long storeId, LocalDateTime updatedAt ) { + super(createdAt); this.email = email; this.password = password; this.nickname = nickname; this.profileImage = profileImage; this.socialType = socialType; this.role = role; + this.storeId = storeId; + this.updatedAt = updatedAt; } - public static User createUserWithId(Long userId, String email, String nickname, String profileImage, SocialType socialType, Role role){ + public static User createUserWithId(Long userId, String email, String nickname, String profileImage, + SocialType socialType, Role role, Long storeId) { User user = User.builder() .email(email) .nickname(nickname) .profileImage(profileImage) .socialType(socialType) .role(role) + .storeId(storeId) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) .build(); user.id = userId; @@ -71,7 +99,19 @@ public static User createUserWithId(Long userId, String email, String nickname, public void updateNickname(String nickname){ this.nickname = nickname; } + public void encodePassword(PasswordEncoder passwordEncoder) { password = passwordEncoder.encode(password); } + + public void setPhoneNumberAndMarkEntered(String phoneNumber, LocalDateTime ts) { + this.phoneNumber = phoneNumber; + this.phoneEntered = true; + this.updatedAt = ts; + } + + public void setIsMarketingAgree(boolean agree, LocalDateTime ts) { + this.isMarketingAgree = agree; + this.updatedAt = ts; + } } diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/user/exception/UserNotFoundException.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/user/exception/UserNotFoundException.java new file mode 100644 index 00000000..ec742363 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/user/exception/UserNotFoundException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincorerdb.user.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class UserNotFoundException extends RuntimeException { + public UserNotFoundException() { + super(ErrorMessage.NOT_FOUND_USER.getMessage()); + } +} diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/user/repository/UserRepository.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/user/repository/UserRepository.java new file mode 100644 index 00000000..090025c3 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/user/repository/UserRepository.java @@ -0,0 +1,18 @@ +package com.nowait.domaincorerdb.user.repository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.nowait.domaincorerdb.user.entity.User; + +@Repository +public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); + Optional findByNickname(String nickName); + List findByIdIn(Collection ids); + boolean existsByPhoneNumberAndIdNot(String phoneNumber, Long id); +} diff --git a/nowait-domain/domain-core-rdb/src/test/java/com/nowait/domaincorerdb/menu/repository/MenuRepositoryTest.java b/nowait-domain/domain-core-rdb/src/test/java/com/nowait/domaincorerdb/menu/repository/MenuRepositoryTest.java new file mode 100644 index 00000000..7f9b2a60 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/test/java/com/nowait/domaincorerdb/menu/repository/MenuRepositoryTest.java @@ -0,0 +1,69 @@ +package com.nowait.domaincorerdb.menu.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; + +import org.hibernate.SessionFactory; +import org.hibernate.stat.Statistics; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +import com.nowait.domaincorerdb.menu.entity.Menu; +import com.nowait.domaincorerdb.menu.entity.MenuImage; + +@DataJpaTest(properties = "spring.jpa.properties.hibernate.generate_statistics=true") +class MenuRepositoryTest { + + @SpringBootConfiguration + @EntityScan(basePackageClasses = Menu.class) + @EnableJpaRepositories(basePackageClasses = MenuRepository.class) + static class Config { + } + + @Autowired + MenuRepository menuRepository; + + @Autowired + TestEntityManager em; + + @Test + void 메뉴와_이미지를_쿼리_한번에_메뉴별로_묶는다() { + Menu second = menu(1L, 2L, false); + Menu first = menu(1L, 1L, false); + Menu noImage = menu(1L, 3L, false); + menu(1L, 4L, true); + menu(2L, 1L, false); + MenuImage a = image(first); + MenuImage b = image(second); + MenuImage c = image(second); + em.flush(); + em.clear(); + Statistics stats = em.getEntityManager().getEntityManagerFactory().unwrap(SessionFactory.class).getStatistics(); + stats.clear(); + + Map> result = menuRepository.findAllWithImagesByStoreId(1L); + + assertThat(stats.getPrepareStatementCount()).isEqualTo(1); + // sortOrder 순서, 삭제된 메뉴·다른 주점 제외, 이미지 없는 메뉴는 빈 목록 + assertThat(result.keySet()).extracting(Menu::getId) + .containsExactly(first.getId(), second.getId(), noImage.getId()); + assertThat(result.values()).extracting(images -> images.stream().map(MenuImage::getId).toList()) + .containsExactly(List.of(a.getId()), List.of(b.getId(), c.getId()), List.of()); + } + + private Menu menu(Long storeId, Long sortOrder, boolean deleted) { + return em.persist(Menu.builder().storeId(storeId).name("menu").isSoldOut(false).sortOrder(sortOrder) + .deleted(deleted).build()); + } + + private MenuImage image(Menu menu) { + return em.persist(MenuImage.builder().menu(menu).imageUrl("url").fileKey("key").build()); + } +} diff --git a/nowait-domain/domain-redis/build.gradle b/nowait-domain/domain-redis/build.gradle new file mode 100644 index 00000000..3c03aef4 --- /dev/null +++ b/nowait-domain/domain-redis/build.gradle @@ -0,0 +1,40 @@ +plugins { + id 'java-library' +} + +jar { + enabled = true +} + + +group = 'com.nowait' +version = rootProject.version + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation project(':nowait-common') + implementation project(':nowait-event') + + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'com.github.ben-manes.caffeine:caffeine' + implementation 'io.micrometer:micrometer-core' + + // SWAGGER + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' + + compileOnly 'org.projectlombok:lombok:1.18.26' + annotationProcessor 'org.projectlombok:lombok:1.18.26' + + testImplementation 'org.junit.jupiter:junit-jupiter' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/cache/CacheInvalidationPublisher.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/cache/CacheInvalidationPublisher.java new file mode 100644 index 00000000..59855a7c --- /dev/null +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/cache/CacheInvalidationPublisher.java @@ -0,0 +1,55 @@ +package com.nowait.domaincoreredis.common.cache; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 캐시 무효화 메시지 발행 (INVALIDATE-{key}, CLEAR) + * 구독 측은 {@link DistributedCachedDao} + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CacheInvalidationPublisher { + + public static final String INVALIDATE_PREFIX = "INVALIDATE-"; + public static final String CLEAR = "CLEAR"; + + private final StringRedisTemplate redisTemplate; + + public void invalidate(String channel, String key) { + publishAfterCommit(channel, INVALIDATE_PREFIX + key); + } + + public void clear(String channel) { + publishAfterCommit(channel, CLEAR); + } + + // 커밋 전에 발행하면 구독 측이 evict 직후 커밋 전 값을 다시 읽어 TTL 동안 캐싱한다 + private void publishAfterCommit(String channel, String message) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + publish(channel, message); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + publish(channel, message); + } + }); + } + + private void publish(String channel, String message) { + try { + redisTemplate.convertAndSend(channel, message); + } catch (Exception e) { + // 발행 실패 시 TTL 만료 전까지 옛 값이 남는다 → redis-cli PUBLISH {channel} CLEAR 로 수동 무효화 + log.error("캐시 무효화 발행 실패 channel={}, message={}", channel, message, e); + } + } +} diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/cache/DistributedCachedDao.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/cache/DistributedCachedDao.java new file mode 100644 index 00000000..d87287b0 --- /dev/null +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/cache/DistributedCachedDao.java @@ -0,0 +1,75 @@ +package com.nowait.domaincoreredis.common.cache; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +import org.springframework.data.redis.connection.Message; +import org.springframework.data.redis.connection.MessageListener; +import org.springframework.data.redis.connection.SubscriptionListener; +import org.springframework.data.redis.listener.ChannelTopic; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.binder.cache.CaffeineCacheMetrics; + +/** + * 로컬 캐시(Caffeine) + Redis Pub/Sub 키 무효화 전파 + * - 값은 공유하지 않고 키만 전파한다 (직렬화 불필요, 결과적 일관성) + * - put 은 의도적으로 제공하지 않는다. 갱신은 invalidate 후 get 으로 재로딩 + * - 키는 메시지에 그대로 싣기 위해 String 으로 제한한다 + */ +public abstract class DistributedCachedDao implements MessageListener, SubscriptionListener { + + private final Cache cache; + private final String channel; + private final CacheInvalidationPublisher publisher; + + protected DistributedCachedDao(String channel, Duration ttl, long maximumSize, + RedisMessageListenerContainer container, CacheInvalidationPublisher publisher, MeterRegistry meterRegistry) { + this.cache = Caffeine.newBuilder() + .expireAfterWrite(ttl) + .maximumSize(maximumSize) + .recordStats() + .build(); + this.channel = channel; + this.publisher = publisher; + // 히트율: cache_gets_total{cache=, result=hit|miss} + CaffeineCacheMetrics.monitor(meterRegistry, cache, channel); + container.addMessageListener(this, new ChannelTopic(channel)); + } + + protected abstract V load(String key); + + public V get(String key) { + return cache.get(key, this::load); + } + + public void invalidate(String key) { + cache.invalidate(key); + publisher.invalidate(channel, key); + } + + public void clearCache() { + cache.invalidateAll(); + publisher.clear(channel); + } + + @Override + public void onMessage(Message message, byte[] pattern) { + String body = new String(message.getBody(), StandardCharsets.UTF_8); + if (CacheInvalidationPublisher.CLEAR.equals(body)) { + cache.invalidateAll(); + } else if (body.startsWith(CacheInvalidationPublisher.INVALIDATE_PREFIX)) { + cache.invalidate(body.substring(CacheInvalidationPublisher.INVALIDATE_PREFIX.length())); + } + } + + // 최초 구독 + 연결 끊김 후 재구독 시 호출된다 → 끊긴 동안 놓친 무효화를 전체 비우기로 수습 + @Override + public void onChannelSubscribed(byte[] channel, long count) { + cache.invalidateAll(); + } +} diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/util/RedisKeyUtils.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/util/RedisKeyUtils.java new file mode 100644 index 00000000..adad3e11 --- /dev/null +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/util/RedisKeyUtils.java @@ -0,0 +1,134 @@ +package com.nowait.domaincoreredis.common.util; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Date; + +public class RedisKeyUtils { + + // Store rank keys + private static final String KEY_CURRENT = "nowait:store:rank:current"; + private static final String KEY_PREVIOUS = "nowait:store:rank:previous"; + private static final String KEY_NEXT = "nowait:store:rank:next"; + + // Menu rank keys + private static final String KEY_FMT = "popular:%d:%s"; + private static final DateTimeFormatter DTF = DateTimeFormatter.ofPattern("yyyyMMdd"); + + // Waiting keys + private static final String WAITING_KEY_PREFIX = "waiting:"; + private static final String WAITING_USER_LIST_KEY_PREFIX = "waiting:user:"; + private static final String WAITING_PARTYSIZE_KEY_PREFIX = "waiting:party:"; + private static final String WAITING_STATUS_KEY_PREFIX = "waiting:status:"; + + // Cache invalidation channels + private static final String MENU_CACHE_CHANNEL = "cache:menus"; + + public static String buildMenuCacheChannel() { + return MENU_CACHE_CHANNEL; + } + + // Waiting User keys + public static String buildUserHoldingKey(String userId) { + return "waiting:user:{" + userId + "}:holding"; // ZSET(member=token, score=expireEpochMs) + } + public static String buildUserActiveKey(String userId) { + return "waiting:user:{" + userId + "}:active"; // SET(member="storeId:reservationId") + } + + + private RedisKeyUtils() { + throw new UnsupportedOperationException("유틸리티 서비스는 인스턴스화 할 수 없습니다."); + } + + public static String buildCurrentKey() { + return KEY_CURRENT; + } + + public static String buildPreviousKey() { + return KEY_PREVIOUS; + } + + public static String buildNextKey() { + return KEY_NEXT; + } + + public static String buildMenuKey() { + return KEY_FMT; + } + + public static DateTimeFormatter buildMenuDateKey() { + return DTF; + } + + public static String buildWaitingUserListKeyPrefix() { + return WAITING_USER_LIST_KEY_PREFIX; + } + + public static String buildWaitingKeyPrefix() { + return WAITING_KEY_PREFIX; + } + + public static String buildWaitingPartySizeKeyPrefix() { + return WAITING_PARTYSIZE_KEY_PREFIX; + } + + public static String buildWaitingStatusKeyPrefix() { + return WAITING_STATUS_KEY_PREFIX; + } + + // Waiting Reservation Number key + public static String buildReservationSeqKey(Long storeId) { + return String.format("reservation:seq:%d", storeId); + } + + public static String buildWaitingSeqKey(Long storeId) { + return String.format("waiting:sequence:%d", storeId); + } + + public static String buildReservationNumberKey(Long storeId) { + return String.format("reservation:number:%d", storeId); + } + + public static String buildReservationUserKey(Long storeId) { + return String.format("reservation:user:%d", storeId); + } + + public static String buildUserLeaseCountKey(String userId) { return "userID:{" + userId + "}:lease:cnt"; } + + /** + * 대기 호출 시각(hash)에 사용할 키 접두사 + */ + public static String buildWaitingCalledAtKeyPrefix() { + return "waiting:calledAt:"; + } + + /** + * 웨이팅 리팩토링 작업중 + */ + private static final String USER_WAITING_LIMIT_COUNT_KEY_FMT = "waiting:user:%s:limit:count"; + + public static String buildUserWaitingLimitCountKey(String userId) { + return String.format(USER_WAITING_LIMIT_COUNT_KEY_FMT, userId); + } + + public static Date expireAtNext03() { + ZoneId zone = ZoneId.of("Asia/Seoul"); + LocalDateTime now = LocalDateTime.now(zone); + LocalDateTime next03 = now.toLocalDate().plusDays(1).atTime(3, 0); + Instant instant = next03.atZone(zone).toInstant(); + + return Date.from(instant); + } + + public static Date expireAt10Minute() { + ZoneId zone = ZoneId.of("Asia/Seoul"); + LocalDateTime now = LocalDateTime.now(zone); + LocalDateTime nextHour = now.toLocalDate().atTime(0, 10); + Instant instant = nextHour.atZone(zone).toInstant(); + + return Date.from(instant); + } +} diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/config/RedisConfig.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/config/RedisConfig.java new file mode 100644 index 00000000..07dd0147 --- /dev/null +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/config/RedisConfig.java @@ -0,0 +1,56 @@ +package com.nowait.domaincoreredis.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +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.listener.RedisMessageListenerContainer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + @Value("${spring.data.redis.host}") + private String host; + + @Value("${spring.data.redis.port}") + private int port; + + @Value("${spring.data.redis.password}") + private String password; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration redisConfiguration = new RedisStandaloneConfiguration(); + redisConfiguration.setHostName(host); + redisConfiguration.setPort(port); + redisConfiguration.setPassword(password); + return new LettuceConnectionFactory(redisConfiguration); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + + redisTemplate.setConnectionFactory(redisConnectionFactory()); + + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + + redisTemplate.setHashKeySerializer(new StringRedisSerializer()); + redisTemplate.setHashValueSerializer(new StringRedisSerializer()); + + redisTemplate.setDefaultSerializer(new StringRedisSerializer()); + + return redisTemplate; + } + + @Bean + public RedisMessageListenerContainer redisMessageListenerContainer() { + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + container.setConnectionFactory(redisConnectionFactory()); + return container; + } +} diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/dto/RankingEntry.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/dto/RankingEntry.java new file mode 100644 index 00000000..9dcc2e45 --- /dev/null +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/dto/RankingEntry.java @@ -0,0 +1,18 @@ +package com.nowait.domaincoreredis.rank.dto; + +import lombok.Getter; + +@Getter +public class RankingEntry { + private final Long storeId; + private final Integer totalSales; + private final Long currentRank; + private final Integer delta; + + public RankingEntry(Long storeId, Integer totalSales, Long currentRank, Integer delta) { + this.storeId = storeId; + this.totalSales = totalSales; + this.currentRank = currentRank; + this.delta = delta; + } +} diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/exception/MenuCounterUpdateException.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/exception/MenuCounterUpdateException.java new file mode 100644 index 00000000..fc8c13ab --- /dev/null +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/exception/MenuCounterUpdateException.java @@ -0,0 +1,7 @@ +package com.nowait.domaincoreredis.rank.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class MenuCounterUpdateException extends RuntimeException { + public MenuCounterUpdateException() { super(ErrorMessage.MENU_COUNTER_UPDATE.getMessage()); } +} diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/listener/CookingCompleteListener.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/listener/CookingCompleteListener.java new file mode 100644 index 00000000..37b1ee92 --- /dev/null +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/listener/CookingCompleteListener.java @@ -0,0 +1,33 @@ +package com.nowait.domaincoreredis.rank.listener; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import com.nowait.domaincoreredis.rank.service.MenuCounterService; +import com.nowait.nowaitevent.order.event.CookingCompleteEvent; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class CookingCompleteListener { + private final MenuCounterService menuCounterService; + + @Async + @TransactionalEventListener( + classes = CookingCompleteEvent.class, + phase = TransactionPhase.AFTER_COMMIT + ) + public void onCookingComplete(CookingCompleteEvent event) { + Long storeId = event.getStoreId(); + for (CookingCompleteEvent.Item item : event.getItems()) { + menuCounterService.incrementMenuCounter( + item.getMenuId(), + storeId, + item.getQuantity() + ); + } + } +} diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/repository/RankingQueryRepository.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/repository/RankingQueryRepository.java new file mode 100644 index 00000000..f056b146 --- /dev/null +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/repository/RankingQueryRepository.java @@ -0,0 +1,16 @@ +package com.nowait.domaincoreredis.rank.repository; + +import java.util.List; + +import com.nowait.domaincoreredis.rank.dto.RankingEntry; + +public interface RankingQueryRepository { + + void addToRanking(String key, Long storeId, Integer totalSales); + + Long findPrevRank(String key, Long storeId); + + List findTopStores(String key, int topN); + + List findTopStoresWithUser(String key, Long userStoreId, int topN, List entries); +} diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/repository/RankingQueryRepositoryImpl.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/repository/RankingQueryRepositoryImpl.java new file mode 100644 index 00000000..fa792f2f --- /dev/null +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/repository/RankingQueryRepositoryImpl.java @@ -0,0 +1,66 @@ +package com.nowait.domaincoreredis.rank.repository; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import com.nowait.domaincoreredis.rank.dto.RankingEntry; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class RankingQueryRepositoryImpl implements RankingQueryRepository { + + private final StringRedisTemplate redisTemplate; + + @Override + public void addToRanking(String key, Long storeId, Integer totalSales) { + double totalSalesDouble = totalSales != null ? totalSales.doubleValue() : 0.0; + redisTemplate.opsForZSet().add(key, String.valueOf(storeId), totalSalesDouble); + } + + @Override + public Long findPrevRank(String key, Long storeId) { + return redisTemplate.opsForZSet().reverseRank(key, String.valueOf(storeId)); + } + + @Override + public List findTopStores(String key, int topN) { + var topTuples = redisTemplate.opsForZSet().reverseRangeWithScores(key, 0, topN - 1); + + List entries = topTuples.stream() + .map(tuple -> new RankingEntry( + Long.parseLong(tuple.getValue()), + tuple.getScore().intValue(), + redisTemplate.opsForZSet().reverseRank(key, tuple.getValue()) + 1, + 0 + )) + .collect(Collectors.toCollection(ArrayList::new)); + + return entries; + } + + @Override + public List findTopStoresWithUser(String key, Long userStoreId, int topN, List entries) { + Long userZero = redisTemplate.opsForZSet().reverseRank(key, userStoreId.toString()); + + if (userZero != null && userZero >= topN) { + List top4 = entries.subList(0, topN - 1); + Double userScore = redisTemplate.opsForZSet().score(key, userStoreId.toString()); + Integer userSales = userScore != null ? userScore.intValue() : 0; + + top4.add(new RankingEntry( + userStoreId, + userSales, + userZero + 1, + 0 // 초기 등락 값은 0으로 설정 + )); + entries = top4; + } + return entries; + } +} diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/service/MenuCounterService.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/service/MenuCounterService.java new file mode 100644 index 00000000..d1024785 --- /dev/null +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/service/MenuCounterService.java @@ -0,0 +1,12 @@ +package com.nowait.domaincoreredis.rank.service; + +import java.util.Set; + +import org.springframework.data.redis.core.ZSetOperations; + +public interface MenuCounterService { + + void incrementMenuCounter(Long menuId, Long storeId, int qty); + + Set> getTopMenus(Long storeId, int topN); +} diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/service/MenuCounterServiceImpl.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/service/MenuCounterServiceImpl.java new file mode 100644 index 00000000..f2ffbce3 --- /dev/null +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/service/MenuCounterServiceImpl.java @@ -0,0 +1,61 @@ +package com.nowait.domaincoreredis.rank.service; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.stereotype.Service; + +import com.nowait.domaincoreredis.common.util.RedisKeyUtils; +import com.nowait.domaincoreredis.rank.exception.MenuCounterUpdateException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class MenuCounterServiceImpl implements MenuCounterService { + + private final String KEY_FMT = RedisKeyUtils.buildMenuKey(); + private final DateTimeFormatter DTF = RedisKeyUtils.buildMenuDateKey(); + private final RedisTemplate redis; + + @Override + public void incrementMenuCounter(Long menuId, Long storeId, int qty) { + + try { + String date = LocalDate.now().format(DTF); + String key = String.format(KEY_FMT, storeId, date); + + redis.opsForZSet().incrementScore(key, menuId.toString(), qty); + + Long expirationTime = redis.getExpire(key); + if (expirationTime == null || expirationTime < 0) { + long secondsUntilMidnight = Duration.between( + LocalDateTime.now(), + LocalDate.now().plusDays(1).atStartOfDay() + ).getSeconds(); + + redis.expire(key, Duration.ofSeconds(secondsUntilMidnight)); + } + + } catch (Exception e) { + log.error("Failed to increment menu counter for menuId: {}, storeId: {}", + menuId, storeId, e); + throw new MenuCounterUpdateException(); + } + } + + @Override + public Set> getTopMenus(Long storeId, int topN) { + String date = LocalDate.now().format(DTF); + String key = String.format(KEY_FMT, storeId, date); + + return redis.opsForZSet().reverseRangeWithScores(key, 0, topN - 1); + } +} diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/service/RankingQueryService.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/service/RankingQueryService.java new file mode 100644 index 00000000..5b0eb5b5 --- /dev/null +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/service/RankingQueryService.java @@ -0,0 +1,13 @@ +package com.nowait.domaincoreredis.rank.service; + +import java.util.List; + +import com.nowait.domaincoreredis.rank.dto.RankingEntry; + +public interface RankingQueryService { + + /** + * 로그인한 사용자의 주점을 포함하여 상위 topN 순위와 등락 정보를 반환한다. + */ + List getRankings(Long userStoreId, int topN); +} diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/service/RankingQueryServiceImpl.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/service/RankingQueryServiceImpl.java new file mode 100644 index 00000000..7718f358 --- /dev/null +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/rank/service/RankingQueryServiceImpl.java @@ -0,0 +1,52 @@ +package com.nowait.domaincoreredis.rank.service; + +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; + +import com.nowait.domaincoreredis.common.util.RedisKeyUtils; +import com.nowait.domaincoreredis.rank.dto.RankingEntry; +import com.nowait.domaincoreredis.rank.repository.RankingQueryRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class RankingQueryServiceImpl implements RankingQueryService { + + private final RankingQueryRepository rankingQueryRepository; + + @Override + public List getRankings(Long userStoreId, int topN) { + // 1) 현재 스냅샷에서 상위 topN 주점 조회 + List entries = getCurrentRankings(userStoreId, topN); + // 2) 이전 스냅샷에서 등락 정보 조회 + return calculateRankingDeltas(entries); + } + + private List getCurrentRankings(Long userStoreId, int topN) { + String currentKey = RedisKeyUtils.buildCurrentKey(); + List entries = rankingQueryRepository.findTopStores(currentKey, topN); + return rankingQueryRepository.findTopStoresWithUser(currentKey, userStoreId, topN, entries); + } + + private List calculateRankingDeltas(List entries) { + String previousKey = RedisKeyUtils.buildPreviousKey(); + return entries.stream() + .map(entry -> calculateDeltaForEntry(entry, previousKey)) + .collect(Collectors.toList()); + } + + private RankingEntry calculateDeltaForEntry(RankingEntry entry, String previousKey) { + Long prevZero = rankingQueryRepository.findPrevRank(previousKey, entry.getStoreId()); + long prevRank = (prevZero == null ? entry.getCurrentRank() : prevZero + 1); + int delta = (int)(prevRank - entry.getCurrentRank()); + return new RankingEntry( + entry.getStoreId(), + entry.getTotalSales(), + entry.getCurrentRank(), + delta + ); + } +} diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/exception/AlreadyWaitingException.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/exception/AlreadyWaitingException.java new file mode 100644 index 00000000..f028b155 --- /dev/null +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/exception/AlreadyWaitingException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincoreredis.reservation.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class AlreadyWaitingException extends RuntimeException { + public AlreadyWaitingException() { + super(ErrorMessage.DUPLICATE_RESERVATION.getMessage()); + } +} diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/exception/ReservationDataInconsistencyException.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/exception/ReservationDataInconsistencyException.java new file mode 100644 index 00000000..22cc5151 --- /dev/null +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/exception/ReservationDataInconsistencyException.java @@ -0,0 +1,10 @@ +package com.nowait.domaincoreredis.reservation.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class ReservationDataInconsistencyException extends RuntimeException { + public ReservationDataInconsistencyException(String details) { + super(ErrorMessage.RESERVATION_DATA_INCONSISTENCY.format(details)); + } +} + diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/exception/UserWaitingLimitExceededException.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/exception/UserWaitingLimitExceededException.java new file mode 100644 index 00000000..8f594f9e --- /dev/null +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/exception/UserWaitingLimitExceededException.java @@ -0,0 +1,9 @@ +package com.nowait.domaincoreredis.reservation.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class UserWaitingLimitExceededException extends RuntimeException { + public UserWaitingLimitExceededException() { + super(ErrorMessage.USER_WAITING_LIMIT_EXCEEDED.getMessage()); + } +} diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/repository/WaitingPermitLuaRepository.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/repository/WaitingPermitLuaRepository.java new file mode 100644 index 00000000..997c4a22 --- /dev/null +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/repository/WaitingPermitLuaRepository.java @@ -0,0 +1,190 @@ +package com.nowait.domaincoreredis.reservation.repository; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import org.springframework.data.redis.connection.ReturnType; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import com.nowait.domaincoreredis.common.util.RedisKeyUtils; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class WaitingPermitLuaRepository { + + private final StringRedisTemplate redis; + + private static final String ACQUIRE_SCRIPT = + "redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1]);" + + "local holding = redis.call('ZCARD', KEYS[1]);" + + "local active = redis.call('SCARD', KEYS[2]);" + + "if (holding + active) >= tonumber(ARGV[3]) then return 0 end;" + + "redis.call('ZADD', KEYS[1], tonumber(ARGV[1]) + tonumber(ARGV[2]), ARGV[4]);" + + "return 1;"; + + private static final String ACQUIRE_SCRIPT_V2 = + """ + -- KEYS[1] = holding zset (u:{uid}:holding) + -- KEYS[2] = active set (u:{uid}:active) + -- KEYS[3] = lease count (u:{uid}:lease:cnt) + + -- ARGV[1] = nowMs + -- ARGV[2] = leaseMs + -- ARGV[3] = limit + -- ARGV[4] = token + -- ARGV[5] = ttlMs + + -- 1) 만료된 holding 정리 + cnt 보정 + local removed = redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1]) + if removed and removed > 0 then + local after = redis.call('DECRBY', KEYS[3], removed) + if after < 0 then redis.call('SET', KEYS[3], 0) end + end + + -- 2) limit 체크 + local active = redis.call('SCARD', KEYS[2]) + local holding = redis.call('ZCARD', KEYS[1]) + + if (active + holding) >= tonumber(ARGV[3]) then + return 0 + end + + -- 3) lease 카운트 증가 + redis.call('INCR', KEYS[3]) + + -- 4) holding 추가 + redis.call( + 'ZADD', + KEYS[1], + tonumber(ARGV[1]) + tonumber(ARGV[2]), + ARGV[4] + ) + + -- 5) TTL 동기화 + redis.call('PEXPIRE', KEYS[1], ARGV[5]) + redis.call('PEXPIRE', KEYS[2], ARGV[5]) + redis.call('PEXPIRE', KEYS[3], ARGV[5]) + + return 1 + """; + + private static final String FINALIZE_SCRIPT = + "redis.call('ZREM', KEYS[1], ARGV[1]);" + + "redis.call('SADD', KEYS[2], ARGV[2]);" + + "return 1;"; + + private static final String RELEASE_SCRIPT_V2 = + """ + local removed = redis.call('ZREM', KEYS[1], ARGV[1]) + if removed == 1 then + local cnt = redis.call('DECR', KEYS[2]) + if cnt < 0 then redis.call('SET', KEYS[2], 0) end + end + return removed + """; + + private static final String REMOVE_ACTIVE_SCRIPT = + """ + local removed = redis.call('SREM', KEYS[1], ARGV[1]) + if removed == 1 then + local cnt = redis.call('DECR', KEYS[2]) + if cnt < 0 then redis.call('SET', KEYS[2], 0) end + end + return removed + """; + + + public boolean acquireLease(String userId, String token, long nowMs, long leaseMs, int limit, Duration ttlTo3am) { + final String hk = RedisKeyUtils.buildUserHoldingKey(userId); // u:{uid}:holding + final String ak = RedisKeyUtils.buildUserActiveKey(userId); // u:{uid}:active + final String ck = RedisKeyUtils.buildUserLeaseCountKey(userId); // u:{uid}:lease:cnt + + Long ok = redis.execute((RedisCallback) conn -> { + Object res = conn.eval( + ACQUIRE_SCRIPT_V2.getBytes(StandardCharsets.UTF_8), + ReturnType.INTEGER, + 3, + raw(hk), raw(ak), raw(ck), + raw(Long.toString(nowMs)), + raw(Long.toString(leaseMs)), + raw(Integer.toString(limit)), + raw(token), + raw(Long.toString(ttlTo3am.toMillis())) + ); + return (Long) res; + }); + return ok != null && ok == 1L; + } + + public void finalizeActive(String userId, String token, String storeId, String reservationId, Duration ttlTo3am) { + final String hk = RedisKeyUtils.buildUserHoldingKey(userId); + final String ak = RedisKeyUtils.buildUserActiveKey(userId); + final String member = storeId + ":" + reservationId; + + redis.execute((RedisCallback) conn -> { + conn.eval( + FINALIZE_SCRIPT.getBytes(StandardCharsets.UTF_8), + ReturnType.INTEGER, + 2, + raw(hk), raw(ak), + raw(token), raw(member) + ); + conn.pExpire(raw(ak), ttlTo3am.toMillis()); + return null; + }); + } + + public void releaseLease(String userId, String token) { + final String hk = RedisKeyUtils.buildUserHoldingKey(userId); + final String ck = RedisKeyUtils.buildUserLeaseCountKey(userId); + + redis.execute((RedisCallback) conn -> { + conn.eval( + RELEASE_SCRIPT_V2.getBytes(StandardCharsets.UTF_8), + ReturnType.INTEGER, + 2, + raw(hk), raw(ck), + raw(token) + ); + return null; + }); + } + + public Set getActiveMembers(String userId) { + final String ak = RedisKeyUtils.buildUserActiveKey(userId); + return redis.execute((RedisCallback>) conn -> { + Set raw = conn.sMembers(raw(ak)); + if (raw == null || raw.isEmpty()) return Collections.emptySet(); + Set out = new HashSet<>(raw.size()); + for (byte[] b : raw) out.add(string(b)); + return out; + }); + } + + public void removeActiveMember(String userId, String storeId, String reservationId) { + final String ak = RedisKeyUtils.buildUserActiveKey(userId); + final String ck = RedisKeyUtils.buildUserLeaseCountKey(userId); + final String member = storeId + ":" + reservationId; + + redis.execute((RedisCallback) conn -> { + conn.eval( + REMOVE_ACTIVE_SCRIPT.getBytes(StandardCharsets.UTF_8), + ReturnType.INTEGER, + 2, + raw(ak), raw(ck), + raw(member) + ); + return null; + }); + } + + private byte[] raw(String s) { return redis.getStringSerializer().serialize(s); } + private String string(byte[] b) { return redis.getStringSerializer().deserialize(b); } +} diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/repository/WaitingRedisRepository.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/repository/WaitingRedisRepository.java new file mode 100644 index 00000000..b0b74bc2 --- /dev/null +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/reservation/repository/WaitingRedisRepository.java @@ -0,0 +1,217 @@ +package com.nowait.domaincoreredis.reservation.repository; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.stereotype.Repository; + +import com.nowait.domaincoreredis.common.util.RedisKeyUtils; +import com.nowait.domaincoreredis.reservation.exception.AlreadyWaitingException; +import com.nowait.domaincoreredis.reservation.exception.UserWaitingLimitExceededException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Repository +@RequiredArgsConstructor +@Slf4j +public class WaitingRedisRepository { + + private final StringRedisTemplate redisTemplate; + + // 대기열 전체 인원수 조회 + public List> getAllWaitingWithScore(Long storeId) { + String queueKey = RedisKeyUtils.buildWaitingKeyPrefix() + storeId; + Set> waitingSet = redisTemplate.opsForZSet().rangeWithScores(queueKey, 0, -1); + return waitingSet == null ? List.of() : new ArrayList<>(waitingSet); + } + + public List getAllWaitingUserIds(Long storeId) { + String queueKey = RedisKeyUtils.buildWaitingKeyPrefix() + storeId; + // 0부터 -1까지: 전체 범위 + Set userIds = redisTemplate.opsForZSet().range(queueKey, 0, -1); + return userIds == null ? List.of() : new ArrayList<>(userIds); + } + + // 상태값 저장 및 변경 + public void setWaitingStatus(Long storeId, String userId, String status) { + String statusKey = RedisKeyUtils.buildWaitingStatusKeyPrefix() + storeId; + redisTemplate.opsForHash().put(statusKey, userId, status); + } + + // 상태값 조회 + public String getWaitingStatus(Long storeId, String userId) { + String statusKey = RedisKeyUtils.buildWaitingStatusKeyPrefix() + storeId; + Object value = redisTemplate.opsForHash().get(statusKey, userId); + return value == null ? null : value.toString(); + } + + // partySize 조회 + public Integer getWaitingPartySize(Long storeId, String userId) { + String partyKey = RedisKeyUtils.buildWaitingPartySizeKeyPrefix() + storeId; + Object value = redisTemplate.opsForHash().get(partyKey, userId); + return value == null ? null : Integer.valueOf(value.toString()); + } + + // userId → reservationNumber 조회 + public String getReservationId(Long storeId, String userId) { + String status = getWaitingStatus(storeId, userId); + if (!"WAITING".equals(status) && !"CALLING".equals(status)) { + // 이미 종료된 대기라면, 예약 번호도 없던 것처럼 취급 + return null; + } + String numberMapKey = RedisKeyUtils.buildReservationNumberKey(storeId); + Object val = redisTemplate.opsForHash().get(numberMapKey, userId); + + return val != null ? val.toString() : null; + } + + // reservationNumber → userId 조회 + public String getUserIdByReservationNumber(Long storeId, String reservationNumber) { + String userMapKey = RedisKeyUtils.buildReservationUserKey(storeId); + Object val = redisTemplate.opsForHash().get(userMapKey, reservationNumber); + return val == null ? null : val.toString(); + } + + public void deleteWaiting(Long storeId, String userId) { + String numberMapKey = RedisKeyUtils.buildReservationNumberKey(storeId); + String userMapKey = RedisKeyUtils.buildReservationUserKey(storeId); + + Object reservationNumber = redisTemplate.opsForHash().get(numberMapKey, userId); + + // userId → reservationNumber 삭제 + redisTemplate.opsForHash().delete(numberMapKey, userId); + + // reservationNumber → userId 삭제 + redisTemplate.opsForHash().delete(userMapKey, reservationNumber); + + String statusKey = RedisKeyUtils.buildWaitingStatusKeyPrefix() + storeId; + redisTemplate.opsForHash().delete(statusKey, userId); + + String queueKey = RedisKeyUtils.buildWaitingKeyPrefix() + storeId; + redisTemplate.opsForZSet().remove(queueKey, userId); + + String partyKey = RedisKeyUtils.buildWaitingPartySizeKeyPrefix() + storeId; + redisTemplate.opsForHash().delete(partyKey, userId); + + String calledAtKey = RedisKeyUtils.buildWaitingCalledAtKeyPrefix() + storeId; + redisTemplate.opsForHash().delete(calledAtKey, userId); + } + + // 호출 시각 기록 + public void setWaitingCalledAt(Long storeId, String userId, long timestamp) { + String key = RedisKeyUtils.buildWaitingCalledAtKeyPrefix() + storeId; + redisTemplate.opsForHash().put(key, userId, String.valueOf(timestamp)); + + redisTemplate.expireAt(key, RedisKeyUtils.expireAtNext03()); + } + + public Long getWaitingCalledAt(Long storeId, String userId) { + String key = RedisKeyUtils.buildWaitingCalledAtKeyPrefix() + storeId; + Object val = redisTemplate.opsForHash().get(key, userId); + return val == null ? null : Long.valueOf(val.toString()); + } + + /** + * 웨이팅 대기열 리팩토링 작업중 + */ + // 대기열 추가 + public void addWaiting(Long storeId, Long userId, LocalDateTime timestamp) { + String queueKey = RedisKeyUtils.buildWaitingKeyPrefix() + storeId; + String userListKey = RedisKeyUtils.buildWaitingUserListKeyPrefix() + userId; + String userWaitingLimitCountKey = RedisKeyUtils.buildUserWaitingLimitCountKey(String.valueOf(userId)); + + long score = timestamp + .atZone(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli(); + + // TODO ttl 설정 필요 + try { + redisTemplate.opsForZSet().add(queueKey, String.valueOf(userId), score); + log.info("웨이팅 대기열 추가 - storeId : {}, userId : {}", storeId, userId); + + redisTemplate.opsForZSet().add(userListKey, String.valueOf(storeId), score); + log.info("유저 웨이팅 목록 추가 - userId : {}, storeId : {}", userId, storeId); + } catch (Exception e) { + log.error("Redis 웨이팅 대기열 추가 실패 - storeId : {}, userId : {}, error: {}", storeId, userId, e.getMessage()); + throw e; + } + } + + public void removeWaiting(Long storeId, Long userId) { + String queueKey = RedisKeyUtils.buildWaitingKeyPrefix() + storeId; + String userListKey = RedisKeyUtils.buildWaitingUserListKeyPrefix() + userId; + String userWaitingLimitCountKey = RedisKeyUtils.buildUserWaitingLimitCountKey(String.valueOf(userId)); + + try { + redisTemplate.opsForZSet().remove(queueKey, String.valueOf(userId)); + log.info("웨이팅 대기열 제거 - storeId : {}, userId : {}", storeId, userId); + + redisTemplate.opsForZSet().remove(userListKey, String.valueOf(storeId)); + log.info("유저 웨이팅 목록 제거 - userId : {}, storeId : {}", userId, storeId); + + redisTemplate.opsForValue().decrement(userWaitingLimitCountKey, 1); + log.info("유저 웨이팅 제한 카운트 감소 - userId : {}, currentCount : {}", userId, + redisTemplate.opsForValue().get(userWaitingLimitCountKey)); + } catch (Exception e) { + log.error("Redis 웨이팅 대기열 제거 실패 - storeId : {}, userId : {}, error: {}", storeId, userId, e.getMessage()); + throw e; + } + } + + public void incrementAndCheckWaitingLimit(Long userId, Long maxLimit) { + String userWaitingLimitCountKey = RedisKeyUtils.buildUserWaitingLimitCountKey(String.valueOf(userId)); + + Long current = redisTemplate.opsForValue().increment(userWaitingLimitCountKey, 1); + log.info("유저 웨이팅 제한 카운트 증가 - userId : {}, currentCount : {}", userId, redisTemplate.opsForValue().get(userWaitingLimitCountKey)); + + // TTL 없으면 하루 단위로 묶어야 함 (중요) + // redisTemplate.expireAt(key, RedisKeyUtils.expireAtNext03()); + + if (current != null && current > maxLimit) { + redisTemplate.opsForValue().decrement(userWaitingLimitCountKey, 1); + throw new UserWaitingLimitExceededException(); + } + } + + // 일일 시퀀스 증가 - 예약 번호 전용 + // TODO : 현재 중복 웨이팅 요청에도 시퀀스가 증가하는 문제가 있음 (추후 개선 필요) + public Long incrementDailySequence(String dailySeqKey) { + return redisTemplate.opsForValue().increment(dailySeqKey, 1); + } + + // TODO : 대기 순번 조회 (추후 사용 예정) + public Long getWaitingRank(Long storeId, Long userId) { + String queueKey = RedisKeyUtils.buildWaitingKeyPrefix() + storeId; + return redisTemplate.opsForZSet().rank(queueKey, String.valueOf(userId)); + } + + public Long getWaitingCount(Long storeId) { + String queueKey = RedisKeyUtils.buildWaitingKeyPrefix() + storeId; + Long size = redisTemplate.opsForZSet().zCard(queueKey); + + return size != null ? size : 0L; + } + + // 웨이팅 여부 조회 + // TODO: 구현 필요 + public Boolean isWaiting(Long storeId, Long userId) { + redisTemplate.opsForZSet() + .score( + RedisKeyUtils.buildWaitingKeyPrefix() + storeId, + String.valueOf(userId) + ); + + Boolean isWaiting = true; + + return isWaiting; + } +} + diff --git a/nowait-domain/domain-redis/src/test/java/com/nowait/domaincoreredis/common/cache/DistributedCachedDaoTest.java b/nowait-domain/domain-redis/src/test/java/com/nowait/domaincoreredis/common/cache/DistributedCachedDaoTest.java new file mode 100644 index 00000000..c0f78ace --- /dev/null +++ b/nowait-domain/domain-redis/src/test/java/com/nowait/domaincoreredis/common/cache/DistributedCachedDaoTest.java @@ -0,0 +1,52 @@ +package com.nowait.domaincoreredis.common.cache; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.connection.DefaultMessage; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; + +class DistributedCachedDaoTest { + + static class CountingDao extends DistributedCachedDao { + int loads; + + CountingDao() { + super("cache:test", Duration.ofDays(1), 100, new RedisMessageListenerContainer(), null, + new SimpleMeterRegistry()); + } + + @Override + protected Integer load(String key) { + return ++loads; + } + } + + private static void receive(CountingDao dao, String body) { + dao.onMessage(new DefaultMessage("cache:test".getBytes(StandardCharsets.UTF_8), + body.getBytes(StandardCharsets.UTF_8)), null); + } + + @Test + void 무효화_메시지를_받으면_해당_키만_다시_로딩한다() { + CountingDao dao = new CountingDao(); + assertEquals(1, dao.get("1")); + assertEquals(2, dao.get("2")); + assertEquals(1, dao.get("1")); + + receive(dao, "INVALIDATE-1"); + assertEquals(3, dao.get("1")); + assertEquals(2, dao.get("2")); + + receive(dao, "CLEAR"); + assertEquals(4, dao.get("2")); + + dao.onChannelSubscribed("cache:test".getBytes(StandardCharsets.UTF_8), 1); + assertEquals(5, dao.get("2")); + } +} diff --git a/domain-menu/.gitignore b/nowait-domain/domain-user-rdb/.gitignore similarity index 100% rename from domain-menu/.gitignore rename to nowait-domain/domain-user-rdb/.gitignore diff --git a/security-front/build.gradle b/nowait-domain/domain-user-rdb/build.gradle similarity index 55% rename from security-front/build.gradle rename to nowait-domain/domain-user-rdb/build.gradle index f5d61d33..5a14ae53 100644 --- a/security-front/build.gradle +++ b/nowait-domain/domain-user-rdb/build.gradle @@ -2,6 +2,11 @@ plugins { id 'java-library' } +jar { + enabled = true +} + + group = 'com.nowaiting' version = rootProject.version @@ -16,24 +21,23 @@ repositories { } dependencies { - implementation project(':common') - implementation project(':domain-user') - implementation project(':domain-base') - implementation project(':domain-token') - // SPRING SECURITY - api 'org.springframework.boot:spring-boot-starter-security' - api 'org.springframework.boot:spring-boot-starter-test' - // Lombok (optional) - compileOnly 'org.projectlombok:lombok:1.18.26' - annotationProcessor 'org.projectlombok:lombok:1.18.26' - // jwt - implementation 'io.jsonwebtoken:jjwt-api:0.12.3' - implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' - implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' - // OAUTH2 + implementation project(':nowait-common') + implementation project(':nowait-domain:domain-core-rdb') + + api 'org.springframework.boot:spring-boot-starter-data-jpa' + api 'jakarta.persistence:jakarta.persistence-api:3.1.0' + + // OAuth implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' implementation 'jakarta.servlet:jakarta.servlet-api:6.0.0' + // SWAGGER implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' + + compileOnly 'org.projectlombok:lombok:1.18.26' + annotationProcessor 'org.projectlombok:lombok:1.18.26' + + testImplementation 'org.junit.jupiter:junit-jupiter' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' } diff --git a/domain-bookmark/src/main/java/com/nowait/domainbookmark/entity/Bookmark.java b/nowait-domain/domain-user-rdb/src/main/java/com/nowait/domainuserrdb/bookmark/entity/Bookmark.java similarity index 71% rename from domain-bookmark/src/main/java/com/nowait/domainbookmark/entity/Bookmark.java rename to nowait-domain/domain-user-rdb/src/main/java/com/nowait/domainuserrdb/bookmark/entity/Bookmark.java index 18f69e0c..c36a0dd5 100644 --- a/domain-bookmark/src/main/java/com/nowait/domainbookmark/entity/Bookmark.java +++ b/nowait-domain/domain-user-rdb/src/main/java/com/nowait/domainuserrdb/bookmark/entity/Bookmark.java @@ -1,9 +1,12 @@ -package com.nowait.domainbookmark.entity; +package com.nowait.domainuserrdb.bookmark.entity; -import com.example.domainstore.entity.Store; -import com.nowait.base.BaseTimeEntity; -import com.nowaiting.user.entity.User; +import java.time.LocalDateTime; +import com.nowait.domaincorerdb.base.entity.BaseTimeEntity; +import com.nowait.domaincorerdb.store.entity.Store; +import com.nowait.domaincorerdb.user.entity.User; + +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; @@ -43,4 +46,14 @@ public class Bookmark extends BaseTimeEntity { @JoinColumn(name = "store_id") private Store store; + @Column(nullable = false) + private boolean deleted = false; + + public void softDelete() { + this.deleted = true; + } + + public void restore() { + this.deleted = false; + } } diff --git a/nowait-domain/domain-user-rdb/src/main/java/com/nowait/domainuserrdb/bookmark/exception/AlreadyDeletedBookmarkException.java b/nowait-domain/domain-user-rdb/src/main/java/com/nowait/domainuserrdb/bookmark/exception/AlreadyDeletedBookmarkException.java new file mode 100644 index 00000000..1e48a5e8 --- /dev/null +++ b/nowait-domain/domain-user-rdb/src/main/java/com/nowait/domainuserrdb/bookmark/exception/AlreadyDeletedBookmarkException.java @@ -0,0 +1,7 @@ +package com.nowait.domainuserrdb.bookmark.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class AlreadyDeletedBookmarkException extends RuntimeException { + public AlreadyDeletedBookmarkException() { super(ErrorMessage.ALREADY_DELETED_BOOKMARK.getMessage()); } +} diff --git a/nowait-domain/domain-user-rdb/src/main/java/com/nowait/domainuserrdb/bookmark/exception/BookmarkNotFoundException.java b/nowait-domain/domain-user-rdb/src/main/java/com/nowait/domainuserrdb/bookmark/exception/BookmarkNotFoundException.java new file mode 100644 index 00000000..936296d8 --- /dev/null +++ b/nowait-domain/domain-user-rdb/src/main/java/com/nowait/domainuserrdb/bookmark/exception/BookmarkNotFoundException.java @@ -0,0 +1,9 @@ +package com.nowait.domainuserrdb.bookmark.exception; + +import com.nowait.common.exception.ErrorMessage; + +public class BookmarkNotFoundException extends RuntimeException { + public BookmarkNotFoundException() { + super(ErrorMessage.NOT_FOUND_BOOKMARK.getMessage()); + } +} diff --git a/domain-bookmark/src/main/java/com/nowait/domainbookmark/exception/BookmarkOwnerMismatchException.java b/nowait-domain/domain-user-rdb/src/main/java/com/nowait/domainuserrdb/bookmark/exception/BookmarkOwnerMismatchException.java similarity index 63% rename from domain-bookmark/src/main/java/com/nowait/domainbookmark/exception/BookmarkOwnerMismatchException.java rename to nowait-domain/domain-user-rdb/src/main/java/com/nowait/domainuserrdb/bookmark/exception/BookmarkOwnerMismatchException.java index 733d40d9..0f7cdd9f 100644 --- a/domain-bookmark/src/main/java/com/nowait/domainbookmark/exception/BookmarkOwnerMismatchException.java +++ b/nowait-domain/domain-user-rdb/src/main/java/com/nowait/domainuserrdb/bookmark/exception/BookmarkOwnerMismatchException.java @@ -1,6 +1,6 @@ -package com.nowait.domainbookmark.exception; +package com.nowait.domainuserrdb.bookmark.exception; -import com.nowaiting.common.exception.ErrorMessage; +import com.nowait.common.exception.ErrorMessage; public class BookmarkOwnerMismatchException extends RuntimeException { public BookmarkOwnerMismatchException() { diff --git a/domain-bookmark/src/main/java/com/nowait/domainbookmark/exception/DuplicateBookmarkException.java b/nowait-domain/domain-user-rdb/src/main/java/com/nowait/domainuserrdb/bookmark/exception/DuplicateBookmarkException.java similarity index 62% rename from domain-bookmark/src/main/java/com/nowait/domainbookmark/exception/DuplicateBookmarkException.java rename to nowait-domain/domain-user-rdb/src/main/java/com/nowait/domainuserrdb/bookmark/exception/DuplicateBookmarkException.java index 29e70466..7b9ef268 100644 --- a/domain-bookmark/src/main/java/com/nowait/domainbookmark/exception/DuplicateBookmarkException.java +++ b/nowait-domain/domain-user-rdb/src/main/java/com/nowait/domainuserrdb/bookmark/exception/DuplicateBookmarkException.java @@ -1,6 +1,6 @@ -package com.nowait.domainbookmark.exception; +package com.nowait.domainuserrdb.bookmark.exception; -import com.nowaiting.common.exception.ErrorMessage; +import com.nowait.common.exception.ErrorMessage; public class DuplicateBookmarkException extends RuntimeException { public DuplicateBookmarkException() { diff --git a/nowait-domain/domain-user-rdb/src/main/java/com/nowait/domainuserrdb/bookmark/repository/BookmarkRepository.java b/nowait-domain/domain-user-rdb/src/main/java/com/nowait/domainuserrdb/bookmark/repository/BookmarkRepository.java new file mode 100644 index 00000000..fc9e0a23 --- /dev/null +++ b/nowait-domain/domain-user-rdb/src/main/java/com/nowait/domainuserrdb/bookmark/repository/BookmarkRepository.java @@ -0,0 +1,26 @@ +package com.nowait.domainuserrdb.bookmark.repository; + +import java.util.Collection; +import java.util.List; +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 org.springframework.stereotype.Repository; + +import com.nowait.domaincorerdb.store.entity.Store; +import com.nowait.domaincorerdb.user.entity.User; +import com.nowait.domainuserrdb.bookmark.entity.Bookmark; + +@Repository +public interface BookmarkRepository extends JpaRepository { + + Optional findByUserAndStoreAndDeletedFalse(User user, Store store); + + Optional findByUserAndStore(User user, Store store); + + Collection findAllByUserAndDeletedFalse(User user); + + List findStoreIdByUserAndDeletedFalse(User user); +} diff --git a/security-front/src/main/java/com/nowait/auth/dto/CustomOAuth2User.java b/nowait-domain/domain-user-rdb/src/main/java/com/nowait/domainuserrdb/oauth/dto/CustomOAuth2User.java similarity index 80% rename from security-front/src/main/java/com/nowait/auth/dto/CustomOAuth2User.java rename to nowait-domain/domain-user-rdb/src/main/java/com/nowait/domainuserrdb/oauth/dto/CustomOAuth2User.java index 91eaaea9..544c91c7 100644 --- a/security-front/src/main/java/com/nowait/auth/dto/CustomOAuth2User.java +++ b/nowait-domain/domain-user-rdb/src/main/java/com/nowait/domainuserrdb/oauth/dto/CustomOAuth2User.java @@ -1,4 +1,4 @@ -package com.nowait.auth.dto; +package com.nowait.domainuserrdb.oauth.dto; import java.util.ArrayList; import java.util.Collection; @@ -7,18 +7,16 @@ import org.springframework.security.core.GrantedAuthority; import org.springframework.security.oauth2.core.user.OAuth2User; -import com.nowaiting.user.entity.User; +import com.nowait.domaincorerdb.user.entity.User; +import lombok.Getter; import lombok.RequiredArgsConstructor; @RequiredArgsConstructor +@Getter public class CustomOAuth2User implements OAuth2User { - private User user; - - // User 객체를 받는 생성자 - public CustomOAuth2User(User user) { - this.user = user; - } + private final User user; + private final boolean newUser; @Override public Map getAttributes() { @@ -40,6 +38,12 @@ public String getAuthority() { return authorities; } + // JWT 인증 시 (항상 기존 유저 취급) + public CustomOAuth2User(User user) { + this.user = user; + this.newUser = false; + } + @Override public String getName() { return user.getEmail(); diff --git a/domain-order/build.gradle b/nowait-event/build.gradle similarity index 56% rename from domain-order/build.gradle rename to nowait-event/build.gradle index 263db1df..f802a2ff 100644 --- a/domain-order/build.gradle +++ b/nowait-event/build.gradle @@ -1,8 +1,15 @@ +import org.gradle.jvm.toolchain.JavaLanguageVersion + plugins { id 'java-library' } -group = 'com.nowaiting' +jar { + enabled = true +} + + +group = 'com.nowait' version = rootProject.version java { @@ -16,14 +23,16 @@ repositories { } dependencies { - implementation project(':common') - implementation project(':domain-base') - implementation project(':domain-store') - implementation project(':domain-menu') + implementation project(':nowait-common') + implementation project(':nowait-domain:domain-core-rdb') + implementation project(':nowait-domain:domain-admin-rdb') api 'org.springframework.boot:spring-boot-starter-data-jpa' api 'jakarta.persistence:jakarta.persistence-api:3.1.0' + implementation 'org.springframework.boot:spring-boot-starter-validation' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + compileOnly 'org.projectlombok:lombok:1.18.26' annotationProcessor 'org.projectlombok:lombok:1.18.26' diff --git a/nowait-event/src/main/java/com/nowait/nowaitevent/order/event/CookingCompleteEvent.java b/nowait-event/src/main/java/com/nowait/nowaitevent/order/event/CookingCompleteEvent.java new file mode 100644 index 00000000..4db8b75d --- /dev/null +++ b/nowait-event/src/main/java/com/nowait/nowaitevent/order/event/CookingCompleteEvent.java @@ -0,0 +1,29 @@ +package com.nowait.nowaitevent.order.event; + +import java.util.List; + +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class CookingCompleteEvent { + + @NotNull + private final Long storeId; + + @NotNull + private final List items; + + @Getter + @RequiredArgsConstructor + public static class Item { + + @NotNull + private final Long menuId; + + @NotNull + private final int quantity; + } +} diff --git a/nowait-event/src/main/java/com/nowait/nowaitevent/order/event/OrderCancelledEvent.java b/nowait-event/src/main/java/com/nowait/nowaitevent/order/event/OrderCancelledEvent.java new file mode 100644 index 00000000..117655e6 --- /dev/null +++ b/nowait-event/src/main/java/com/nowait/nowaitevent/order/event/OrderCancelledEvent.java @@ -0,0 +1,16 @@ +package com.nowait.nowaitevent.order.event; + +import java.time.Instant; + +import com.nowait.domainadminrdb.cancelOrder.entity.CancelReason; + +import lombok.Value; + +@Value +public class OrderCancelledEvent { + Long orderId; + Long storeId; + String orderSignature; + CancelReason reason; + Instant cancelAt; +} diff --git a/common/.gitattributes b/nowait-infra/.gitattributes similarity index 100% rename from common/.gitattributes rename to nowait-infra/.gitattributes diff --git a/domain-order/.gitignore b/nowait-infra/.gitignore similarity index 100% rename from domain-order/.gitignore rename to nowait-infra/.gitignore diff --git a/infra-aws/build.gradle b/nowait-infra/build.gradle similarity index 67% rename from infra-aws/build.gradle rename to nowait-infra/build.gradle index c4a109cf..ce38b13d 100644 --- a/infra-aws/build.gradle +++ b/nowait-infra/build.gradle @@ -2,6 +2,11 @@ plugins { id 'java' } +jar { + enabled = true +} + + group = 'com.nowaiting' version = rootProject.version @@ -15,17 +20,36 @@ repositories { mavenCentral() } +ext { + set('springCloudVersion', "2025.0.0") +} + +dependencyManagement { + imports { + mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" + } +} + + dependencies { // Multipart File Upload // 추후 분리 필요 implementation 'org.springframework.boot:spring-boot-starter-web' + + // 비동기 실행 + implementation 'org.springframework.boot:spring-boot-starter-aop' + // S3 implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + // Resilience4j implementation 'io.github.resilience4j:resilience4j-spring-boot3:2.2.0' implementation 'io.github.resilience4j:resilience4j-bulkhead:2.3.0' - // 비동기 실행 - implementation 'org.springframework.boot:spring-boot-starter-aop' + + //feign + implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' + implementation group: 'io.github.openfeign', name: 'feign-gson', version: '11.0' + // lombok compileOnly 'org.projectlombok:lombok:1.18.30' annotationProcessor 'org.projectlombok:lombok:1.18.30' diff --git a/nowait-infra/src/main/java/com/nowait/discord/client/DiscordClientAdmin.java b/nowait-infra/src/main/java/com/nowait/discord/client/DiscordClientAdmin.java new file mode 100644 index 00000000..c7504eb0 --- /dev/null +++ b/nowait-infra/src/main/java/com/nowait/discord/client/DiscordClientAdmin.java @@ -0,0 +1,18 @@ +package com.nowait.discord.client; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +import com.nowait.discord.config.DiscordFeignConfiguration; +import com.nowait.discord.dto.DiscordMessage; + +@FeignClient( + name = "discordClientAdmin", + contextId = "discordClientAdmin", + url = "${discord.webhook.admin-url}", + configuration = DiscordFeignConfiguration.class) +public interface DiscordClientAdmin { + @PostMapping() + void sendAlarm(@RequestBody DiscordMessage message); +} diff --git a/nowait-infra/src/main/java/com/nowait/discord/client/DiscordClientUser.java b/nowait-infra/src/main/java/com/nowait/discord/client/DiscordClientUser.java new file mode 100644 index 00000000..79238c4c --- /dev/null +++ b/nowait-infra/src/main/java/com/nowait/discord/client/DiscordClientUser.java @@ -0,0 +1,18 @@ +package com.nowait.discord.client; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +import com.nowait.discord.config.DiscordFeignConfiguration; +import com.nowait.discord.dto.DiscordMessage; + +@FeignClient( + name = "discordClientUser", + contextId = "discordClientUser", + url = "${discord.webhook.user-url}", + configuration = DiscordFeignConfiguration.class) +public interface DiscordClientUser { + @PostMapping() + void sendAlarm(@RequestBody DiscordMessage message); +} diff --git a/nowait-infra/src/main/java/com/nowait/discord/config/DiscordFeignConfiguration.java b/nowait-infra/src/main/java/com/nowait/discord/config/DiscordFeignConfiguration.java new file mode 100644 index 00000000..2c16cf7a --- /dev/null +++ b/nowait-infra/src/main/java/com/nowait/discord/config/DiscordFeignConfiguration.java @@ -0,0 +1,14 @@ +package com.nowait.discord.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import feign.Logger; + +@Configuration +public class DiscordFeignConfiguration { + @Bean + Logger.Level feignLoggerLevel(){ + return Logger.Level.FULL; + } +} diff --git a/nowait-infra/src/main/java/com/nowait/discord/dto/DiscordMessage.java b/nowait-infra/src/main/java/com/nowait/discord/dto/DiscordMessage.java new file mode 100644 index 00000000..8d3bd13c --- /dev/null +++ b/nowait-infra/src/main/java/com/nowait/discord/dto/DiscordMessage.java @@ -0,0 +1,29 @@ +package com.nowait.discord.dto; + +import java.util.List; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Builder +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class DiscordMessage { + + private String content; + private List embeds; + + @Builder + @AllArgsConstructor(access = AccessLevel.PROTECTED) + @NoArgsConstructor(access = AccessLevel.PROTECTED) + @Getter + public static class Embed { + + private String title; + private String description; + } +} diff --git a/nowait-infra/src/main/java/com/nowait/discord/service/DiscordAlarmService.java b/nowait-infra/src/main/java/com/nowait/discord/service/DiscordAlarmService.java new file mode 100644 index 00000000..b94499dc --- /dev/null +++ b/nowait-infra/src/main/java/com/nowait/discord/service/DiscordAlarmService.java @@ -0,0 +1,79 @@ +package com.nowait.discord.service; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Arrays; +import java.util.List; + +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Service; +import org.springframework.web.context.request.ServletWebRequest; +import org.springframework.web.context.request.WebRequest; + +import com.nowait.discord.client.DiscordClientAdmin; +import com.nowait.discord.dto.DiscordMessage; +import com.nowait.discord.client.DiscordClientUser; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@Slf4j +@RequiredArgsConstructor +public class DiscordAlarmService { + + private final DiscordClientUser discordClientUser; + private final DiscordClientAdmin discordClientAdmin; + private final Environment environment; + + public void sendDiscordUserAlarm(Exception e, WebRequest request) { + if (shouldNotifyUser()) { + discordClientUser.sendAlarm(createMessage(e, request)); + } + } + + public void sendDiscordAdminAlarm(Exception e, WebRequest request) { + if (shouldNotifyAdmin()) { + discordClientAdmin.sendAlarm(createMessage(e, request)); + } + } + + private boolean shouldNotifyAdmin() { + return Arrays.stream(environment.getActiveProfiles()).noneMatch("admin-local"::equals); + } + + private boolean shouldNotifyUser() { + return Arrays.stream(environment.getActiveProfiles()).noneMatch("user-local"::equals); + } + + private DiscordMessage createMessage(Exception e, WebRequest request) { + return DiscordMessage.builder() + .content("# 🚨 에러 발생 비이이이이사아아아앙") + .embeds(List.of(DiscordMessage.Embed.builder() + .title("ℹ️ 에러 정보") + .description( + "### 🕖 발생 시간\n" + LocalDateTime.now(ZoneId.of("Asia/Seoul")) + "\n" + "### 🔗 요청 URL\n" + createRequestFullPath(request) + + "\n" + "### 📄 Stack Trace\n" + "```\n" + getStackTrace(e).substring(0, 1000) + "\n```") + .build())) + .build(); + } + + private String createRequestFullPath(WebRequest webRequest) { + HttpServletRequest request = ((ServletWebRequest)webRequest).getRequest(); + String fullPath = request.getMethod() + " " + request.getRequestURL(); + String queryString = request.getQueryString(); + if (queryString != null) { + fullPath += "?" + queryString; + } + return fullPath; + } + + private String getStackTrace(Exception e) { + StringWriter stringWriter = new StringWriter(); + e.printStackTrace(new PrintWriter(stringWriter)); + return stringWriter.toString(); + } +} diff --git a/infra-aws/src/main/java/com/example/infrastorage/s3/S3Service.java b/nowait-infra/src/main/java/com/nowait/infraaws/aws/s3/S3Service.java similarity index 64% rename from infra-aws/src/main/java/com/example/infrastorage/s3/S3Service.java rename to nowait-infra/src/main/java/com/nowait/infraaws/aws/s3/S3Service.java index 20d9abf0..604d06b4 100644 --- a/infra-aws/src/main/java/com/example/infrastorage/s3/S3Service.java +++ b/nowait-infra/src/main/java/com/nowait/infraaws/aws/s3/S3Service.java @@ -1,4 +1,4 @@ -package com.example.infrastorage.s3; +package com.nowait.infraaws.aws.s3; import java.io.InputStream; import java.util.UUID; @@ -21,31 +21,40 @@ public class S3Service { private final AmazonS3Client amazonS3Client; @Value("${cloud.aws.s3.bucket}") - private String bucket; + private String originalBucket; - public record S3UploadResult(String key, String url) { + @Value("${cloud.aws.s3.resize-bucket}") + private String resizeBucket; + + public record S3UploadResult(String key, String originalUrl, String resizedUrl) { } @Bulkhead(name = "s3UploadBulkhead", type = Bulkhead.Type.THREADPOOL) @Async("s3UploadExecutor") - public CompletableFuture upload(String type, Long refId, MultipartFile file) { // TODO MultipartFile 분리 필요 (Spring에 의존하면 안 됨) + public CompletableFuture upload(String type, Long refId, + MultipartFile file) { // TODO MultipartFile 분리 필요 (Spring에 의존하면 안 됨) try (InputStream inputStream = file.getInputStream()) { String key = createFileKey(type, refId, file.getOriginalFilename()); ObjectMetadata metadata = new ObjectMetadata(); metadata.setContentLength(file.getSize()); - amazonS3Client.putObject(bucket, key, inputStream, metadata); - String url = amazonS3Client.getUrl(bucket, key).toString(); + // 1) 원본 버킷에 이미지 업로드 + amazonS3Client.putObject(originalBucket, key, inputStream, metadata); + + // 2) 각 버킷의 URL 생성 + String originalUrl = amazonS3Client.getUrl(originalBucket, key).toString(); + String resizedUrl = amazonS3Client.getUrl(resizeBucket, key).toString(); - return CompletableFuture.completedFuture(new S3UploadResult(key, url)); + return CompletableFuture.completedFuture(new S3UploadResult(key, originalUrl, resizedUrl)); } catch (Exception e) { throw new RuntimeException("S3 업로드 실패", e); } } - public void delete(String filename) { + public void delete(String key) { try { - amazonS3Client.deleteObject(bucket, filename); + amazonS3Client.deleteObject(originalBucket, key); + amazonS3Client.deleteObject(resizeBucket, key); } catch (Exception e) { throw new RuntimeException("S3 파일 삭제 실패", e); } diff --git a/application-config/src/main/java/com/example/applicationconfig/config/AsyncConfig.java b/nowait-infra/src/main/java/com/nowait/infraaws/config/AsyncConfig.java similarity index 92% rename from application-config/src/main/java/com/example/applicationconfig/config/AsyncConfig.java rename to nowait-infra/src/main/java/com/nowait/infraaws/config/AsyncConfig.java index 9c839fbb..78ea0dbe 100644 --- a/application-config/src/main/java/com/example/applicationconfig/config/AsyncConfig.java +++ b/nowait-infra/src/main/java/com/nowait/infraaws/config/AsyncConfig.java @@ -1,4 +1,4 @@ -package com.example.applicationconfig.config; +package com.nowait.infraaws.config; import java.util.concurrent.Executor; diff --git a/infra-aws/src/main/java/com/example/infrastorage/config/AwsS3Config.java b/nowait-infra/src/main/java/com/nowait/infraaws/config/AwsS3Config.java similarity index 95% rename from infra-aws/src/main/java/com/example/infrastorage/config/AwsS3Config.java rename to nowait-infra/src/main/java/com/nowait/infraaws/config/AwsS3Config.java index 4eda87c2..e0470e0e 100644 --- a/infra-aws/src/main/java/com/example/infrastorage/config/AwsS3Config.java +++ b/nowait-infra/src/main/java/com/nowait/infraaws/config/AwsS3Config.java @@ -1,4 +1,4 @@ -package com.example.infrastorage.config; +package com.nowait.infraaws.config; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; diff --git a/performance/menu-cache/01-join.md b/performance/menu-cache/01-join.md new file mode 100644 index 00000000..e0c3ca73 --- /dev/null +++ b/performance/menu-cache/01-join.md @@ -0,0 +1,110 @@ +# 1단계: 메뉴 목록 조회의 N+1 을 JOIN 한 번으로 + +실행 원본: [EC2](results/2026-10-26-ec2.md), [Mac](results/2026-10-26-mac.md) + +## 1. 질문 + +기존 손님 메뉴 목록 조회(user-api `MenuService.getAllMenusByStoreId`, 커밋 `d20c593`)는 메뉴마다 이미지를 따로 조회했다. admin-api 의 같은 이름 메서드도 같은 모양이다. + +```java +List menus = menuRepository.findAllByStoreIdAndDeletedFalseOrderBySortOrder(storeId); // 1번 +menus.stream().map(menu -> { + List images = menuImageRepository.findByMenu(menu); // 메뉴마다 1번 + ... +``` + +- 요청당 쿼리 = 주점 1 + 메뉴 1 + 이미지 N. 메뉴 20개면 22개 +- JPA 지연 로딩 N+1 이 아니라, 리포지토리를 루프에서 부르는 N+1 이다 +- 연관관계가 `MenuImage → Menu` 단방향 `@ManyToOne` 뿐이라 `Menu` 쪽에서 이미지를 fetch join 할 수 없다 + +질문 +- JOIN 한 번으로 바꿔도 응답이 기존과 같은가? (이미지 없는 메뉴, 이미지 여러 장, 정렬) +- 쿼리가 22개 → 2개가 되면 MySQL 한계가 얼마나 오르는가? + +## 2. 바꾼 것 + +`MenuRepository` 에 JOIN 쿼리와, 결과 행을 메뉴별로 묶는 default 메서드를 넣었다. user(`DistributedCachedMenuDao.load`)와 admin(`MenuService.getAllMenusByStoreId`) 둘 다 이것을 쓴다. + +```java +// 이미지가 아직 없는 메뉴도 보여야 하므로 LEFT JOIN (행: [Menu, MenuImage 또는 null]) +@Query(value = """ + SELECT m, i + FROM Menu m + LEFT JOIN MenuImage i ON i.menu = m + WHERE m.storeId = :storeId + AND m.deleted = false + ORDER BY m.sortOrder, i.id + """) +List findAllWithImageRowsByStoreId(@Param("storeId") Long storeId); + +default Map> findAllWithImagesByStoreId(Long storeId) { + return findAllWithImageRowsByStoreId(storeId).stream() + .collect(Collectors.groupingBy(row -> (Menu)row[0], LinkedHashMap::new, + Collectors.flatMapping(row -> Stream.ofNullable((MenuImage)row[1]), Collectors.toList()))); +} +``` + +- INNER JOIN 이 아니라 LEFT JOIN: admin 에서 메뉴 생성과 이미지 업로드가 따로라, 이미지가 아직 없는 메뉴가 있을 수 있다. INNER 면 손님 목록에서 사라진다 +- `LinkedHashMap` 으로 `sortOrder` 순서를 지키고, 이미지가 없는 메뉴는 빈 목록이 된다 +- `ORDER BY i.id`: 기존 `findByMenu` 가 돌려주던 이미지 순서(id 순)와 맞춘다 + +``` +기준 주점 SELECT → 메뉴 SELECT → 이미지 SELECT × 20 (+ 트랜잭션 설정 문장 약 5개) +1단계 주점 SELECT → 메뉴 LEFT JOIN 이미지 SELECT 1번 +``` + +이 단계의 user-api(`user-tobe.jar`)는 조회 트랜잭션도 없다(3단계에서 넣은 변경, [3단계 2절](03-cache.md#2-바꾼-것)). 기준 코드는 `@Transactional(readOnly = true)` 라 요청마다 `SET autocommit`, `SET SESSION TRANSACTION READ ONLY / READ WRITE`, `COMMIT` 이 더 나갔다. 이 단계의 차이에는 그것도 섞여 있다. 크기는 4절에서 나눠 본다. + +## 3. 실행 명령 + +```bash +performance/menu-cache/run.sh verify +RATES="100 200 400" performance/menu-cache/run.sh bench nplus1 +RATES="200 500 1000" performance/menu-cache/run.sh bench join # 캐시 끔(ttl 0s), 인덱스 지운 상태 +``` + +## 4. 관찰 결과 + +**응답은 기존과 같다.** 기존 jar 와 바꾼 jar 를 동시에 띄워 200개 주점 응답을 비교했다. user·admin 모두 200/200 같았다(EC2, Mac 둘 다). admin 목록 200회의 메뉴 관련 쿼리는 4,200(menus 200 + menu_images 4,000) → 200 이다. + +**요청당 쿼리 시간** (EC2, `performance_schema` 의 누적 실행 시간 ÷ 요청 수. 기준은 100 req/s, 1단계는 200 req/s) + +| 문장 | 기준 N+1 | 1단계 JOIN | +|---|---:|---:| +| 이미지 SELECT × 20 (`menu_images`) | 2.02ms | - | +| 메뉴 SELECT / 메뉴·이미지 JOIN (`menus`) | 1.88ms | 2.09ms | +| 주점 SELECT (`stores`) | 0.12ms | 0.14ms | +| 그 밖의 문장 (트랜잭션 설정 등) | 0.23ms | 0.03ms (admin 쪽) | +| 합계 | 4.25ms | 2.26ms | + +- 줄어든 약 2ms 의 대부분은 이미지 SELECT 20번(2.02ms)이다. 트랜잭션 설정 문장(요청당 약 5개)은 0.2ms 정도였다 +- `menus` 는 두 단계 모두 요청마다 약 4,000행을 읽었다(메뉴 테이블 전체 스캔). 이것이 2단계의 주제다 + +**같은 부하에서 (EC2)** + +| 목표 req/s | 단계 | 처리 req/s | p50 | p95 | p99 | 못 보낸 요청 | 요청당 쿼리 | MySQL CPU | user-api CPU | +|---:|---|---:|---:|---:|---:|---:|---:|---:|---:| +| 100 | N+1 | 100 | 9.3ms | 10.0ms | 11.4ms | 0 | 22.01 | 0.47 | 0.48 | +| 200 | N+1 | 187 | 1,492ms | 2,502ms | 3,157ms | 285 | 22.00 | 0.99 | 0.84 | +| 400 | N+1 | 187 | 10,625ms | 10,749ms | 11,704ms | 10,778 | 22.00 | 0.99 | 0.80 | +| 200 | JOIN | 200 | 3.5ms | 4.2ms | 4.7ms | 0 | 2.01 | 0.46 | 0.43 | +| 500 | JOIN | 499 | 74.2ms | 129.5ms | 160.6ms | 0 | 1.84 | 0.99 | 0.54 | +| 1000 | JOIN | 658 | 2,893ms | 3,170ms | 3,323ms | 18,517 | 1.62 | 0.99 | 0.66 | + +- N+1 은 200 req/s 에서 이미 무너졌다. MySQL 이 1코어를 다 쓰고 187 req/s 에서 멈췄다 +- JOIN 은 200 req/s 에서 MySQL 0.46코어로 여유가 있고, 500 에서 1코어가 찼다 +- p95 200ms 안에서 처리한 최대 부하: 100 → 500 +- JOIN 500·1000 은 캐시를 껐는데도 히트율이 16%·38% 로 찍혔다. TTL 0s 라도 같은 주점에 동시에 온 요청은 로딩 한 번을 나눠 받기 때문이다(Caffeine 키 단위 로딩, [3단계 4절](03-cache.md#4-관찰-결과)). 과부하 구간의 JOIN 수치는 그만큼 유리하다 + +Mac 에서도 순서는 같았다. N+1 은 200 req/s 를 넣어도 116 req/s, JOIN 은 200 req/s 를 p95 7.4ms 로 처리했다. + +## 5. 보장 범위와 해결하지 못한 문제 + +- 응답이 같다는 비교는 시드 데이터(이미지 0·1·2장) 기준이다. 삭제된 메뉴 제외는 `MenuRepositoryTest` 로만 확인했다 +- 이미지 순서를 위해 `ORDER BY i.id` 를 넣어 실행 계획에 `Using temporary; Using filesort` 가 남는다. 20행 정렬이라 무시했다 +- 이 단계의 차이에는 조회 트랜잭션 제거가 섞여 있다. 둘을 나눠 재지 않고, 쿼리 시간으로 크기만 나눠 봤다 +- 결과를 `Object[]` 행으로 받아 Java 에서 묶는다. `Menu` 에 `@OneToMany images` 를 두고 fetch join 하는 방법은 두 앱이 함께 쓰는 엔티티를 바꿔야 해서 택하지 않았다 + +## 6. 다음에 볼 것 + +- 남은 요청당 약 2ms 의 대부분이 `menus` 전체 스캔이다 → 2단계 인덱스 diff --git a/performance/menu-cache/02-index.md b/performance/menu-cache/02-index.md new file mode 100644 index 00000000..9e3a4ac3 --- /dev/null +++ b/performance/menu-cache/02-index.md @@ -0,0 +1,85 @@ +# 2단계: menus 에 (store_id, deleted, sort_order) 인덱스 + +실행 원본: [EC2](results/2026-10-26-ec2.md), [Mac](results/2026-10-26-mac.md) + +## 1. 질문 + +1단계 JOIN 쿼리는 요청마다 `menus` 를 약 4,000행 읽었다. `menus.store_id` 에 인덱스가 없었기 때문이다. + +- `Menu.storeId` 는 연관관계가 아니라 `Long` 컬럼이라 FK 가 없고, InnoDB 가 FK 때문에 자동으로 만드는 인덱스도 없다 +- 다른 쪽은 이미 인덱스가 있다: `menu_images.menu_id` 는 FK 인덱스, `stores.public_code` 는 unique 인덱스 + +질문 +- 인덱스로 요청당 읽는 행과 MySQL CPU 가 얼마나 줄고, 한계가 얼마나 오르는가? +- 운영 DB 에 별도 마이그레이션 없이 만들 수 있는가? + +## 2. 바꾼 것 + +```java +// 주점별 메뉴 조회(WHERE store_id = ? AND deleted = ? ORDER BY sort_order)가 전체 스캔·filesort 없이 끝나게 한다 +@Table(name = "menus", indexes = @Index(name = "idx_menus_store_id_deleted_sort_order", + columnList = "store_id, deleted, sort_order")) +public class Menu extends BaseTimeEntity { +``` + +- 같다(`=`)로 거르는 컬럼 둘을 앞에, 정렬 컬럼을 뒤에 뒀다. `deleted` 는 값 종류가 적지만 앞에 같다 조건으로 있어야 `sort_order` 순서로 바로 읽힌다 +- 두 앱 모두 `ddl-auto: update` 라 배포 때 Hibernate 가 만든다. EC2·Mac 모두 앱 시작 후 생성된 것을 확인했다(`idx_menus_store_id_deleted_sort_order(store_id, deleted, sort_order)`) +- 측정에서는 `run.sh` 가 단계에 맞게 인덱스를 지우거나 다시 만든다(앱이 시작할 때 만들기 때문) + +**실행 계획** (Mac, 같은 시드 데이터) + +``` +EXPLAIN SELECT m.*, i.* FROM menus m LEFT JOIN menu_images i ON i.menu_id = m.id + WHERE m.store_id = 7 AND m.deleted = 0 ORDER BY m.sort_order, i.id + +인덱스 없음 m: type ALL, rows 3978, filtered 5.00, Using where; Using temporary; Using filesort + i: type ref, key FK(menu_id), rows 1 +인덱스 있음 m: type ref, key idx_menus_store_id_deleted_sort_order, ref const,const, rows 20, Using temporary; Using filesort + i: type ref, key FK(menu_id), rows 1 +``` + +`Using filesort` 는 두 번째 테이블 컬럼(`i.id`)으로도 정렬해서 남는다. 20행 정렬이다. + +## 3. 실행 명령 + +```bash +RATES="200 1000 2000 3000 4000" performance/menu-cache/run.sh bench join-idx # 캐시 끔(ttl 0s), 인덱스 있음 +``` + +## 4. 관찰 결과 + +**읽는 행이 4,042 → 62 로 줄었다 (EC2, 200 req/s)** + +| | 1단계 JOIN | 2단계 + 인덱스 | +|---|---:|---:| +| 요청당 읽은 행 | 4,042 | 62 | +| 요청당 쿼리 시간: `menus` (JOIN) | 2.09ms | 0.32ms | +| 요청당 쿼리 시간: `stores` | 0.14ms | 0.15ms | +| MySQL CPU | 0.46코어 | 0.11코어 | +| p95 | 4.2ms | 2.4ms | + +**속도별 (EC2)** + +| 목표 req/s | 처리 req/s | p50 | p95 | p99 | 못 보낸 요청 | 요청당 읽은 행 | MySQL CPU | user-api CPU | +|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| 200 | 200 | 1.8ms | 2.4ms | 2.8ms | 0 | 62 | 0.11 | 0.41 | +| 1000 | 998 | 1.6ms | 1.8ms | 2.6ms | 0 | 62 | 0.50 | 0.92 | +| 2000 | 1,995 | 13.4ms | 57.6ms | 74.5ms | 0 | 56 | 0.99 | 2.00 | +| 3000 | 2,356 | 799ms | 937ms | 1,067ms | 36,353 | 44 | 0.99 | 2.21 | +| 4000 | 2,333 | 822ms | 976ms | 1,100ms | 97,755 | 44 | 0.99 | 2.25 | + +- p95 200ms 안에서 처리한 최대 부하: 500 → 2,000 (약 4배). 2,000 에서 MySQL 이 1코어를 다 썼고, 3,000 을 넣으면 2,356 에서 멈췄다 +- 요청당 MySQL CPU: 2.3ms (200 req/s) → 0.50ms (1,000 req/s) +- 3,000·4,000 에서 요청당 읽은 행이 44 로 줄어든 것은 동시에 온 같은 주점 요청이 로딩을 나눠 받아서다(히트율 29.7%·29.6%) + +Mac 에서도 한계가 비슷하게 올랐다. 1,000 req/s 를 p95 4.6ms 로 처리했고, 3,000 을 넣으면 2,205 에서 멈췄다. + +## 5. 보장 범위와 해결하지 못한 문제 + +- 인덱스 이름과 컬럼이 엔티티(`Menu`)와 `run.sh` 두 곳에 적혀 있다. 한쪽만 바꾸면 측정이 어긋난다 +- 운영 테이블에 인덱스를 만드는 동안의 잠금은 확인하지 않았다. MySQL 8 의 보조 인덱스 생성은 온라인 DDL 이고, `menus` 는 작은 테이블이다 +- `Using temporary; Using filesort` 가 남는다(2절) + +## 6. 다음에 볼 것 + +- 남은 요청당 쿼리 시간은 `menus` 0.32ms + `stores` 0.15ms 다. 거의 바뀌지 않는 같은 데이터를 요청마다 다시 읽는다 → 3단계 캐시 diff --git a/performance/menu-cache/03-cache.md b/performance/menu-cache/03-cache.md new file mode 100644 index 00000000..4927e3bb --- /dev/null +++ b/performance/menu-cache/03-cache.md @@ -0,0 +1,148 @@ +# 3단계: 로컬 캐시 + Redis Pub/Sub 무효화 + +실행 원본: [EC2](results/2026-10-26-ec2.md), [Mac](results/2026-10-26-mac.md) + +## 1. 질문 + +2단계 뒤에도 손님 메뉴 조회는 요청마다 DB 에 갔다(주점 SELECT + 메뉴·이미지 JOIN). 2,000 req/s 에서 MySQL 이 1코어를 다 썼다. 메뉴는 거의 바뀌지 않는데 같은 데이터를 매번 다시 읽는다. + +캐시를 붙이는 데 걸리는 제약 +- **쓰는 서버와 읽는 서버가 다르다.** 메뉴·품절은 admin-api 가 바꾸고 user-api 가 읽는다. user-api 에 로컬 캐시만 두면 admin-api 의 변경을 모른다 +- **품절은 바로 보여야 한다.** 짧은 TTL 은 히트율을 떨어뜨리고, 긴 TTL 은 품절된 메뉴를 그동안 보여 준다 +- **Redis 는 웨이팅 대기열과 함께 쓴다.** 조회마다 Redis 를 거치는 전역 캐시는 대기열과 자원을 나눠 쓴다 + +채널톡 기술 블로그의 Distributed Cache 도입기([1](https://tech.channel.io/kr/articles/tech-distributed-cache-1-67a392c5), [2](https://docs.channel.io/tech-blog/ko/articles/tech-distributed-cache-2-f77c0868), [3](https://docs.channel.io/tech-blog/ko/articles/tech-cache-rdb-4cc0bbf4))는 같은 문제를 로컬 캐시 + Redis Pub/Sub 키 무효화로 풀었다. 짧은 TTL 의 로컬 캐시와 Redis 전역 캐시(단일 노드 CPU 70%, 롤백)를 거친 뒤다. 그 구조를 그대로 가져왔다. + +질문 +- 캐시를 켜면 DB 쿼리와 MySQL CPU 가 얼마나 줄고, 한계가 얼마나 오르는가? +- 부하 중에 품절을 바꾸면 손님 응답에 바로 반영되는가? +- 무효화 직후 같은 주점 요청이 몰리면 DB 로 새는가? + +## 2. 바꾼 것 + +``` +[조회] 손님 → user-api → 로컬 캐시(Caffeine) 히트 → 응답 + └ 미스 → MySQL 에서 읽어 캐시에 넣음 (같은 키 동시 미스는 한 번만 읽음) + +[변경] 점주 → admin-api → MySQL 커밋 + └ 커밋 후 → Redis PUBLISH cache:menus "INVALIDATE-{storeId}" + └ 구독 중인 모든 user-api 가 그 주점 키만 지움 +``` + +| 파일 | 하는 일 | +|---|---| +| `domain-redis` `DistributedCachedDao` | Caffeine 캐시 + 채널 구독. `get(key)` = `cache.get(key, this::load)`, 메시지를 받으면 키를 지운다 | +| `domain-redis` `CacheInvalidationPublisher` | `INVALIDATE-{key}` / `CLEAR` 를 커밋 후 발행 | +| user-api `DistributedCachedMenuDao` | 키 = storeId, 값 = 주점 메뉴 목록. TTL 1일(`nowait.cache.menu.ttl`, 0s 면 끔), 최대 1,000개 | +| admin-api `MenuService`·`MenuImageService` | 무효화 7곳: 메뉴 생성·수정·순서 변경·삭제·품절 토글, 이미지 업로드·삭제 | + +- 채널톡 구현처럼 Redis 에는 데이터가 아니라 키만 보낸다. 캐시에 값을 넣는(put) 경로는 없고, 지운 뒤 다음 조회가 DB 에서 다시 읽는다 +- Redis 에는 메뉴가 바뀔 때만 메시지가 오간다. 조회 경로는 Redis 를 거치지 않는다 + +```java +public V get(String key) { + return cache.get(key, this::load); +} + +@Override +public void onMessage(Message message, byte[] pattern) { + String body = new String(message.getBody(), StandardCharsets.UTF_8); + if (CacheInvalidationPublisher.CLEAR.equals(body)) { + cache.invalidateAll(); + } else if (body.startsWith(CacheInvalidationPublisher.INVALIDATE_PREFIX)) { + cache.invalidate(body.substring(CacheInvalidationPublisher.INVALIDATE_PREFIX.length())); + } +} +``` + +**정합성을 지키는 장치 세 가지** + +1. **커밋 후에만 발행한다.** 커밋 전에 지우면, 그사이 들어온 조회가 커밋 전 값을 다시 읽어 TTL 동안 캐시한다. `TransactionSynchronization.afterCommit` 에서 발행한다 +2. **재구독하면 전부 비운다.** Pub/Sub 은 연결이 끊긴 동안의 메시지를 다시 주지 않는다. `onChannelSubscribed`(처음 구독할 때와 끊겼다 다시 구독할 때 모두 불린다)에서 `invalidateAll()` +3. **조회 트랜잭션을 없앴다.** user-api 조회에 `@Transactional(readOnly = true)` 가 있으면 주점 조회 때 잡힌 스냅샷(REPEATABLE READ) 안에서 캐시 미스 로딩이 일어난다. 무효화 직후에도 커밋 전 메뉴를 읽어 하루 동안 캐시할 수 있다. 쿼리마다 따로 커밋된 최신 값을 읽게 했다 + +AS-IS(2단계)와 TO-BE(3단계)는 같은 jar 에서 TTL 설정만 다르다(`0s` / `1d`). 두 단계의 차이는 캐시 하나다. + +## 3. 실행 명령 + +```bash +RATES="200 1000 2000 3000 4000" performance/menu-cache/run.sh bench join-idx # AS-IS: 캐시 끔 +RATES="200 1000 2000 4000 6000" performance/menu-cache/run.sh bench cache # TO-BE +performance/menu-cache/run.sh delay # 400 req/s 부하 중 품절 토글 30회 → 손님 응답 반영 확인 (probe.sh) +``` + +## 4. 관찰 결과 + +**DB 부하 (EC2, 1,000 req/s 60초)** + +| | AS-IS | TO-BE | 감소 | +|---|---:|---:|---:| +| 메뉴 쿼리 (`menus`, 메뉴·이미지 JOIN) | 59,808 | 123 | 99.8% | +| 주점 쿼리 (`stores`) | 59,982 | 59,960 | 0% (캐시 대상 아님) | +| RDB 쿼리 합계 | 119,790 | 60,083 | 49.8% (요청당 2 → 1) | +| 요청당 읽은 행 | 62 | 1 | 98% | +| 요청당 쿼리 시간 | 0.45ms | 0.13ms | 71% | +| MySQL CPU | 0.50코어 | 0.15코어 | 70% | +| 히트율 | - | 99.9% | | +| p95 | 1.8ms | 1.1ms | | + +- TO-BE 에 남은 메뉴 쿼리 123건은 초당 1건 품절 토글 때문이다. 점주 쪽 토글 처리가 메뉴를 읽은 약 60건과, 무효화된 키를 다시 채운 약 60건이다 +- RDB 쿼리 합계는 절반만 줄었다. 요청마다 나가는 주점 조회를 캐시하지 않았기 때문이다 + +**속도별 (EC2)** + +| 목표 req/s | AS-IS 처리 | AS-IS p95 | AS-IS MySQL CPU | TO-BE 처리 | TO-BE p95 | TO-BE MySQL CPU | TO-BE user-api CPU | +|---:|---:|---:|---:|---:|---:|---:|---:| +| 200 | 200 | 2.4ms | 0.11 | 200 | 1.6ms | 0.04 | 0.36 | +| 1000 | 998 | 1.8ms | 0.50 | 998 | 1.1ms | 0.15 | 0.69 | +| 2000 | 1,995 | 57.6ms | 0.99 | 1,996 | 1.3ms | 0.31 | 1.35 | +| 3000 | 2,356 | 937ms | 0.99 | - | - | - | - | +| 4000 | 2,333 | 976ms | 0.99 | 3,992 | 2.2ms | 0.59 | 2.62 | +| 6000 | - | - | - | 5,894 | 256ms | 0.88 | 3.89 | + +- p95 200ms 안에서 처리한 최대 부하: 2,000 → 4,000 (2배). 최대 처리량: 2,356 → 5,894 +- 같은 2,000 req/s 에서 p95 57.6ms → 1.3ms +- 요청당 MySQL CPU: 0.50ms → 0.15ms (1,000 req/s) +- AS-IS 는 과부하 구간에서 같은 주점 동시 요청이 로딩을 나눠 받아(2,000 에서 히트율 10.2%, 3,000 이상 약 30%) 실제보다 조금 유리하게 측정됐다. 캐시 효과는 표보다 조금 크다 + +**무효화 직후 몰려도 DB 로 새지 않았다** (EC2, 초당 1건 품절 토글, 60초 동안의 메뉴 쿼리) + +| 목표 req/s | 200 | 1,000 | 2,000 | 4,000 | 6,000 | +|---|---:|---:|---:|---:|---:| +| 메뉴 쿼리 | 123 | 123 | 122 | 122 | 123 | + +- 부하가 30배가 돼도 메뉴 쿼리 수가 그대로다. 토글은 인기 주점에 몰리게 보냈으니 무효화 직후 같은 주점 요청이 실제로 몰렸다. 그래도 무효화 1번에 다시 채우는 쿼리는 1번이다 +- `cache.get(key, loader)` 는 같은 키를 로딩하는 동안 다른 요청을 기다리게 하고 그 결과를 나눠 준다. 별도 락 없이 서버 한 대 안에서 미스가 하나로 모인다 + +**품절 반영** (EC2, 400 req/s 조회 부하 중 품절 토글 30회. 목록 80% · 단건 20%) + +| 토글 응답 p50 / p95 | 토글 응답 후 바뀐 값 확인 p50 / p95 / 최대 | 옛 값을 받은 조회 | +|---|---|---:| +| 17ms / 18ms | 9ms / 10ms / 11ms | 0 / 30회 | + +- 30번 모두 토글 응답 직후 첫 조회부터 바뀐 값이 나왔다. "바뀐 값 확인" 9ms 는 조회 한 번에 걸린 시간이다 +- 무효화는 커밋 후, HTTP 응답 전에 발행된다. 같은 서브넷의 Pub/Sub 전달이 응답보다 먼저 끝났다 + +**재구독** (Mac 로컬 E2E, 2026-10-26, 원본 기록 없음. 콘솔 출력으로만 확인) +- Redis 에서 `CLIENT KILL TYPE pubsub` 으로 구독 연결을 끊자 약 200ms 뒤 다시 구독했고(`PUBSUB NUMSUB cache:menus` = 1), 바로 다음 조회는 캐시 미스였다(전부 비워짐) + +**다음 한계**: 6,000 req/s 에서 user-api CPU 가 3.89/4 코어였다. MySQL 은 0.88코어로, 남은 쿼리는 주점 조회뿐이다. k6 인스턴스 CPU 는 재지 않아 못 보낸 요청 5,180건이 부하 발생기 쪽 한계인지는 가르지 못했다. + +## 5. 보장 범위와 해결하지 못한 문제 + +- 결과적 일관성이다. 커밋과 무효화 메시지 처리 사이(같은 서브넷에서 수 ms)에는 옛 값이 나갈 수 있다. 측정에서는 한 번도 보이지 않았다(4절) +- **무효화 메시지는 유실될 수 있다.** + - 연결이 끊긴 동안 놓친 메시지는 재구독 때 전부 비워 메운다 + - 커밋 때 Redis 가 죽어 발행 자체가 실패하면 옛 값이 TTL(1일)까지 남는다. 에러 로그를 남기고 `redis-cli PUBLISH cache:menus CLEAR` 로 수동 복구한다. 재시도·아웃박스는 넣지 않았다. TTL 을 없애지 않은 이유이기도 하다(채널톡도 같은 이유로 남겼다) +- **미스가 하나로 모이는 것은 서버 한 대 안에서다.** user-api 가 N대면 무효화 한 번에 DB 조회도 N번이다. 분산 락은 넣지 않았다(미스마다 Redis 왕복이 늘고, Redis 를 대기열과 함께 쓰는 문제가 돌아온다) +- **로딩하는 동안 같은 주점 요청은 기다린다.** 옛 값을 주면서 다시 읽는 방식(refresh)은 품절 직후 품절 전 메뉴를 보여 주므로 택하지 않았다. DB 가 느려지면 그 주점 요청도 함께 느려진다 +- `CLEAR` 나 배포 직후에는 모든 키가 비어 있다. 주점 200개면 서버마다 최대 200번 다시 읽는다 +- 주점 조회는 캐시하지 않았다. 캐시를 켠 뒤 MySQL 부하의 거의 전부다 +- user-api 한 대로만 쟀다. 여러 대에 무효화가 퍼지는 것과, 그때의 반영 시간은 재지 않았다 + +## 6. 다음에 볼 것 + +- 주점 조회 캐시: 같은 구조(키 = publicCode)로 요청당 남은 쿼리 1개를 없애기. 주점 정보 변경 지점에 무효화를 넣어야 한다 +- user-api 를 두 대 이상 띄워 무효화가 모든 서버에 퍼지는지, 반영 시간이 어떻게 되는지 +- 발행 실패 대비: 실패한 무효화를 재시도하거나, 커밋과 함께 남겨 두고(아웃박스) 나중에 다시 보내기 +- k6 인스턴스 CPU 를 함께 기록해, 6,000 req/s 의 한계가 앱 서버인지 부하 발생기인지 가르기 diff --git a/performance/menu-cache/README.md b/performance/menu-cache/README.md new file mode 100644 index 00000000..62227a36 --- /dev/null +++ b/performance/menu-cache/README.md @@ -0,0 +1,118 @@ +# 메뉴 목록 조회 성능: N+1 → JOIN → 인덱스 → 로컬 캐시 + Redis Pub/Sub 무효화 + +한 단계씩 바꾸고, 같은 조건에서 직접 잰 것만 적는다. 주제는 3단계(캐시)이고, 1·2단계는 3단계의 AS-IS 를 만든 과정이다. + +| 단계 | 주제 | 상태 | +|---|---|---| +| 1 | [N+1 을 JOIN 한 번으로](01-join.md) | 구현·측정 완료 | +| 2 | [menus 에 (store_id, deleted, sort_order) 인덱스](02-index.md) | 구현·측정 완료 | +| 3 | [로컬 캐시 + Redis Pub/Sub 무효화](03-cache.md) | 구현·측정 완료 | + +실행 원본: [EC2 측정 (2026-10-26)](results/2026-10-26-ec2.md), [Mac 측정 (2026-10-26)](results/2026-10-26-mac.md) + +## 한눈에 (EC2, 2026-10-26) + +| 단계 | 요청당 쿼리 | 요청당 읽은 행 | 요청당 MySQL CPU (잰 속도) | p95 200ms 안에서 처리한 최대 부하 | 그때 p95 | +|---|---:|---:|---|---:|---:| +| 기준: N+1 | 22 | 4,041 | 4.7ms (100 req/s) | 100 | 10.0ms | +| 1: JOIN | 2 | 4,042 | 2.3ms (200 req/s) | 500 | 129.5ms | +| 2: + 인덱스 | 2 | 62 | 0.50ms (1,000 req/s) | 2,000 | 57.6ms | +| 3: + 캐시 | 1 | 1 | 0.15ms (1,000 req/s) | 4,000 | 2.2ms | + +- 요청당 MySQL CPU 는 MySQL 이 포화되지 않은 속도에서 잰 값이다. 낮은 속도일수록 MySQL 의 기본 사용량이 섞여 조금 크게 나온다 +- 3단계에서 남은 쿼리 1개는 주점 조회(`stores`)다. 메뉴 쿼리는 1,000 req/s 60초 동안 59,808 → 123 (99.8% 감소) +- 3단계는 6,000 req/s 에서 user-api CPU 가 3.9/4 코어로 먼저 찼다. 한계가 DB 에서 앱 서버로 옮겨갔다 + +## 단계별 설정 + +같은 user-api jar 에서 설정만 바꾼 단계는 차이가 그 설정 하나다. + +| 단계 | `run.sh bench` 이름 | user-api jar | menus 인덱스 | 캐시 (`nowait.cache.menu.ttl`) | +|---|---|---|---|---| +| 기준 | `nplus1` | `user-asis.jar` (커밋 `d20c593`, 캐시 작업 전) | 없음 | 캐시 코드 없음 | +| 1 | `join` | `user-tobe.jar` | 없음 | `0s` (끔) | +| 2 | `join-idx` | `user-tobe.jar` | 있음 | `0s` (끔) | +| 3 | `cache` | `user-tobe.jar` | 있음 | `1d` (기본값) | + +- admin-api 는 모든 단계에서 `admin.jar`(작업 트리)를 쓴다. 손님 조회 경로에는 들어가지 않고, 품절 토글과 무효화 발행만 한다 +- 기준 단계의 user-api 는 조회에 `@Transactional(readOnly = true)` 가 붙어 있다. 1단계부터는 없다(3단계 2절). 그래서 기준 → 1단계 차이에는 트랜잭션 설정 문장도 섞여 있다(1단계 4절) + +## 공통 조건 + +**인스턴스** (쿠폰 발급 토이 프로젝트의 실험용 EC2 세 대를 빌려 썼다) + +| 이름 | 유형 | 하는 일 | +|---|---|---| +| db | c7g.large (2 vCPU, 4GiB) | MySQL 8.0.36·Redis 7.2 컨테이너 (각 1 CPU / 1GB 제한). 스키마는 따로 `nowait_lab` | +| app | c7g.xlarge (4 vCPU, 8GiB) | user-api(8081)·admin-api(8085). 둘 다 `-Xms256m -Xmx512m -XX:+UseG1GC` | +| k6 | c7g.large (2 vCPU, 4GiB) | 부하 발생 (k6 v2.1.0) | + +서울 리전, 세 대 같은 서브넷, Ubuntu 24.04 (Arm). 앱은 `user-local`·`admin-local` 프로필에 DB·Redis 주소만 바꾸고, SQL 로그를 끄고, Discord 알림 웹훅을 막아 띄운다. + +**데이터** (`seed.sql`): 주점 200 × 메뉴 20 = 메뉴 4,000건, 이미지 4,000장. 메뉴마다 이미지 1장인데, 2번 메뉴는 2장, 20번 메뉴는 0장이다(JOIN 결과를 메뉴별로 묶는지, 이미지 없는 메뉴가 빠지지 않는지 보려고). + +**트래픽** (`menu-load.js`) +- 손님 메뉴 목록 조회 `GET /v1/stores/{publicCode}/menus` 를 일정한 속도로(constant-arrival-rate) +- 주점은 앞 번호일수록 자주 고른다(`floor(200 × u²) + 1`). 인기 주점에 몰리는 모양 +- 동시에 점주 품절 토글 `PATCH /admin/menus/toggle-soldout/{id}` 초당 1건 (각 주점의 1번 메뉴는 반영 지연 측정용으로 빼 둔다) + +**순서**: 단계마다 user-api 를 새로 띄우고 품절을 초기화하고 인덱스를 단계에 맞춘다 → 초당 100건 20초 예열(버림) → 속도마다 60초씩 1회. + +**측정값** + +| 값 | 어디서 | +|---|---| +| 처리 req/s, p50·p95·p99, 실패, 못 보낸 요청 | k6 요약(조회 시나리오만). 처리 req/s 는 끝난 요청 수 ÷ 실행 시간(마무리 대기 포함) | +| 테이블별 쿼리 수·읽은 행·쿼리 시간 | `performance_schema.events_statements_summary_by_digest` (`digest.sql`). 속도마다 비운다. admin 쿼리(토글·로그인, 초당 1건 수준)가 섞인다. 메뉴·이미지 JOIN 쿼리는 `menus` 로 센다 | +| MySQL CPU | MySQL 컨테이너 cgroup `usage_usec` 증가량 ÷ 측정 시간 | +| user-api CPU | user-api JVM 누적 CPU 시간(`ps -o time`) 증가량 ÷ 측정 시간 | +| 히트율 | Micrometer `cache_gets_total{cache="cache:menus"}` 증가량 | + +**p95 200ms 안에서 처리한 최대 부하**: 처리 req/s ≥ 목표의 99%, 못 보낸 요청 0, p95 ≤ 200ms 를 모두 만족한 가장 높은 목표 속도. 잰 속도 간격이 넓어(예: 2,000 다음 3,000) 실제 한계는 이보다 높을 수 있다. + +## 정합성 확인 + +`run.sh verify` 가 기존 코드(user `user-asis.jar`, admin `admin-asis.jar`)와 바꾼 코드를 동시에 띄우고, 200개 주점 전부의 메뉴 목록 응답을 문자열로 비교한다. + +| 비교 | EC2 | Mac | +|---|---|---| +| user 메뉴 목록 | 같음 200 / 다름 0 | 같음 200 / 다름 0 | +| admin 메뉴 목록 | 같음 200 / 다름 0 | 같음 200 / 다름 0 | + +단위 테스트: `MenuRepositoryTest`(H2. JOIN 쿼리 1번, 정렬, 이미지 묶기, 삭제 메뉴·다른 주점 제외, 이미지 없는 메뉴), `DistributedCachedDaoTest`(키 무효화, CLEAR, 재구독 시 비우기). + +## 실행 방법 + +```bash +# jar 네 벌. 기존 코드는 캐시 작업 전 커밋에서 빌드한다 +# application-*.yml 은 git 에 없으므로 작업 트리에서 복사하고, +# admin 은 Mac 에서 빌드하면 storePayment 패키지 대소문자 충돌로 뜨지 않아 storepayment 패키지 수정 3파일도 복사한다 +git worktree add --detach /tmp/asis d20c593 +cp -R nowait-app-user-api/src/main/resources/. /tmp/asis/nowait-app-user-api/src/main/resources/ +cp -R nowait-app-admin-api/src/main/resources/. /tmp/asis/nowait-app-admin-api/src/main/resources/ +(cd /tmp/asis && ./gradlew :nowait-app-user-api:bootJar :nowait-app-admin-api:bootJar) # → user-asis.jar, admin-asis.jar +./gradlew :nowait-app-user-api:bootJar :nowait-app-admin-api:bootJar # → user-tobe.jar, admin.jar + +# EC2 (쿠폰 프로젝트의 build/ec2/hosts.env 를 읽는다) +JARS= performance/menu-cache/run.sh setup +performance/menu-cache/run.sh verify +RATES="100 200 400" performance/menu-cache/run.sh bench nplus1 +RATES="200 500 1000" performance/menu-cache/run.sh bench join +RATES="200 1000 2000 3000 4000" performance/menu-cache/run.sh bench join-idx +RATES="200 1000 2000 4000 6000" performance/menu-cache/run.sh bench cache +performance/menu-cache/run.sh delay +performance/menu-cache/run.sh cleanup # 앱 종료, nowait_lab 삭제 + +# Mac 한 대 (MySQL·Redis 는 도커, 각 1 CPU / 1GB) +LOCAL=1 JARS= performance/menu-cache/run.sh setup +LOCAL=1 RATES="200 500 1000" performance/menu-cache/run.sh bench nplus1 join join-idx cache +``` + +결과는 `performance/menu-cache/out/<단계>/` 에 쌓이고(git 에 올리지 않는다) `report.md` 로 요약된다. + +## 공통 한계 + +- 운영 트래픽이 아니라 실험 환경에서 잰 값이다. 데이터 규모(메뉴 4,000건, 주점당 20개)가 바뀌면 단계별 효과 크기도 바뀐다 +- 속도마다 1회만 쟀다(쿠폰 측정은 2회) +- k6 인스턴스 CPU 는 기록하지 않았다. 가장 높은 속도(6,000 req/s)의 "못 보낸 요청" 이 부하 발생기 한계인지 가르지 못한다 +- user-api 는 한 대로 쟀다. 여러 대에 무효화가 퍼지는 것은 재지 않았다 diff --git a/performance/menu-cache/digest.sql b/performance/menu-cache/digest.sql new file mode 100644 index 00000000..c603b4bc --- /dev/null +++ b/performance/menu-cache/digest.sql @@ -0,0 +1,14 @@ +-- 측정 구간 동안 테이블별 쿼리 수, 읽은 행 수, 누적 실행 시간(ms). admin(로그인·토글) 쿼리도 같은 스키마라 섞이지만 초당 1건 수준 +-- 메뉴·이미지 JOIN 쿼리는 FROM `menus` 로 시작하므로 menus 로 센다 +SELECT CASE + WHEN DIGEST_TEXT LIKE '%FROM `menu_images`%' THEN 'menu_images' + WHEN DIGEST_TEXT LIKE '%FROM `menus`%' THEN 'menus' + WHEN DIGEST_TEXT LIKE '%FROM `stores`%' THEN 'stores' + ELSE 'other' END AS tbl, + SUM(COUNT_STAR) AS calls, + SUM(SUM_ROWS_EXAMINED) AS rows_examined, + ROUND(SUM(SUM_TIMER_WAIT) / 1e9, 1) AS total_ms +FROM performance_schema.events_statements_summary_by_digest +WHERE SCHEMA_NAME = 'nowait_lab' +GROUP BY tbl +ORDER BY tbl; diff --git a/performance/menu-cache/menu-load.js b/performance/menu-cache/menu-load.js new file mode 100644 index 00000000..aabf31f9 --- /dev/null +++ b/performance/menu-cache/menu-load.js @@ -0,0 +1,59 @@ +// 손님 메뉴 조회(목록 LIST_RATIO, 나머지 단건) + 점주 품절 토글. 인기 주점에 몰리게 앞쪽 주점일수록 자주 고른다(u^2 분포) +import http from 'k6/http'; +import { check } from 'k6'; + +const USER = __ENV.USER_URL; +const ADMIN = __ENV.ADMIN_URL; +const STORES = Number(__ENV.STORES || 50); +const LIST_RATIO = Number(__ENV.LIST_RATIO || 1); +const MENUS_PER_STORE = 20; +const DURATION = __ENV.DURATION || '60s'; + +export const options = { + discardResponseBodies: true, + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(95)', 'p(99)'], + scenarios: { + reads: { + executor: 'constant-arrival-rate', exec: 'read', rate: Number(__ENV.RATE), timeUnit: '1s', + duration: DURATION, preAllocatedVUs: 200, maxVUs: 2000, + }, + writes: { + executor: 'constant-arrival-rate', exec: 'write', rate: Number(__ENV.WRITE_RATE || 1), timeUnit: '1s', + duration: DURATION, preAllocatedVUs: 2, maxVUs: 10, + }, + }, + // 손님 조회만 따로 집계되게 하는 형식상 기준 (판정용 아님) + thresholds: { + 'http_req_duration{scenario:reads}': ['max>=0'], + 'http_reqs{scenario:reads}': ['count>=0'], + 'http_req_failed{scenario:reads}': ['rate<=1'], + }, +}; + +export function setup() { + const res = http.post(`${ADMIN}/admin/users/login`, + JSON.stringify({ email: 'load@test.com', password: 'loadPass1!' }), + { headers: { 'Content-Type': 'application/json' }, responseType: 'text' }); + return { token: res.json('response.body.accessToken') }; +} + +const pickStore = () => Math.floor(STORES * Math.random() ** 2) + 1; +const code = (store) => 'LOAD' + String(store).padStart(8, '0'); +const menuOf = (store, n) => (store - 1) * MENUS_PER_STORE + n; + +export function read() { + const store = pickStore(); + const res = Math.random() < LIST_RATIO + ? http.get(`${USER}/v1/stores/${code(store)}/menus`, { tags: { name: 'list' } }) + : http.get(`${USER}/v1/stores/${code(store)}/menus/${menuOf(store, Math.floor(Math.random() * MENUS_PER_STORE) + 1)}`, + { tags: { name: 'detail' } }); + check(res, { 'read 200': (r) => r.status === 200 }); +} + +// 각 주점의 1번 메뉴는 반영 지연 측정(probe.sh)용으로 남겨 둔다 +export function write(data) { + const menuId = menuOf(pickStore(), Math.floor(Math.random() * (MENUS_PER_STORE - 1)) + 2); + const res = http.patch(`${ADMIN}/admin/menus/toggle-soldout/${menuId}`, null, + { headers: { Authorization: `Bearer ${data.token}` }, tags: { name: 'toggle' } }); + check(res, { 'toggle 200': (r) => r.status === 200 }); +} diff --git a/performance/menu-cache/probe.sh b/performance/menu-cache/probe.sh new file mode 100755 index 00000000..79da6359 --- /dev/null +++ b/performance/menu-cache/probe.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# 품절 토글 → 손님 조회 반영까지. app 인스턴스에서 실행. 한 줄에: 토글 응답 ms, 토글 응답 후 반영 확인까지 ms, 그 사이 옛 값을 받은 조회 수 +U=localhost:8081/v1/stores/LOAD00000001/menus/1 +T=$(curl -s -X POST localhost:8085/admin/users/login -H 'Content-Type: application/json' \ + -d '{"email":"load@test.com","password":"loadPass1!"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["response"]["body"]["accessToken"])') +sold() { curl -s $U | grep -o '"isSoldOut":[a-z]*'; } +for i in $(seq ${N:-30}); do + b=$(sold) + t0=$(date +%s%3N) + curl -s -o /dev/null -X PATCH localhost:8085/admin/menus/toggle-soldout/1 -H "Authorization: Bearer $T" + t1=$(date +%s%3N) + n=0 + while [ "$(sold)" = "$b" ] && [ $n -lt 1000 ]; do n=$((n + 1)); done + t2=$(date +%s%3N) + echo "$((t1 - t0)) $((t2 - t1)) $n" + sleep 1 +done diff --git a/performance/menu-cache/results/2026-10-26-ec2.md b/performance/menu-cache/results/2026-10-26-ec2.md new file mode 100644 index 00000000..d7b21f78 --- /dev/null +++ b/performance/menu-cache/results/2026-10-26-ec2.md @@ -0,0 +1,149 @@ +# EC2 측정 원본 (2026-10-26 22:26 ~ 22:48) + +실행 조건 +- 인스턴스: db c7g.large, app c7g.xlarge, k6 c7g.large (서울, 같은 서브넷, Ubuntu 24.04 Arm). 쿠폰 프로젝트 실험용 인스턴스를 중지했다 다시 켠 뒤 썼다 +- MySQL 8.0.36 (1 CPU / 1GB), Redis 7.2 (1 CPU / 1GB). 쿠폰 프로젝트 컨테이너를 그대로 쓰고 스키마만 `nowait_lab` 으로 나눴다(측정 후 삭제) +- user-api·admin-api: `-Xms256m -Xmx512m -XX:+UseG1GC`, `user-local`·`admin-local` 프로필, SQL 로그 끔, Discord 웹훅 막음 +- 데이터: 주점 200, 메뉴 4,000, 이미지 4,000, 관리자 1 (`seed.sql`, 주점 수 `STORES=200`) +- 트래픽: 손님 메뉴 목록 조회만(`LIST_RATIO=1`) + 품절 토글 초당 1건(`WRITE_RATE=1`). 단계마다 초당 100건 20초 예열을 버리고, 속도마다 60초 1회 +- 표의 열: 처리 req/s·지연·실패·못 보낸 요청은 k6 조회 시나리오만. stores·menus·menu_images 는 그 테이블 쿼리 호출 수(`performance_schema` digest, admin 쿼리 포함). MySQL CPU 는 컨테이너 cgroup, user-api CPU 는 JVM 누적 CPU 시간에서 계산한 코어 수 + +## 정합성 (verify) + +`performance/menu-cache/run.sh verify` (기존 user :8081·admin :8086, 바꾼 user :8082·admin :8085) + +``` +user 메뉴 목록: 같음 200 / 다름 0 +admin 메뉴 목록: 같음 200 / 다름 0 +admin :8086 메뉴 목록 200 회 — menu_images 4000건 menus 200건 other 2208건 +admin :8085 메뉴 목록 200 회 — menus 200건 other 2208건 +``` + +`other` 2,208건(1회당 약 11개)은 인증 필터의 회원 조회와 트랜잭션 설정 문장이다. 두 버전이 같다. + +## 기준: N+1 (`user-asis.jar`, 인덱스 없음) + +`RATES="100 200 400" performance/menu-cache/run.sh bench nplus1` + +| 목표 req/s | 처리 req/s | p50 ms | p95 ms | p99 ms | 실패 | 못 보낸 요청 | 히트율 | stores | menus | menu_images | 요청당 쿼리 | 요청당 읽은 행 | MySQL CPU(코어) | user-api CPU(코어) | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| 100 | 100 | 9.3 | 10.0 | 11.4 | 0.00% | 0 | - | 6001 | 6062 | 120020 | 22.01 | 4041 | 0.47 | 0.48 | +| 200 | 187 | 1492.1 | 2501.9 | 3157.2 | 0.00% | 285 | - | 11716 | 11777 | 234248 | 22.00 | 4041 | 0.99 | 0.84 | +| 400 | 187 | 10625.1 | 10749.1 | 11704.1 | 0.00% | 10778 | - | 13222 | 13283 | 264384 | 22.00 | 4041 | 0.99 | 0.80 | + +## 1단계: JOIN (`user-tobe.jar`, 캐시 ttl 0s, 인덱스 없음) + +`RATES="200 500 1000" performance/menu-cache/run.sh bench join` + +| 목표 req/s | 처리 req/s | p50 ms | p95 ms | p99 ms | 실패 | 못 보낸 요청 | 히트율 | stores | menus | menu_images | 요청당 쿼리 | 요청당 읽은 행 | MySQL CPU(코어) | user-api CPU(코어) | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| 200 | 200 | 3.5 | 4.2 | 4.7 | 0.00% | 0 | 0.0% | 12001 | 12062 | 0 | 2.01 | 4042 | 0.46 | 0.43 | +| 500 | 499 | 74.2 | 129.5 | 160.6 | 0.00% | 0 | 16.0% | 29989 | 25248 | 0 | 1.84 | 3395 | 0.99 | 0.54 | +| 1000 | 658 | 2893.1 | 3169.5 | 3322.6 | 0.00% | 18517 | 38.4% | 41466 | 25627 | 0 | 1.62 | 2492 | 0.99 | 0.66 | + +## 2단계: JOIN + 인덱스 (`user-tobe.jar`, 캐시 ttl 0s, 인덱스 있음) + +`RATES="200 1000 2000 3000 4000" performance/menu-cache/run.sh bench join-idx` + +| 목표 req/s | 처리 req/s | p50 ms | p95 ms | p99 ms | 실패 | 못 보낸 요청 | 히트율 | stores | menus | menu_images | 요청당 쿼리 | 요청당 읽은 행 | MySQL CPU(코어) | user-api CPU(코어) | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| 200 | 200 | 1.8 | 2.4 | 2.8 | 0.00% | 0 | 0.0% | 11999 | 12062 | 0 | 2.00 | 62 | 0.11 | 0.41 | +| 1000 | 998 | 1.6 | 1.8 | 2.6 | 0.00% | 0 | 0.4% | 59982 | 59808 | 0 | 2.00 | 62 | 0.50 | 0.92 | +| 2000 | 1995 | 13.4 | 57.6 | 74.5 | 0.00% | 0 | 10.2% | 119915 | 107749 | 0 | 1.90 | 56 | 0.99 | 2.00 | +| 3000 | 2356 | 799.0 | 937.0 | 1067.2 | 0.00% | 36353 | 29.7% | 143616 | 101090 | 0 | 1.70 | 44 | 0.99 | 2.21 | +| 4000 | 2333 | 821.6 | 975.9 | 1100.3 | 0.00% | 97755 | 29.6% | 142196 | 100222 | 0 | 1.70 | 44 | 0.99 | 2.25 | + +## 3단계: 캐시 (`user-tobe.jar`, 캐시 ttl 1d, 인덱스 있음) + +`RATES="200 1000 2000 4000 6000" performance/menu-cache/run.sh bench cache` + +| 목표 req/s | 처리 req/s | p50 ms | p95 ms | p99 ms | 실패 | 못 보낸 요청 | 히트율 | stores | menus | menu_images | 요청당 쿼리 | 요청당 읽은 행 | MySQL CPU(코어) | user-api CPU(코어) | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| 200 | 200 | 1.1 | 1.6 | 1.9 | 0.00% | 0 | 99.5% | 12000 | 123 | 0 | 1.01 | 1 | 0.04 | 0.36 | +| 1000 | 998 | 1.0 | 1.1 | 1.4 | 0.00% | 0 | 99.9% | 59960 | 123 | 0 | 1.00 | 1 | 0.15 | 0.69 | +| 2000 | 1996 | 1.1 | 1.3 | 1.8 | 0.00% | 0 | 99.9% | 118113 | 122 | 0 | 0.99 | 1 | 0.31 | 1.35 | +| 4000 | 3992 | 1.2 | 2.2 | 4.0 | 0.00% | 0 | 100.0% | 239756 | 122 | 0 | 1.00 | 1 | 0.59 | 2.62 | +| 6000 | 5894 | 69.3 | 255.6 | 283.5 | 0.00% | 5180 | 100.0% | 354741 | 123 | 0 | 1.00 | 1 | 0.88 | 3.89 | + +## 테이블별 쿼리 (호출 수 / 읽은 행 / 누적 실행 시간 ms) + +`digest.sql`, 속도마다 비우고 60초 측정 뒤 읽음. 메뉴·이미지 JOIN 은 `menus` 로 센다. "그 밖" 은 트랜잭션 설정 문장(`SET autocommit`, `SET SESSION TRANSACTION ...`, `COMMIT`)과 admin 쪽 쿼리다. + +### 기준: N+1 + +| 목표 req/s | stores | menus | menu_images | 그 밖 (호출 / ms) | +|---:|---|---|---|---| +| 100 | 6,001 / 6,001 / 727 | 6,062 / 24,124,081 / 11,283 | 120,020 / 120,020 / 12,129 | 30,623 / 1,399 | +| 200 | 11,716 / 11,716 / 2,787 | 11,777 / 47,098,381 / 126,403 | 234,248 / 234,285 / 64,541 | 59,194 / 4,272 | +| 400 | 13,222 / 13,223 / 3,193 | 13,283 / 53,156,521 / 145,156 | 264,384 / 264,419 / 72,100 | 66,733 / 5,977 | + +### 1단계: JOIN + +| 목표 req/s | stores | menus | menu_images | 그 밖 (호출 / ms) | +|---:|---|---|---|---| +| 200 | 12,001 / 12,001 / 1,640 | 12,062 / 48,496,102 / 25,083 | - | 618 / 417 | +| 500 | 29,989 / 29,999 / 8,969 | 25,248 / 101,809,015 / 381,505 | - | 618 / 464 | +| 1,000 | 41,466 / 41,476 / 13,576 | 25,627 / 103,336,512 / 377,917 | - | 608 / 530 | + +### 2단계: JOIN + 인덱스 + +| 목표 req/s | stores | menus | menu_images | 그 밖 (호출 / ms) | +|---:|---|---|---|---| +| 200 | 11,999 / 12,001 / 1,758 | 12,062 / 732,061 / 3,810 | - | 618 / 398 | +| 1,000 | 59,982 / 59,994 / 7,801 | 59,808 / 3,646,458 / 18,708 | - | 618 / 424 | +| 2,000 | 119,915 / 119,976 / 23,663 | 107,749 / 6,570,187 / 69,006 | - | 608 / 481 | +| 3,000 | 143,616 / 143,634 / 31,632 | 101,090 / 6,163,867 / 70,295 | - | 618 / 1,647 | +| 4,000 | 142,196 / 142,235 / 29,993 | 100,222 / 6,110,491 / 67,125 | - | 609 / 459 | + +### 3단계: 캐시 + +| 목표 req/s | stores | menus | menu_images | 그 밖 (호출 / ms) | +|---:|---|---|---|---| +| 200 | 12,000 / 12,001 / 1,748 | 123 / 3,843 / 31 | - | 618 / 400 | +| 1,000 | 59,960 / 59,988 / 7,204 | 123 / 3,843 / 31 | - | 618 / 402 | +| 2,000 | 118,113 / 119,225 / 14,857 | 122 / 3,782 / 31 | - | 618 / 409 | +| 4,000 | 239,756 / 239,934 / 29,361 | 122 / 3,782 / 32 | - | 619 / 422 | +| 6,000 | 354,741 / 354,799 / 44,472 | 123 / 3,843 / 33 | - | 618 / 421 | + +- 기준의 "그 밖" 호출(요청당 약 5개)은 조회 `@Transactional(readOnly = true)` 의 설정 문장이다. 1단계부터는 admin 쪽 것만 남는다(60초에 약 618개) +- 3단계 `menus` 123건 ≈ 품절 토글 60번 × (admin 의 메뉴 조회 1 + 무효화 뒤 다시 채우기 1) + +## 품절 반영 지연 (delay) + +`performance/menu-cache/run.sh delay`: 캐시 단계 user-api 에 400 req/s 조회(목록 80%, 단건 20%)를 120초 걸고, 15초 뒤 app 인스턴스에서 `probe.sh` 를 돌렸다. 1번 주점 1번 메뉴(k6 토글 대상에서 뺀 메뉴)를 토글하고, 단건 조회를 반복해 바뀐 값이 나올 때까지 잰다. + +``` +토글 응답 ms 토글 응답 후 바뀐 값 확인까지 ms 그사이 옛 값을 받은 조회 수 +17 9 0 +18 9 0 +16 10 0 +16 9 0 +17 8 0 +16 9 0 +17 9 0 +16 9 0 +17 8 0 +16 8 0 +17 11 0 +16 9 0 +17 8 0 +17 8 0 +16 8 0 +16 8 0 +17 7 0 +17 9 0 +17 8 0 +17 8 0 +16 9 0 +16 9 0 +16 9 0 +17 8 0 +15 9 0 +16 9 0 +16 9 0 +17 7 0 +19 8 0 +16 8 0 +``` + +토글 30회 | 토글 응답 p50 17ms p95 18ms | 응답 후 반영 확인 p50 9ms p95 10ms 최대 11ms | 옛 값을 받은 조회 0회 diff --git a/performance/menu-cache/results/2026-10-26-mac.md b/performance/menu-cache/results/2026-10-26-mac.md new file mode 100644 index 00000000..9a6aeab9 --- /dev/null +++ b/performance/menu-cache/results/2026-10-26-mac.md @@ -0,0 +1,79 @@ +# Mac 측정 원본 (2026-10-26 21:51 ~ 22:13) + +EC2 가 꺼져 있어 먼저 잰 측정이다. 앱·k6·도커가 한 기계(Apple Silicon 8코어, 16GB)의 CPU 를 나눠 쓰므로 절대값보다 단계 간 차이를 본다. + +실행 조건 +- `LOCAL=1 performance/menu-cache/run.sh ...`. MySQL 8.0.36·Redis 7.2-alpine 은 도커(Docker Desktop, 각 `--cpus=1 -m 1g`), user-api·admin-api(JDK 17)와 k6 는 Mac 에서 +- 데이터·트래픽·예열·측정 시간은 EC2 와 같다(주점 200, 목록 조회만, 품절 토글 초당 1건, 예열 100 req/s 20초, 속도마다 60초 1회) +- 2단계·3단계는 200·500·1000 을 먼저 재고, 한계에 닿지 않아 2000·3000 을 따로 다시 쟀다(사이에 앱을 다시 띄웠다) + +## 정합성 (verify) + +``` +user 메뉴 목록: 같음 200 / 다름 0 +admin 메뉴 목록: 같음 200 / 다름 0 +admin :8086 메뉴 목록 200 회 — menus 200건 menu_images 4000건 other 2208건 +admin :8085 메뉴 목록 200 회 — menus 200건 other 2208건 +``` + +## 기준: N+1 + +`LOCAL=1 RATES="200 500 1000" performance/menu-cache/run.sh bench nplus1` + +| 목표 req/s | 처리 req/s | p50 ms | p95 ms | p99 ms | 실패 | 못 보낸 요청 | 히트율 | stores | menus | menu_images | 요청당 쿼리 | 요청당 읽은 행 | MySQL CPU(코어) | user-api CPU(코어) | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| 200 | 116 | 11314.5 | 15897.1 | 17061.7 | 0.00% | 3397 | - | 8603 | 8664 | 171732 | 21.96 | 4041 | 0.94 | 0.64 | +| 500 | 104 | 14341.2 | 29772.4 | 30890.2 | 0.00% | 22284 | - | 7720 | 7781 | 154046 | 21.96 | 4041 | 0.94 | 0.51 | +| 1000 | 146 | 12559.0 | 16610.0 | 17431.5 | 0.00% | 49334 | - | 10666 | 10728 | 212876 | 21.96 | 4041 | 0.98 | 0.52 | + +## 1단계: JOIN + +`LOCAL=1 RATES="200 500 1000" performance/menu-cache/run.sh bench join` + +| 목표 req/s | 처리 req/s | p50 ms | p95 ms | p99 ms | 실패 | 못 보낸 요청 | 히트율 | stores | menus | menu_images | 요청당 쿼리 | 요청당 읽은 행 | MySQL CPU(코어) | user-api CPU(코어) | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| 200 | 200 | 4.0 | 7.4 | 66.0 | 0.00% | 0 | 0.1% | 12000 | 12047 | 0 | 2.00 | 4037 | 0.41 | 0.47 | +| 500 | 456 | 1101.0 | 2856.8 | 3370.8 | 0.00% | 1269 | 25.8% | 28690 | 21364 | 0 | 1.74 | 2997 | 0.96 | 0.46 | +| 1000 | 430 | 4518.1 | 5281.3 | 5756.3 | 0.00% | 32329 | 28.1% | 27620 | 19953 | 0 | 1.72 | 2907 | 0.99 | 0.44 | + +## 2단계: JOIN + 인덱스 + +`LOCAL=1 RATES="200 500 1000" ... bench join-idx`, 이어서 `LOCAL=1 RATES="2000 3000" ... bench join-idx` + +| 목표 req/s | 처리 req/s | p50 ms | p95 ms | p99 ms | 실패 | 못 보낸 요청 | 히트율 | stores | menus | menu_images | 요청당 쿼리 | 요청당 읽은 행 | MySQL CPU(코어) | user-api CPU(코어) | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| 200 | 200 | 3.3 | 6.6 | 13.8 | 0.00% | 0 | 0.0% | 11998 | 12057 | 0 | 2.00 | 62 | 0.21 | 0.47 | +| 500 | 499 | 2.0 | 3.9 | 9.2 | 0.00% | 0 | 0.1% | 29995 | 30031 | 0 | 2.00 | 62 | 0.29 | 0.50 | +| 1000 | 998 | 1.3 | 4.6 | 42.8 | 0.00% | 0 | 0.4% | 59996 | 59793 | 0 | 2.00 | 62 | 0.39 | 0.62 | +| 2000 | 1982 | 7.8 | 151.8 | 312.6 | 0.00% | 816 | 7.4% | 119097 | 110409 | 0 | 1.93 | 57 | 0.92 | 2.10 | +| 3000 | 2205 | 815.7 | 1176.8 | 1469.5 | 0.00% | 45659 | 20.4% | 134241 | 106967 | 0 | 1.80 | 50 | 0.98 | 1.74 | + +## 3단계: 캐시 + +`LOCAL=1 RATES="200 500 1000" ... bench cache`, 이어서 `LOCAL=1 RATES="2000 3000" ... bench cache` + +| 목표 req/s | 처리 req/s | p50 ms | p95 ms | p99 ms | 실패 | 못 보낸 요청 | 히트율 | stores | menus | menu_images | 요청당 쿼리 | 요청당 읽은 행 | MySQL CPU(코어) | user-api CPU(코어) | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| 200 | 199 | 2.3 | 5.2 | 12.5 | 0.00% | 0 | 99.5% | 11993 | 124 | 0 | 1.01 | 1 | 0.11 | 0.42 | +| 500 | 499 | 1.4 | 4.1 | 13.9 | 0.00% | 0 | 99.8% | 29973 | 122 | 0 | 1.00 | 1 | 0.19 | 0.37 | +| 1000 | 997 | 1.0 | 2.2 | 8.3 | 0.00% | 0 | 99.9% | 59961 | 122 | 0 | 1.00 | 1 | 0.24 | 0.45 | +| 2000 | 1996 | 0.6 | 2.5 | 11.7 | 0.00% | 0 | 99.9% | 119959 | 125 | 0 | 1.00 | 1 | 0.26 | 0.91 | +| 3000 | 2992 | 0.5 | 3.0 | 17.8 | 0.00% | 162 | 100.0% | 179802 | 121 | 0 | 1.00 | 1 | 0.35 | 0.89 | + +## EXPLAIN (메뉴·이미지 JOIN, 주점 7) + +``` +인덱스 있음 +id select_type table type possible_keys key key_len ref rows filtered Extra +1 SIMPLE m ref idx_menus_store_id_deleted_sort_order idx_menus_store_id_deleted_sort_order 9 const,const 20 100.00 Using temporary; Using filesort +1 SIMPLE i ref FKqpgn1rya57y74a1ncxnc476mj FKqpgn1rya57y74a1ncxnc476mj 8 nowait_lab.m.id 1 100.00 NULL + +인덱스 없음 +1 SIMPLE m ALL NULL NULL NULL NULL 3978 5.00 Using where; Using temporary; Using filesort +1 SIMPLE i ref FKqpgn1rya57y74a1ncxnc476mj FKqpgn1rya57y74a1ncxnc476mj 8 nowait_lab.m.id 1 100.00 NULL +``` + +## EC2 와 다른 점 + +- 순서와 한계의 자리는 EC2 와 같았다(N+1 < JOIN < 인덱스 < 캐시). 숫자는 EC2 가 더 좋았다: 같은 단계·같은 속도에서 p95 가 더 낮고, N+1 의 처리 상한이 116~146 → 187 +- Mac 은 앱·k6 가 MySQL 과 같은 기계에서 돌아, p99 가 들쭉날쭉했다(예: 인덱스 1000 req/s 의 p99 42.8ms) diff --git a/performance/menu-cache/run.sh b/performance/menu-cache/run.sh new file mode 100755 index 00000000..a27c0a54 --- /dev/null +++ b/performance/menu-cache/run.sh @@ -0,0 +1,210 @@ +#!/bin/bash +# 메뉴 목록 조회 단계별 비교: 기존 N+1 → JOIN → JOIN+인덱스 → JOIN+인덱스+캐시. 결과는 performance/menu-cache/out/<이름>/ +# 기본은 쿠폰 프로젝트의 EC2 3대(db·app·k6, ~/Desktop/CouponToyProject/performance/ec2/README.md)를 Mac 에서 SSH 로만 다룬다 +# LOCAL=1 이면 Mac 한 대: MySQL·Redis 는 도커(EC2 db 처럼 각 1 CPU·1GB), 앱·k6 는 Mac. CPU 를 나눠 쓰니 절대값보다 단계 간 차이를 본다 +# JARS= run.sh setup jar·스크립트 올리기, 스키마·목업 데이터 만들기 +# run.sh verify 기존 코드와 JOIN 코드의 메뉴 목록 응답 비교(user·admin, 전 주점) + admin 목록 1회당 쿼리 수 +# run.sh bench <단계>... nplus1 | join | join-idx | cache. RATES 단계마다 측정 (손님 목록 조회 + 초당 WRITE_RATE 건 품절 토글) +# run.sh delay 부하 중 품절 토글 → 손님 조회 반영까지 걸린 시간 (cache) +# run.sh cleanup 앱 종료, nowait_lab 스키마 삭제 (LOCAL 은 컨테이너까지) +# STORES=200 RATES="200 500 1000" DURATION=60s WRITE_RATE=1 LIST_RATIO=1 DELAY_RATE=400 +set -u +cd "$(dirname "$0")/../.." +D=performance/menu-cache +STORES=${STORES:-200} +IDX=idx_menus_store_id_deleted_sort_order # Menu 엔티티의 @Index 와 같게 + +if [ "${LOCAL:-}" ]; then + WORK=$D/out/local; mkdir -p $WORK/logs + DB_PUB=local APP_PUB=local K6_PUB=local DB_PRIV=127.0.0.1 APP_PRIV=127.0.0.1 DB_PORT=13306 REDIS_PORT=16379 DB_PASSWORD=bench + MYSQL="docker exec -i -e MYSQL_PWD=$DB_PASSWORD nowait-bench-mysql mysql -uroot -h127.0.0.1" + on() { shift; (cd $WORK && bash -c "$*"); } + put() { shift; local f; for f in "${@:1:$#-1}"; do ln -sf "$(realpath "$f")" $WORK/; done; } + get() { cp $WORK/$2 $3; } + q() { $MYSQL -N nowait_lab -e "$1"; } + sqlf() { $MYSQL $1; } # 표준 입력의 SQL 실행 + mysql_usec() { docker exec nowait-bench-mysql cat /sys/fs/cgroup/cpu.stat | awk '$1=="usage_usec"{print $2}'; } +else + source ${HOSTS:-$HOME/Desktop/CouponToyProject/build/ec2/hosts.env} + DB_PORT=3306 REDIS_PORT=6379 + SSH_OPTS="-i $KEY -o StrictHostKeyChecking=accept-new -o LogLevel=ERROR -o ControlMaster=auto -o ControlPath=$HOME/.ssh/cm-%r@%h:%p -o ControlPersist=10m" + on() { local h=$1; shift; ssh $SSH_OPTS ubuntu@$h "$@"; } + put() { local h=$1; shift; scp -q $SSH_OPTS "${@:1:$#-1}" ubuntu@$h:"${@: -1}"; } + get() { scp -q $SSH_OPTS ubuntu@$1:"$2" "$3"; } + q() { on $DB_PUB "mysql -N nowait_lab -e \"$1\""; } + sqlf() { on $DB_PUB "mysql $1"; } + mysql_usec() { on $DB_PUB 'cat /sys/fs/cgroup/system.slice/docker-$(docker inspect -f "{{.Id}}" coupon-toy-mysql).scope/cpu.stat' | awk '$1=="usage_usec"{print $2}'; } +fi +now() { python3 -c 'import time; print(time.time())'; } + +# 운영 시작 스크립트와 같은 힙. SQL 로그는 끄고, 에러 알림이 팀 Discord 로 가지 않게 웹훅을 막는다 +APP_ARGS="--spring.datasource.url='jdbc:mysql://$DB_PRIV:$DB_PORT/nowait_lab?createDatabaseIfNotExist=true&serverTimezone=Asia/Seoul' \ + --spring.datasource.username=root --spring.datasource.password=$DB_PASSWORD \ + --spring.data.redis.host=$DB_PRIV --spring.data.redis.port=$REDIS_PORT --spring.data.redis.password= \ + --spring.jpa.show-sql=false --spring.jpa.properties.hibernate.show_sql=false \ + --discord.webhook.user-url=http://127.0.0.1:9 --discord.webhook.admin-url=http://127.0.0.1:9" +start() { # start <포트> <프로필> [추가 인자...] + local jar=$1 port=$2 profile=$3; shift 3 + on $APP_PUB "nohup java -Xms256m -Xmx512m -XX:+UseG1GC -Duser.timezone=Asia/Seoul -jar $jar --server.port=$port \ + --spring.profiles.active=$profile $APP_ARGS $* > logs/${jar%.jar}.log 2>&1 < /dev/null &" + local i; for i in $(seq 1 120); do on $APP_PUB "curl -sf -m 2 localhost:$port/health-check" > /dev/null && return 0; sleep 1; done + echo "$jar did not start"; on $APP_PUB "tail -30 logs/${jar%.jar}.log"; return 1 +} +# pkill·pgrep 패턴은 [j] [.] 로 써서 그 명령을 돌리는 원격 셸 자신과는 맞지 않게 한다 +running() { on $APP_PUB "pgrep -f '[j]ava .*${1%.jar}[.]jar' > /dev/null"; } +stop() { on $APP_PUB "pkill -f '[j]ava .*${1%.jar}[.]jar'; for i in \$(seq 1 30); do pgrep -f '[j]ava .*${1%.jar}[.]jar' > /dev/null || exit 0; sleep 1; done"; } +cpu_time() { on $APP_PUB "ps -o time= -p \$(pgrep -f '[j]ava .*${1%.jar}[.]jar')" | tr -d ' '; } # 그 JVM 의 누적 CPU 시간 + +cache_stats() { # hitmiss (캐시 없는 기존 코드는 빈 값) + on $APP_PUB "curl -s localhost:8081/nowait/management/prometheus" | awk '/^cache_gets_total\{/ && /cache="cache:menus"/ { + if ($0 ~ /result="hit"/) h=$2; else if ($0 ~ /result="miss"/) m=$2 } END { printf "%s\t%s", h, m }' +} +index() { # index on|off 앱(ddl-auto)이 만든 인덱스를 단계에 맞게 지우거나 다시 만든다 + q "DROP INDEX $IDX ON menus" 2>/dev/null + [ $1 = off ] || q "CREATE INDEX $IDX ON menus (store_id, deleted, sort_order)" +} + +setup() { + set -e + local J=${JARS:?JARS=} + if [ "${LOCAL:-}" ]; then + docker rm -f nowait-bench-mysql nowait-bench-redis > /dev/null 2>&1 || true + docker run -d --name nowait-bench-mysql --cpus=1 -m 1g -e MYSQL_ROOT_PASSWORD=$DB_PASSWORD -p $DB_PORT:3306 mysql:8.0.36 > /dev/null + docker run -d --name nowait-bench-redis --cpus=1 -m 1g -p $REDIS_PORT:6379 redis:7.2-alpine > /dev/null + until $MYSQL -e 'SELECT 1' > /dev/null 2>&1; do sleep 2; done + else + on $APP_PUB "command -v java >/dev/null || { sudo apt-get update -q && sudo apt-get install -yq openjdk-17-jre-headless; }; mkdir -p logs" + fi + put $APP_PUB $J/user-asis.jar $J/user-tobe.jar $J/admin-asis.jar $J/admin.jar $D/probe.sh $D/verify.sh . + put $K6_PUB $D/menu-load.js . + # 스키마는 앱이 만든다(ddl-auto: update). 두 앱이 동시에 만들면 FK 이름이 겹치므로 차례로 띄운다 + stop user-asis.jar; stop user-tobe.jar; stop admin-asis.jar; stop admin.jar + start user-tobe.jar 8081 user-local + start admin.jar 8085 admin-local + { echo "SET @stores = $STORES;"; cat $D/seed.sql; } | sqlf nowait_lab + on $APP_PUB "curl -s -X POST localhost:8085/admin/users/signup -H 'Content-Type: application/json' \ + -d '{\"email\":\"load@test.com\",\"password\":\"loadPass1!\",\"nickname\":\"loadtester\",\"socialType\":\"LOCAL\"}'"; echo + q "UPDATE users SET role = 'SUPER_ADMIN' WHERE email = 'load@test.com'" # 모든 주점의 admin 메뉴 목록을 볼 수 있게 + echo "stores menus menu_images users: $(q "SELECT (SELECT COUNT(*) FROM stores), (SELECT COUNT(*) FROM menus), (SELECT COUNT(*) FROM menu_images), (SELECT COUNT(*) FROM users)")" + stop user-tobe.jar +} + +verify() { + L=$D/out/verify; mkdir -p $L + running admin.jar || start admin.jar 8085 admin-local || return 1 + stop user-asis.jar; stop user-tobe.jar; stop admin-asis.jar + start user-asis.jar 8081 user-local && start user-tobe.jar 8082 user-local && start admin-asis.jar 8086 admin-local || return 1 + on $APP_PUB "STORES=$STORES bash verify.sh diff" | tee $L/diff.txt + local port; for port in 8086 8085; do + q "TRUNCATE TABLE performance_schema.events_statements_summary_by_digest" + on $APP_PUB "STORES=$STORES bash verify.sh admin $port" + echo "admin :$port 메뉴 목록 $STORES 회 — $(sqlf "-N nowait_lab" < $D/digest.sql | awk '{printf "%s %s건 ", $1, $2}')" + done | tee $L/admin-queries.txt + stop user-asis.jar; stop user-tobe.jar; stop admin-asis.jar +} + +level() { # level <이름> <초당 요청> <시간> + local name=$1 rate=$2 dur=$3 + q "TRUNCATE TABLE performance_schema.events_statements_summary_by_digest" + local c0=$(cache_stats) m0=$(mysql_usec) p0=$(cpu_time $JAR) t0=$(now) + on $K6_PUB "env USER_URL=http://$APP_PRIV:8081 ADMIN_URL=http://$APP_PRIV:8085 STORES=$STORES RATE=$rate DURATION=$dur \ + WRITE_RATE=${WRITE_RATE:-1} LIST_RATIO=${LIST_RATIO:-1} k6 run --quiet --no-color --summary-export k6-$name.json menu-load.js" > $L/$name.log 2>&1 + local ec=$? t1=$(now) c1=$(cache_stats) m1=$(mysql_usec) p1=$(cpu_time $JAR) + sqlf "-N nowait_lab" < $D/digest.sql > $L/$name.digest + get $K6_PUB k6-$name.json $L/$name.json + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' $name $rate "$c0" "$c1" $((m1 - m0)) $(python3 -c "print($t1 - $t0)") $p0 $p1 >> $L/runs.tsv + echo "$name k6_exit=$ec" +} + +bench() { + local cfg rate + for cfg in "$@"; do + local ttl= idx=on; JAR=user-tobe.jar + case $cfg in + nplus1) JAR=user-asis.jar idx=off ;; + join) ttl=0s idx=off ;; + join-idx) ttl=0s ;; + cache) ;; + *) echo "모르는 단계: $cfg"; return 1 ;; + esac + L=$D/out/$cfg; mkdir -p $L; : > $L/runs.tsv + running admin.jar || start admin.jar 8085 admin-local || return 1 + stop user-asis.jar; stop user-tobe.jar + q "UPDATE menus SET is_sold_out = 0" + start $JAR 8081 user-local ${ttl:+--nowait.cache.menu.ttl=$ttl} || return 1 + index $idx + level warm-$cfg 100 20s # 예열 (보고서에서 뺀다) + for rate in ${RATES:-200 500 1000}; do level $cfg-$rate $rate ${DURATION:-60s}; done + stop $JAR + report $L | tee $L/report.md + done +} + +report() { + python3 - "$1" <<'EOF' +import json, sys +L = sys.argv[1] +def secs(t): # ps 의 time: [D-]HH:MM:SS (Linux), [H:]M:SS.ss (macOS) + d, _, hms = t.rpartition('-') + s = 0.0 + for x in hms.split(':'): + s = s * 60 + float(x) + return s + int(d or 0) * 86400 +print('| 목표 req/s | 처리 req/s | p50 ms | p95 ms | p99 ms | 실패 | 못 보낸 요청 | 히트율 | stores | menus | menu_images | 요청당 쿼리 | 요청당 읽은 행 | MySQL CPU(코어) | user-api CPU(코어) |') +print('|' + '---|' * 15) +for line in open(f'{L}/runs.tsv'): + name, rate, h0, m0, h1, m1, mus, el, p0, p1 = line.rstrip('\n').split('\t') + if name.startswith('warm'): + continue + k = json.load(open(f'{L}/{name}.json'))['metrics'] + d, n = k['http_req_duration{scenario:reads}'], k['http_reqs{scenario:reads}'] + failed = k['http_req_failed{scenario:reads}'].get('value', 0) + dropped = k.get('dropped_iterations', {}).get('count', 0) + q = {t: (int(c), int(r)) for t, c, r, _ in (l.split('\t') for l in open(f'{L}/{name}.digest') if l.strip())} + hit, miss = float(h1 or 0) - float(h0 or 0), float(m1 or 0) - float(m0 or 0) + hr = f'{hit / (hit + miss) * 100:.1f}%' if hit + miss else '-' + calls = {t: q.get(t, (0, 0))[0] for t in ('stores', 'menus', 'menu_images')} + rows = sum(q.get(t, (0, 0))[1] for t in ('stores', 'menus', 'menu_images')) + el = float(el) + print(f"| {rate} | {n['rate']:.0f} | {d['med']:.1f} | {d['p(95)']:.1f} | {d['p(99)']:.1f} | {failed * 100:.2f}% | {dropped:.0f} " + f"| {hr} | {calls['stores']} | {calls['menus']} | {calls['menu_images']} | {sum(calls.values()) / n['count']:.2f} " + f"| {rows / n['count']:.0f} | {int(mus) / 1e6 / el:.2f} | {(secs(p1) - secs(p0)) / el:.2f} |") +EOF +} + +delay() { + L=$D/out/delay; mkdir -p $L + running admin.jar || start admin.jar 8085 admin-local || return 1 + stop user-asis.jar + running user-tobe.jar || start user-tobe.jar 8081 user-local || return 1 + index on + on $K6_PUB "nohup env USER_URL=http://$APP_PRIV:8081 ADMIN_URL=http://$APP_PRIV:8085 STORES=$STORES RATE=${DELAY_RATE:-400} DURATION=120s \ + WRITE_RATE=1 LIST_RATIO=${LIST_RATIO:-0.8} k6 run --quiet --no-color menu-load.js > delay-k6.log 2>&1 < /dev/null &" + sleep 15 + on $APP_PUB "N=${N:-30} bash probe.sh" | tee $L/probe.txt + on $K6_PUB "while pgrep -x k6 > /dev/null; do sleep 1; done" + python3 - $L/probe.txt <<'EOF' | tee $L/report.md +import statistics, sys +rows = [list(map(int, l.split())) for l in open(sys.argv[1]) if l.strip()] +toggle, reflect, stale = zip(*rows) +p = lambda xs, q: sorted(xs)[min(len(xs) - 1, int(len(xs) * q))] +print(f'토글 {len(rows)}회 | 토글 응답 p50 {p(toggle, .5)}ms p95 {p(toggle, .95)}ms ' + f'| 응답 후 반영 확인 p50 {p(reflect, .5)}ms p95 {p(reflect, .95)}ms 최대 {max(reflect)}ms ' + f'| 옛 값을 받은 조회 {sum(stale)}회') +EOF +} + +cleanup() { + stop user-asis.jar; stop user-tobe.jar; stop admin-asis.jar; stop admin.jar + if [ "${LOCAL:-}" ]; then docker rm -f nowait-bench-mysql nowait-bench-redis > /dev/null; else q "DROP DATABASE IF EXISTS nowait_lab"; fi +} + +case ${1:-} in + setup) setup ;; + verify) verify ;; + bench) shift; bench "$@" ;; + delay) delay ;; + report) report "$2" ;; + cleanup) cleanup ;; + *) sed -n 2,10p "$0"; exit 1 ;; +esac diff --git a/performance/menu-cache/seed.sql b/performance/menu-cache/seed.sql new file mode 100644 index 00000000..cb3dbf81 --- /dev/null +++ b/performance/menu-cache/seed.sql @@ -0,0 +1,25 @@ +-- 목업: 학과 5, 주점 @stores, 주점당 메뉴 20. 메뉴 id = (store_id - 1) * 20 + n, 공개 코드 = LOAD + 8자리 store_id +-- 이미지: 메뉴당 1장, 단 2번 메뉴는 2장·20번 메뉴는 없음 (JOIN 결과를 메뉴별로 묶는지, 이미지 없는 메뉴가 빠지지 않는지 확인용) +-- 실행 전에 SET @stores = <주점 수>; +SET SESSION cte_max_recursion_depth = 1000000; +DELETE FROM menu_images; +DELETE FROM menus; +DELETE FROM stores; +DELETE FROM departments; + +INSERT INTO departments (id, name) +WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 5) +SELECT i, CONCAT('dept-', i) FROM n; + +INSERT INTO stores (store_id, created_at, deleted, department_id, is_active, name, public_code, location, description) +WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < @stores) +SELECT i, NOW(), 0, i % 5 + 1, 1, CONCAT('store-', i), CONCAT('LOAD', LPAD(i, 8, '0')), 'location', 'description' FROM n; + +INSERT INTO menus (id, created_at, admin_display_name, deleted, description, is_sold_out, name, price, sort_order, store_id) +WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < @stores * 20) +SELECT i, NOW(), CONCAT('menu-', i), 0, 'description', 0, CONCAT('menu-', i), 1000 * ((i - 1) % 20 + 1), (i - 1) % 20 + 1, (i - 1) DIV 20 + 1 FROM n; + +INSERT INTO menu_images (id, created_at, file_key, image_url, menu_id) +SELECT id, NOW(), CONCAT('menu/', id), CONCAT('https://img.example.com/menu/', id, '.png'), id FROM menus WHERE sort_order <> 20 +UNION ALL +SELECT id + @stores * 20, NOW(), CONCAT('menu/', id, '-2'), CONCAT('https://img.example.com/menu/', id, '-2.png'), id FROM menus WHERE sort_order = 2; diff --git a/performance/menu-cache/verify.sh b/performance/menu-cache/verify.sh new file mode 100644 index 00000000..72eb4e57 --- /dev/null +++ b/performance/menu-cache/verify.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# app 인스턴스에서 실행 +# diff 기존(user :8081, admin :8086)과 JOIN(user :8082, admin :8085)의 메뉴 목록 응답을 전 주점 비교 +# admin <포트> 그 admin 으로 전 주점 메뉴 목록을 한 번씩 조회 (쿼리 수 집계용) +T=$(curl -s -X POST localhost:8085/admin/users/login -H 'Content-Type: application/json' \ + -d '{"email":"load@test.com","password":"loadPass1!"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["response"]["body"]["accessToken"])') +user() { curl -s localhost:$1/v1/stores/LOAD$(printf %08d $2)/menus; } +admin() { curl -s localhost:$1/admin/menus/all-menus/stores/$2 -H "Authorization: Bearer $T"; } +case $1 in + diff) + for kind in user admin; do + if [ $kind = user ]; then old=8081 new=8082; else old=8086 new=8085; fi + same=0 diff=0 + for s in $(seq $STORES); do + a=$($kind $old $s) b=$($kind $new $s) + if [ "$a" = "$b" ] && [[ $a == *'"menuId"'* ]]; then same=$((same + 1)); continue; fi + diff=$((diff + 1)) + [ $diff = 1 ] && printf '%s 주점 %s 다름\n기존: %.600s\nJOIN: %.600s\n' $kind $s "$a" "$b" + done + echo "$kind 메뉴 목록: 같음 $same / 다름 $diff" + done ;; + admin) for s in $(seq $STORES); do admin $2 $s > /dev/null; done ;; +esac diff --git a/scripts/deploy-admin-docker.sh b/scripts/deploy-admin-docker.sh new file mode 100644 index 00000000..93297fbb --- /dev/null +++ b/scripts/deploy-admin-docker.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +REPOSITORY="/home/ubuntu/spring-github-action-admin-docker" +cd $REPOSITORY + +START_LOG="$REPOSITORY/start.log" + +echo "1. find container id" +CONTAINER_ID=$(docker ps -q --filter "name=nowait-app-admin-api") +CONTAINER_PROMETHEUS_ID=$(docker ps -aq --filter "name=prometheus-admin") +CONTAINER_GRAFANA_ID=$(docker ps -aq --filter "name=grafana-admin") + +echo "2. stop container" +if [ -n "$CONTAINER_ID" ]; then + echo "Stopping container $CONTAINER_ID" + docker rm -f "$CONTAINER_ID" +else + echo "No admin container found." +fi + +echo "Cleaning up old containers…" +docker-compose -f docker-compose.admin.yml -f docker-compose.admin-monitoring.yml -p nowait_dev_admin down + +echo "3. start container" +sudo docker-compose -f docker-compose.admin.yml -f docker-compose.admin-monitoring.yml -p nowait_dev_admin pull nowait-app-admin-api prometheus-admin grafana-admin +sudo docker-compose -f docker-compose.admin.yml -f docker-compose.admin-monitoring.yml -p nowait_dev_admin up -d nowait-app-admin-api prometheus-admin grafana-admin + +echo "4. check container status" +NEW_CONTAINER_ID=$(docker ps -q --filter "name=nowait-app-admin-api") +NOW=$(date +%c) + +echo "[$NOW] > Container ID: $NEW_CONTAINER_ID" diff --git a/scripts/deploy-user-docker.sh b/scripts/deploy-user-docker.sh new file mode 100644 index 00000000..46c78a4f --- /dev/null +++ b/scripts/deploy-user-docker.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +REPOSITORY="/home/ubuntu/spring-github-action-user-docker" +cd $REPOSITORY + +START_LOG="$REPOSITORY/start.log" + +echo "1. find container id" +CONTAINER_ID=$(docker ps -q --filter "name=nowait-app-user-api") +CONTAINER_PROMETHEUS_ID=$(docker ps -aq --filter "name=prometheus-user") +CONTAINER_GRAFANA_ID=$(docker ps -aq --filter "name=grafana-user") + +echo "2. stop container" +if [ -n "$CONTAINER_ID" ]; then + echo "Stopping container $CONTAINER_ID" + docker rm -f "$CONTAINER_ID" +else + echo "No User container found." +fi + +echo "Cleaning up old containers…" +docker-compose -f docker-compose.user.yml -f docker-compose.user-monitoring.yml -p nowait_dev_user down + +echo "3. start container" +sudo docker-compose -f docker-compose.user.yml -f docker-compose.user-monitoring.yml -p nowait_dev_user pull nowait-app-user-api prometheus-user grafana-user +sudo docker-compose -f docker-compose.user.yml -f docker-compose.user-monitoring.yml -p nowait_dev_user up -d nowait-app-user-api prometheus-user grafana-user + +echo "4. check container status" +NEW_CONTAINER_ID=$(docker ps -q --filter "name=nowait-app-user-api") +NOW=$(date +%c) + +echo "[$NOW] > Container ID: $NEW_CONTAINER_ID" diff --git a/scripts/start-admin.sh b/scripts/start-admin.sh new file mode 100644 index 00000000..fbab278a --- /dev/null +++ b/scripts/start-admin.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +REPOSITORY="/home/ubuntu/spring-github-action-admin" +cd $REPOSITORY + +APP_NAME=nowait-app-admin-api +JAR_NAME=$(ls $REPOSITORY | grep '.jar' | tail -n 1) +JAR_PATH=$REPOSITORY/$JAR_NAME + +START_LOG="$REPOSITORY/start.log" +ERROR_LOG="$REPOSITORY/error.log" +APP_LOG="$REPOSITORY/application.log" + +$ chmod 666 "$START_LOG" +$ chmod 666 "$ERROR_LOG" +$ chmod 666 "$APP_LOG" + +SERVICE_PID=$(pgrep -f ${APP_NAME}.*.jar) # 실행중인 Spring 서버의 PID +NOW=$(date +%c) + + +echo "[$NOW] > $JAR_PATH 실행" >> $START_LOG +nohup java -Xms256m -Xmx512m -XX:+UseG1GC -jar $JAR_PATH --spring.profiles.active=admin > $APP_LOG 2> $ERROR_LOG & + +echo "[$NOW] > 서비스 PID: $SERVICE_PID" >> $START_LOG diff --git a/scripts/start-user.sh b/scripts/start-user.sh new file mode 100644 index 00000000..366e247b --- /dev/null +++ b/scripts/start-user.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +REPOSITORY="/home/ubuntu/spring-github-action-user" +cd $REPOSITORY + +APP_NAME=nowait-app-user-api +JAR_NAME=$(ls $REPOSITORY | grep '.jar' | tail -n 1) +JAR_PATH=$REPOSITORY/$JAR_NAME + +START_LOG="$REPOSITORY/start.log" +ERROR_LOG="$REPOSITORY/error.log" +APP_LOG="$REPOSITORY/application.log" + +$ chmod 666 $START_LOG +$ chmod 666 $ERROR_LOG +$ chmod 666 $APP_LOG + +SERVICE_PID=$(pgrep -f ${APP_NAME}.*.jar) # 실행중인 Spring 서버의 PID +NOW=$(date +%c) + + +echo "[$NOW] > $JAR_PATH 실행" >> $START_LOG +nohup java -Xms256m -Xmx512m -XX:+UseG1GC -jar $JAR_PATH --spring.profiles.active=user > $APP_LOG 2> $ERROR_LOG & + +echo "[$NOW] > 서비스 PID: $SERVICE_PID" >> $START_LOG diff --git a/scripts/stop-admin.sh b/scripts/stop-admin.sh new file mode 100644 index 00000000..9c59f9ee --- /dev/null +++ b/scripts/stop-admin.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +REPOSITORY="/home/ubuntu/spring-github-action-admin" +cd $REPOSITORY + +APP_NAME=nowait-app-admin-api +JAR_NAME=$(ls $REPOSITORY | grep '.jar' | tail -n 1) +JAR_PATH=$REPOSITORY/$JAR_NAME +STOP_LOG="$REPOSITORY/stop.log" + +$ chmod 666 "$STOP_LOG" + +CURRENT_PID=$(pgrep -f ${APP_NAME}.*.jar) + +if [ -z $CURRENT_PID ] +then + echo "서비스 NouFound" >> $STOP_LOG +else + echo "> kill -15 $CURRENT_PID" + kill -15 $CURRENT_PID + sleep 5 +fi diff --git a/scripts/stop-user.sh b/scripts/stop-user.sh new file mode 100644 index 00000000..0b112c68 --- /dev/null +++ b/scripts/stop-user.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +REPOSITORY="/home/ubuntu/spring-github-action-user" +cd $REPOSITORY + +APP_NAME=nowait-app-user-api +JAR_NAME=$(ls $REPOSITORY | grep '.jar' | tail -n 1) +JAR_PATH=$REPOSITORY/$JAR_NAME +STOP_LOG="$REPOSITORY/stop.log" + +$ chmod 666 "$STOP_LOG" + +CURRENT_PID=$(pgrep -f ${APP_NAME}.*.jar) + +if [ -z $CURRENT_PID ] +then + echo "서비스 NouFound" >> $STOP_LOG +else + echo "> kill -15 $CURRENT_PID" + kill -15 $CURRENT_PID + sleep 5 +fi diff --git a/security-admin/.gitattributes b/security-admin/.gitattributes deleted file mode 100644 index 8af972cd..00000000 --- a/security-admin/.gitattributes +++ /dev/null @@ -1,3 +0,0 @@ -/gradlew text eol=lf -*.bat text eol=crlf -*.jar binary diff --git a/security-admin/.gitignore b/security-admin/.gitignore deleted file mode 100644 index c2065bc2..00000000 --- a/security-admin/.gitignore +++ /dev/null @@ -1,37 +0,0 @@ -HELP.md -.gradle -build/ -!gradle/wrapper/gradle-wrapper.jar -!**/src/main/**/build/ -!**/src/test/**/build/ - -### STS ### -.apt_generated -.classpath -.factorypath -.project -.settings -.springBeans -.sts4-cache -bin/ -!**/src/main/**/bin/ -!**/src/test/**/bin/ - -### IntelliJ IDEA ### -.idea -*.iws -*.iml -*.ipr -out/ -!**/src/main/**/out/ -!**/src/test/**/out/ - -### NetBeans ### -/nbproject/private/ -/nbbuild/ -/dist/ -/nbdist/ -/.nb-gradle/ - -### VS Code ### -.vscode/ diff --git a/security-admin/build.gradle b/security-admin/build.gradle deleted file mode 100644 index 2a00307c..00000000 --- a/security-admin/build.gradle +++ /dev/null @@ -1,37 +0,0 @@ -plugins { - id 'java-library' -} - -group = 'com.nowaiting' -version = rootProject.version - -java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } -} - -repositories { - mavenCentral() -} - -dependencies { - implementation project(':common') - implementation project(':domain-user') - implementation project(':domain-base') - implementation project(':domain-token') - // SPRING SECURITY - api 'org.springframework.boot:spring-boot-starter-security' - api 'org.springframework.boot:spring-boot-starter-test' - // Lombok (optional) - compileOnly 'org.projectlombok:lombok:1.18.26' - annotationProcessor 'org.projectlombok:lombok:1.18.26' - // jwt - implementation 'io.jsonwebtoken:jjwt-api:0.12.3' - implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' - implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' - implementation 'jakarta.servlet:jakarta.servlet-api:6.0.0' - implementation 'com.auth0:java-jwt:4.4.0' - // SWAGGER - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' -} diff --git a/security-admin/src/main/java/com/nowait/adminsecurity/auth/config/CorsConfig.java b/security-admin/src/main/java/com/nowait/adminsecurity/auth/config/CorsConfig.java deleted file mode 100644 index 0c8ce56d..00000000 --- a/security-admin/src/main/java/com/nowait/adminsecurity/auth/config/CorsConfig.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.nowait.adminsecurity.auth.config; - -import java.util.List; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.CorsConfigurationSource; -import org.springframework.web.cors.UrlBasedCorsConfigurationSource; - -@Configuration -public class CorsConfig { - @Bean - public CorsConfigurationSource corsConfigurationSource() { - CorsConfiguration config = new CorsConfiguration(); - - config.setAllowCredentials(true); // 쿠키나 인증헤더 자격증명 허용 - config.setAllowedOrigins(List.of("http://localhost:3000", "http://localhost:8083")); // 허용할 출처 설정 - config.setAllowedMethods(List.of("GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS")); // 메서드 허용 - config.setAllowedHeaders(List.of("*")); //클라이언트가 보낼 수 있는 헤더 - config.setExposedHeaders(List.of("Authorization")); //클라이언트(브라우저)가 접근할 수 있는 헤더 지정 - // config.setAllowCredentials(true); // 쿠키 포함 허용 - - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", config); //** 뜻은 모든 URL 경로에 적용한다는 의미 - return source; - } -} diff --git a/security-admin/src/main/java/com/nowait/adminsecurity/exception/TokenBadRequestException.java b/security-admin/src/main/java/com/nowait/adminsecurity/exception/TokenBadRequestException.java deleted file mode 100644 index 146a36b1..00000000 --- a/security-admin/src/main/java/com/nowait/adminsecurity/exception/TokenBadRequestException.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.nowait.adminsecurity.exception; - -import com.example.domaintoken.exception.BusinessException; -import com.nowaiting.common.exception.ErrorMessage; - -public class TokenBadRequestException extends BusinessException { - public TokenBadRequestException() { - super(ErrorMessage.DOES_NOT_MATCH_REFRESH_TOKEN); - } - -} diff --git a/security-front/.gitignore b/security-front/.gitignore deleted file mode 100644 index c2065bc2..00000000 --- a/security-front/.gitignore +++ /dev/null @@ -1,37 +0,0 @@ -HELP.md -.gradle -build/ -!gradle/wrapper/gradle-wrapper.jar -!**/src/main/**/build/ -!**/src/test/**/build/ - -### STS ### -.apt_generated -.classpath -.factorypath -.project -.settings -.springBeans -.sts4-cache -bin/ -!**/src/main/**/bin/ -!**/src/test/**/bin/ - -### IntelliJ IDEA ### -.idea -*.iws -*.iml -*.ipr -out/ -!**/src/main/**/out/ -!**/src/test/**/out/ - -### NetBeans ### -/nbproject/private/ -/nbbuild/ -/dist/ -/nbdist/ -/.nb-gradle/ - -### VS Code ### -.vscode/ diff --git a/security-front/src/main/java/com/nowait/auth/oauth2/OAuth2LoginSuccessHandler.java b/security-front/src/main/java/com/nowait/auth/oauth2/OAuth2LoginSuccessHandler.java deleted file mode 100644 index b483f6d9..00000000 --- a/security-front/src/main/java/com/nowait/auth/oauth2/OAuth2LoginSuccessHandler.java +++ /dev/null @@ -1,80 +0,0 @@ -package com.nowait.auth.oauth2; - -import java.io.IOException; -import java.time.LocalDateTime; -import java.util.Collection; -import java.util.Iterator; -import java.util.Map; - -import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; -import org.springframework.stereotype.Component; - -import com.example.domaintoken.entity.Token; -import com.example.domaintoken.repository.TokenRepository; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.nowait.auth.dto.CustomOAuth2User; -import com.nowait.auth.jwt.JwtUtil; -import com.nowaiting.user.entity.User; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -// 카카오 로그인 성공 시, 콜백 핸들러 -// 1. JWT 토큰 발급 -// - 이때, JWT payload는 보안상 최소한의 정보(userId, role)만 담겠다 -// 2. refreshToken만 DB에 저장 -// 3. JSON 응답으로, accessToken과 refreshToken 을 반환해준다. -@Component -@RequiredArgsConstructor -@Slf4j -public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { - private final JwtUtil jwtUtil; - private final TokenRepository tokenRepository; - - @Override - public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, - Authentication authentication) throws IOException { - - // 1. CustomOAuth2UserService에서 설정한 OAuth2User 정보 가져오기 - CustomOAuth2User customUserDetails = (CustomOAuth2User)authentication.getPrincipal(); - - User user = customUserDetails.getUser(); - Long userId = customUserDetails.getUserId(); - String email = customUserDetails.getName(); - - Collection authorities = authentication.getAuthorities(); - Iterator iterator = authorities.iterator(); - GrantedAuthority auth = iterator.next(); - - String role = auth.getAuthority(); - - log.info("user, userId, email, role :: {} {} {} {}", user, userId, email, role); - - // 2. 1)의 사용자 정보를 담아, accessToken과 refreshToken 발행 - String accessToken = jwtUtil.createAccessToken("accessToken", userId, role, 30 * 60 * 1000L); // 유효기간 30분 - String refreshToken = jwtUtil.createRefreshToken("refreshToken", userId, - 30 * 24 * 60 * 60 * 1000L); // 유효기간 30일 - - // 3. refreshToken을 DB에 저장 - Token refreshTokenEntity = Token.toEntity(user, refreshToken, LocalDateTime.now().plusDays(30)); - tokenRepository.save(refreshTokenEntity); - - // 4. JSON 응답으로, accessToken과 refreshToken 을 반환해준다. - response.setContentType("application/json"); - response.setCharacterEncoding("utf-8"); - - ObjectMapper objectMapper = new ObjectMapper(); // 객체 -> json 문자열로 변환 - String body = objectMapper.writeValueAsString( - Map.of( - "accessToken", accessToken, - "refreshToken", refreshToken - ) - ); - response.getWriter().write(body); - } - -} diff --git a/security-front/src/main/java/com/nowait/exception/BusinessException.java b/security-front/src/main/java/com/nowait/exception/BusinessException.java deleted file mode 100644 index 60eec165..00000000 --- a/security-front/src/main/java/com/nowait/exception/BusinessException.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.nowait.exception; - -public abstract class BusinessException extends RuntimeException { - private final ErrorMessage errorMessage; - - protected BusinessException(ErrorMessage errorMessage) { - super(errorMessage.getMessage()); - this.errorMessage = errorMessage; - } - - public String getCode() { - return errorMessage.getCode(); - } -} diff --git a/security-front/src/main/java/com/nowait/exception/ErrorMessage.java b/security-front/src/main/java/com/nowait/exception/ErrorMessage.java deleted file mode 100644 index c365a92e..00000000 --- a/security-front/src/main/java/com/nowait/exception/ErrorMessage.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.nowait.exception; - -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -@Getter -@RequiredArgsConstructor -public enum ErrorMessage { - // global - INVALID_INPUT_VALUE("입력값이 올바르지 않습니다.", "global001"), - - // auth - UNAUTHORIZED("권한이 없습니다", "auth001"), - - // token - REFRESH_TOKEN_NOT_FOUND("기존 리프레시 토큰을 찾을 수 없습니다.", "token001"), - DOES_NOT_MATCH_REFRESH_TOKEN("기존 리프레시 토큰이 일치하지 않습니다.", "token002"); - - private final String message; - private final String code; - -} diff --git a/security-front/src/main/java/com/nowait/exception/ErrorResponse.java b/security-front/src/main/java/com/nowait/exception/ErrorResponse.java deleted file mode 100644 index a15935e8..00000000 --- a/security-front/src/main/java/com/nowait/exception/ErrorResponse.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.nowait.exception; - -import java.util.HashMap; -import java.util.Map; - -import lombok.Getter; - -@Getter -public class ErrorResponse { - private final String message; - private final String code; - private final Map errors; - - public ErrorResponse(String message, String code) { - this.message = message; - this.code = code; - errors = new HashMap<>(); - } - - public ErrorResponse(String message, String code, Map errors) { - this.message = message; - this.code = code; - this.errors = errors; - } - -} diff --git a/security-front/src/main/java/com/nowait/exception/GlobalExceptionHandler.java b/security-front/src/main/java/com/nowait/exception/GlobalExceptionHandler.java deleted file mode 100644 index b9f7c1e2..00000000 --- a/security-front/src/main/java/com/nowait/exception/GlobalExceptionHandler.java +++ /dev/null @@ -1,108 +0,0 @@ -package com.nowait.exception; - -import static com.nowait.exception.ErrorMessage.*; -import static org.springframework.http.HttpStatus.*; -import static org.springframework.http.HttpStatus.UNAUTHORIZED; - -import java.util.Map; -import java.util.stream.Collectors; - -import org.springframework.http.converter.HttpMessageNotReadableException; -import org.springframework.security.oauth2.core.OAuth2AuthenticationException; -import org.springframework.validation.FieldError; -import org.springframework.validation.ObjectError; -import org.springframework.web.bind.MethodArgumentNotValidException; -import org.springframework.web.bind.MissingRequestValueException; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.ResponseStatus; -import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.web.multipart.MultipartException; - -import io.swagger.v3.oas.annotations.Hidden; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@Hidden -@RestControllerAdvice -public class GlobalExceptionHandler { - - // OAUTH 인증 실패 에러처리 메서드 - @ResponseStatus(value = BAD_REQUEST) - @ExceptionHandler(OAuth2AuthenticationException.class) - public ErrorResponse handlerOAuth2AuthenticationException(OAuth2AuthenticationException e) { - log.error("handleOAuth2AuthenticationException", e); - - return new ErrorResponse("OAuth 인증 실패 : " + e.getMessage(), INVALID_INPUT_VALUE.getCode()); - } - - @ResponseStatus(value = BAD_REQUEST) - @ExceptionHandler(BusinessException.class) - public ErrorResponse handleBusinessException(BusinessException e) { - log.error("handleBusinessException", e); - return new ErrorResponse(e.getMessage(), e.getCode()); - } - - @ResponseStatus(value = BAD_REQUEST) - @ExceptionHandler(MethodArgumentNotValidException.class) - public ErrorResponse handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { - log.error("handleMethodArgumentNotValidException", e); - Map errors = getErrors(e); - return new ErrorResponse(INVALID_INPUT_VALUE.getMessage(), INVALID_INPUT_VALUE.getCode(), errors); - } - - @ResponseStatus(value = BAD_REQUEST) - @ExceptionHandler(HttpMessageNotReadableException.class) - public ErrorResponse handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { - log.error("handleHttpMessageNotReadableException", e); - return new ErrorResponse(INVALID_INPUT_VALUE.getMessage(), INVALID_INPUT_VALUE.getCode()); - } - - @ResponseStatus(value = BAD_REQUEST) - @ExceptionHandler(IllegalArgumentException.class) - public ErrorResponse handleIllegalArgumentException(IllegalArgumentException e) { - log.error("handleIllegalArgumentException", e); - return new ErrorResponse(e.getMessage(), INVALID_INPUT_VALUE.getCode()); - } - - @ResponseStatus(value = BAD_REQUEST) - @ExceptionHandler(MissingRequestValueException.class) - public ErrorResponse handleMissingRequestValueException(MissingRequestValueException e) { - log.error("handleMissingRequestValueExceptionException", e); - return new ErrorResponse(INVALID_INPUT_VALUE.getMessage(), INVALID_INPUT_VALUE.getCode()); - } - - @ResponseStatus(value = UNAUTHORIZED) - @ExceptionHandler(UnauthorizedException.class) - public ErrorResponse handleUnauthorizedException(UnauthorizedException e) { - log.error("handleUnauthorizedExceptionException", e); - return new ErrorResponse(e.getMessage(), e.getCode()); - } - - @ResponseStatus(value = NOT_FOUND) - @ExceptionHandler(ResourceNotFoundException.class) - public ErrorResponse handleResourceNotFoundException(ResourceNotFoundException e) { - log.error("handleResourceNotFoundExceptionException", e); - return new ErrorResponse(e.getMessage(), e.getCode()); - } - - @ResponseStatus(value = BAD_REQUEST) - @ExceptionHandler(MultipartException.class) - public ErrorResponse handleMultipartException(MultipartException e) { - log.error("handleMultipartException", e); - return new ErrorResponse(e.getMessage(), INVALID_INPUT_VALUE.getCode()); - } - - - private static Map getErrors(MethodArgumentNotValidException e) { - return e.getBindingResult() - .getAllErrors() - .stream() - .filter(ObjectError.class::isInstance) - .collect(Collectors.toMap( - error -> error instanceof FieldError ? ((FieldError)error).getField() : error.getObjectName(), - ObjectError::getDefaultMessage, - (msg1, msg2) -> msg1 + ";" + msg2 - )); - } - -} diff --git a/security-front/src/main/java/com/nowait/exception/TokenBadRequestException.java b/security-front/src/main/java/com/nowait/exception/TokenBadRequestException.java deleted file mode 100644 index 504852ee..00000000 --- a/security-front/src/main/java/com/nowait/exception/TokenBadRequestException.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.nowait.exception; - -public class TokenBadRequestException extends BusinessException { - public TokenBadRequestException() { - super(ErrorMessage.DOES_NOT_MATCH_REFRESH_TOKEN); - } - -} diff --git a/settings.gradle b/settings.gradle index 501d4ca7..79aeb646 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,18 +1,11 @@ -rootProject.name = 'nowaiting' - -include 'common' -include 'domain-base' -include 'domain-user' -include 'domain-menu' -include 'domain-store' -include 'domain-token' -include 'domain-bookmark' -include 'domain-order' -include 'domain-reservation' -include 'api-user' -include 'api-admin' -include 'infra-aws' -include 'security-admin' -include 'security-front' -include 'application-config' +rootProject.name = 'nowait' +include 'nowait-app-user-api' +include 'nowait-app-admin-api' +include 'nowait-common' +include 'nowait-domain:domain-core-rdb' +include 'nowait-domain:domain-admin-rdb' +include 'nowait-domain:domain-user-rdb' +include 'nowait-domain:domain-redis' +include 'nowait-event' +include 'nowait-infra' diff --git a/src/main/java/org/example/nowaiting/NowaitingApplication.java b/src/main/java/org/example/nowaiting/NowaitingApplication.java deleted file mode 100644 index 489db5d7..00000000 --- a/src/main/java/org/example/nowaiting/NowaitingApplication.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.example.nowaiting; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication -public class NowaitingApplication { - - public static void main(String[] args) { - SpringApplication.run(NowaitingApplication.class, args); - } - -} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 6de0033f..00000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=nowaiting diff --git a/src/test/java/org/example/nowaiting/NowaitingApplicationTests.java b/src/test/java/org/example/nowaiting/NowaitingApplicationTests.java deleted file mode 100644 index 2465630a..00000000 --- a/src/test/java/org/example/nowaiting/NowaitingApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.example.nowaiting; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class NowaitingApplicationTests { - - @Test - void contextLoads() { - } - -}