diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 00000000..0bd28cc8 --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,101 @@ +## ๐Ÿ“Œ CI/CD ๊ฐœ์š” +์ด ํ”„๋กœ์ ํŠธ๋Š” **GitHub Actions + Docker + Nginx + Blue-Green Deployment**๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ **์ž๋™ํ™”๋œ CI/CD**๋ฅผ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค. + +## โœ… CI (Continuous Integration) +| ๋ธŒ๋žœ์น˜ | ๋™์ž‘ | +|---------------------------------|-------------------------------------------| +| `feature/*, bugfix/*, hotfix/*` | **๋นŒ๋“œ & Docker Push** (`kok-CI.yml`) | +| `develop` | **๋นŒ๋“œ & Docker Push** (`kok-CI.yml`) | + +--- + +## โœ… CD (Continuous Deployment) +| ๋ธŒ๋žœ์น˜ | ๋™์ž‘ | +|-----------|-------------------------------------------| +| `develop` | **๊ฐœ๋ฐœ ์„œ๋ฒ„** ๋ฐฐํฌ (`kok-dev-CD.yml`) | +| `main` | **์šด์˜ ์„œ๋ฒ„ (Blue-Green ๋ฐฐํฌ, ์ˆ˜๋™ ์‹คํ–‰)** (`kok-prod-CD.yml`) | + +--- + +## ๐Ÿ“‚ CI/CD ์›Œํฌํ”Œ๋กœ์šฐ ํŒŒ์ผ ์„ค๋ช… + +| ํŒŒ์ผ๋ช… | ์„ค๋ช… | +|-------------------------|--------------------| +| `kok-CI.yml` | ๋นŒ๋“œ ๋ฐ Docker ๋ฐฐํฌ | +| `kok-dev-CD.yml` | ๊ฐœ๋ฐœ ์„œ๋ฒ„ ๋ฐฐํฌ | +| `kok-prod-CD.yml` | ์šด์˜ ์„œ๋ฒ„ (Blue-Green) ๋ฐฐํฌ | +| `blue-green-Nginx.conf` | Nginx ์„ค์ • (ํŠธ๋ž˜ํ”ฝ ์Šค์œ„์นญ) | + +--- + +## ๐Ÿ”‘ **ํ•„์š”ํ•œ GitHub Secrets ๋ชฉ๋ก** +| ์ด๋ฆ„ | ์„ค๋ช… | +|------|------| +| `DOCKERHUB_USERNAME` | Docker Hub ๋กœ๊ทธ์ธ ID | +| `DOCKERHUB_PASSWORD` | Docker Hub ๋กœ๊ทธ์ธ ๋น„๋ฐ€๋ฒˆํ˜ธ | +| `NCP_HOST` | NCP ์„œ๋ฒ„ ์ฃผ์†Œ (๋ฐฐํฌ ์„œ๋ฒ„ IP) | +| `NCP_USER` | NCP ์„œ๋ฒ„ ๋กœ๊ทธ์ธ ๊ณ„์ • (๋ณดํ†ต `ubuntu` ๋˜๋Š” `root`) | +| `NCP_KEY` | NCP ์„œ๋ฒ„ ์ ‘๊ทผ์„ ์œ„ํ•œ SSH Key (pem ํŒŒ์ผ ๋‚ด์šฉ) | +| `COMPOSE_FILE_PATH` | Docker Compose๊ฐ€ ์‹คํ–‰๋  ์„œ๋ฒ„ ๊ฒฝ๋กœ | +| `IMAGE_NAME` | Docker Hub์— ์—…๋กœ๋“œํ•  ์ด๋ฏธ์ง€๋ช… | + +--- + +## ๐Ÿ› ๏ธ GitHub Actions ์‹คํ–‰ ๋ฐฉ๋ฒ• + +### 1๏ธโƒฃ **์ˆ˜๋™ ์‹คํ–‰ for Prod(`workflow_dispatch`)** +GitHub Actions์—์„œ `Run Workflow` ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ๋ฐฐํฌํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + +| ๋ธŒ๋žœ์น˜ | ์‹คํ–‰ ์›Œํฌํ”Œ๋กœ์šฐ| +|---------|-----------------------------| +| `main` | `kok-prod-CD.yml` (์šด์˜ ์„œ๋ฒ„ ๋ฐฐํฌ) | + +### 2๏ธโƒฃ **์ž๋™ ์‹คํ–‰ for Dev & CI** +| ๋ธŒ๋žœ์น˜ | ํŠธ๋ฆฌ๊ฑฐ | ์‹คํ–‰๋˜๋Š” ์›Œํฌํ”Œ๋กœ์šฐ | +|-----------|--------|------------------------------| +| `feature/*, bugfix/*, hotfix/*` | `PR` | `kok-CI.yml` (๋นŒ๋“œ & Docker ๋ฐฐํฌ) | +| `develop` | `PR` | `kok-CI.yml` (๋นŒ๋“œ & Docker ๋ฐฐํฌ) | +| `develop` | `Push` | `kok-CI.yml` (๋นŒ๋“œ & Docker ๋ฐฐํฌ) | +| `develop` | `Push` | `kok-dev-CD.yml` (๊ฐœ๋ฐœ ์„œ๋ฒ„ ๋ฐฐํฌ) | + +--- + +## ๐Ÿš€ ์šด์˜(Prod) ๋ฐฐํฌ ํ”„๋กœ์„ธ์Šค (Blue-Green Deployment) +์šด์˜ ๋ฐฐํฌ๋Š” **Blue-Green Deployment** ๋ฐฉ์‹์„ ์‚ฌ์šฉํ•˜์—ฌ **๋ฌด์ค‘๋‹จ ๋ฐฐํฌ**๋ฅผ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค. + +### **1๏ธโƒฃ ๋ฐฐํฌ ํŠธ๋ฆฌ๊ฑฐ** +- `main` ๋ธŒ๋žœ์น˜์— ์ฝ”๋“œ๋ฅผ ํ‘ธ์‹œํ•˜๊ณ  ๋‚œ ํ›„, ์ˆ˜๋™์œผ๋กœ (`workflow_dispatch`) ๋ฐฐํฌ๋ฅผ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค. +- GitHub Actions๊ฐ€ `docker-compose-prod.yml`์„ ์„œ๋ฒ„์— ์—…๋กœ๋“œํ•˜๊ณ  ๋ฐฐํฌ๋ฅผ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค. + +### **2๏ธโƒฃ ํ˜„์žฌ ์‹คํ–‰ ์ค‘์ธ ์„œ๋น„์Šค ํ™•์ธ** +- `nginx.conf`์—์„œ ํ˜„์žฌ ์‚ฌ์šฉ ์ค‘์ธ ์ปจํ…Œ์ด๋„ˆ(`kok-blue` ๋˜๋Š” `kok-green`์„ ํ™•์ธ)ํ•ฉ๋‹ˆ๋‹ค. + +### **3๏ธโƒฃ ์ƒˆ๋กœ์šด ์ปจํ…Œ์ด๋„ˆ ์‹คํ–‰** +- ํ˜„์žฌ ์‚ฌ์šฉ ์ค‘์ธ ํ™˜๊ฒฝ์ด `kok-blue`์ด๋ฉด `kok-green`์„ ์‹คํ–‰ (`docker-compose-green.yml` ์‚ฌ์šฉ). +- ํ˜„์žฌ ์‚ฌ์šฉ ์ค‘์ธ ํ™˜๊ฒฝ์ด `kok-green`์ด๋ฉด `kok-blue`๋ฅผ ์‹คํ–‰ (`docker-compose-blue.yml` ์‚ฌ์šฉ). + +### **4๏ธโƒฃ Health Check** +- ์ƒˆ๋กญ๊ฒŒ ์‹คํ–‰๋œ ์ปจํ…Œ์ด๋„ˆ๊ฐ€ ์ •์ƒ์ ์œผ๋กœ ์ž‘๋™ํ•˜๋Š”์ง€ `/health_check` ์—”๋“œํฌ์ธํŠธ๋ฅผ ํ†ตํ•ด ํ™•์ธํ•ฉ๋‹ˆ๋‹ค. +- Health Check ์‹คํŒจ ์‹œ ๋กค๋ฐฑํ•˜์—ฌ ๊ธฐ์กด ์ปจํ…Œ์ด๋„ˆ๋ฅผ ์œ ์ง€ํ•ฉ๋‹ˆ๋‹ค. + +### **5๏ธโƒฃ ํŠธ๋ž˜ํ”ฝ ์ „ํ™˜** +- `nginx.conf`์˜ `upstream` ์„ค์ •์„ ๋ณ€๊ฒฝํ•˜์—ฌ, ์ƒˆ๋กœ์šด ์ปจํ…Œ์ด๋„ˆ๋กœ ํŠธ๋ž˜ํ”ฝ์„ ์ „ํ™˜ํ•ฉ๋‹ˆ๋‹ค. +- `nginx -s reload` ๋ช…๋ น์–ด๋ฅผ ์‹คํ–‰ํ•˜์—ฌ Nginx์„ค์ •์„ ๋ฐ˜์˜ํ•ฉ๋‹ˆ๋‹ค. + +### **6๏ธโƒฃ ๊ธฐ์กด ์ปจํ…Œ์ด๋„ˆ ์ •๋ฆฌ** +- ์ƒˆ๋กœ์šด ํ™˜๊ฒฝ์ด ์ •์ƒ์ ์œผ๋กœ ๋ฐฐํฌ๋œ ํ›„, ์ด์ „ ์ปจํ…Œ์ด๋„ˆ๋ฅผ ์ค‘์ง€ํ•˜๊ณ  ์‚ญ์ œํ•˜์—ฌ ๋ฆฌ์†Œ์Šค๋ฅผ ์ ˆ์•ฝํ•ฉ๋‹ˆ๋‹ค. + +--- + +## ๐Ÿš€ ๊ฐœ๋ฐœ(Dev) ๋ฐฐํฌ ํ”„๋กœ์„ธ์Šค +๊ฐœ๋ฐœ ๋ฐฐํฌ๋Š” ์šด์˜ ํ™˜๊ฒฝ๋ณด๋‹ค **๊ฐ„๋‹จํ•œ ๋‹จ์ผ ์ปจํ…Œ์ด๋„ˆ ๋ฐฐํฌ** ๋ฐฉ์‹์œผ๋กœ ์ง„ํ–‰๋ฉ๋‹ˆ๋‹ค. + +### **1๏ธโƒฃ ๋ฐฐํฌ ํŠธ๋ฆฌ๊ฑฐ** +- `develop` ๋ธŒ๋žœ์น˜์— ์ฝ”๋“œ๊ฐ€ ํ‘ธ์‹œ๋˜๋ฉด `kok-dev-CD.yml`์ด ์‹คํ–‰๋ฉ๋‹ˆ๋‹ค. +- GitHub Actions๊ฐ€ `docker-compose-dev.yml`์„ ์„œ๋ฒ„์— ์—…๋กœ๋“œํ•˜๊ณ  ๋ฐฐํฌ๋ฅผ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค. + +### **2๏ธโƒฃ ๊ธฐ์กด ์ปจํ…Œ์ด๋„ˆ ์ข…๋ฃŒ** +- ๊ธฐ์กด์— ์‹คํ–‰ ์ค‘์ธ `kok-dev` ์ปจํ…Œ์ด๋„ˆ๋ฅผ ์ค‘์ง€ (`docker compose down` ์‹คํ–‰). + +### **3๏ธโƒฃ ์ƒˆ๋กœ์šด ์ปจํ…Œ์ด๋„ˆ ์‹คํ–‰** +- `docker compose -f docker-compose-dev.yml up -d` ๋ช…๋ น์–ด๋ฅผ ์‹คํ–‰ํ•˜์—ฌ ์ƒˆ๋กœ์šด ์ปจํ…Œ์ด๋„ˆ๋ฅผ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค. \ No newline at end of file diff --git a/.github/workflows/kok-CI.yml b/.github/workflows/kok-CI.yml new file mode 100644 index 00000000..7de15e9d --- /dev/null +++ b/.github/workflows/kok-CI.yml @@ -0,0 +1,101 @@ +name: kok-CI (Build & Push for kok) + +on: + pull_request: + branches: + - develop + - main + push: + branches: + - develop + - main + +jobs: + build: + runs-on: ubuntu-latest + outputs: + IMAGE_TAG: ${{ steps.determine-tag.outputs.IMAGE_TAG }} + steps: + - name: Checkout Repository + uses: actions/checkout@v2 + + - name: Set up JDK 21 + uses: actions/setup-java@v2 + with: + distribution: 'zulu' + java-version: '21' + + - name: Determine Image Tag (prod/dev) + id: determine-tag + run: | + if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then + BRANCH_NAME="$GITHUB_BASE_REF" + else + BRANCH_NAME="$GITHUB_REF_NAME" + fi + + echo "๐Ÿ” ํ˜„์žฌ ๋ธŒ๋žœ์น˜: $BRANCH_NAME" + + if [[ "$BRANCH_NAME" == "main" ]]; then + echo "IMAGE_TAG=prod" | tee -a $GITHUB_ENV + echo "::set-output name=IMAGE_TAG::prod" + else + echo "IMAGE_TAG=dev" | tee -a $GITHUB_ENV + echo "::set-output name=IMAGE_TAG::dev" + fi + + echo "โœ… ํ˜„์žฌ GITHUB_ENV ๊ฐ’:" + cat $GITHUB_ENV + + - name: Build with Gradle (๋ฉ€ํ‹ฐ๋ชจ๋“ˆ) + run: | + ./gradlew clean build -x test + + - name: Save Build Artifacts + uses: actions/upload-artifact@v4 + with: + name: build-artifact + path: kok-api/build/libs/*.jar + + docker: + runs-on: ubuntu-latest + needs: build + env: + IMAGE_TAG: ${{ needs.build.outputs.IMAGE_TAG }} + + steps: + - name: Checkout Repository + uses: actions/checkout@v2 + + - name: Download Build Artifacts + uses: actions/download-artifact@v4 + with: + name: build-artifact + path: kok-api/build/libs/ + + - name: Login to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: Build & Push Docker Image + run: | + echo "๐Ÿท IMAGE_TAG ๊ฐ’ ํ™•์ธ: '$IMAGE_TAG'" + + if [[ -z "$IMAGE_TAG" ]]; then + echo "๐Ÿšจ ERROR: IMAGE_TAG ๊ฐ’์ด ๋น„์–ด ์žˆ์Œ!" + exit 1 + fi + + IMAGE_NAME="${{ secrets.DOCKERHUB_USERNAME }}/kok-${IMAGE_TAG}" + + echo "๐Ÿš€ Building Docker Image: $IMAGE_NAME:$GITHUB_SHA" + + # Docker Build & Push + docker build -t $IMAGE_NAME:$GITHUB_SHA -f Dockerfile . + docker push $IMAGE_NAME:$GITHUB_SHA + + - name: Notify Deployment Trigger + run: | + echo "๐Ÿš€ Docker ์ด๋ฏธ์ง€ ์ƒ์„ฑ ์™„๋ฃŒ! Image: $IMAGE_NAME" diff --git a/.github/workflows/kok-dev-CD.yml b/.github/workflows/kok-dev-CD.yml new file mode 100644 index 00000000..e3880ceb --- /dev/null +++ b/.github/workflows/kok-dev-CD.yml @@ -0,0 +1,47 @@ +name: kok-dev-CD (Deploy to Dev) + +on: + push: + branches: + - develop + - deploy/dev_cd # ๋ฐฐํฌ ํ…Œ์ŠคํŠธ ๋ธŒ๋žœ์น˜ + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Get Latest Docker Image Tag + id: latest_tag + run: | + LATEST_TAG=$(curl -s "https://hub.docker.com/v2/repositories/${{ secrets.DOCKERHUB_USERNAME }}/kok-dev/tags" | jq -r '.results[0].name') + echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV + + + - name: Create SSH key file + run: echo "${{ secrets.AWS_KEY }}" > /tmp/AWS_KEY.pem + + - name: Set permissions for SSH key file + run: chmod 400 /tmp/AWS_KEY.pem + + - name: Upload `docker-compose.yml` to AWS + run: | + scp -i /tmp/AWS_KEY.pem -o StrictHostKeyChecking=no infra/docker-compose-dev.yml ${{ secrets.AWS_USER }}@${{ secrets.AWS_DEV_HOST }}:${{ secrets.COMPOSE_FILE_PATH }} + + - name: Deploy to Dev Server + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.AWS_DEV_HOST }} + username: ${{ secrets.AWS_USER }} + key: ${{ secrets.AWS_KEY }} + script: | + cd ${{ secrets.COMPOSE_FILE_PATH }} + + sed -i '/^KOK_DEV_TAG = /d' .env + echo "KOK_DEV_TAG = ${{ env.LATEST_TAG }}" >> .env + + sudo docker compose -f docker-compose-dev.yml pull + sudo docker compose -f docker-compose-dev.yml down + sudo docker compose -f docker-compose-dev.yml up -d diff --git a/.github/workflows/kok-prod-CD.yml b/.github/workflows/kok-prod-CD.yml new file mode 100644 index 00000000..59ab5bb2 --- /dev/null +++ b/.github/workflows/kok-prod-CD.yml @@ -0,0 +1,40 @@ +name: kok-prod-CD (Deploy to Prod - Blue-Green Auto) + +on: + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Get Latest Docker Image Tag + id: latest_tag + run: | + LATEST_TAG=$(curl -s "https://hub.docker.com/v2/repositories/${{ secrets.DOCKERHUB_USERNAME }}/kok-prod/tags" | jq -r '.results[0].name') + echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV + + - name: Create SSH key file + run: echo "${{ secrets.AWS_KEY }}" > /tmp/AWS_KEY.pem + + - name: Set permissions for SSH key file + run: chmod 600 /tmp/AWS_KEY.pem + + - name: Upload Compose files & deploy.sh to AWS + run: | + scp -i /tmp/AWS_KEY.pem -o StrictHostKeyChecking=no infra/docker-compose-blue.yml ${{ secrets.AWS_USER }}@${{ secrets.AWS_PROD_HOST }}:${{ secrets.COMPOSE_FILE_PATH }} + scp -i /tmp/AWS_KEY.pem -o StrictHostKeyChecking=no infra/docker-compose-green.yml ${{ secrets.AWS_USER }}@${{ secrets.AWS_PROD_HOST }}:${{ secrets.COMPOSE_FILE_PATH }} + scp -i /tmp/AWS_KEY.pem -o StrictHostKeyChecking=no infra/deploy.sh ${{ secrets.AWS_USER }}@${{ secrets.AWS_PROD_HOST }}:${{ secrets.COMPOSE_FILE_PATH }} + + - name: Deploy to AWS via deploy.sh + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.AWS_PROD_HOST }} + username: ${{ secrets.AWS_USER }} + key: ${{ secrets.AWS_KEY }} + script: | + cd ${{ secrets.COMPOSE_FILE_PATH }} + chmod +x deploy.sh + ./deploy.sh "${{ env.LATEST_TAG }}" diff --git a/.gitignore b/.gitignore index a57b77c8..68d7f2b4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,19 +1,325 @@ -# IntelliJ IDEA settings -/.idea/ -/*.iml -/.vscode/ - -# Gradle ๊ด€๋ จ ํŒŒ์ผ -.gradle/ -gradle/ -!gradle/wrapper/ -!gradlew -!gradlew.bat - -# Build artifacts -/build/ +# Created by https://www.toptal.com/developers/gitignore/api/windows,macos,intellij,eclipse,visualstudiocode,redis,gradle,java +# Edit at https://www.toptal.com/developers/gitignore?templates=windows,macos,intellij,eclipse,visualstudiocode,redis,gradle,java + +### Eclipse ### +.metadata +bin/ +tmp/ +*.tmp +*.bak +*.swp +*~.nib +local.properties +.settings/ +.loadpath +.recommenders + +# External tool builders +.externalToolBuilders/ + +# Locally stored "Eclipse launch configurations" +*.launch + +# PyDev specific (Python IDE for Eclipse) +*.pydevproject + +# CDT-specific (C/C++ Development Tooling) +.cproject + +# CDT- autotools +.autotools + +# Java annotation processor (APT) +.factorypath + +# PDT-specific (PHP Development Tools) +.buildpath + +# sbteclipse plugin +.target + +# Tern plugin +.tern-project + +# TeXlipse plugin +.texlipse + +# STS (Spring Tool Suite) +.springBeans + +# Code Recommenders +.recommenders/ + +# Annotation Processing +.apt_generated/ +.apt_generated_test/ + +# Scala IDE specific (Scala & Java development for Eclipse) +.cache-main +.scala_dependencies +.worksheet + +# Uncomment this line if you wish to ignore the project description file. +# Typically, this file would be tracked if it contains build/dependency configurations: +#.project + +### Eclipse Patch ### +# Spring Boot Tooling +.sts4-cache/ + +### Intellij ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +.idea/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Intellij Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +# https://plugins.jetbrains.com/plugin/7973-sonarlint +.idea/**/sonarlint/ + +# SonarQube Plugin +# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator-enh.xml +.idea/**/markdown-navigator/ + +# Cache file creation bug +# See https://youtrack.jetbrains.com/issue/JBR-2257 +.idea/$CACHE_FILE$ + +# CodeStream plugin +# https://plugins.jetbrains.com/plugin/12206-codestream +.idea/codestream.xml + +# Azure Toolkit for IntelliJ plugin +# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij +.idea/**/azureSettings.xml + +### Java ### +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Redis ### +# Ignore redis binary dump (dump.rdb) files + +*.rdb + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +### Gradle ### +.gradle +**/build/ +!src/**/build/ + +# Ignore Gradle GUI config +gradle-app.setting + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar + +# Avoid ignore Gradle wrappper properties +!gradle-wrapper.properties + +# Cache of project +.gradletasknamecache + +# Eclipse Gradle plugin generated files +# Eclipse Core +.project +# JDT-specific (Eclipse Java Development Tools) +.classpath + +### Gradle Patch ### +# Java heap dump +*.hprof + +# End of https://www.toptal.com/developers/gitignore/api/windows,macos,intellij,eclipse,visualstudiocode,redis,gradle,java + +======= */build/ -out/ # ํ™˜๊ฒฝ ์„ค์ • ๋ฐ OS ### *.log @@ -21,3 +327,10 @@ out/ *.DS_Store *.class *.jar + +## ํ‚ค ## +*.pem +*.pub + +**/application-local.yml +/infra/docker-compose-local.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..37a35c93 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +# 1. Base Image ์„ค์ • +FROM openjdk:21-jdk + +# 2. ์ž‘์—… ๋””๋ ‰ํ† ๋ฆฌ ์„ค์ • +WORKDIR /app + +# 3. JAR ํŒŒ์ผ ๋ณต์‚ฌ (GitHub Actions์—์„œ ๋นŒ๋“œ๋œ ๊ฒฐ๊ณผ๋ฌผ ์‚ฌ์šฉ) +COPY kok-api/build/libs/*.jar app.jar + +# 4. ๊ธฐ๋ณธ ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ์„ค์ • (์™ธ๋ถ€์—์„œ ๋ณ€๊ฒฝ ๊ฐ€๋Šฅ) +ENV SPRING_PROFILES_ACTIVE=dev + +# 5. ์ปจํ…Œ์ด๋„ˆ ์‹คํ–‰ ๋ช…๋ น์–ด +CMD ["java", "-jar", "app.jar"] diff --git a/build.gradle b/build.gradle index ec9fce93..32dfd727 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ plugins { id 'java' id 'org.springframework.boot' version '3.4.2' id 'io.spring.dependency-management' version '1.1.7' + id 'jacoco' } bootJar.enabled = false @@ -33,13 +34,35 @@ subprojects { dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' - testImplementation 'org.springframework.boot:spring-boot-starter-test' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + + implementation 'org.hibernate:hibernate-spatial:6.4.4.Final' + implementation 'org.locationtech.jts:jts-core:1.18.2' + compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok:1.18.34' - testImplementation 'junit:junit:4.13.1' + + runtimeOnly 'com.h2database:h2' + runtimeOnly 'com.mysql:mysql-connector-j' + + implementation 'org.flywaydb:flyway-core' + implementation 'org.flywaydb:flyway-mysql' + + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + } +} + +jacocoTestReport { + reports { + html.required.set(true) + xml.required.set(true) + csv.required.set(false) + xml.outputLocation.set(layout.buildDirectory.file("reports/jacoco/test/jacocoTestReport.xml")) } } tasks.named('test') { useJUnitPlatform() -} \ No newline at end of file + finalizedBy jacocoTestReport +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..a4b76b95 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..9355b415 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 00000000..f5feea6d --- /dev/null +++ b/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright ยฉ 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions ยซ$varยป, ยซ${var}ยป, ยซ${var:-default}ยป, ยซ${var+SET}ยป, +# ยซ${var#prefix}ยป, ยซ${var%suffix}ยป, and ยซ$( cmd )ยป; +# * compound commands having a testable exit status, especially ยซcaseยป; +# * various built-in commands including ยซcommandยป, ยซsetยป, and ยซulimitยป. +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..9d21a218 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/infra/deploy.sh b/infra/deploy.sh new file mode 100644 index 00000000..6fb5bb85 --- /dev/null +++ b/infra/deploy.sh @@ -0,0 +1,57 @@ +#!/bin/bash + +LATEST_TAG=$1 + +# .env ์ตœ์‹  ํƒœ๊ทธ ์—…๋ฐ์ดํŠธ +sed -i '/^KOK_PROD_TAG=/d' .env || true +echo "KOK_PROD_TAG=$LATEST_TAG" >> .env + +# ํ˜„์žฌ ์‚ฌ์šฉ ์ค‘์ธ Blue/Green ํ™•์ธ +CURRENT_PORT=$(grep -o '127.0.0.1:[0-9]\+' /etc/nginx/conf.d/service-url.inc | awk -F: '{print $2}') + +if [ "$CURRENT_PORT" == "8081" ]; then + CURRENT_ENV="kok-blue" + NEW_ENV="kok-green" + NEW_PORT=8082 + COMPOSE_FILE="docker-compose-green.yml" + NEW_SERVICE_URL_PATH="/etc/nginx/conf.d/service-url-green.inc" +elif [ "$CURRENT_PORT" == "8082" ]; then + CURRENT_ENV="kok-green" + NEW_ENV="kok-blue" + NEW_PORT=8081 + COMPOSE_FILE="docker-compose-blue.yml" + NEW_SERVICE_URL_PATH="/etc/nginx/conf.d/service-url-blue.inc" +else + echo "โŒ ํ˜„์žฌ service-url.inc์— ์•Œ ์ˆ˜ ์—†๋Š” ํฌํŠธ๊ฐ’์ด ์žˆ์Šต๋‹ˆ๋‹ค: $CURRENT_PORT" + exit 1 +fi + +echo "ํ˜„์žฌ ํ™˜๊ฒฝ: $CURRENT_ENV โ†’ ์ƒˆ ํ™˜๊ฒฝ: $NEW_ENV" + +# ์ƒˆ ํ™˜๊ฒฝ ๋ฐฐํฌ +docker compose -f $COMPOSE_FILE pull +docker compose -f $COMPOSE_FILE up -d + +echo "๐Ÿฉบ Health Check (60์ดˆ ๋Œ€๊ธฐ)" +sleep 60 +HEALTH=$(curl -s http://127.0.0.1:$NEW_PORT/v1/api/health) +echo "Health Check ๊ฒฐ๊ณผ: $HEALTH" +CODE=$(echo "$HEALTH" | jq -r '.code') +DATA=$(echo "$HEALTH" | jq -r '.data') + +if [[ "$CODE" != "200" || "$DATA" != "OK" ]]; then + echo "โŒ Health Check ์‹คํŒจ (code: $CODE, data: $DATA), ๋กค๋ฐฑ!" + docker compose -f $COMPOSE_FILE stop $NEW_ENV + docker compose -f $COMPOSE_FILE rm -f $NEW_ENV + exit 1 +fi + +echo "โš™๏ธ service-url.inc ๊ต์ฒด: $NEW_SERVICE_URL_PATH โ†’ /etc/nginx/conf.d/service-url.inc" +sudo cp $NEW_SERVICE_URL_PATH /etc/nginx/conf.d/service-url.inc + +echo "๐Ÿ”„ Nginx ์„ค์ • reload" +sudo nginx -t && sudo systemctl reload nginx + +echo "๐Ÿงน ์ด์ „ ํ™˜๊ฒฝ($CURRENT_ENV) ์ •๋ฆฌ" +docker compose -f docker-compose-${CURRENT_ENV#kok-}.yml stop $CURRENT_ENV +docker compose -f docker-compose-${CURRENT_ENV#kok-}.yml rm -f $CURRENT_ENV diff --git a/infra/docker-compose-blue.yml b/infra/docker-compose-blue.yml new file mode 100644 index 00000000..c57a7b32 --- /dev/null +++ b/infra/docker-compose-blue.yml @@ -0,0 +1,18 @@ +version: '3.8' + +services: + kok-blue: + container_name: kok-blue + image: ${DOCKERHUB_USERNAME}/kok-prod:${KOK_PROD_TAG} + restart: always + env_file: + - .env + environment: + - SPRING_PROFILES_ACTIVE=prod + ports: + - "8081:8080" + networks: + - kok-network +networks: + kok-network: + external: true diff --git a/infra/docker-compose-dev.yml b/infra/docker-compose-dev.yml new file mode 100644 index 00000000..8056385c --- /dev/null +++ b/infra/docker-compose-dev.yml @@ -0,0 +1,19 @@ +version: '3.8' + +services: + kok-dev: + container_name: kok-dev + image: ${DOCKERHUB_USERNAME}/kok-dev:${KOK_DEV_TAG} + restart: always + env_file: + - .env + environment: + - SPRING_PROFILES_ACTIVE=dev + ports: + - "8080:8080" + networks: + - kok-network + +networks: + kok-network: + driver: bridge diff --git a/infra/docker-compose-env.yml b/infra/docker-compose-env.yml new file mode 100644 index 00000000..cbc88e57 --- /dev/null +++ b/infra/docker-compose-env.yml @@ -0,0 +1,32 @@ +services: + # MySQL ์„ค์ • + mysql: + image: mysql:8.4 + container_name: my-mysql + ports: + - "3306:3306" + environment: + MYSQL_ROOT_PASSWORD: ${DB_PASSWORD} + volumes: + - mysql_data:/var/lib/mysql + networks: + - kok-network + + # Redis ์„ค์ • + redis: + image: redis:7.0 + container_name: my-redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + networks: + - kok-network + +volumes: + mysql_data: + redis_data: + +networks: + kok-network: + external: true diff --git a/infra/docker-compose-green.yml b/infra/docker-compose-green.yml new file mode 100644 index 00000000..ec1325f3 --- /dev/null +++ b/infra/docker-compose-green.yml @@ -0,0 +1,19 @@ +version: '3.8' + +services: + kok-green: + container_name: kok-green + image: ${DOCKERHUB_USERNAME}/kok-prod:${KOK_PROD_TAG} + restart: always + env_file: + - .env + environment: + - SPRING_PROFILES_ACTIVE=prod + ports: + - "8082:8080" + networks: + - kok-network + +networks: + kok-network: + external: true diff --git a/infra/docker-compose-nginx.yml b/infra/docker-compose-nginx.yml new file mode 100644 index 00000000..24c014fe --- /dev/null +++ b/infra/docker-compose-nginx.yml @@ -0,0 +1,21 @@ +version: '3.8' + +services: + nginx: + image: nginx:latest + container_name: kok-nginx + restart: always + ports: + - "8080:80" + - "443:443" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + - /etc/letsencrypt:/etc/letsencrypt:ro + - ./certbot:/var/www/certbot + networks: + - kok-network + +networks: + kok-network: + external: true + diff --git a/infra/nginx/nginx.conf b/infra/nginx/nginx.conf new file mode 100644 index 00000000..651da358 --- /dev/null +++ b/infra/nginx/nginx.conf @@ -0,0 +1,42 @@ +events { + worker_connections 1024; +} + +http { + include mime.types; + default_type application/octet-stream; + sendfile on; + keepalive_timeout 65; + + server { + listen 80; + server_name prod-api.kokokok.com; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } + } + + server { + listen 443 ssl; + server_name prod-api.kokokok.com; + + ssl_certificate /etc/letsencrypt/live/prod-api.kokokok.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/prod-api.kokokok.com/privkey.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + location / { + proxy_pass http://kok-blue:8080; # โ† ๋ฐฐํฌ ์Šคํฌ๋ฆฝํŠธ์—์„œ ์ด ๋ถ€๋ถ„๋งŒ kok-green์œผ๋กœ ๊ต์ฒด + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } +} diff --git a/kok-api/build.gradle b/kok-api/build.gradle index 84a5056a..195f49e5 100644 --- a/kok-api/build.gradle +++ b/kok-api/build.gradle @@ -1,37 +1,51 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.4.2' - id 'io.spring.dependency-management' version '1.1.7' + id 'java' + id 'org.springframework.boot' version '3.4.2' + id 'io.spring.dependency-management' version '1.1.7' } group = 'com.kok' version = '0.0.1-SNAPSHOT' java { - toolchain { - languageVersion = JavaLanguageVersion.of(21) - } + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } } configurations { - compileOnly { - extendsFrom annotationProcessor - } + compileOnly { + extendsFrom annotationProcessor + } } repositories { - mavenCentral() + mavenCentral() } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-data-redis' - implementation 'org.springframework.boot:spring-boot-starter-web' - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + implementation project(':kok-core') + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.3' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.18.2' + implementation 'org.springframework.retry:spring-retry' + implementation 'org.springframework:spring-aspects' + + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + testImplementation 'org.testcontainers:testcontainers-bom:1.20.5' + testImplementation 'org.testcontainers:junit-jupiter' + testImplementation 'org.testcontainers:mysql' + testImplementation 'com.redis:testcontainers-redis' + + testImplementation 'io.rest-assured:rest-assured:5.3.1' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform() } diff --git a/kok-api/src/main/java/com/kok/kokapi/KokApiApplication.java b/kok-api/src/main/java/com/kok/kokapi/KokApiApplication.java index 3e93cf1e..8599d4c7 100644 --- a/kok-api/src/main/java/com/kok/kokapi/KokApiApplication.java +++ b/kok-api/src/main/java/com/kok/kokapi/KokApiApplication.java @@ -6,8 +6,8 @@ @SpringBootApplication public class KokApiApplication { - public static void main(String[] args) { - SpringApplication.run(KokApiApplication.class, args); - } + public static void main(String[] args) { + SpringApplication.run(KokApiApplication.class, args); + } } diff --git a/kok-api/src/main/java/com/kok/kokapi/centroid/adapter/in/dto/request/LocationRequest.java b/kok-api/src/main/java/com/kok/kokapi/centroid/adapter/in/dto/request/LocationRequest.java new file mode 100644 index 00000000..a5d7d1ab --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/centroid/adapter/in/dto/request/LocationRequest.java @@ -0,0 +1,31 @@ +package com.kok.kokapi.centroid.adapter.in.dto.request; + +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.math.BigDecimal; + +public record LocationRequest( + + @NotBlank(message = "roomId๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + String roomId, + + @NotNull(message = "memberId(๋ฉค๋ฒ„ ์ผ๋ จ๋ฒˆํ˜ธ)๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + String memberId, + + @NotNull(message = "latitude(์œ„๋„)๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + @DecimalMin(value = "33.0", message = "์œ„๋„๋Š” 33.0 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + @DecimalMax(value = "43.0", message = "์œ„๋„๋Š” 43.0 ์ดํ•˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + BigDecimal latitude, + + @NotNull(message = "longitude(๊ฒฝ๋„)๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + @DecimalMin(value = "124.0", message = "๊ฒฝ๋„๋Š” 124.0 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + @DecimalMax(value = "132.0", message = "๊ฒฝ๋„๋Š” 132.0 ์ดํ•˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + BigDecimal longitude, + + @NotNull(message = "name(์œ„์น˜๋ช…)์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + String name +) { + +} diff --git a/kok-api/src/main/java/com/kok/kokapi/centroid/adapter/in/dto/response/CentroidResponse.java b/kok-api/src/main/java/com/kok/kokapi/centroid/adapter/in/dto/response/CentroidResponse.java new file mode 100644 index 00000000..e188c728 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/centroid/adapter/in/dto/response/CentroidResponse.java @@ -0,0 +1,17 @@ +package com.kok.kokapi.centroid.adapter.in.dto.response; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +public record CentroidResponse( + String roomId, + BigDecimal latitude, + BigDecimal longitude +) { + + public static CentroidResponse of(String roomId, BigDecimal longitude, BigDecimal latitude) { + return new CentroidResponse(roomId, + latitude.setScale(6, RoundingMode.HALF_UP), + longitude.setScale(6, RoundingMode.HALF_UP)); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/centroid/adapter/in/dto/response/ConvexHullLocationResponse.java b/kok-api/src/main/java/com/kok/kokapi/centroid/adapter/in/dto/response/ConvexHullLocationResponse.java new file mode 100644 index 00000000..a10b0c78 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/centroid/adapter/in/dto/response/ConvexHullLocationResponse.java @@ -0,0 +1,14 @@ +package com.kok.kokapi.centroid.adapter.in.dto.response; + +import java.util.List; + +public record ConvexHullLocationResponse( + List convexHull, + List inside +) { + + public static ConvexHullLocationResponse of(List convexHull, + List inside) { + return new ConvexHullLocationResponse(convexHull, inside); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/centroid/adapter/in/dto/response/LocationResponse.java b/kok-api/src/main/java/com/kok/kokapi/centroid/adapter/in/dto/response/LocationResponse.java new file mode 100644 index 00000000..5f9bedc9 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/centroid/adapter/in/dto/response/LocationResponse.java @@ -0,0 +1,30 @@ +package com.kok.kokapi.centroid.adapter.in.dto.response; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +public record LocationResponse( + String roomId, + String memberId, + String imageUrl, + BigDecimal latitude, + BigDecimal longitude, + String name +) { + + public static LocationResponse of(String roomId, String memberId, BigDecimal latitude, + BigDecimal longitude, String name) { + return new LocationResponse(roomId, memberId, "", + latitude.setScale(6, RoundingMode.HALF_UP), + longitude.setScale(6, RoundingMode.HALF_UP), + name); + } + + public static LocationResponse of(String roomId, String memberId, String imageUrl, BigDecimal latitude, + BigDecimal longitude, String name) { + return new LocationResponse(roomId, memberId, imageUrl, + latitude.setScale(6, RoundingMode.HALF_UP), + longitude.setScale(6, RoundingMode.HALF_UP), + name); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/centroid/adapter/in/web/LocationController.java b/kok-api/src/main/java/com/kok/kokapi/centroid/adapter/in/web/LocationController.java new file mode 100644 index 00000000..5a592cf0 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/centroid/adapter/in/web/LocationController.java @@ -0,0 +1,127 @@ +package com.kok.kokapi.centroid.adapter.in.web; + +import com.kok.kokapi.centroid.adapter.in.dto.request.LocationRequest; +import com.kok.kokapi.centroid.adapter.in.dto.response.CentroidResponse; +import com.kok.kokapi.centroid.adapter.in.dto.response.ConvexHullLocationResponse; +import com.kok.kokapi.centroid.adapter.in.dto.response.LocationResponse; +import com.kok.kokapi.centroid.adapter.out.mapper.LocationMapper; +import com.kok.kokapi.common.response.ApiResponseDto; +import com.kok.kokapi.config.annotion.V1Controller; +import com.kok.kokcore.location.domain.Location; +import com.kok.kokcore.location.usecase.CreateLocationUseCase; +import com.kok.kokcore.location.usecase.LoadCentroidUseCase; +import com.kok.kokcore.location.usecase.ReadLocationUseCase; +import com.kok.kokcore.room.domain.Member; +import com.kok.kokcore.room.usecase.GetRoomUseCase; +import com.kok.kokcore.room.usecase.UpdateRoomUseCase; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.util.Pair; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; + +@V1Controller +@RequiredArgsConstructor +public class LocationController { + + private final CreateLocationUseCase createLocationUsecase; + private final LoadCentroidUseCase loadCentroidUsecase; + private final ReadLocationUseCase readLocationUsecase; + private final GetRoomUseCase getRoomUseCase; + private final UpdateRoomUseCase updateRoomUseCase; + private final LocationMapper locationMapper; + + @Operation(summary = "์œ„์น˜ ์ž…๋ ฅ", description = "Create a new location with the provided details.") + @PostMapping("/locations") + public ResponseEntity> createLocation( + @Valid @RequestBody LocationRequest locationRequest) { + createLocationUsecase.createLocation( + locationRequest.roomId(), + locationRequest.memberId(), + locationRequest.latitude(), + locationRequest.longitude(), + locationRequest.name() + ); + + Pair centroid = loadCentroidUsecase.readCentroidCoordinates( + locationRequest.roomId()); + + updateRoomUseCase.startVote(locationRequest.roomId(), LocalDateTime.now()); + + return ResponseEntity.ok(ApiResponseDto.success( + CentroidResponse.of(locationRequest.roomId(), centroid.getFirst(), centroid.getSecond()) + )); + } + + // For Test + @Operation(summary = "์ค‘์‹ฌ ์ขŒํ‘œ ์กฐํšŒ", description = "Retrieve the centroid coordinates for a location using its roomId") + @GetMapping("/locations/centroid/{roomId}") + public ResponseEntity> getCentroid( + @PathVariable String roomId) { + Pair centroid = loadCentroidUsecase.readCentroidCoordinates(roomId); + + return ResponseEntity.ok(ApiResponseDto.success( + CentroidResponse.of(roomId, centroid.getFirst(), centroid.getSecond()) + )); + } + + @Operation(summary = "์œ„์น˜ ์กฐํšŒ Basic", description = "Retrieve detailed information for a location using its roomId and member ID") + @GetMapping("/locations/{roomId}/{memberId}") + public ResponseEntity> getLocation(@PathVariable String roomId, + @PathVariable String memberId) { + Location location = readLocationUsecase.readLocation(roomId, memberId); + Member member = getRoomUseCase.getParticipant(roomId, memberId); + + return ResponseEntity.ok(ApiResponseDto.success( + locationMapper.toResponse(location, member) + )); + } + + @Operation(summary = "์œ„์น˜์กฐํšŒ ConvexHull", description = "Retrieve the ConvexHull inside list, outside list of locations for a roomId") + @GetMapping("/locations/ConvH/{roomId}") + public ResponseEntity> getConvexHullLocations( + @PathVariable String roomId) { + List convexHull = locationMapper.toResponseList( + readLocationUsecase.readConvexHull(roomId)); + List inside = locationMapper.toResponseList( + readLocationUsecase.readInsideConvexHull(roomId)); + + return ResponseEntity.ok( + ApiResponseDto.success(ConvexHullLocationResponse.of(convexHull, inside))); + } + + @Operation(summary = "์œ„์น˜ ๋ชฉ๋ก ์กฐํšŒ", description = "Retrieve the list of locations for a roomId") + @GetMapping("/locations/{roomId}") + public ResponseEntity>> getLocations( + @PathVariable String roomId) { + List responses = locationMapper.toResponseList( + readLocationUsecase.readLocations(roomId)); + + return ResponseEntity.ok(ApiResponseDto.success(responses)); + } + + @Operation(summary = "์œ„์น˜ ์ˆ˜์ •", description = "Update the location with the provided details.") + @PutMapping("/locations") + public ResponseEntity> updateLocation( + @Valid @RequestBody LocationRequest locationRequest) { + Location location = createLocationUsecase.updateLocation( + locationRequest.roomId(), + locationRequest.memberId(), + locationRequest.latitude(), + locationRequest.longitude(), + locationRequest.name() + ); + LocationResponse response = locationMapper.toResponse(location); + + return ResponseEntity.ok(ApiResponseDto.success(response)); + } +} + diff --git a/kok-api/src/main/java/com/kok/kokapi/centroid/adapter/out/mapper/LocationMapper.java b/kok-api/src/main/java/com/kok/kokapi/centroid/adapter/out/mapper/LocationMapper.java new file mode 100644 index 00000000..c40656ad --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/centroid/adapter/out/mapper/LocationMapper.java @@ -0,0 +1,49 @@ +package com.kok.kokapi.centroid.adapter.out.mapper; + +import com.kok.kokapi.centroid.adapter.in.dto.response.LocationResponse; +import com.kok.kokapi.config.geometry.PointConverter; +import com.kok.kokcore.location.domain.Location; +import com.kok.kokcore.room.domain.Member; +import java.math.BigDecimal; +import java.util.List; +import org.springframework.data.util.Pair; +import org.springframework.stereotype.Component; + +@Component +public class LocationMapper { + + private final PointConverter pointConverter; + + public LocationMapper(PointConverter pointConverter) { + this.pointConverter = pointConverter; + } + + public LocationResponse toResponse(Location location) { + Pair coordinates = pointConverter.toCoordinates( + location.getLocation_point()); + return LocationResponse.of( + location.getRoomId(), + location.getMemberId(), + coordinates.getFirst(), + coordinates.getSecond(), + location.getName() + ); + } + + public LocationResponse toResponse(Location location, Member member) { + Pair coordinates = pointConverter.toCoordinates( + location.getLocation_point()); + return LocationResponse.of( + location.getRoomId(), + member.getMemberId(), + member.getProfile(), + coordinates.getFirst(), + coordinates.getSecond(), + location.getName() + ); + } + + public List toResponseList(List locations) { + return locations.stream().map(this::toResponse).toList(); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/centroid/adapter/out/persistence/LocationPersistenceAdapter.java b/kok-api/src/main/java/com/kok/kokapi/centroid/adapter/out/persistence/LocationPersistenceAdapter.java new file mode 100644 index 00000000..6a93110b --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/centroid/adapter/out/persistence/LocationPersistenceAdapter.java @@ -0,0 +1,72 @@ +package com.kok.kokapi.centroid.adapter.out.persistence; + + +import com.kok.kokcore.location.domain.Location; +import com.kok.kokcore.location.port.out.ReadCentroidPort; +import com.kok.kokcore.location.port.out.ReadLocationPort; +import com.kok.kokcore.location.port.out.SaveLocationPort; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.locationtech.jts.geom.Point; +import org.locationtech.jts.io.WKTReader; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Repository +@RequiredArgsConstructor +public class LocationPersistenceAdapter implements ReadCentroidPort, SaveLocationPort, + ReadLocationPort { + + private final LocationRepository locationRepository; + + @Override + @Transactional(readOnly = true) + public Optional findLocationByRoomIdAndMemberId(String roomId, String memberId) { + return locationRepository.findLocationByRoomIdAndMemberId(roomId, memberId); + } + + @Override + @Transactional(readOnly = true) + public List findLocationsByRoomId(String roomId) { + return locationRepository.findLocationsByRoomId(roomId); + } + + @Override + @Transactional(readOnly = true) + public List findInsideConvexHull(String roomId) { + return locationRepository.findInsideConvexHull(roomId); + } + + @Override + @Transactional(readOnly = true) + public List findConvexHull(String roomId) { + return locationRepository.findConvexHull(roomId); + } + + @Override + public long countParticipantsById(String roomId) { + return locationRepository.countByRoomId(roomId); + } + + @Override + @Transactional(readOnly = true) + public Point findCentroidByRoomId(String roomId) { + String centroidWKT = locationRepository.findCentroidByRoomId(roomId); // WKT ํ˜•์‹์œผ๋กœ ๋ฐ›์Œ + if (centroidWKT == null) { + return null; + } + + try { + WKTReader reader = new WKTReader(); + return (Point) reader.read(centroidWKT); + } catch (Exception e) { + throw new RuntimeException("ํŒŒ์‹ฑ ์‹คํŒจ..", e); + } + } + + @Override + public Location saveLocation(String roomId, String memberId, Point point, String name) { + return locationRepository.save(new Location(roomId, memberId, point, name)); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/centroid/adapter/out/persistence/LocationRepository.java b/kok-api/src/main/java/com/kok/kokapi/centroid/adapter/out/persistence/LocationRepository.java new file mode 100644 index 00000000..52e7caee --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/centroid/adapter/out/persistence/LocationRepository.java @@ -0,0 +1,68 @@ +package com.kok.kokapi.centroid.adapter.out.persistence; + +import com.kok.kokcore.location.domain.Location; +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; + +public interface LocationRepository extends JpaRepository { + + /* ํ•ด๋‹น ์ฟผ๋ฆฌ๋Š” ํŠน์ • roomId๋ฅผ ๊ธฐ์ค€์œผ๋กœ ์œ„์น˜ ๋ฐ์ดํ„ฐ๋ฅผ ์กฐํšŒํ•˜๊ณ , ํ•ด๋‹น ์œ„์น˜๋“ค์˜ ์ค‘์‹ฌ์ ์„ ๊ณ„์‚ฐํ•ฉ๋‹ˆ๋‹ค. + ๋จผ์ €, SRID 4326 (์œ„๋„/๊ฒฝ๋„) ์ขŒํ‘œ๊ณ„๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๋ฐ์ดํ„ฐ๋ฅผ SRID 3857 (ํ‰๋ฉด ์ขŒํ‘œ๊ณ„)๋กœ ๋ณ€ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + ๋ณ€ํ™˜๋œ ์ขŒํ‘œ๋“ค์„ ST_Collect๋ฅผ ์ด์šฉํ•ด ํ•˜๋‚˜์˜ MULTIPOINT ํ˜•ํƒœ๋กœ ๋ณ‘ํ•ฉํ•ฉ๋‹ˆ๋‹ค. + ๋ณ‘ํ•ฉ๋œ MULTIPOINT์—์„œ ST_Centroid๋ฅผ ์‚ฌ์šฉํ•ด ์ค‘์‹ฌ์ ์„ ๊ณ„์‚ฐํ•ฉ๋‹ˆ๋‹ค. + ๊ณ„์‚ฐ๋œ ์ค‘์‹ฌ์ ์„ ๋‹ค์‹œ SRID 4326 (์œ„๋„/๊ฒฝ๋„)์œผ๋กœ ๋ณ€ํ™˜ํ•ฉ๋‹ˆ๋‹ค. (์ง€๋„ API์™€ ํ˜ธํ™˜์„ฑ ์œ ์ง€) + ์ตœ์ข…์ ์œผ๋กœ ST_AsText๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๊ฒฐ๊ณผ๋ฅผ WKT(Well-Known Text) ํฌ๋งท์œผ๋กœ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. */ + + @Query(value = """ + SELECT ST_AsText( + ST_Transform( + ST_Centroid( + ST_Collect( + ST_Transform(location_point, 3857))),4326) + ) + FROM location + WHERE room_id = :roomId + """, nativeQuery = true) + String findCentroidByRoomId(@Param("roomId") String roomId); + + Optional findLocationByRoomIdAndMemberId(String roomId, String memberId); + + List findLocationsByRoomId(String roomId); + + @Query(value = """ + WITH ConvexHull AS ( + SELECT ST_ConvexHull(ST_Collect(ST_GeomFromText(ST_AsText(location_point)))) AS hull + FROM location + WHERE room_id = :roomId + ) + SELECT l.* + FROM location l, ConvexHull ch + WHERE l.room_id = :roomId + AND ST_Contains(ch.hull, ST_GeomFromText(ST_AsText(l.location_point))) + """, nativeQuery = true) + List findInsideConvexHull(@Param("roomId") String roomId); + + @Query(value = """ + WITH ConvexHull AS ( + SELECT ST_ConvexHull(ST_Collect(ST_GeomFromText(ST_AsText(location_point)))) AS hull, + ST_Centroid(ST_ConvexHull(ST_Collect(ST_GeomFromText(ST_AsText(location_point))))) AS center + FROM location + WHERE room_id = :roomId + ) + SELECT l.*, + ATAN2( + ST_Y(ST_GeomFromText(ST_AsText(l.location_point))) - ST_Y(ch.center), + ST_X(ST_GeomFromText(ST_AsText(l.location_point))) - ST_X(ch.center) + ) AS angle + FROM location l, ConvexHull ch + WHERE l.room_id = :roomId + AND NOT ST_Contains(ch.hull, ST_GeomFromText(ST_AsText(l.location_point))) + ORDER BY angle + """, nativeQuery = true) + List findConvexHull(@Param("roomId") String roomId); + + long countByRoomId(String roomId); +} diff --git a/kok-api/src/main/java/com/kok/kokapi/centroid/application/service/CentroidQueryService.java b/kok-api/src/main/java/com/kok/kokapi/centroid/application/service/CentroidQueryService.java new file mode 100644 index 00000000..768b3856 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/centroid/application/service/CentroidQueryService.java @@ -0,0 +1,32 @@ +package com.kok.kokapi.centroid.application.service; + +import com.kok.kokapi.config.geometry.PointConverter; +import com.kok.kokcore.location.port.out.ReadCentroidPort; +import com.kok.kokcore.location.usecase.LoadCentroidUseCase; +import java.math.BigDecimal; +import lombok.RequiredArgsConstructor; +import org.locationtech.jts.geom.Point; +import org.springframework.data.util.Pair; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CentroidQueryService implements LoadCentroidUseCase { + + private final ReadCentroidPort readCentroidPort; + private final PointConverter pointConverter; + + @Override + public Point readCentroid(String roomId) { + return readCentroidPort.findCentroidByRoomId(roomId); + } + + @Override + public Pair readCentroidCoordinates(String roomId) { + Point centroidPoint = readCentroidPort.findCentroidByRoomId(roomId); + if (centroidPoint == null) { + throw new IllegalArgumentException("ํ•ด๋‹น roomId์— ๋Œ€ํ•œ ์ค‘์‹ฌ์ ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + return pointConverter.toCoordinates(centroidPoint); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/centroid/application/service/LocationCommandService.java b/kok-api/src/main/java/com/kok/kokapi/centroid/application/service/LocationCommandService.java new file mode 100644 index 00000000..3846a357 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/centroid/application/service/LocationCommandService.java @@ -0,0 +1,40 @@ +package com.kok.kokapi.centroid.application.service; + +import com.kok.kokapi.config.geometry.PointConverter; +import com.kok.kokcore.location.domain.Location; +import com.kok.kokcore.location.port.out.ReadLocationPort; +import com.kok.kokcore.location.port.out.SaveLocationPort; +import com.kok.kokcore.location.usecase.CreateLocationUseCase; +import jakarta.transaction.Transactional; +import java.math.BigDecimal; +import lombok.RequiredArgsConstructor; +import org.locationtech.jts.geom.Point; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class LocationCommandService implements CreateLocationUseCase { + + private final SaveLocationPort saveLocationPort; + private final ReadLocationPort readLocationPort; + private final PointConverter pointConverter; + + @Override + public Location createLocation(String roomId, String memberId, BigDecimal latitude, + BigDecimal longitude, String name) { + Point point = pointConverter.fromCoordinates(latitude, longitude); + return saveLocationPort.saveLocation(roomId, memberId, point, name); + } + + @Override + @Transactional + public Location updateLocation(String roomId, String memberId, BigDecimal latitude, + BigDecimal longitude, String name) { + Location location = readLocationPort.findLocationByRoomIdAndMemberId(roomId, memberId) + .orElseThrow(() -> new IllegalArgumentException("ํ•ด๋‹น ID์˜ ์ •๋ณด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.")); + Point newPoint = pointConverter.fromCoordinates(latitude, longitude); + location.changePoint(newPoint); + location.changeName(name); + return location; + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/centroid/application/service/LocationQueryService.java b/kok-api/src/main/java/com/kok/kokapi/centroid/application/service/LocationQueryService.java new file mode 100644 index 00000000..137be13a --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/centroid/application/service/LocationQueryService.java @@ -0,0 +1,36 @@ +package com.kok.kokapi.centroid.application.service; + +import com.kok.kokcore.location.domain.Location; +import com.kok.kokcore.location.port.out.ReadLocationPort; +import com.kok.kokcore.location.usecase.ReadLocationUseCase; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class LocationQueryService implements ReadLocationUseCase { + + private final ReadLocationPort readLocationPort; + + @Override + public Location readLocation(String roomId, String memberId) { + return readLocationPort.findLocationByRoomIdAndMemberId(roomId, memberId) + .orElseThrow(() -> new IllegalArgumentException("ํ•ด๋‹น ๋ฉค๋ฒ„์˜ ์œ„์น˜๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + } + + @Override + public List readLocations(String roomId) { + return readLocationPort.findLocationsByRoomId(roomId); + } + + @Override + public List readInsideConvexHull(String roomId) { + return readLocationPort.findInsideConvexHull(roomId); + } + + @Override + public List readConvexHull(String roomId) { + return readLocationPort.findConvexHull(roomId); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/common/error/ErrorCode.java b/kok-api/src/main/java/com/kok/kokapi/common/error/ErrorCode.java new file mode 100644 index 00000000..07428313 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/common/error/ErrorCode.java @@ -0,0 +1,52 @@ +package com.kok.kokapi.common.error; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum ErrorCode { + + // 400 BAD REQUEST + BAD_REQUEST(HttpStatus.BAD_REQUEST, "์ž˜๋ชป๋œ ์š”์ฒญ์ž…๋‹ˆ๋‹ค."), + INVALID_INPUT(HttpStatus.BAD_REQUEST, "์ž…๋ ฅ๊ฐ’์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."), + MISSING_PARAMETER(HttpStatus.BAD_REQUEST, "ํ•„์ˆ˜ ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ ๋ˆ„๋ฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + INVALID_FORMAT(HttpStatus.BAD_REQUEST, "์ž…๋ ฅ๊ฐ’์˜ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."), + EXCEEDED_USAGE_LIMIT(HttpStatus.BAD_REQUEST, "ํ˜ธ์ถœ ๊ฐ€๋Šฅ ํšŸ์ˆ˜๊ฐ€ ์ดˆ๊ณผ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + REQUEST_EXPIRED(HttpStatus.BAD_REQUEST, "์š”์ฒญ์ด ๋งŒ๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + UNSUPPORTED_OPERATION(HttpStatus.BAD_REQUEST, "์ง€์›ํ•˜์ง€ ์•Š๋Š” ๊ธฐ๋Šฅ์ž…๋‹ˆ๋‹ค."), + + // 401 UNAUTHORIZED + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "์ธ์ฆ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."), + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "ํ† ํฐ์ด ์œ ํšจํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."), + TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "ํ† ํฐ์ด ๋งŒ๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + TOKEN_MALFORMED(HttpStatus.UNAUTHORIZED, "ํ† ํฐ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."), + SESSION_EXPIRED(HttpStatus.UNAUTHORIZED, "์„ธ์…˜์ด ๋งŒ๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + + // 403 FORBIDDEN + FORBIDDEN(HttpStatus.FORBIDDEN, "๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."), + IP_BLOCKED(HttpStatus.FORBIDDEN, "ํ•ด๋‹น IP๋Š” ์ฐจ๋‹จ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + INSUFFICIENT_PERMISSIONS(HttpStatus.FORBIDDEN, "ํ•ด๋‹น ์š”์ฒญ์„ ์ˆ˜ํ–‰ํ•  ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."), + + + // 404 NOT FOUND + RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "์š”์ฒญํ•œ ๋ฆฌ์†Œ์Šค๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + ROOM_NOT_FOUND(HttpStatus.NOT_FOUND, "์•ฝ์†๋ฐฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + + // 409 CONFLICT + CONFLICT(HttpStatus.CONFLICT, "์ถฉ๋Œ์ด ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."), + VERSION_CONFLICT(HttpStatus.CONFLICT, "๋ฒ„์ „ ์ถฉ๋Œ์ด ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค. ์ตœ์‹  ๋ฐ์ดํ„ฐ๋ฅผ ํ™•์ธํ•ด์ฃผ์„ธ์š”."), + + + // 500 INTERNAL SERVER ERROR + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "์„œ๋ฒ„ ๋‚ด๋ถ€ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."), + SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "ํ˜„์žฌ ์„œ๋น„์Šค๋ฅผ ์ด์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + DATABASE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."), + EXTERNAL_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "์™ธ๋ถ€ API ํ˜ธ์ถœ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."), + UNKNOWN_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."), + TIMEOUT_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "์š”์ฒญ ์‹œ๊ฐ„์ด ์ดˆ๊ณผ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + + private final HttpStatus status; + private final String message; +} diff --git a/kok-api/src/main/java/com/kok/kokapi/common/exception/GlobalExceptionHandler.java b/kok-api/src/main/java/com/kok/kokapi/common/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..8c7235e6 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,49 @@ +package com.kok.kokapi.common.exception; + +import com.kok.kokapi.common.error.ErrorCode; +import com.kok.kokapi.common.response.ApiResponseDto; +import jakarta.validation.ConstraintViolationException; +import java.util.stream.Collectors; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity> handleConstraintViolationException( + ConstraintViolationException ex) { + String message = ex.getConstraintViolations() + .stream() + .map(cv -> cv.getPropertyPath() + " " + cv.getMessage()) + .collect(Collectors.joining(", ")); + return ResponseEntity.badRequest() + .body(ApiResponseDto.error(ErrorCode.INVALID_INPUT, message)); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleMethodArgumentNotValidException( + MethodArgumentNotValidException ex) { + String message = ex.getBindingResult().getFieldErrors() + .stream() + .map(fe -> fe.getField() + " " + fe.getDefaultMessage()) + .collect(Collectors.joining(", ")); + return ResponseEntity.badRequest() + .body(ApiResponseDto.error(ErrorCode.INVALID_INPUT, message)); + } + + @ExceptionHandler({IllegalArgumentException.class, IllegalStateException.class}) + public ResponseEntity> handleBadRequestException(RuntimeException ex) { + return ResponseEntity.badRequest() + .body(ApiResponseDto.error(ErrorCode.BAD_REQUEST, ex.getMessage())); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGlobalException(Exception ex) { + return ResponseEntity + .status(ErrorCode.INTERNAL_SERVER_ERROR.getStatus()) + .body(ApiResponseDto.error(ErrorCode.INTERNAL_SERVER_ERROR, ex.getMessage())); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/common/response/ApiResponseDto.java b/kok-api/src/main/java/com/kok/kokapi/common/response/ApiResponseDto.java new file mode 100644 index 00000000..63b1497b --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/common/response/ApiResponseDto.java @@ -0,0 +1,20 @@ +package com.kok.kokapi.common.response; + + +import com.kok.kokapi.common.error.ErrorCode; +import org.springframework.http.HttpStatus; + +public record ApiResponseDto(int code, String message, T data) { + + public static ApiResponseDto success(T data) { + return new ApiResponseDto<>(HttpStatus.OK.value(), "Success", data); + } + + public static ApiResponseDto error(ErrorCode errorCode) { + return new ApiResponseDto<>(errorCode.getStatus().value(), errorCode.getMessage(), null); + } + + public static ApiResponseDto error(ErrorCode errorCode, String customMessage) { + return new ApiResponseDto<>(errorCode.getStatus().value(), customMessage, null); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/common/util/RedisExecutor.java b/kok-api/src/main/java/com/kok/kokapi/common/util/RedisExecutor.java new file mode 100644 index 00000000..a637ce49 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/common/util/RedisExecutor.java @@ -0,0 +1,75 @@ +package com.kok.kokapi.common.util; + +import java.util.function.Supplier; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataAccessException; +import org.springframework.data.redis.RedisConnectionFailureException; +import org.springframework.data.redis.RedisSystemException; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Retryable; + +@Slf4j +public class RedisExecutor { + + /** + * Redis ์ž‘์—…์„ ์‹คํ–‰ํ•˜๊ณ  ์˜ˆ์™ธ ๋ฐœ์ƒ ์‹œ fallback ๊ฐ’์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + */ + public static T runOrElseGet(String operationName, Supplier operation, T fallbackValue) { + try { + return retry(operationName, operation); + } catch (RedisConnectionFailureException e) { + log.error("[Redis][{}] Connection failure. Retry or alert needed.", operationName, e); + } catch (RedisSystemException e) { + log.error("[Redis][{}] System error (serialization, etc).", operationName, e); + } catch (DataAccessException e) { + log.warn("[Redis][{}] Data access issue.", operationName, e); + } catch (Exception e) { + log.error("[Redis][{}] Unexpected exception.", operationName, e); + } + return fallbackValue; + } + + /** + * Redis ์ž‘์—…์„ ์‹คํ–‰ํ•˜๊ณ  ์‹คํŒจ ์‹œ ์˜ˆ์™ธ๋ฅผ ๊ทธ๋Œ€๋กœ ๋˜์ง‘๋‹ˆ๋‹ค. + */ + public static T runOrThrow(String operationName, Supplier operation) { + try { + return retry(operationName, operation); + } catch (RedisConnectionFailureException e) { + log.error("[Redis][{}] Connection failure.", operationName, e); + throw e; + } catch (RedisSystemException e) { + log.error("[Redis][{}] System error (serialization, etc).", operationName, e); + throw e; + } catch (DataAccessException e) { + log.warn("[Redis][{}] Data access issue.", operationName, e); + throw e; + } catch (Exception e) { + log.error("[Redis][{}] Unexpected exception.", operationName, e); + throw e; + } + } + + /** + * ๋ฐ˜ํ™˜๊ฐ’์ด ์—†๋Š” Redis ์ž‘์—… ์‹คํ–‰ ๋ฐ ์˜ˆ์™ธ ๋ฐœ์ƒ ์‹œ throwํ•˜๊ธฐ ์œ„ํ•œ ๋ฉ”์„œ๋“œ์ž…๋‹ˆ๋‹ค. + */ + public static void runOrThrow(String operationName, Runnable operation) { + runOrThrow(operationName, () -> { + operation.run(); + return null; + }); + } + + /** + * Redis ์—ฐ๊ฒฐ ์‹คํŒจ์— ๋Œ€ํ•ด์„œ๋งŒ ์žฌ์‹œ๋„ํ•˜๊ณ , ๋‚˜๋จธ์ง€๋Š” ์ฆ‰์‹œ ์ฒ˜๋ฆฌ + */ + @Retryable( + value = RedisConnectionFailureException.class, + maxAttempts = 3, + backoff = @Backoff(delay = 300) + ) + protected static T retry(String operationName, Supplier operation) { + log.debug("[Redis][{}] Retrying operation.", operationName); + return operation.get(); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/config/annotion/V1Controller.java b/kok-api/src/main/java/com/kok/kokapi/config/annotion/V1Controller.java new file mode 100644 index 00000000..68048c9e --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/config/annotion/V1Controller.java @@ -0,0 +1,16 @@ +package com.kok.kokapi.config.annotion; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/api") +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface V1Controller { + +} diff --git a/kok-api/src/main/java/com/kok/kokapi/config/geometry/GeometryConfig.java b/kok-api/src/main/java/com/kok/kokapi/config/geometry/GeometryConfig.java new file mode 100644 index 00000000..417f7891 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/config/geometry/GeometryConfig.java @@ -0,0 +1,19 @@ +package com.kok.kokapi.config.geometry; + +import org.locationtech.jts.geom.GeometryFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class GeometryConfig { + + @Bean + public GeometryFactory geometryFactory() { + return new GeometryFactory(); + } + + @Bean + public PointConverter pointConverter(GeometryFactory geometryFactory) { + return new PointConverter(geometryFactory); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/config/geometry/PointConverter.java b/kok-api/src/main/java/com/kok/kokapi/config/geometry/PointConverter.java new file mode 100644 index 00000000..6db1a014 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/config/geometry/PointConverter.java @@ -0,0 +1,28 @@ +package com.kok.kokapi.config.geometry; + +import java.math.BigDecimal; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.Point; +import org.springframework.data.util.Pair; + +public class PointConverter { + + private final GeometryFactory geometryFactory; + + public PointConverter(GeometryFactory geometryFactory) { + this.geometryFactory = geometryFactory; + } + + public Point fromCoordinates(BigDecimal latitude, BigDecimal longitude) { + Coordinate coordinate = new Coordinate(longitude.doubleValue(), latitude.doubleValue()); + Point point = geometryFactory.createPoint(coordinate); + point.setSRID(4326); // WGS84 ์ขŒํ‘œ๊ณ„ -> Kakao, Naver ๋“ฑ์—์„œ ์‚ฌ์šฉํ•˜๋Š” ์ขŒํ‘œ๊ณ„ + return point; + } + + public Pair toCoordinates(Point point) { + return Pair.of(BigDecimal.valueOf(point.getY()), BigDecimal.valueOf(point.getX())); + } + +} diff --git a/kok-api/src/main/java/com/kok/kokapi/config/jpa/JpaConfig.java b/kok-api/src/main/java/com/kok/kokapi/config/jpa/JpaConfig.java new file mode 100644 index 00000000..3cbe366e --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/config/jpa/JpaConfig.java @@ -0,0 +1,10 @@ +package com.kok.kokapi.config.jpa; + +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EntityScan(basePackages = {"com.kok.kokcore"}) +public class JpaConfig { + +} diff --git a/kok-api/src/main/java/com/kok/kokapi/config/redis/RedisCacheConfig.java b/kok-api/src/main/java/com/kok/kokapi/config/redis/RedisCacheConfig.java new file mode 100644 index 00000000..669a05a5 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/config/redis/RedisCacheConfig.java @@ -0,0 +1,78 @@ +package com.kok.kokapi.config.redis; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator; +import java.time.Duration; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@EnableCaching +@Configuration +public class RedisCacheConfig { + + @Bean("cacheManager") + @Primary + public CacheManager defaultCacheManager(RedisConnectionFactory redisConnectionFactory) { + return RedisCacheManager.RedisCacheManagerBuilder + .fromConnectionFactory(redisConnectionFactory) + .build(); + } + + @Bean("stationCacheManager") + public CacheManager stationCacheManager(RedisConnectionFactory redisConnectionFactory) { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.activateDefaultTyping( + LaissezFaireSubTypeValidator.instance, + ObjectMapper.DefaultTyping.NON_FINAL + ); // ํƒ€์ž… ์ •๋ณด๋ฅผ ์œ ์ง€ํ•˜์—ฌ ์—ญ์ง๋ ฌํ™”ํ•  ๋•Œ ์›๋ณธ ํƒ€์ž…์„ ์œ ์ง€ + + // Jackson Serializer ์„ค์ • + GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer( + objectMapper); + + // RedisCacheConfiguration ์„ค์ • + RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer( + new StringRedisSerializer())) + .serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer(serializer)) + .entryTtl(Duration.ofDays(3)); // ์บ์‹œ ์ˆ˜๋ช… 3์ผ + + return RedisCacheManager.builder(redisConnectionFactory) + .cacheDefaults(redisCacheConfiguration) + .build(); + } + + @Bean("publicTransportationCacheManager") + public CacheManager publicTransportationCacheManager( + RedisConnectionFactory redisConnectionFactory) { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.deactivateDefaultTyping(); + + GenericJackson2JsonRedisSerializer genericSerializer = new GenericJackson2JsonRedisSerializer( + objectMapper); + + // RedisCacheConfiguration ์„ค์ • + RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer( + new StringRedisSerializer())) + .serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer(genericSerializer)) + .entryTtl(Duration.ofDays(3)); // ์บ์‹œ ์ˆ˜๋ช… 3์ผ + + return RedisCacheManager.RedisCacheManagerBuilder + .fromConnectionFactory(redisConnectionFactory) + .cacheDefaults(redisCacheConfiguration) + .build(); + + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/config/redis/RedisConfig.java b/kok-api/src/main/java/com/kok/kokapi/config/redis/RedisConfig.java new file mode 100644 index 00000000..c92cc465 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/config/redis/RedisConfig.java @@ -0,0 +1,31 @@ +package com.kok.kokapi.config.redis; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.springframework.retry.annotation.EnableRetry; + +@Configuration +@RequiredArgsConstructor +@EnableRetry +public class RedisConfig { + + // ์ถ”ํ›„ ConnectionFactory์„ค์ • ๋ณ€๊ฒฝ์„ ๊ณ ๋ ค. (Sentinel, Cluster, etc...) + @Bean + public RedisTemplate redisTemplate( + RedisConnectionFactory redisConnectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory); + + // Key Serializer ์„ค์ • (UUID -> String ๋ณ€ํ™˜) + redisTemplate.setKeySerializer(new StringRedisSerializer()); + + // Value Serializer ์„ค์ • (๊ฐ์ฒด -> JSON ๋ณ€ํ™˜) + redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + return redisTemplate; + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/config/swagger/SwaggerConfig.java b/kok-api/src/main/java/com/kok/kokapi/config/swagger/SwaggerConfig.java new file mode 100644 index 00000000..8c8def7a --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/config/swagger/SwaggerConfig.java @@ -0,0 +1,38 @@ +package com.kok.kokapi.config.swagger; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.servers.Server; +import java.util.List; +import org.springdoc.core.models.GroupedOpenApi; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + @Value("${swagger.appname}") + private String applicationName; + + @Bean + public OpenAPI customOpenAPI() { + Server server = new Server(); + server.setUrl(applicationName); + return new OpenAPI() + .info(new Info() + .title("KOK") + .version("1.0") + .description("API ๋ฌธ์„œ์ž…๋‹ˆ๋‹ค.")) + .servers(List.of(server)); + } + + @Bean + public GroupedOpenApi v1Api() { + return GroupedOpenApi.builder() + .group("V1 API") // Swagger UI์—์„œ "V1 API" ๊ทธ๋ฃน์œผ๋กœ ํ‘œ์‹œ + .pathsToMatch("/v1/api/**") // v1 ๊ด€๋ จ API๋งŒ ํฌํ•จ + .build(); + } +} + diff --git a/kok-api/src/main/java/com/kok/kokapi/config/time/TimeZoneConfig.java b/kok-api/src/main/java/com/kok/kokapi/config/time/TimeZoneConfig.java new file mode 100644 index 00000000..f15afc52 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/config/time/TimeZoneConfig.java @@ -0,0 +1,16 @@ +package com.kok.kokapi.config.time; + +import jakarta.annotation.PostConstruct; +import java.util.TimeZone; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class TimeZoneConfig { + + @PostConstruct + public void setTimeZone() { + TimeZone.setDefault(TimeZone.getTimeZone("UTC")); + System.out.println("Set JVM default timezone to UTC"); + } +} + diff --git a/kok-api/src/main/java/com/kok/kokapi/config/web/WebMvcConfig.java b/kok-api/src/main/java/com/kok/kokapi/config/web/WebMvcConfig.java new file mode 100644 index 00000000..cc81a5e3 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/config/web/WebMvcConfig.java @@ -0,0 +1,19 @@ +package com.kok.kokapi.config.web; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOriginPatterns("*") + .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(true) + .maxAge(3600); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/monitoring/adapter/in/web/HealthCheckController.java b/kok-api/src/main/java/com/kok/kokapi/monitoring/adapter/in/web/HealthCheckController.java new file mode 100644 index 00000000..2c7d5a6d --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/monitoring/adapter/in/web/HealthCheckController.java @@ -0,0 +1,19 @@ +package com.kok.kokapi.monitoring.adapter.in.web; + +import com.kok.kokapi.common.response.ApiResponseDto; +import com.kok.kokapi.config.annotion.V1Controller; +import com.kok.kokapi.monitoring.application.service.HealthCheckService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; + +@V1Controller +@RequiredArgsConstructor +public class HealthCheckController { + + private final HealthCheckService healthCheckService; + + @GetMapping("/health") + public ApiResponseDto checkHealth() { + return ApiResponseDto.success(healthCheckService.checkHealth()); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/monitoring/application/service/HealthCheckService.java b/kok-api/src/main/java/com/kok/kokapi/monitoring/application/service/HealthCheckService.java new file mode 100644 index 00000000..0326ad5e --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/monitoring/application/service/HealthCheckService.java @@ -0,0 +1,11 @@ +package com.kok.kokapi.monitoring.application.service; + +import org.springframework.stereotype.Service; + +@Service +public class HealthCheckService { + + public String checkHealth() { + return "OK"; + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/place/adapter/in/dto/request/PlacesRequest.java b/kok-api/src/main/java/com/kok/kokapi/place/adapter/in/dto/request/PlacesRequest.java new file mode 100644 index 00000000..a79ab791 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/place/adapter/in/dto/request/PlacesRequest.java @@ -0,0 +1,31 @@ +package com.kok.kokapi.place.adapter.in.dto.request; + +import com.kok.kokcore.places.port.in.PlaceInput; +import com.kok.kokcore.places.domain.model.vo.PlaceType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; + + +public record PlacesRequest( + @NotBlank(message = "์žฅ์†Œ ์นดํ…Œ๊ณ ๋ฆฌ๋Š” ํ•„์ˆ˜๊ฐ’์ž…๋‹ˆ๋‹ค.") + PlaceType placeType, + + @DecimalMin(value = "33.0", message = "์œ„๋„๋Š” 33 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + @DecimalMax(value = "43.0", message = "์œ„๋„๋Š” 43 ์ดํ•˜์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + @Schema(defaultValue = "37.5665", description = "์œ„๋„") + double latitude, + + @DecimalMin(value = "123.0", message = "๊ฒฝ๋„๋Š” 123 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + @DecimalMax(value = "132.0", message = "๊ฒฝ๋„๋Š” 132 ์ดํ•˜์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + @Schema(defaultValue = "126.9788", description = "๊ฒฝ๋„") + double longitude, + + @Schema(defaultValue = "20", description = "์ตœ๋Œ€ ๊ฐœ์ˆ˜ 20") + Integer maxCount +) { + public PlaceInput toPlaceInput() { + return new PlaceInput(placeType, latitude, longitude, maxCount); + } +} \ No newline at end of file diff --git a/kok-api/src/main/java/com/kok/kokapi/place/adapter/in/dto/response/PlaceResponse.java b/kok-api/src/main/java/com/kok/kokapi/place/adapter/in/dto/response/PlaceResponse.java new file mode 100644 index 00000000..ae134677 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/place/adapter/in/dto/response/PlaceResponse.java @@ -0,0 +1,31 @@ +package com.kok.kokapi.place.adapter.in.dto.response; + +import com.kok.kokcore.places.domain.model.Place; +import lombok.Getter; +import lombok.ToString; + +@Getter +@ToString +public class PlaceResponse { + private final String displayName; + private final String formattedAddress; + private final Location location; + + public PlaceResponse(Place place) { + this.displayName = place.getName(); + this.formattedAddress = place.getAddress(); + this.location = new Location(place.getLatitude(), place.getLongitude()); + } + + @Getter + @ToString + public static class Location { + private final double latitude; + private final double longitude; + + public Location(double latitude, double longitude) { + this.latitude = latitude; + this.longitude = longitude; + } + } +} \ No newline at end of file diff --git a/kok-api/src/main/java/com/kok/kokapi/place/adapter/in/dto/response/PlacesResponse.java b/kok-api/src/main/java/com/kok/kokapi/place/adapter/in/dto/response/PlacesResponse.java new file mode 100644 index 00000000..73a40c7b --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/place/adapter/in/dto/response/PlacesResponse.java @@ -0,0 +1,23 @@ +package com.kok.kokapi.place.adapter.in.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.kok.kokcore.places.domain.model.PlacesResult; +import java.util.stream.Collectors; + +import java.util.List; +import lombok.Getter; +import lombok.ToString; + +@Getter +@JsonIgnoreProperties(ignoreUnknown = true) +@ToString +public class PlacesResponse { + private final List placeResponses; + + public PlacesResponse(PlacesResult placesResult) { + this.placeResponses = placesResult.places() + .stream() + .map(PlaceResponse::new) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/kok-api/src/main/java/com/kok/kokapi/place/adapter/in/web/PlacesController.java b/kok-api/src/main/java/com/kok/kokapi/place/adapter/in/web/PlacesController.java new file mode 100644 index 00000000..727a6a4b --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/place/adapter/in/web/PlacesController.java @@ -0,0 +1,28 @@ +package com.kok.kokapi.place.adapter.in.web; + +import com.kok.kokapi.common.response.ApiResponseDto; +import com.kok.kokapi.config.annotion.V1Controller; +import com.kok.kokapi.place.adapter.in.dto.request.PlacesRequest; +import com.kok.kokapi.place.adapter.in.dto.response.PlacesResponse; +import com.kok.kokcore.places.usecase.SearchPlaceUseCase; +import com.kok.kokcore.places.domain.model.PlacesResult; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +@V1Controller +@RequiredArgsConstructor +public class PlacesController { + + private final SearchPlaceUseCase searchPlaceUseCase; + + @Operation(summary = "์ฃผ๋ณ€ ํ”Œ๋ ˆ์ด์Šค ์กฐํšŒ", description = "์ถ”์ฒœ ์žฅ์†Œ ์ฃผ๋ณ€์˜ ํ•ซํ”Œ๋ ˆ์ด์Šค ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") + @PostMapping("/search/places") + public ResponseEntity> searchPlaces(@RequestBody PlacesRequest request) throws Exception { + PlacesResult result = searchPlaceUseCase.getPlaces(request.toPlaceInput()); + PlacesResponse response = new PlacesResponse(result); + return ResponseEntity.ok(ApiResponseDto.success(response)); + } +} \ No newline at end of file diff --git a/kok-api/src/main/java/com/kok/kokapi/place/adapter/out/external/GooglePlaceApiAdapter.java b/kok-api/src/main/java/com/kok/kokapi/place/adapter/out/external/GooglePlaceApiAdapter.java new file mode 100644 index 00000000..029994d2 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/place/adapter/out/external/GooglePlaceApiAdapter.java @@ -0,0 +1,109 @@ +package com.kok.kokapi.place.adapter.out.external; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kok.kokcore.places.port.in.PlaceInput; +import com.kok.kokcore.places.port.out.LoadPlacesPort; +import com.kok.kokcore.places.domain.model.Place; +import com.kok.kokcore.places.domain.model.PlacesResult; +import com.kok.kokcore.places.domain.model.vo.PlaceType; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class GooglePlaceApiAdapter implements LoadPlacesPort { + + @Value("${google.places.api.key}") + private String apiKey; + + private static final String GOOGLE_PLACE_BASE_URL = "https://places.googleapis.com/v1/places:searchNearby"; + private static final double DEFAULT_RADIUS = 5000.0; + + private final ObjectMapper objectMapper; + + @Override + public PlacesResult getPlaces(PlaceInput input) { + String jsonBody = buildRequestBody(input); + RestClient restClient = RestClient.create(); + + try { + String responseBody = restClient.method(HttpMethod.POST) + .uri(GOOGLE_PLACE_BASE_URL) + .header("Content-Type", "application/json") + .header("X-Goog-Api-Key", apiKey) + .header("X-Goog-FieldMask", + "places.displayName,places.formattedAddress,places.location").body(jsonBody) + .retrieve() + .body(String.class); + + return mapToPlacesResult(responseBody, input.placeType()); + } catch (IOException e) { + log.error("Error while calling Google Places API", e); + throw new RuntimeException("Call Google Places API Failed", e); + } + } + + private static String buildRequestBody(PlaceInput input) { + String includedTypes = input.placeType().getPlaceCategories().stream() + .map(category -> "\"" + category + "\"") + .collect(Collectors.joining(", ")); + + return String.format(""" + { + "includedTypes": [%s], + "maxResultCount": %d, + "locationRestriction": { + "circle": { + "center": { + "latitude": %.6f, + "longitude": %.6f + }, + "radius": %.1f + } + }, + "languageCode": "ko" + } + """, + includedTypes, + input.maxCount(), + input.latitude(), + input.longitude(), + DEFAULT_RADIUS); + } + + private PlacesResult mapToPlacesResult(String responseBody, PlaceType placeType) throws JsonProcessingException { + JsonNode root = objectMapper.readTree(responseBody); + JsonNode placesNode = root.get("places"); + List places = new ArrayList<>(); + + for (JsonNode node : placesNode) { + String name = node.path("displayName").path("text").asText(); + String address = node.path("formattedAddress").asText(); + double latitude = node.path("location").path("latitude").asDouble(); + double longitude = node.path("location").path("longitude").asDouble(); + + Place place = Place.builder() + .name(name) + .address(address) + .latitude(latitude) + .longitude(longitude) + .placeType(placeType) + .build(); + + places.add(place); + } + return new PlacesResult(places); + } +} \ No newline at end of file diff --git a/kok-api/src/main/java/com/kok/kokapi/place/application/service/PlacesService.java b/kok-api/src/main/java/com/kok/kokapi/place/application/service/PlacesService.java new file mode 100644 index 00000000..30ee24de --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/place/application/service/PlacesService.java @@ -0,0 +1,20 @@ +package com.kok.kokapi.place.application.service; + +import com.kok.kokcore.places.port.in.PlaceInput; +import com.kok.kokcore.places.port.out.LoadPlacesPort; +import com.kok.kokcore.places.usecase.SearchPlaceUseCase; +import com.kok.kokcore.places.domain.model.PlacesResult; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class PlacesService implements SearchPlaceUseCase { + + private final LoadPlacesPort loadPlacesPort; + + @Override + public PlacesResult getPlaces(PlaceInput input) { + return loadPlacesPort.getPlaces(input); + } +} \ No newline at end of file diff --git a/kok-api/src/main/java/com/kok/kokapi/public_transportation/adapter/in/dto/request/RouteRequest.java b/kok-api/src/main/java/com/kok/kokapi/public_transportation/adapter/in/dto/request/RouteRequest.java new file mode 100644 index 00000000..83f631df --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/public_transportation/adapter/in/dto/request/RouteRequest.java @@ -0,0 +1,13 @@ +package com.kok.kokapi.public_transportation.adapter.in.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record RouteRequest( + @NotBlank(message = "roomId๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + String roomId, + @NotNull(message = "Member ID(๋ฉค๋ฒ„ ์ผ๋ จ๋ฒˆํ˜ธ)๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + String memberId +) { + +} diff --git a/kok-api/src/main/java/com/kok/kokapi/public_transportation/adapter/in/dto/response/TmapComplexPublicTransportationParsedResponse.java b/kok-api/src/main/java/com/kok/kokapi/public_transportation/adapter/in/dto/response/TmapComplexPublicTransportationParsedResponse.java new file mode 100644 index 00000000..d6594f85 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/public_transportation/adapter/in/dto/response/TmapComplexPublicTransportationParsedResponse.java @@ -0,0 +1,37 @@ +package com.kok.kokapi.public_transportation.adapter.in.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.io.Serializable; +import java.util.List; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@JsonIgnoreProperties(ignoreUnknown = true) +public class TmapComplexPublicTransportationParsedResponse implements Serializable { + + private ParsedItinerary parsedItinerary; + + // DTO ํด๋ž˜์Šค ์ •์˜ + @Getter + @Setter + public static class ParsedItinerary { + + private int totalDistance; + private int totalTime; + private List legs; + } + + @Getter + @Setter + public static class ParsedLeg { + + private String mode; + private int distance; + private int sectionTime; + private String route; // ์ง€ํ•˜์ฒ ์ด๋ฉด ํ˜ธ์„  ์ •๋ณด, ๋ฒ„์Šค๋ฉด ๋…ธ์„  ์ •๋ณด + private String routeColor; // ๋…ธ์„  ์ƒ‰ + + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/public_transportation/adapter/in/dto/response/TmapPublicTransportationParsedResponse.java b/kok-api/src/main/java/com/kok/kokapi/public_transportation/adapter/in/dto/response/TmapPublicTransportationParsedResponse.java new file mode 100644 index 00000000..bbf2ab9a --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/public_transportation/adapter/in/dto/response/TmapPublicTransportationParsedResponse.java @@ -0,0 +1,12 @@ +package com.kok.kokapi.public_transportation.adapter.in.dto.response; + +public record TmapPublicTransportationParsedResponse( + Integer totalTime, + Integer transferCount +) { + + public static TmapPublicTransportationParsedResponse of(Integer totalTime, + Integer transferCount) { + return new TmapPublicTransportationParsedResponse(totalTime, transferCount); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/public_transportation/adapter/in/web/PublicTransportationController.java b/kok-api/src/main/java/com/kok/kokapi/public_transportation/adapter/in/web/PublicTransportationController.java new file mode 100644 index 00000000..8e0056aa --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/public_transportation/adapter/in/web/PublicTransportationController.java @@ -0,0 +1,56 @@ +package com.kok.kokapi.public_transportation.adapter.in.web; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kok.kokapi.common.response.ApiResponseDto; +import com.kok.kokapi.config.annotion.V1Controller; +import com.kok.kokapi.public_transportation.adapter.in.dto.request.RouteRequest; +import com.kok.kokapi.public_transportation.adapter.in.dto.response.TmapComplexPublicTransportationParsedResponse; +import com.kok.kokapi.public_transportation.adapter.in.dto.response.TmapPublicTransportationParsedResponse; +import com.kok.kokcore.public_transportation.usecase.RetrievePublicTransportationUseCase; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + + +@V1Controller +@RequiredArgsConstructor +public class PublicTransportationController { + + private final RetrievePublicTransportationUseCase retrievePublicTransportationUsecase; + private final ObjectMapper objectMapper; + + @Operation(summary = "๋Œ€์ค‘๊ตํ†ต ์กฐํšŒ", description = "Retrieve the total time and transfer count for a route using the station ID") + @PostMapping("/route/{stationId}") + public ResponseEntity> getPublicTransportation( + @PathVariable Long stationId, @RequestBody RouteRequest routeRequest) { + try { + TmapPublicTransportationParsedResponse publicTransportation = objectMapper.readValue( + retrievePublicTransportationUsecase.retrievePublicTransportation(stationId, + routeRequest.roomId(), routeRequest.memberId()) + , TmapPublicTransportationParsedResponse.class); + return ResponseEntity.ok(ApiResponseDto.success(publicTransportation)); + } catch (JsonProcessingException e) { + throw new RuntimeException("ํŒŒ์‹ฑ ์‹คํŒจ.."); + } + } + + @Operation(summary = "๋Œ€์ค‘๊ตํ†ต ์ „๋ฌธ ์กฐํšŒ", description = "Retrieve the total time and transfer count for a route using the station ID") + @PostMapping("/route/complex/{stationId}") + public ResponseEntity> getComplexPublicTransportation( + @PathVariable Long stationId, @RequestBody RouteRequest routeRequest) { + try { + TmapComplexPublicTransportationParsedResponse publicTransportation = objectMapper.readValue( + retrievePublicTransportationUsecase.retrieveComplexPublicTransportation(stationId, + routeRequest.roomId(), routeRequest.memberId()) + , TmapComplexPublicTransportationParsedResponse.class); + return ResponseEntity.ok(ApiResponseDto.success(publicTransportation)); + } catch (JsonProcessingException e) { + throw new RuntimeException("ํŒŒ์‹ฑ ์‹คํŒจ.."); + } + } + +} diff --git a/kok-api/src/main/java/com/kok/kokapi/public_transportation/adapter/out/external/PublicTransportationClient.java b/kok-api/src/main/java/com/kok/kokapi/public_transportation/adapter/out/external/PublicTransportationClient.java new file mode 100644 index 00000000..bb658060 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/public_transportation/adapter/out/external/PublicTransportationClient.java @@ -0,0 +1,101 @@ +package com.kok.kokapi.public_transportation.adapter.out.external; + +import com.kok.kokapi.config.geometry.PointConverter; +import com.kok.kokapi.public_transportation.adapter.out.external.dto.TmapPublicTransportationResponse; +import com.kok.kokcore.location.domain.Location; +import com.kok.kokcore.location.port.out.ReadLocationPort; +import com.kok.kokcore.station.domain.entity.Station; +import com.kok.kokcore.station.port.out.RetrieveStationsPort; +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder; +import org.springframework.boot.http.client.ClientHttpRequestFactorySettings; +import org.springframework.data.util.Pair; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + + +@Component +@EnableConfigurationProperties(TmapClientProperties.class) +@Slf4j +public class PublicTransportationClient { + + private final RestClient restClient; + private final TmapClientProperties properties; + + private final RetrieveStationsPort retrieveStationsPort; + private final ReadLocationPort readLocationPort; + private final PointConverter pointConverter; + + public PublicTransportationClient(TmapClientProperties properties, + RetrieveStationsPort retrieveStationsPort, ReadLocationPort readLocationPort, + PointConverter pointConverter) { + this.properties = properties; + this.retrieveStationsPort = retrieveStationsPort; + this.readLocationPort = readLocationPort; + this.pointConverter = pointConverter; + this.restClient = getRestClient(); + } + + public RestClient getRestClient() { + return RestClient.builder() + .requestFactory(getRequestFactory()) + .defaultHeader(properties.keyname(), properties.key()) // API Key ์ถ”๊ฐ€ + .baseUrl(properties.url()) // Base URL ์„ค์ • + .build(); + } + + public RestClient getClient() { + return this.restClient; + } + + private ClientHttpRequestFactory getRequestFactory() { + return ClientHttpRequestFactoryBuilder.detect() + .build(ClientHttpRequestFactorySettings.defaults()); + } + + public TmapPublicTransportationResponse callPublicTransportRoute(Long stationId, String roomId, + String memberId) { + log.info("Tmap api call : {}-{}-{}", stationId, roomId, memberId); + return getClient().post() + .body(buildRequestBody( + getUserLocation(roomId, memberId), + getStation(stationId))) + .retrieve() + .onStatus(HttpStatusCode::is4xxClientError, (status, response) -> { + throw new RuntimeException("Tmap api ํ˜ธ์ถœ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. by 4xx" + status); + }) + .onStatus(HttpStatusCode::is5xxServerError, (status, response) -> { + throw new RuntimeException("Tmap api ํ˜ธ์ถœ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. by 5xx" + status); + }) + .body(TmapPublicTransportationResponse.class); + } + + private Map buildRequestBody(Pair userLocation, + Station station) { + Map requestBody = new HashMap<>(); + requestBody.put("startX", userLocation.getSecond()); // ๊ฒฝ๋„ + requestBody.put("startY", userLocation.getFirst()); // ์œ„๋„ + requestBody.put("endX", station.getLongitude()); // ๊ฒฝ๋„ + requestBody.put("endY", station.getLatitude()); // ์œ„๋„ + requestBody.put("count", 1); // ๊ฒฝ๋กœ ํƒ์ƒ‰ ๊ฒฐ๊ณผ ์ค‘ 1๊ฐœ๋งŒ ๋ฐ˜ํ™˜ + requestBody.put("format", "json"); + return requestBody; + } + + private Pair getUserLocation(String roomId, String memberId) { + Location userPoint = readLocationPort.findLocationByRoomIdAndMemberId(roomId, memberId) + .orElseThrow(() -> new IllegalArgumentException("ํ•ด๋‹น roomId์˜ ์‚ฌ์šฉ์ž ์œ„์น˜๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.")); + return pointConverter.toCoordinates(userPoint.getLocation_point()); + } + + private Station getStation(Long stationId) { + return retrieveStationsPort.retrieveStation(stationId) + .orElseThrow(() -> new IllegalArgumentException("ํ•ด๋‹น ID์˜ ์—ญ์ด ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.")); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/public_transportation/adapter/out/external/PublicTransportationComplexClient.java b/kok-api/src/main/java/com/kok/kokapi/public_transportation/adapter/out/external/PublicTransportationComplexClient.java new file mode 100644 index 00000000..5419d3c9 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/public_transportation/adapter/out/external/PublicTransportationComplexClient.java @@ -0,0 +1,102 @@ +package com.kok.kokapi.public_transportation.adapter.out.external; + +import com.kok.kokapi.config.geometry.PointConverter; +import com.kok.kokapi.public_transportation.adapter.out.external.dto.TmapComplexPublicTransportationResponse; +import com.kok.kokcore.location.domain.Location; +import com.kok.kokcore.location.port.out.ReadLocationPort; +import com.kok.kokcore.station.domain.entity.Station; +import com.kok.kokcore.station.port.out.RetrieveStationsPort; +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder; +import org.springframework.boot.http.client.ClientHttpRequestFactorySettings; +import org.springframework.data.util.Pair; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +@Component +@EnableConfigurationProperties(TmapComplexClientProperties.class) +@Slf4j +public class PublicTransportationComplexClient { + + private final RestClient restClient; + private final TmapComplexClientProperties properties; + + private final RetrieveStationsPort retrieveStationsPort; + private final ReadLocationPort readLocationPort; + private final PointConverter pointConverter; + + public PublicTransportationComplexClient(TmapComplexClientProperties properties, + RetrieveStationsPort retrieveStationsPort, ReadLocationPort readLocationPort, + PointConverter pointConverter) { + this.properties = properties; + this.retrieveStationsPort = retrieveStationsPort; + this.readLocationPort = readLocationPort; + this.pointConverter = pointConverter; + this.restClient = getRestClient(); + } + + public RestClient getRestClient() { + return RestClient.builder() + .requestFactory(getRequestFactory()) + .defaultHeader(properties.keyname(), properties.key()) // API Key ์ถ”๊ฐ€ + .baseUrl(properties.url()) // Base URL ์„ค์ • + .build(); + } + + public RestClient getClient() { + return this.restClient; + } + + private ClientHttpRequestFactory getRequestFactory() { + return ClientHttpRequestFactoryBuilder.detect() + .build(ClientHttpRequestFactorySettings.defaults()); + } + + public TmapComplexPublicTransportationResponse callComplexPublicTransportRoute(Long stationId, + String roomId, String memberId) { + log.info("Tmap api call : {}-{}-{}", stationId, roomId, memberId); + return getClient().post() + .body(buildRequestBody( + getUserLocation(roomId, memberId), + getStation(stationId))) + .retrieve() + .onStatus(HttpStatusCode::is4xxClientError, (status, response) -> { + throw new RuntimeException("Tmap api ํ˜ธ์ถœ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. by 4xx" + status); + }) + .onStatus(HttpStatusCode::is5xxServerError, (status, response) -> { + throw new RuntimeException("Tmap api ํ˜ธ์ถœ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. by 5xx" + status); + }) + .body(TmapComplexPublicTransportationResponse.class); + } + + private Pair getUserLocation(String roomId, String memberId) { + Location userPoint = readLocationPort.findLocationByRoomIdAndMemberId(roomId, memberId) + .orElseThrow(() -> new IllegalArgumentException("ํ•ด๋‹น roomId์˜ ์‚ฌ์šฉ์ž ์œ„์น˜๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.")); + return pointConverter.toCoordinates(userPoint.getLocation_point()); + } + + private Station getStation(Long stationId) { + return retrieveStationsPort.retrieveStation(stationId) + .orElseThrow(() -> new IllegalArgumentException("ํ•ด๋‹น ID์˜ ์—ญ์ด ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.")); + } + + private Map buildRequestBody(Pair userLocation, + Station station) { + Map requestBody = new HashMap<>(); + requestBody.put("startX", userLocation.getSecond()); // ๊ฒฝ๋„ + requestBody.put("startY", userLocation.getFirst()); // ์œ„๋„ + requestBody.put("endX", station.getLongitude()); // ๊ฒฝ๋„ + requestBody.put("endY", station.getLatitude()); // ์œ„๋„ + requestBody.put("count", 1); // ๊ฒฝ๋กœ ํƒ์ƒ‰ ๊ฒฐ๊ณผ ์ค‘ 1๊ฐœ๋งŒ ๋ฐ˜ํ™˜ + requestBody.put("format", "json"); + return requestBody; + } + +} + diff --git a/kok-api/src/main/java/com/kok/kokapi/public_transportation/adapter/out/external/TmapClientProperties.java b/kok-api/src/main/java/com/kok/kokapi/public_transportation/adapter/out/external/TmapClientProperties.java new file mode 100644 index 00000000..c9c5b3b1 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/public_transportation/adapter/out/external/TmapClientProperties.java @@ -0,0 +1,12 @@ +package com.kok.kokapi.public_transportation.adapter.out.external; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "tmap-sub") +public record TmapClientProperties( + String key, + String url, + String keyname +) { + +} diff --git a/kok-api/src/main/java/com/kok/kokapi/public_transportation/adapter/out/external/TmapComplexClientProperties.java b/kok-api/src/main/java/com/kok/kokapi/public_transportation/adapter/out/external/TmapComplexClientProperties.java new file mode 100644 index 00000000..812e2841 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/public_transportation/adapter/out/external/TmapComplexClientProperties.java @@ -0,0 +1,12 @@ +package com.kok.kokapi.public_transportation.adapter.out.external; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "tmap-complex") +public record TmapComplexClientProperties( + String key, + String url, + String keyname +) { + +} diff --git a/kok-api/src/main/java/com/kok/kokapi/public_transportation/adapter/out/external/dto/TmapComplexPublicTransportationResponse.java b/kok-api/src/main/java/com/kok/kokapi/public_transportation/adapter/out/external/dto/TmapComplexPublicTransportationResponse.java new file mode 100644 index 00000000..f14bf2c0 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/public_transportation/adapter/out/external/dto/TmapComplexPublicTransportationResponse.java @@ -0,0 +1,160 @@ +package com.kok.kokapi.public_transportation.adapter.out.external.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@JsonIgnoreProperties(ignoreUnknown = true) +public class TmapComplexPublicTransportationResponse { + + @JsonProperty("metaData") + private MetaData metaData; + + @Getter + @Setter + public static class MetaData { + + @JsonProperty("requestParameters") + private RequestParameters requestParameters; + + @JsonProperty("plan") + private Plan plan; + } + + @Getter + @Setter + public static class RequestParameters { + + private int busCount; + private int expressbusCount; + private int subwayCount; + private int airplaneCount; + private String locale; + private double endY; + private double endX; + private int wideareaRouteCount; + private int subwayBusCount; + private double startY; + private double startX; + private int ferryCount; + private int trainCount; + private String reqDttm; + } + + @Getter + @Setter + public static class Plan { + + @JsonProperty("itineraries") + private List itineraries; + } + + @Getter + @Setter + public static class Itinerary { + + @JsonProperty("fare") + private Fare fare; + + private int totalTime; + private List legs; + private int totalWalkTime; + private int transferCount; + private int totalDistance; + private int pathType; + private int totalWalkDistance; + } + + @Getter + @Setter + public static class Fare { + + @JsonProperty("regular") + private RegularFare regular; + } + + @Getter + @Setter + public static class RegularFare { + + private int totalFare; + private Currency currency; + } + + @Getter + @Setter + public static class Currency { + + private String symbol; + private String currency; + private String currencyCode; + } + + @Getter + @Setter + public static class Leg { + + private String mode; + private int sectionTime; + private int distance; + private Location start; + private Location end; + private List steps; + private String routeColor; + private String route; + private String routeId; + private int service; + private PassStopList passStopList; + private int type; + private PassShape passShape; + } + + @Getter + @Setter + public static class Location { + + private String name; + private double lon; + private double lat; + } + + @Getter + @Setter + public static class Step { + + private String streetName; + private int distance; + private String description; + private String linestring; + } + + @Getter + @Setter + public static class PassStopList { + + private List stationList; + } + + @Getter + @Setter + public static class Station { + + private int index; + private String stationName; + private String lon; + private String lat; + private String stationID; + } + + @Getter + @Setter + public static class PassShape { + + private String linestring; + } + +} diff --git a/kok-api/src/main/java/com/kok/kokapi/public_transportation/adapter/out/external/dto/TmapPublicTransportationResponse.java b/kok-api/src/main/java/com/kok/kokapi/public_transportation/adapter/out/external/dto/TmapPublicTransportationResponse.java new file mode 100644 index 00000000..28c2a1e8 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/public_transportation/adapter/out/external/dto/TmapPublicTransportationResponse.java @@ -0,0 +1,90 @@ +package com.kok.kokapi.public_transportation.adapter.out.external.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.List; +import lombok.Getter; +import lombok.Setter; + +// Tmap ์ฒ˜๋ฆฌ๋ฅผ ์œ„ํ•œ POJO +@Getter +@Setter +@JsonIgnoreProperties(ignoreUnknown = true) +public class TmapPublicTransportationResponse { + + // MetaData -> RequestParameters, Plan + // Plan -> Itinerary + // Itinerary -> Fare, totalTime, totalWalkTime, pathType, transferCount, totalDistance, totalWalkDistance + // ํ˜•์‹๊ณผ ๋‹ค๋ฅธ Json์€ ๋ฌด์‹œ. + + private MetaData metaData; + + @Getter + @Setter + public static class MetaData { + + private RequestParameters requestParameters; + private Plan plan; + + } + + @Getter + @Setter + public static class RequestParameters { + + private String endY; + private String endX; + private String startY; + private String startX; + private String reqDttm; + + } + + @Getter + @Setter + public static class Plan { + + private List itineraries; + + } + + @Getter + @Setter + public static class Itinerary { + + private Fare fare; + private int totalTime; + private int totalWalkTime; + private int pathType; + private int transferCount; + private int totalDistance; + private int totalWalkDistance; + + } + + @Getter + @Setter + public static class Fare { + + private RegularFare regular; + + } + + @Getter + @Setter + public static class RegularFare { + + private int totalFare; + private Currency currency; + + } + + @Getter + @Setter + public static class Currency { + + private String symbol; + private String currency; + private String currencyCode; + + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/public_transportation/application/service/TmapPublicTransportationService.java b/kok-api/src/main/java/com/kok/kokapi/public_transportation/application/service/TmapPublicTransportationService.java new file mode 100644 index 00000000..827ef2f0 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/public_transportation/application/service/TmapPublicTransportationService.java @@ -0,0 +1,101 @@ +package com.kok.kokapi.public_transportation.application.service; + +import static com.kok.kokapi.public_transportation.adapter.in.dto.response.TmapComplexPublicTransportationParsedResponse.ParsedItinerary; +import static com.kok.kokapi.public_transportation.adapter.in.dto.response.TmapComplexPublicTransportationParsedResponse.ParsedLeg; +import static com.kok.kokapi.public_transportation.adapter.out.external.dto.TmapComplexPublicTransportationResponse.Itinerary; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kok.kokapi.public_transportation.adapter.in.dto.response.TmapComplexPublicTransportationParsedResponse; +import com.kok.kokapi.public_transportation.adapter.in.dto.response.TmapPublicTransportationParsedResponse; +import com.kok.kokapi.public_transportation.adapter.out.external.PublicTransportationClient; +import com.kok.kokapi.public_transportation.adapter.out.external.PublicTransportationComplexClient; +import com.kok.kokapi.public_transportation.adapter.out.external.dto.TmapComplexPublicTransportationResponse; +import com.kok.kokapi.public_transportation.adapter.out.external.dto.TmapPublicTransportationResponse; +import com.kok.kokcore.public_transportation.usecase.RetrievePublicTransportationUseCase; +import java.util.List; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class TmapPublicTransportationService implements RetrievePublicTransportationUseCase { + + private final PublicTransportationClient publicTransportationClient; + private final PublicTransportationComplexClient publicTransportationComplexClient; + private final ObjectMapper objectMapper; + + + @Cacheable(value = "sub", cacheManager = "publicTransportationCacheManager", key = "'PTSubCache:' + #stationId + '-' + #roomId + '-' + #memberId") + @Override + public String retrievePublicTransportation(Long stationId, String roomId, String memberId) { + TmapPublicTransportationResponse rawRoute = publicTransportationClient.callPublicTransportRoute( + stationId, roomId, memberId); + try { + return objectMapper.writeValueAsString(parseTmapResponse(rawRoute)); + } catch (JsonProcessingException e) { + throw new RuntimeException("ํŒŒ์‹ฑ ์‹คํŒจ.."); + } + } + + @Cacheable(value = "complex", cacheManager = "publicTransportationCacheManager", key = "'PTComplexCache:' + #stationId + '-' + #roomId + '-' + #memberId") + @Override + public String retrieveComplexPublicTransportation(Long stationId, String roomId, + String memberId) { + TmapComplexPublicTransportationResponse rawRoute = publicTransportationComplexClient.callComplexPublicTransportRoute( + stationId, roomId, memberId); + try { + return objectMapper.writeValueAsString(parseComplexTmapResponse(rawRoute)); + } catch (JsonProcessingException e) { + throw new RuntimeException("ํŒŒ์‹ฑ ์‹คํŒจ.."); + } + } + + public TmapComplexPublicTransportationParsedResponse parseComplexTmapResponse( + TmapComplexPublicTransportationResponse response) { + if (response == null || response.getMetaData() == null + || response.getMetaData().getPlan() == null + || response.getMetaData().getPlan().getItineraries() == null || response.getMetaData() + .getPlan().getItineraries().isEmpty()) { + return null; + } + + Itinerary itinerary = response.getMetaData().getPlan().getItineraries().getFirst(); + ParsedItinerary parsedItinerary = new ParsedItinerary(); + parsedItinerary.setTotalDistance(itinerary.getTotalDistance()); + parsedItinerary.setTotalTime(itinerary.getTotalTime()); + + List parsedLegs = itinerary.getLegs().stream().map(leg -> { + ParsedLeg parsedLeg = new ParsedLeg(); + parsedLeg.setMode(leg.getMode()); + parsedLeg.setDistance(leg.getDistance()); + parsedLeg.setSectionTime(leg.getSectionTime()); + parsedLeg.setRoute(leg.getRoute()); + parsedLeg.setRouteColor(leg.getRouteColor()); + return parsedLeg; + }).collect(Collectors.toList()); + + parsedItinerary.setLegs(parsedLegs); + + TmapComplexPublicTransportationParsedResponse parsedResponse = new TmapComplexPublicTransportationParsedResponse(); + parsedResponse.setParsedItinerary(parsedItinerary); + return parsedResponse; + } + + public TmapPublicTransportationParsedResponse parseTmapResponse( + TmapPublicTransportationResponse response) { + if (response == null || response.getMetaData() == null + || response.getMetaData().getPlan() == null + || response.getMetaData().getPlan().getItineraries() == null) { + return null; + } + return TmapPublicTransportationParsedResponse.of( + response.getMetaData().getPlan().getItineraries().getFirst().getTotalTime(), + response.getMetaData().getPlan().getItineraries().getFirst().getTransferCount()); + } + +} diff --git a/kok-api/src/main/java/com/kok/kokapi/room/adapter/in/dto/request/CreateRoomRequest.java b/kok-api/src/main/java/com/kok/kokapi/room/adapter/in/dto/request/CreateRoomRequest.java new file mode 100644 index 00000000..50f72880 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/room/adapter/in/dto/request/CreateRoomRequest.java @@ -0,0 +1,25 @@ +package com.kok.kokapi.room.adapter.in.dto.request; + + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CreateRoomRequest( + @NotBlank(message = "๋ฐฉ ์ด๋ฆ„์€ ํ•„์ˆ˜ ์ž…๋ ฅ๊ฐ’์ž…๋‹ˆ๋‹ค.") + @Size(max = 30, message = "๋ฐฉ ์ด๋ฆ„์€ ์ตœ๋Œ€ 30์ž๊นŒ์ง€ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.") + String roomName, + + @Min(value = 2, message = "์ฐธ์—ฌ ์ธ์› ์ˆ˜๋Š” ์ตœ์†Œ 2๋ช… ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + @Max(value = 15, message = "์ฐธ์—ฌ ์ธ์› ์ˆ˜๋Š” ์ตœ๋Œ€ 15๋ช…๊นŒ์ง€ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.") + Integer capacity, + + @NotBlank(message = "ํ”„๋กœํ•„ ์ด๋ฏธ์ง€๋Š” ํ•„์ˆ˜ ์ž…๋ ฅ ๊ฐ’์ž…๋‹ˆ๋‹ค.") + String hostProfile, + + @NotBlank(message = "๋‹‰๋„ค์ž„์€ ํ•„์ˆ˜ ์ž…๋ ฅ๊ฐ’์ž…๋‹ˆ๋‹ค.") + String hostNickname +) { + +} diff --git a/kok-api/src/main/java/com/kok/kokapi/room/adapter/in/dto/request/JoinRoomParticipantRequest.java b/kok-api/src/main/java/com/kok/kokapi/room/adapter/in/dto/request/JoinRoomParticipantRequest.java new file mode 100644 index 00000000..b8b94c47 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/room/adapter/in/dto/request/JoinRoomParticipantRequest.java @@ -0,0 +1,15 @@ +package com.kok.kokapi.room.adapter.in.dto.request; + + +import jakarta.validation.constraints.NotBlank; + +public record JoinRoomParticipantRequest( + @NotBlank(message = "ํ”„๋กœํ•„ ์ •๋ณด๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + String profile, + + @NotBlank(message = "๋‹‰๋„ค์ž„ ์ •๋ณด๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + String nickname +) { + +} + diff --git a/kok-api/src/main/java/com/kok/kokapi/room/adapter/in/dto/response/CreateRoomResponse.java b/kok-api/src/main/java/com/kok/kokapi/room/adapter/in/dto/response/CreateRoomResponse.java new file mode 100644 index 00000000..2d5aa2c2 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/room/adapter/in/dto/response/CreateRoomResponse.java @@ -0,0 +1,24 @@ +package com.kok.kokapi.room.adapter.in.dto.response; + +import com.kok.kokcore.room.domain.Room; + +public record CreateRoomResponse( + String id, + String roomName, + int capacity, + MemberResponse member, + int participantCount, + int nonParticipantCount +) { + + public static CreateRoomResponse of(Room room, int participantCount, int nonParticipantCount) { + return new CreateRoomResponse( + room.getId(), + room.getRoomName(), + room.getCapacity(), + MemberResponse.from(room.getMember()), + participantCount, + nonParticipantCount + ); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/room/adapter/in/dto/response/JoinRoomResponse.java b/kok-api/src/main/java/com/kok/kokapi/room/adapter/in/dto/response/JoinRoomResponse.java new file mode 100644 index 00000000..22a987ce --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/room/adapter/in/dto/response/JoinRoomResponse.java @@ -0,0 +1,12 @@ +package com.kok.kokapi.room.adapter.in.dto.response; + +public record JoinRoomResponse( + String id, + String profile, + String nickname, + int participantCount, + int nonParticipantCount +) { + +} + diff --git a/kok-api/src/main/java/com/kok/kokapi/room/adapter/in/dto/response/MemberResponse.java b/kok-api/src/main/java/com/kok/kokapi/room/adapter/in/dto/response/MemberResponse.java new file mode 100644 index 00000000..a18e8008 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/room/adapter/in/dto/response/MemberResponse.java @@ -0,0 +1,20 @@ +package com.kok.kokapi.room.adapter.in.dto.response; + +import com.kok.kokcore.room.domain.Member; +import com.kok.kokcore.room.domain.vo.MemberRole; + +public record MemberResponse( + String id, + String nickname, + String profile, + MemberRole role +) { + + public static MemberResponse from(Member member) { + return new MemberResponse( + member.getMemberId(), + member.getNickname(), + member.getProfile(), + member.getRole()); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/room/adapter/in/dto/response/RandomProfileResponse.java b/kok-api/src/main/java/com/kok/kokapi/room/adapter/in/dto/response/RandomProfileResponse.java new file mode 100644 index 00000000..dbfea7d8 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/room/adapter/in/dto/response/RandomProfileResponse.java @@ -0,0 +1,8 @@ +package com.kok.kokapi.room.adapter.in.dto.response; + +public record RandomProfileResponse( + String imageUrl, + String nickname +) { + +} diff --git a/kok-api/src/main/java/com/kok/kokapi/room/adapter/in/dto/response/RoomDetailResponse.java b/kok-api/src/main/java/com/kok/kokapi/room/adapter/in/dto/response/RoomDetailResponse.java new file mode 100644 index 00000000..eb1a444f --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/room/adapter/in/dto/response/RoomDetailResponse.java @@ -0,0 +1,20 @@ +package com.kok.kokapi.room.adapter.in.dto.response; + +import com.kok.kokcore.room.domain.Room; + +public record RoomDetailResponse( + String id, + String roomName, + long nonParticipantCount, + String roomStatus +) { + + public static RoomDetailResponse of(Room room, long participantCount) { + return new RoomDetailResponse( + room.getId(), + room.getRoomName(), + room.getCapacity() - participantCount, + room.getStatus().name() + ); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/room/adapter/in/dto/response/RoomParticipantResponse.java b/kok-api/src/main/java/com/kok/kokapi/room/adapter/in/dto/response/RoomParticipantResponse.java new file mode 100644 index 00000000..f1cb5101 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/room/adapter/in/dto/response/RoomParticipantResponse.java @@ -0,0 +1,11 @@ +package com.kok.kokapi.room.adapter.in.dto.response; + +import com.kok.kokcore.room.domain.vo.MemberRole; + +public record RoomParticipantResponse( + String memberId, + String profile, + String nickname, + MemberRole role, + String address +) { } diff --git a/kok-api/src/main/java/com/kok/kokapi/room/adapter/in/dto/response/RoomParticipantsResponse.java b/kok-api/src/main/java/com/kok/kokapi/room/adapter/in/dto/response/RoomParticipantsResponse.java new file mode 100644 index 00000000..bade6e50 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/room/adapter/in/dto/response/RoomParticipantsResponse.java @@ -0,0 +1,33 @@ +package com.kok.kokapi.room.adapter.in.dto.response; + +import com.kok.kokcore.location.domain.Location; +import com.kok.kokcore.room.domain.Member; +import com.kok.kokcore.room.domain.Room; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public record RoomParticipantsResponse( + boolean isFull, + List members +) { + + public static RoomParticipantsResponse of(Room room, List members, List locations) { + Map locationMap = locations.stream() + .collect(Collectors.toMap( + Location::getMemberId, + Location::getName + )); + + List responses = members.stream() + .map(member -> new RoomParticipantResponse( + member.getMemberId(), + member.getProfile(), + member.getNickname(), + member.getRole(), + locationMap.getOrDefault(member.getMemberId(), "") + )).toList(); + + return new RoomParticipantsResponse(room.isFull(members.size()), responses); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/room/adapter/in/dto/response/RoomStatusResponse.java b/kok-api/src/main/java/com/kok/kokapi/room/adapter/in/dto/response/RoomStatusResponse.java new file mode 100644 index 00000000..18b0f0ae --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/room/adapter/in/dto/response/RoomStatusResponse.java @@ -0,0 +1,10 @@ +package com.kok.kokapi.room.adapter.in.dto.response; + +import com.kok.kokcore.room.domain.Room; + +public record RoomStatusResponse(String roomStatus) { + + public static RoomStatusResponse of(Room room) { + return new RoomStatusResponse(room.getStatus().name()); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/room/adapter/in/web/RoomController.java b/kok-api/src/main/java/com/kok/kokapi/room/adapter/in/web/RoomController.java new file mode 100644 index 00000000..b36ced5d --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/room/adapter/in/web/RoomController.java @@ -0,0 +1,115 @@ +package com.kok.kokapi.room.adapter.in.web; + +import com.kok.kokapi.common.response.ApiResponseDto; +import com.kok.kokapi.config.annotion.V1Controller; +import com.kok.kokapi.room.adapter.in.dto.request.CreateRoomRequest; +import com.kok.kokapi.room.adapter.in.dto.request.JoinRoomParticipantRequest; +import com.kok.kokapi.room.adapter.in.dto.response.CreateRoomResponse; +import com.kok.kokapi.room.adapter.in.dto.response.JoinRoomResponse; +import com.kok.kokapi.room.adapter.in.dto.response.RoomDetailResponse; +import com.kok.kokapi.room.adapter.in.dto.response.RoomParticipantsResponse; +import com.kok.kokapi.room.adapter.in.dto.response.RoomStatusResponse; +import com.kok.kokcore.location.domain.Location; +import com.kok.kokcore.location.usecase.ReadLocationUseCase; +import com.kok.kokcore.room.domain.Member; +import com.kok.kokcore.room.domain.Room; +import com.kok.kokcore.room.domain.vo.MemberRole; +import com.kok.kokcore.room.usecase.CreateRoomUseCase; +import com.kok.kokcore.room.usecase.GetRoomUseCase; +import com.kok.kokcore.room.usecase.JoinRoomUseCase; +import com.kok.kokcore.room.usecase.UpdateRoomUseCase; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +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; + +@V1Controller +@RequiredArgsConstructor +public class RoomController { + + private final GetRoomUseCase getRoomUseCase; + private final CreateRoomUseCase createRoomUseCase; + private final JoinRoomUseCase joinRoomUseCase; + private final ReadLocationUseCase readLocationUseCase; + private final UpdateRoomUseCase updateRoomUseCase; + + @Operation(summary = "์•ฝ์†๋ฐฉ ์กฐํšŒ", description = "์•ฝ์†๋ฐฉ ID๋ฅผ ํ†ตํ•ด ์•ฝ์†๋ฐฉ์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. ํˆฌํ‘œ ๋ชจ๋“œ์— ๋Œ€ํ•œ ๊ฐ’์„ ํฌํ•จํ•ฉ๋‹ˆ๋‹ค.") + @GetMapping("/rooms/{roomId}") + public ResponseEntity> getRoomDetail( + @PathVariable String roomId) { + Room room = updateRoomUseCase.updateRoomStatus(roomId, LocalDateTime.now()); + long participantsCount = getRoomUseCase.getParticipantsCount(roomId); + RoomDetailResponse response = RoomDetailResponse.of(room, participantsCount); + return ResponseEntity.ok(ApiResponseDto.success(response)); + } + + @Operation(summary = "์•ฝ์†๋ฐฉ ์ƒํƒœ ์กฐํšŒ", description = "์•ฝ์†๋ฐฉ ID๋ฅผ ํ†ตํ•ด ํ˜„์žฌ ์•ฝ์†๋ฐฉ ์ƒํƒœ(LOCATION_INPUT/VOTE/VOTE_RESULT)๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.") + @GetMapping("/rooms/{roomId}/status") + public ResponseEntity> getRoomStatus( + @PathVariable String roomId) { + Room room = updateRoomUseCase.updateRoomStatus(roomId, LocalDateTime.now()); + RoomStatusResponse response = RoomStatusResponse.of(room); + return ResponseEntity.ok(ApiResponseDto.success(response)); + } + + @Operation(summary = "์•ฝ์†๋ฐฉ ์ƒ์„ฑ", description = "์ƒˆ๋กœ์šด ์•ฝ์†๋ฐฉ์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.") + @PostMapping("/rooms") + public ResponseEntity> createRoom( + @Valid @RequestBody CreateRoomRequest request) { + String nickname = request.hostNickname(); + String profile = request.hostProfile(); + Member host = new Member(nickname, profile, MemberRole.LEADER); + + Room room = createRoomUseCase.createRoom( + request.roomName(), + request.capacity(), + host + ); + + CreateRoomResponse response = CreateRoomResponse.of(room, 1, room.getCapacity() - 1); + + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponseDto.success(response)); + } + + @Operation(summary = "์•ฝ์†๋ฐฉ ์ฐธ์—ฌ์ž ํ”„๋กœํ•„ ๋ชฉ๋ก ์กฐํšŒ", description = "์•ฝ์†๋ฐฉ์— ์ฐธ์—ฌ ์ค‘์ธ ์ฐธ์—ฌ์ž๋“ค์˜ ํ”„๋กœํ•„ ๋ชฉ๋ก์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.") + @GetMapping("/rooms/{roomId}/participants") + public ResponseEntity> getParticipants( + @PathVariable String roomId) { + Room room = updateRoomUseCase.updateRoomStatus(roomId, LocalDateTime.now()); + List participants = getRoomUseCase.getParticipants(room.getId()); + List locations = readLocationUseCase.readLocations(room.getId()); + + RoomParticipantsResponse responses = RoomParticipantsResponse.of(room, participants, + locations); + return ResponseEntity.ok(ApiResponseDto.success(responses)); + } + + @Operation(summary = "์•ฝ์†๋ฐฉ ์ฐธ์—ฌ", description = "์‚ฌ์šฉ์ž๊ฐ€ ์•ฝ์†๋ฐฉ์— ์ฐธ์—ฌํ•ฉ๋‹ˆ๋‹ค.") + @PostMapping("/rooms/{roomId}/join") + public ResponseEntity> joinRoom(@PathVariable String roomId, + @Valid @RequestBody JoinRoomParticipantRequest request) { + Room room = updateRoomUseCase.updateRoomStatus(roomId, LocalDateTime.now()); + + Member participant = new Member(request.nickname(), request.profile(), MemberRole.FOLLOWER); + int participantCount = joinRoomUseCase.joinRoom(roomId, participant); + int nonParticipantCount = room.getCapacity() - participantCount; + + JoinRoomResponse response = new JoinRoomResponse( + participant.getMemberId(), + participant.getProfile(), + participant.getNickname(), + participantCount, + nonParticipantCount + ); + + return ResponseEntity.ok(ApiResponseDto.success(response)); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/room/adapter/in/web/RoomProfileController.java b/kok-api/src/main/java/com/kok/kokapi/room/adapter/in/web/RoomProfileController.java new file mode 100644 index 00000000..08fffc04 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/room/adapter/in/web/RoomProfileController.java @@ -0,0 +1,30 @@ +package com.kok.kokapi.room.adapter.in.web; + +import com.kok.kokapi.common.response.ApiResponseDto; +import com.kok.kokapi.config.annotion.V1Controller; +import com.kok.kokapi.room.adapter.in.dto.response.RandomProfileResponse; +import com.kok.kokcore.room.domain.Profile; +import com.kok.kokcore.room.usecase.CreateRandomProfileUseCase; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; + +@V1Controller +@RequiredArgsConstructor +public class RoomProfileController { + + private final CreateRandomProfileUseCase createRandomProfileUseCase; + + @Operation(summary = "๋žœ๋ค ํ”„๋กœํ•„ ๋ฐ ๋‹‰๋„ค์ž„ ์ƒ์„ฑ ๋ฐ ์กฐํšŒ", description = "๋žœ๋ค์œผ๋กœ ์ƒ์„ฑํ•œ ํ”„๋กœํ•„ ์ด๋ฏธ์ง€์™€ ๋‹‰๋„ค์ž„์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.") + @GetMapping("/rooms/profile/random") + public ResponseEntity> getRandomProfile() { + Profile profile = createRandomProfileUseCase.createProfile(); + RandomProfileResponse response = new RandomProfileResponse( + profile.getImageUrl(), + profile.getNickname() + ); + + return ResponseEntity.ok(ApiResponseDto.success(response)); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/room/adapter/out/persistence/RoomParticipantQueryRedisAdapter.java b/kok-api/src/main/java/com/kok/kokapi/room/adapter/out/persistence/RoomParticipantQueryRedisAdapter.java new file mode 100644 index 00000000..d3203f72 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/room/adapter/out/persistence/RoomParticipantQueryRedisAdapter.java @@ -0,0 +1,59 @@ +package com.kok.kokapi.room.adapter.out.persistence; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kok.kokcore.room.domain.Member; +import com.kok.kokcore.room.port.out.LoadRoomParticipantPort; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class RoomParticipantQueryRedisAdapter implements LoadRoomParticipantPort { + + public static final String PARTICIPANT_KEY_PREFIX = "room:participants:"; + + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + @Override + public Long countParticipantsById(String roomId) { + String key = buildKey(roomId); + if (!redisTemplate.hasKey(key)) { + return 0L; + } + return redisTemplate.opsForList().size(key); + } + + @Override + public List findMembersByRoomId(String roomId) { + String key = buildKey(roomId); + List memberJson = redisTemplate.opsForList().range(key, 0, -1); + List members = new ArrayList<>(); + if (memberJson != null) { + for (String data : memberJson) { + try { + Member member = objectMapper.readValue(data, Member.class); + members.add(member); + } catch (JsonProcessingException ignored) { + } + } + } + return members; + } + + @Override + public Optional findByRoomIdAndMemberId(String roomId, String memberId) { + return findMembersByRoomId(roomId).stream() + .filter(member -> member.getMemberId().equals(memberId)) + .findFirst(); + } + + private String buildKey(String roomId) { + return PARTICIPANT_KEY_PREFIX + roomId; + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/room/adapter/out/persistence/RoomParticipantSaveAdapter.java b/kok-api/src/main/java/com/kok/kokapi/room/adapter/out/persistence/RoomParticipantSaveAdapter.java new file mode 100644 index 00000000..599e056e --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/room/adapter/out/persistence/RoomParticipantSaveAdapter.java @@ -0,0 +1,41 @@ +package com.kok.kokapi.room.adapter.out.persistence; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kok.kokapi.common.util.RedisExecutor; +import com.kok.kokcore.room.domain.Member; +import com.kok.kokcore.room.port.out.SaveRoomParticipantsPort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +@Slf4j +@Repository +@RequiredArgsConstructor +public class RoomParticipantSaveAdapter implements SaveRoomParticipantsPort { + + private static final String PARTICIPANT_KEY_PREFIX = "room:participants:"; + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + @Override + public int joinRoom(String roomId, Member member) { + String key = PARTICIPANT_KEY_PREFIX + roomId; + + try { + String memberJson = objectMapper.writeValueAsString(member); + RedisExecutor.runOrThrow("joinRoom:push" + roomId, + () -> redisTemplate.opsForList().rightPush(key, memberJson)); + Long participantCount = RedisExecutor.runOrThrow("joinRoom:size: " + roomId, + () -> redisTemplate.opsForList().size(key)); + return participantCount != null ? participantCount.intValue() : 0; + } catch (JsonProcessingException e) { + log.error("[RoomParticipant] Failed to serialize member. roomId={}, member={}", roomId, member, e); + throw new RuntimeException("Failed to serialize member", e); + } catch (Exception e) { + log.error("[RoomParticipant] Redis join failed. roomId={}, member={}", roomId, member, e); + throw new RuntimeException("Failed to join room in Redis: " + e.getClass().getSimpleName(), e); + } + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/room/adapter/out/persistence/RoomQueryRedisAdapter.java b/kok-api/src/main/java/com/kok/kokapi/room/adapter/out/persistence/RoomQueryRedisAdapter.java new file mode 100644 index 00000000..fcc61fa9 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/room/adapter/out/persistence/RoomQueryRedisAdapter.java @@ -0,0 +1,45 @@ +package com.kok.kokapi.room.adapter.out.persistence; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kok.kokcore.room.domain.Room; +import com.kok.kokcore.room.port.out.LoadRoomPort; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class RoomQueryRedisAdapter implements LoadRoomPort { + + public static final String ROOM_KEY_PREFIX = "room:"; + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + @Override + public Optional findRoomById(String roomId) { + String key = buildKey(roomId); + return Optional.ofNullable(redisTemplate.opsForValue().get(key)) + .flatMap(this::deserializeRoom); + } + + @Override + public boolean isExistsByRoomId(String roomId) { + String key = buildKey(roomId); + return redisTemplate.hasKey(key); + } + + private Optional deserializeRoom(String roomJson) { + try { + Room room = objectMapper.readValue(roomJson, Room.class); + return Optional.of(room); + } catch (JsonProcessingException e) { + return Optional.empty(); + } + } + + private String buildKey(String roomId) { + return ROOM_KEY_PREFIX + roomId; + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/room/adapter/out/persistence/RoomSaveRedisAdapter.java b/kok-api/src/main/java/com/kok/kokapi/room/adapter/out/persistence/RoomSaveRedisAdapter.java new file mode 100644 index 00000000..7a60342b --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/room/adapter/out/persistence/RoomSaveRedisAdapter.java @@ -0,0 +1,74 @@ +package com.kok.kokapi.room.adapter.out.persistence; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kok.kokapi.common.util.RedisExecutor; +import com.kok.kokcore.room.domain.Room; +import com.kok.kokcore.room.port.out.SaveRoomPort; +import com.kok.kokcore.room.port.out.UpdateRoomPort; +import java.time.Duration; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +@Slf4j +@Repository +@RequiredArgsConstructor +public class RoomSaveRedisAdapter implements SaveRoomPort, UpdateRoomPort { + + private static final String ROOM_KEY_PREFIX = "room"; + private static final Duration ROOM_TTL = Duration.ofDays(3); + + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + @Override + public Room save(Room room) { + String key = buildKey(room.getId()); + try { + String roomJson = objectMapper.writeValueAsString(room); + RedisExecutor.runOrThrow("saveRoom", + () -> redisTemplate.opsForValue().set(key, roomJson, ROOM_TTL)); + } catch (JsonProcessingException e) { + log.error("[Room] Failed to serialize room. roomId={}", room.getId(), e); + throw new RuntimeException("failed to serialize room object", e); + } catch (Exception e) { + log.error("[Room] Save to Redis failed. roomId={}, key={}", room.getId(), key, e); + throw new RuntimeException( + "failed to save room to Redis: " + e.getClass().getSimpleName(), e); + } + return room; + } + + @Override + public void update(Room room) { + String key = buildKey(room.getId()); + try { + String roomJson = objectMapper.writeValueAsString(room); + Duration currentTtl = getTTL(key); + redisTemplate.opsForValue().set(key, roomJson, currentTtl); + } catch (JsonProcessingException e) { + log.error("[Room] Failed to serialize room. roomId={}", room.getId(), e); + throw new RuntimeException("failed to serialize room object", e); + } catch (RuntimeException e) { + log.error("[Room] update to Redis failed. roomId={}, key={}", room.getId(), key, e); + throw new RuntimeException( + "failed to update room in Redis: " + e.getClass().getSimpleName(), e); + } + } + + private String buildKey(String roomId) { + return ROOM_KEY_PREFIX + ":" + roomId; + } + + private Duration getTTL(String key) { + Long expireSeconds = redisTemplate.getExpire(key); + if (Objects.isNull(expireSeconds) || expireSeconds <= 0) { + log.warn("Cannot find key: {}, initiate expire TTL", key); + return ROOM_TTL; + } + return Duration.ofSeconds(expireSeconds); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/room/application/service/RandomProfileService.java b/kok-api/src/main/java/com/kok/kokapi/room/application/service/RandomProfileService.java new file mode 100644 index 00000000..03b5a2d6 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/room/application/service/RandomProfileService.java @@ -0,0 +1,45 @@ +package com.kok.kokapi.room.application.service; + +import com.kok.kokcore.room.domain.Profile; +import com.kok.kokcore.room.usecase.CreateRandomProfileUseCase; +import java.util.List; +import java.util.Random; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class RandomProfileService implements CreateRandomProfileUseCase { + + @Value("${aws.object-storage-url}") + private String objectStorageUrl; + + private static final List ADJECTIVES = List.of( + "๋ฐฐ๊ณ ํ”ˆ", "๋ฉ‹์žˆ๋Š”", "์ฟจํ•œ ", "๋น ๋ฅธ", "์นœ์ ˆํ•œ", "์˜๋ฆฌํ•œ", "ํ–‰๋ณตํ•œ", "์กฐ์šฉํ•œ", "๊ฐ•๋ ฅํ•œ", "์ž์œ ๋กœ์šด", + "์Šค๋งˆํŠธํ•œ", "์ž˜์ƒ๊ธด", "๊ท€์—ฌ์šด", "ํ‰ํ™”๋กœ์šด", "๋น›๋‚˜๋Š”", "๋˜‘๋˜‘ํ•œ", "์‹ ๋‚˜๋Š”", "๋ถ€๋“œ๋Ÿฌ์šด", "์—‰๋šฑํ•œ", "๋ฌด์„œ์šด" + ); + + private static final List NOUNS = List.of( + "ํ† ๋ฏธ", "์ง€๋ฏธ", "๋ผ์ด์–ธ", "๋ฃจ์นด์Šค", "๋ฐ์ด๋น—", "์— ๋งˆ", "์˜ฌ๋ฆฌ๋ฒ„", "์†Œํ”ผ", "๋ด‰๋ด‰์ด", "ํ”ผ์น˜", + "๋ฌด์ง€", "ํŠœ๋ธŒ", "ํ”„๋กœ๋„", "์ฝ˜", "๋ธŒ๋ผ์šด", "์ฝ”์ฝ”", "ํ”„๋ Œ์ฆˆ", "ํ† ๋ผ", "ํŽญ์ˆ˜", "๋ฝ€๋กœ๋กœ" + ); + + @Override + public Profile createProfile() { + String imageUrl = getRandomImageUrl(); + String nickname = generateRandomNickname(); + return new Profile(imageUrl, nickname); + } + + private String getRandomImageUrl() { + int randomImageIndex = new Random().nextInt(15) + 1; + return objectStorageUrl + "/profile_default/" + randomImageIndex + ".png"; + } + + private String generateRandomNickname() { + String adjective = ADJECTIVES.get(new Random().nextInt(ADJECTIVES.size())); + String noun = NOUNS.get(new Random().nextInt(NOUNS.size())); + return adjective + " " + noun; + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/room/application/service/RoomCommandService.java b/kok-api/src/main/java/com/kok/kokapi/room/application/service/RoomCommandService.java new file mode 100644 index 00000000..7ffb679f --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/room/application/service/RoomCommandService.java @@ -0,0 +1,92 @@ +package com.kok.kokapi.room.application.service; + +import com.kok.kokcore.location.domain.Location; +import com.kok.kokcore.location.port.out.ReadLocationPort; +import com.kok.kokcore.room.domain.Member; +import com.kok.kokcore.room.domain.Room; +import com.kok.kokcore.room.port.out.LoadRoomPort; +import com.kok.kokcore.room.port.out.SaveRoomPort; +import com.kok.kokcore.room.port.out.UpdateRoomPort; +import com.kok.kokcore.room.usecase.CreateRoomUseCase; +import com.kok.kokcore.room.usecase.JoinRoomUseCase; +import com.kok.kokcore.room.usecase.UpdateRoomUseCase; +import com.kok.kokcore.vote.port.out.LoadVotePort; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class RoomCommandService implements CreateRoomUseCase, UpdateRoomUseCase { + + private final SaveRoomPort saveRoomPort; + private final LoadRoomPort loadRoomPort; + private final ReadLocationPort readLocationPort; + private final UpdateRoomPort updateRoomPort; + private final LoadVotePort loadVotePort; + private final JoinRoomUseCase joinRoomUseCase; + + @Override + public Room createRoom(String roomName, int capacity, Member host) { + Room room = Room.create(roomName, capacity, host); + Room savedRoom = saveRoomPort.save(room); + + joinRoomUseCase.joinRoom(savedRoom.getId(), savedRoom.getMember()); + return savedRoom; + } + + @Override + public void startVote(String roomId, LocalDateTime current) { + Room room = getRoom(roomId); + if (shouldUpdateVoteDeadline(room, current)) { + room.updateVoteDeadline(current); + room.startVote(); + updateRoomPort.update(room); + } + } + + private boolean shouldUpdateVoteDeadline(Room room, LocalDateTime current) { + List locations = readLocationPort.findLocationsByRoomId(room.getId()); + int locationInputCount = locations.size(); + return room.shouldEndLocationInput(locationInputCount, current); + } + + @Override + public void closeVote(String roomId) { + Room room = getRoom(roomId); + room.closeVote(); + updateRoomPort.update(room); + } + + @Override + public Room updateRoomStatus(String roomId, LocalDateTime current) { + Room room = getRoom(roomId); + if (shouldEndLocationInput(room, current)) { + room.startVote(); + } + if (shouldEndVote(room, current)) { + room.closeVote(); + room.updateVoteDeadline(current); + } + updateRoomPort.update(room); + return room; + } + + private boolean shouldEndLocationInput(Room room, LocalDateTime current) { + List locations = readLocationPort.findLocationsByRoomId(room.getId()); + int locationInputCount = locations.size(); + return room.shouldEndLocationInput(locationInputCount, current); + } + + private boolean shouldEndVote(Room room, LocalDateTime current) { + int votedCount = loadVotePort.countVotedMembersByRoomId(room.getId()); + return room.shouldEndVote(votedCount, current); + } + + private Room getRoom(String roomId) { + return loadRoomPort.findRoomById(roomId) + .orElseThrow( + () -> new IllegalArgumentException("Cannot find room with roomId: " + roomId)); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/room/application/service/RoomParticipantService.java b/kok-api/src/main/java/com/kok/kokapi/room/application/service/RoomParticipantService.java new file mode 100644 index 00000000..38085c63 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/room/application/service/RoomParticipantService.java @@ -0,0 +1,21 @@ +package com.kok.kokapi.room.application.service; + +import com.kok.kokcore.room.domain.Member; +import com.kok.kokcore.room.port.out.SaveRoomParticipantsPort; +import com.kok.kokcore.room.usecase.JoinRoomUseCase; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + + +@Service +@RequiredArgsConstructor +public class RoomParticipantService implements JoinRoomUseCase { + + private final SaveRoomParticipantsPort saveRoomParticipantsPort; + + @Override + public int joinRoom(String roomId, Member member) { + return saveRoomParticipantsPort.joinRoom(roomId, member); + } + +} diff --git a/kok-api/src/main/java/com/kok/kokapi/room/application/service/RoomQueryService.java b/kok-api/src/main/java/com/kok/kokapi/room/application/service/RoomQueryService.java new file mode 100644 index 00000000..b5761d27 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/room/application/service/RoomQueryService.java @@ -0,0 +1,63 @@ +package com.kok.kokapi.room.application.service; + +import com.kok.kokcore.location.port.out.ReadLocationPort; +import com.kok.kokcore.room.domain.Member; +import com.kok.kokcore.room.domain.Room; +import com.kok.kokcore.room.port.out.LoadRoomParticipantPort; +import com.kok.kokcore.room.port.out.LoadRoomPort; +import com.kok.kokcore.room.usecase.GetRoomUseCase; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class RoomQueryService implements GetRoomUseCase { + + private final LoadRoomPort loadRoomPort; + private final LoadRoomParticipantPort loadRoomParticipantPort; + private final ReadLocationPort readLocationPort; + + @Override + public Room findRoomById(String roomId) { + return getRoom(roomId); + } + + private Room getRoom(String roomId) { + return loadRoomPort.findRoomById(roomId) + .orElseThrow(() -> new IllegalArgumentException("Room not found with id: " + roomId)); + } + + @Override + public List getParticipants(String roomId) { + validate(roomId); + return loadRoomParticipantPort.findMembersByRoomId(roomId); + } + + private void validate(String roomId) { + if (!loadRoomPort.isExistsByRoomId(roomId)) { + throw new IllegalArgumentException("Room not found with id: " + roomId); + } + } + + @Override + public Member getParticipant(String roomId, String memberId) { + return getParticipants(roomId).stream() + .filter(member -> member.getMemberId().equals(memberId)) + .findFirst() + .orElseThrow( + () -> new IllegalArgumentException("Member not found with id: " + memberId)); + } + + @Override + public long getParticipantsCount(String roomId) { + return readLocationPort.countParticipantsById(roomId); + } + + @Override + public List getParticipantsByRoomIdInMemberIds(String roomId, List memberIds) { + return getParticipants(roomId).stream() + .filter(member -> memberIds.contains(member.getMemberId())) + .toList(); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/station/adapter/in/dto/response/RecommendedStationResponse.java b/kok-api/src/main/java/com/kok/kokapi/station/adapter/in/dto/response/RecommendedStationResponse.java new file mode 100644 index 00000000..5d3f15d2 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/station/adapter/in/dto/response/RecommendedStationResponse.java @@ -0,0 +1,19 @@ +package com.kok.kokapi.station.adapter.in.dto.response; + +import com.kok.kokcore.station.domain.entity.Route; +import com.kok.kokcore.station.domain.entity.Station; +import java.util.List; + +public record RecommendedStationResponse( + + List routes, + Station station +) { + + public static RecommendedStationResponse of(Station station, List routes) { + return new RecommendedStationResponse( + routes.stream().map(Route::getName).toList(), + station + ); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/station/adapter/in/web/StationController.java b/kok-api/src/main/java/com/kok/kokapi/station/adapter/in/web/StationController.java new file mode 100644 index 00000000..4f7866c1 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/station/adapter/in/web/StationController.java @@ -0,0 +1,81 @@ +package com.kok.kokapi.station.adapter.in.web; + +import com.kok.kokapi.common.response.ApiResponseDto; +import com.kok.kokapi.config.annotion.V1Controller; +import com.kok.kokapi.station.adapter.in.dto.response.RecommendedStationResponse; +import com.kok.kokapi.station.application.service.StationFacadeService; +import com.kok.kokcore.station.domain.entity.Station; +import com.kok.kokcore.station.usecase.RetrieveRouteUseCase; +import com.kok.kokcore.station.usecase.SystemRecommendUseCase; +import com.kok.kokcore.station.usecase.UserRecommendUseCase; +import io.swagger.v3.oas.annotations.Operation; +import java.util.List; +import lombok.RequiredArgsConstructor; +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; + +@V1Controller +@RequiredArgsConstructor +public class StationController { + + private final SystemRecommendUseCase systemRecommendUseCase; + private final UserRecommendUseCase userRecommendUseCase; + private final RetrieveRouteUseCase retrieveRouteUseCase; + private final StationFacadeService stationFacadeService; + + /** + * 1์ฐจ MVP ๊ธฐ์ค€ ์ตœ์ข… ๊ฒฐ๊ณผ ์กฐํšŒ API + * + * @param keyword + * @return + */ + @Operation(summary = "์ถ”์ฒœ ์ง€ํ•˜์ฒ ์—ญ ๋ฆฌํ„ด", description = "Recommend subway stations based on the user's location.") + @GetMapping("/stations/recommend/{roomId}") + public ResponseEntity>> recommendStations( + @PathVariable String roomId) { + + List recommendedStations = systemRecommendUseCase.systemRecommendStation( + roomId).stream() + .map(station -> + RecommendedStationResponse.of(station, + retrieveRouteUseCase.retrieveRoutes(station))) + .toList(); + + return ResponseEntity.ok(ApiResponseDto.success(recommendedStations)); + } + + @Operation(summary = "์ง€ํ•˜์ฒ ์—ญ ๊ฒ€์ƒ‰", description = "Search for subway stations based on the keyword.") + @GetMapping("/stations/search/{keyword}") + public ResponseEntity>> searchStations( + @PathVariable String keyword) { + + List recommendedStations = userRecommendUseCase.searchStations( + keyword).stream() + .map(station -> + RecommendedStationResponse.of(station, + retrieveRouteUseCase.retrieveRoutes(station))) + .toList(); + + return ResponseEntity.ok(ApiResponseDto.success(recommendedStations)); + } + + @Operation(summary = "์ง€ํ•˜์ฒ ์—ญ ์ถ”๊ฐ€", description = "Add a subway station to the user's custom list.") + @PostMapping("/stations/custom/{roomId}/{stationId}") + public ResponseEntity> addCustomStations( + @PathVariable String roomId, @PathVariable Long stationId) { + Station station = userRecommendUseCase.addUserRecommendStation(roomId, stationId); + return ResponseEntity.ok(ApiResponseDto.success( + RecommendedStationResponse.of(station, retrieveRouteUseCase.retrieveRoutes(station)))); + } + + @Operation(summary = "ํˆฌํ‘œํ•  ์ง€ํ•˜์ฒ ์—ญ ๋ฆฌ์ŠคํŠธ ๋ฆฌํ„ด", description = "Return a list of subway stations to vote on.") + @GetMapping("/stations/candidate/{roomId}") + public ResponseEntity>> getCustomRecommendedStations( + @PathVariable String roomId) { + List candidateStations = stationFacadeService.getCandidateStationResponse( + roomId); + return ResponseEntity.ok(ApiResponseDto.success(candidateStations)); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/external/StationClient.java b/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/external/StationClient.java new file mode 100644 index 00000000..fdb022cd --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/external/StationClient.java @@ -0,0 +1,68 @@ +package com.kok.kokapi.station.adapter.out.external; + +import com.kok.kokapi.station.adapter.out.external.dto.StationResponses; +import com.kok.kokcore.station.port.out.LoadStationsPort; +import com.kok.kokcore.station.port.out.dto.StationRouteDtos; +import java.util.StringJoiner; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder; +import org.springframework.boot.http.client.ClientHttpRequestFactorySettings; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +@Slf4j +@Component +@EnableConfigurationProperties(StationClientProperties.class) +public class StationClient implements LoadStationsPort { + + private static final String DELIMITER = "/"; + + private final RestClient restClient; + private final StationClientProperties properties; + private final StationErrorHandler stationErrorHandler; + + public StationClient(StationClientProperties properties, + StationErrorHandler stationErrorHandler) { + this.properties = properties; + this.stationErrorHandler = stationErrorHandler; + this.restClient = getRestClient(); + } + + public RestClient getRestClient() { + return RestClient.builder() + .requestFactory(getRequestFactory()) + .baseUrl(properties.baseUrl()) + //.defaultStatusHandler(stationErrorHandler) + .build(); + } + + private ClientHttpRequestFactory getRequestFactory() { + return ClientHttpRequestFactoryBuilder.detect() + .build(ClientHttpRequestFactorySettings.defaults()); + } + + @Override + public StationRouteDtos loadAllStations() { + StationResponses responses = restClient.get() + .uri(getTargetUri()) + .retrieve() + .body(StationResponses.class); + log.debug("Seoul Data Open API Status Code: {}, Message: {}", + responses.subwayStationMaster().result().code(), + responses.subwayStationMaster().result().message() + ); + return responses.toStationRouteDtos(); + } + + public String getTargetUri() { + StringJoiner stringJoiner = new StringJoiner(DELIMITER); + return stringJoiner.add(properties.secretKey()) + .add(properties.format()) + .add(properties.service()) + .add(properties.startIdx()) + .add(properties.endIdx()) + .toString(); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/external/StationClientProperties.java b/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/external/StationClientProperties.java new file mode 100644 index 00000000..151d683d --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/external/StationClientProperties.java @@ -0,0 +1,15 @@ +package com.kok.kokapi.station.adapter.out.external; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "station") +public record StationClientProperties( + String baseUrl, + String secretKey, + String format, + String service, + String startIdx, + String endIdx +) { + +} diff --git a/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/external/StationErrorHandler.java b/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/external/StationErrorHandler.java new file mode 100644 index 00000000..f4487e65 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/external/StationErrorHandler.java @@ -0,0 +1,35 @@ +package com.kok.kokapi.station.adapter.out.external; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kok.kokapi.station.adapter.out.external.dto.StationResponses; +import java.io.IOException; +import java.net.URI; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpMethod; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.client.ResponseErrorHandler; + +@Component +@RequiredArgsConstructor +public class StationErrorHandler implements ResponseErrorHandler { + + private static final List SERVER_ERROR = List.of("ERROR-500", "ERROR-600", "ERROR-601"); + private final ObjectMapper objectMapper; + + @Override + public boolean hasError(ClientHttpResponse response) throws IOException { + String code = objectMapper.readValue(response.getBody(), StationResponses.class) + .subwayStationMaster() + .result() + .code(); + return SERVER_ERROR.contains(code); + } + + @Override + public void handleError(URI url, HttpMethod method, ClientHttpResponse response) + throws IOException { + throw new RuntimeException("์„œ๋ฒ„ ์˜ค๋ฅ˜์ž…๋‹ˆ๋‹ค. ์ง€์†์ ์œผ๋กœ ๋ฐœ์ƒ์‹œ ์—ด๋ฆฐ ๋ฐ์ดํ„ฐ ๊ด‘์žฅ์œผ๋กœ ๋ฌธ์˜(Q&A) ๋ฐ”๋ž๋‹ˆ๋‹ค."); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/external/dto/Result.java b/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/external/dto/Result.java new file mode 100644 index 00000000..fd5599c4 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/external/dto/Result.java @@ -0,0 +1,12 @@ +package com.kok.kokapi.station.adapter.out.external.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record Result( + @JsonProperty("CODE") + String code, + @JsonProperty("MESSAGE") + String message +) { + +} diff --git a/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/external/dto/StationResponse.java b/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/external/dto/StationResponse.java new file mode 100644 index 00000000..9c65262e --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/external/dto/StationResponse.java @@ -0,0 +1,22 @@ +package com.kok.kokapi.station.adapter.out.external.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.kok.kokcore.station.port.out.dto.StationRouteDto; + +public record StationResponse( + @JsonProperty("BLDN_ID") + long id, + @JsonProperty("BLDN_NM") + String name, + @JsonProperty("ROUTE") + String route, + @JsonProperty("LAT") + String latitude, + @JsonProperty("LOT") + String longitude +) { + + public StationRouteDto toStationRouteDto() { + return new StationRouteDto(name, latitude, longitude, id, route); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/external/dto/StationResponses.java b/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/external/dto/StationResponses.java new file mode 100644 index 00000000..3c9ce15e --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/external/dto/StationResponses.java @@ -0,0 +1,16 @@ +package com.kok.kokapi.station.adapter.out.external.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.kok.kokcore.station.port.out.dto.StationRouteDtos; + +public record StationResponses( + @JsonProperty("subwayStationMaster") + SubwayStationMaster subwayStationMaster +) { + + public StationRouteDtos toStationRouteDtos() { + return new StationRouteDtos(subwayStationMaster.row().stream() + .map(StationResponse::toStationRouteDto) + .toList()); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/external/dto/SubwayStationMaster.java b/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/external/dto/SubwayStationMaster.java new file mode 100644 index 00000000..f1f992a4 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/external/dto/SubwayStationMaster.java @@ -0,0 +1,15 @@ +package com.kok.kokapi.station.adapter.out.external.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +public record SubwayStationMaster( + @JsonProperty("list_total_count") + long listTotalCount, + @JsonProperty("RESULT") + Result result, + @JsonProperty("row") + List row +) { + +} diff --git a/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/persistence/RoutePersistenceAdapter.java b/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/persistence/RoutePersistenceAdapter.java new file mode 100644 index 00000000..0d93b2f7 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/persistence/RoutePersistenceAdapter.java @@ -0,0 +1,58 @@ +package com.kok.kokapi.station.adapter.out.persistence; + +import com.kok.kokcore.station.domain.entity.Route; +import com.kok.kokcore.station.domain.entity.Station; +import com.kok.kokcore.station.port.out.RetrieveRoutePort; +import com.kok.kokcore.station.port.out.SaveRoutePort; +import java.util.List; +import java.util.function.Function; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Repository +@Slf4j +@RequiredArgsConstructor +public class RoutePersistenceAdapter implements SaveRoutePort, RetrieveRoutePort { + + private final RouteRepository routeRepository; + + private static final String INSERT_ROUTE_SQL = """ + INSERT INTO route (name, station_id) + VALUES (:name, :station_id) + """; + private static final Function mapToParams = route -> + new MapSqlParameterSource() + .addValue("name", route.getName()) + .addValue("station_id", route.getStation().getId()); + + private final NamedParameterJdbcTemplate jdbcTemplate; + + @Override + public void saveRoutes(List routes) { + if (routes.isEmpty()) { + log.debug("No routes to save."); + return; + } + batchInsertRoutes(routes); + } + + private void batchInsertRoutes(List routes) { + MapSqlParameterSource[] batchParams = routes.stream() + .map(mapToParams) + .toArray(MapSqlParameterSource[]::new); + int[] batched = jdbcTemplate.batchUpdate(INSERT_ROUTE_SQL, batchParams); + log.debug("Successfully saved a total of {} routes out of {}.", batched.length, + routes.size()); + } + + + @Override + @Transactional(readOnly = true) + public List retrieveRoutes(Station station) { + return routeRepository.findAllByStationOrderByName(station); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/persistence/RouteRepository.java b/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/persistence/RouteRepository.java new file mode 100644 index 00000000..0fdf9a71 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/persistence/RouteRepository.java @@ -0,0 +1,11 @@ +package com.kok.kokapi.station.adapter.out.persistence; + +import com.kok.kokcore.station.domain.entity.Route; +import com.kok.kokcore.station.domain.entity.Station; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RouteRepository extends JpaRepository { + + List findAllByStationOrderByName(Station station); +} diff --git a/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/persistence/StationPersistenceAdapter.java b/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/persistence/StationPersistenceAdapter.java new file mode 100644 index 00000000..6d1dae62 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/persistence/StationPersistenceAdapter.java @@ -0,0 +1,91 @@ +package com.kok.kokapi.station.adapter.out.persistence; + +import com.kok.kokapi.config.geometry.PointConverter; +import com.kok.kokcore.station.domain.entity.Station; +import com.kok.kokcore.station.port.out.ReadStationsPort; +import com.kok.kokcore.station.port.out.RetrieveStationsPort; +import com.kok.kokcore.station.port.out.SaveStationsPort; +import java.math.BigDecimal; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.locationtech.jts.geom.Point; +import org.springframework.data.util.Pair; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Repository +@Slf4j +@RequiredArgsConstructor +public class StationPersistenceAdapter implements SaveStationsPort, ReadStationsPort, + RetrieveStationsPort { + + private final PointConverter pointConverter; + + private static final String INSERT_STATION_SQL = """ + INSERT INTO station (name, latitude, longitude, priority) + VALUES (:name, :latitude, :longitude, :priority) + """; + private static final Function mapToParams = station -> + new MapSqlParameterSource() + .addValue("name", station.getName()) + .addValue("latitude", station.getLatitude()) + .addValue("longitude", station.getLongitude()) + .addValue("priority", station.getPriority()); + + private final StationRepository stationRepository; + private final NamedParameterJdbcTemplate jdbcTemplate; + + @Override + public List saveStations(List stations) { + if (stations.isEmpty()) { + log.debug("No stations to save."); + return List.of(); + } + return batchInsertStations(stations); + } + + private List batchInsertStations(List stations) { + MapSqlParameterSource[] batchParams = stations.stream() + .map(mapToParams) + .toArray(MapSqlParameterSource[]::new); + int[] batched = jdbcTemplate.batchUpdate(INSERT_STATION_SQL, batchParams); + log.debug("Successfully saved a total of {} stations out of {}.", batched.length, + stations.size()); + List names = stations.stream().map(Station::getName).toList(); + return stationRepository.findAllByNameIn(names); + } + + @Override + public boolean hasNoStations() { + return !stationRepository.existsAny(); + } + + + @Override + @Transactional(readOnly = true) + public Optional retrieveStation(Long stationId) { + return stationRepository.findStationById(stationId); + } + + @Override + @Transactional(readOnly = true) + public List retrieveInRangeStations(Point centroid, double dist) { + Pair lonLat = pointConverter.toCoordinates(centroid); + return stationRepository.findInRangeStationsByCentroid( + lonLat.getFirst(), + lonLat.getSecond(), + dist + ); + } + + @Override + public List retrieveStationsByKeyword(String keyword) { + return stationRepository.findByNameContaining(keyword); + } +} + diff --git a/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/persistence/StationRepository.java b/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/persistence/StationRepository.java new file mode 100644 index 00000000..d1340b64 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/persistence/StationRepository.java @@ -0,0 +1,29 @@ +package com.kok.kokapi.station.adapter.out.persistence; + +import com.kok.kokcore.station.domain.entity.Station; +import io.lettuce.core.dynamic.annotation.Param; +import java.math.BigDecimal; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +public interface StationRepository extends JpaRepository { + + @Query("SELECT EXISTS (SELECT 1 FROM Station)") + boolean existsAny(); + + List findAllByNameIn(List names); + + Optional findStationById(Long stationId); + + @Query(value = """ + SELECT * FROM station + WHERE ST_Distance_Sphere(Point(longitude, latitude), Point(:lon, :lat)) < :distance + AND priority > 0 + """, nativeQuery = true) + List findInRangeStationsByCentroid(@Param("lon") BigDecimal lon, + @Param("lat") BigDecimal lat, @Param("distance") Double distance); + + List findByNameContaining(String keyword); +} diff --git a/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/persistence/UserRecommendStationCommandRedisAdapter.java b/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/persistence/UserRecommendStationCommandRedisAdapter.java new file mode 100644 index 00000000..31e3446a --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/persistence/UserRecommendStationCommandRedisAdapter.java @@ -0,0 +1,47 @@ +package com.kok.kokapi.station.adapter.out.persistence; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kok.kokcore.station.domain.entity.Station; +import com.kok.kokcore.station.port.out.SaveUserRecommendStationsPort; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +@RequiredArgsConstructor +@Repository +public class UserRecommendStationCommandRedisAdapter implements SaveUserRecommendStationsPort { + + private static final String USER_RECOMMEND_STATION_PREFIX = "userRecommendStations:"; + + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + @Override + public Station addUserRecommendStation(String roomId, Station station) { + String key = buildKey(roomId); + + try { + String stationJson = redisTemplate.opsForValue().get(key); + List stationList = (stationJson == null) + ? new ArrayList<>() + : objectMapper.readValue(stationJson, new TypeReference<>() { + }); + + stationList.add(station); + String updatedJson = objectMapper.writeValueAsString(stationList); + redisTemplate.opsForValue().set(key, updatedJson); + return station; + + } catch (JsonProcessingException e) { + return null; + } + } + + private String buildKey(String roomId) { + return USER_RECOMMEND_STATION_PREFIX + roomId; + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/persistence/UserRecommendStationQueryRedisAdapter.java b/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/persistence/UserRecommendStationQueryRedisAdapter.java new file mode 100644 index 00000000..c906bb85 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/station/adapter/out/persistence/UserRecommendStationQueryRedisAdapter.java @@ -0,0 +1,43 @@ +package com.kok.kokapi.station.adapter.out.persistence; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kok.kokcore.station.domain.entity.Station; +import com.kok.kokcore.station.port.out.ReadUserRecommendStationsPort; +import java.util.Collections; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class UserRecommendStationQueryRedisAdapter implements ReadUserRecommendStationsPort { + + private static final String USER_RECOMMEND_STATION_PREFIX = "userRecommendStations:"; + + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + @Override + public List findUserRecommendedStationsByRoomId(String roomId) { + String key = buildKey(roomId); + String stationJson = redisTemplate.opsForValue().get(key); + + if (stationJson == null) { + return Collections.emptyList(); + } + + try { + return objectMapper.readValue(stationJson, new TypeReference<>() { + }); + } catch (JsonProcessingException e) { + return Collections.emptyList(); + } + } + + private String buildKey(String roomId) { + return USER_RECOMMEND_STATION_PREFIX + roomId; + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/station/application/service/RouteService.java b/kok-api/src/main/java/com/kok/kokapi/station/application/service/RouteService.java new file mode 100644 index 00000000..23003da3 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/station/application/service/RouteService.java @@ -0,0 +1,21 @@ +package com.kok.kokapi.station.application.service; + +import com.kok.kokcore.station.domain.entity.Route; +import com.kok.kokcore.station.domain.entity.Station; +import com.kok.kokcore.station.port.out.RetrieveRoutePort; +import com.kok.kokcore.station.usecase.RetrieveRouteUseCase; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class RouteService implements RetrieveRouteUseCase { + + private final RetrieveRoutePort retrieveRoutePort; + + @Override + public List retrieveRoutes(Station station) { + return retrieveRoutePort.retrieveRoutes(station); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/station/application/service/StationFacadeService.java b/kok-api/src/main/java/com/kok/kokapi/station/application/service/StationFacadeService.java new file mode 100644 index 00000000..be121c96 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/station/application/service/StationFacadeService.java @@ -0,0 +1,41 @@ +package com.kok.kokapi.station.application.service; + +import com.kok.kokapi.station.adapter.in.dto.response.RecommendedStationResponse; +import com.kok.kokcore.station.domain.entity.Station; +import com.kok.kokcore.station.usecase.RetrieveRouteUseCase; +import com.kok.kokcore.station.usecase.SystemRecommendUseCase; +import com.kok.kokcore.station.usecase.UserRecommendUseCase; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class StationFacadeService { + + private final SystemRecommendUseCase systemRecommendedUseCase; + private final UserRecommendUseCase userRecommendUseCase; + private final RetrieveRouteUseCase retrieveRouteUseCase; + + public List getCandidateStationResponse(String roomId) { + + return getCandidateStation(roomId).stream() + .map(station -> + RecommendedStationResponse.of(station, + retrieveRouteUseCase.retrieveRoutes(station))) + .toList(); + } + + public Set getCandidateStation(String roomId) { + List recommendedStations = systemRecommendedUseCase.systemRecommendStation(roomId); + List customStations = userRecommendUseCase.getUserRecommendStation(roomId); + + return Stream.concat( + recommendedStations.stream(), + customStations.stream() + ).collect(Collectors.toSet()); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/station/application/service/StationService.java b/kok-api/src/main/java/com/kok/kokapi/station/application/service/StationService.java new file mode 100644 index 00000000..5155f6e8 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/station/application/service/StationService.java @@ -0,0 +1,196 @@ +package com.kok.kokapi.station.application.service; + +import com.kok.kokapi.config.geometry.PointConverter; +import com.kok.kokcore.location.port.out.ReadCentroidPort; +import com.kok.kokcore.station.domain.entity.Station; +import com.kok.kokcore.station.port.out.LoadStationsPort; +import com.kok.kokcore.station.port.out.ReadStationsPort; +import com.kok.kokcore.station.port.out.ReadUserRecommendStationsPort; +import com.kok.kokcore.station.port.out.RetrieveStationsPort; +import com.kok.kokcore.station.port.out.SaveRoutePort; +import com.kok.kokcore.station.port.out.SaveStationsPort; +import com.kok.kokcore.station.port.out.SaveUserRecommendStationsPort; +import com.kok.kokcore.station.port.out.dto.StationRouteDtos; +import com.kok.kokcore.station.usecase.GetStationUseCase; +import com.kok.kokcore.station.usecase.SaveStationUseCase; +import com.kok.kokcore.station.usecase.SystemRecommendUseCase; +import com.kok.kokcore.station.usecase.UserRecommendUseCase; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.locationtech.jts.geom.Point; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.data.util.Pair; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class StationService implements SaveStationUseCase, SystemRecommendUseCase, + UserRecommendUseCase, GetStationUseCase { + + private final LoadStationsPort loadStationsPort; + private final SaveStationsPort saveStationsPort; + private final ReadStationsPort readStationsPort; + private final ReadCentroidPort readCentroidPort; + private final SaveRoutePort saveRoutePort; + private final RetrieveStationsPort retrieveStationsPort; + private final ReadUserRecommendStationsPort readUserRecommendStationPort; + private final SaveUserRecommendStationsPort saveUserRecommendStationsPort; + private final PointConverter pointConverter; + + @Override + @Transactional + public void saveStations() { + if (readStationsPort.hasNoStations()) { + StationRouteDtos stationRouteDtos = loadStationsPort.loadAllStations(); + List stations = saveStationsPort.saveStations(stationRouteDtos.toStations()); + saveRoutePort.saveRoutes(stationRouteDtos.toRoutesByStations(stations)); + } + } + + @Override + @Cacheable(value = "searchStations", cacheManager = "stationCacheManager", key = "#keyword") + public List searchStations(String keyword) { + return retrieveStationsPort.retrieveStationsByKeyword(keyword); + } + + @Override + public Station addUserRecommendStation(String roomId, Long stationId) { + Station station = retrieveStationsPort.retrieveStation(stationId) + .orElseThrow(() -> new RuntimeException("ํ•ด๋‹น ์—ญ์ด ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.")); + return saveUserRecommendStationsPort.addUserRecommendStation(roomId, station); + } + + @Override + public List getUserRecommendStation(String roomId) { + return readUserRecommendStationPort.findUserRecommendedStationsByRoomId(roomId); + } + + @Override + @Cacheable(value = "systemRecommendStations", cacheManager = "stationCacheManager", key = "#roomId") + public List systemRecommendStation(String roomId) { + Point centroid = readCentroidPort.findCentroidByRoomId(roomId); + int RECOMMEND_NUM = 2; + double dist = 100; + List stations = List.of(); + + // ์ตœ๋Œ€ ํƒ์ƒ‰ ๊ฑฐ๋ฆฌ ์ œํ•œ 10km + while (dist < 10000) { + stations = retrieveStationsPort.retrieveInRangeStations(centroid, dist); + log.info("Counter : {} Dist : {}", stations.size(), dist); + if (stations.size() >= RECOMMEND_NUM) { + break; + } + dist *= 1.5; + } + + // ์—ญ์„ ์ฐพ์ง€ ๋ชปํ•œ ๊ฒฝ์šฐ ์˜ˆ์™ธ ์ฒ˜๋ฆฌ + if (stations.isEmpty()) { + throw new RuntimeException("๋ฒ”์œ„ ๋‚ด์— ์ง€ํ•˜์ฒ ์—ญ์ด ์—†์Šต๋‹ˆ๋‹ค."); + } + + // ๊ฑฐ๋ฆฌ ๋ฐ ๊ฐ€์ค‘์น˜ ๊ธฐ๋ฐ˜ ํ™•๋ฅ  ๊ณ„์‚ฐ + Map probabilityMap = calculateProbabilities(stations, centroid); + + // ํ™•๋ฅ ์— ๋”ฐ๋ผ ๋žœ๋ค์œผ๋กœ ์ƒ์œ„ 2๊ฐœ ์ง€ํ•˜์ฒ  ์„ ํƒ + return selectTopStations(probabilityMap, RECOMMEND_NUM); + } + + private Map calculateProbabilities(List stations, Point centroid) { + Map weightedDistances = new HashMap<>(); + double distanceSum = 0; + double totalPriority = 0; + + for (Station station : stations) { + double distance = calculateDistance(centroid, station); + if (distance == 0) { + distance = Double.MIN_VALUE; // 0 ๊ฑฐ๋ฆฌ ๋ฐฉ์ง€ + } + + double weight = (1 / distance) * station.getPriority(); + weightedDistances.put(station, weight); + distanceSum += (1 / distance); + totalPriority += station.getPriority(); + } + + // ํ™•๋ฅ  ๊ณ„์‚ฐ + Map probabilityMap = new HashMap<>(); + for (Map.Entry entry : weightedDistances.entrySet()) { + probabilityMap.put(entry.getKey(), entry.getValue() / (distanceSum * totalPriority)); + } + + return probabilityMap; + } + + private List selectTopStations(Map probabilityMap, int count) { + List selectedStations = new ArrayList<>(); + Random random = new Random(); + + while (selectedStations.size() < count && !probabilityMap.isEmpty()) { + double rand = random.nextDouble(); + double cumulativeProbability = 0; + Station selectedStation = null; + + for (Map.Entry entry : probabilityMap.entrySet()) { + cumulativeProbability += entry.getValue(); + if (rand <= cumulativeProbability) { + selectedStation = entry.getKey(); + break; + } + } + + if (selectedStation != null) { + selectedStations.add(selectedStation); + probabilityMap.remove(selectedStation); + } + } + + return selectedStations; + } + +// //์ •๊ตํ•œ ๋ฏธํ„ฐ ๊ฑฐ๋ฆฌ (Haversine) ๊ณ„์‚ฐ +// private double calculateDistance(Point p1, Station station) { +// Pair coordinates = pointConverter.toCoordinates(p1); +// double lat1 = coordinates.getFirst().doubleValue(); +// double lon1 = coordinates.getSecond().doubleValue(); +// double lat2 = station.getLatitude().doubleValue(); +// double lon2 = station.getLongitude().doubleValue(); +// +// // Haversine ๊ณต์‹ ์‚ฌ์šฉ (๋‹จ์œ„: ๋ฏธํ„ฐ) +// double R = 6371000; +// double dLat = Math.toRadians(lat2 - lat1); +// double dLon = Math.toRadians(lon2 - lon1); +// double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + +// Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) * +// Math.sin(dLon / 2) * Math.sin(dLon / 2); +// double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +// +// return R * c; // ๊ฒฐ๊ณผ: ๋ฏธํ„ฐ ๋‹จ์œ„ ๊ฑฐ๋ฆฌ +// } + + // ์œ ํด๋ฆฌ๋“œ ๊ฑฐ๋ฆฌ ๊ธฐ๋ฐ˜. + private double calculateDistance(Point p1, Station station) { + Pair coordinates = pointConverter.toCoordinates(p1); + double lat1 = coordinates.getFirst().doubleValue(); + double lon1 = coordinates.getSecond().doubleValue(); + double lat2 = station.getLatitude().doubleValue(); + double lon2 = station.getLongitude().doubleValue(); + + // ์œ ํด๋ฆฌ๋“œ ๊ฑฐ๋ฆฌ ๊ณต์‹ ์ ์šฉ + return Math.sqrt(Math.pow(lat2 - lat1, 2) + Math.pow(lon2 - lon1, 2)); + } + + @Override + public Station getStation(long stationId) { + return retrieveStationsPort.retrieveStation(stationId) + .orElseThrow( + () -> new IllegalArgumentException("Cannot find station with id: " + stationId)); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/station/config/StationsConfig.java b/kok-api/src/main/java/com/kok/kokapi/station/config/StationsConfig.java new file mode 100644 index 00000000..68ffde0c --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/station/config/StationsConfig.java @@ -0,0 +1,15 @@ +package com.kok.kokapi.station.config; + +import com.kok.kokapi.station.application.service.StationService; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class StationsConfig { + + @Bean + CommandLineRunner initStations(StationService stationService) { + return args -> stationService.saveStations(); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/vote/adapter/in/dto/request/VoteRequest.java b/kok-api/src/main/java/com/kok/kokapi/vote/adapter/in/dto/request/VoteRequest.java new file mode 100644 index 00000000..f3363d5c --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/vote/adapter/in/dto/request/VoteRequest.java @@ -0,0 +1,9 @@ +package com.kok.kokapi.vote.adapter.in.dto.request; + +import java.util.List; + +public record VoteRequest( + List agreedStationIds +) { + +} diff --git a/kok-api/src/main/java/com/kok/kokapi/vote/adapter/in/dto/response/CandidateResponse.java b/kok-api/src/main/java/com/kok/kokapi/vote/adapter/in/dto/response/CandidateResponse.java new file mode 100644 index 00000000..936871d3 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/vote/adapter/in/dto/response/CandidateResponse.java @@ -0,0 +1,60 @@ +package com.kok.kokapi.vote.adapter.in.dto.response; + +import com.kok.kokapi.public_transportation.adapter.in.dto.response.TmapPublicTransportationParsedResponse; +import com.kok.kokcore.station.domain.entity.Route; +import com.kok.kokcore.station.domain.entity.Station; +import java.util.List; +import java.util.Objects; + +public record CandidateResponse( + long stationId, + String stationName, + List routes, + Integer totalTime, + Integer transferCount, + boolean isKokRecommended, + List comments +) { + + public static CandidateResponse recommended( + Station station, + List routes, + TmapPublicTransportationParsedResponse transportationParsedResponse, + List comments //comment ๋„๋ฉ”์ธ ๊ตฌํ˜„ ์‹œ List๋กœ ๊ต์ฒด + ) { + return new CandidateResponse( + station.getId(), + station.getName(), + getRoutes(routes), + Objects.nonNull(transportationParsedResponse) ? + transportationParsedResponse.totalTime() : null, + Objects.nonNull(transportationParsedResponse) ? + transportationParsedResponse.transferCount() : null, + true, + comments + ); + } + + public static CandidateResponse custom( + Station station, + List routes, + TmapPublicTransportationParsedResponse transportationParsedResponse, + List comments //comment ๋„๋ฉ”์ธ ๊ตฌํ˜„ ์‹œ List๋กœ ๊ต์ฒด + ) { + return new CandidateResponse( + station.getId(), + station.getName(), + getRoutes(routes), + Objects.nonNull(transportationParsedResponse) ? + transportationParsedResponse.totalTime() : null, + Objects.nonNull(transportationParsedResponse) ? + transportationParsedResponse.transferCount() : null, + false, + comments + ); + } + + private static List getRoutes(List routes) { + return routes.stream().map(Route::getName).toList(); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/vote/adapter/in/dto/response/CommentResponse.java b/kok-api/src/main/java/com/kok/kokapi/vote/adapter/in/dto/response/CommentResponse.java new file mode 100644 index 00000000..143d7135 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/vote/adapter/in/dto/response/CommentResponse.java @@ -0,0 +1,9 @@ +package com.kok.kokapi.vote.adapter.in.dto.response; + +public record CommentResponse( + String memberId, + String imageUrl, + String comment +) { + +} diff --git a/kok-api/src/main/java/com/kok/kokapi/vote/adapter/in/dto/response/MemberVoteStatusResponse.java b/kok-api/src/main/java/com/kok/kokapi/vote/adapter/in/dto/response/MemberVoteStatusResponse.java new file mode 100644 index 00000000..79531a37 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/vote/adapter/in/dto/response/MemberVoteStatusResponse.java @@ -0,0 +1,23 @@ +package com.kok.kokapi.vote.adapter.in.dto.response; + +import com.kok.kokcore.location.domain.Location; +import com.kok.kokcore.room.domain.Member; + +public record MemberVoteStatusResponse( + String memberId, + String nickname, + String imageUrl, + String address, + boolean isVoted +) { + + public static MemberVoteStatusResponse of(Member member, Location location, boolean isVoted) { + return new MemberVoteStatusResponse( + member.getMemberId(), + member.getNickname(), + member.getProfile(), + location.getName(), + isVoted + ); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/vote/adapter/in/dto/response/ResultResponse.java b/kok-api/src/main/java/com/kok/kokapi/vote/adapter/in/dto/response/ResultResponse.java new file mode 100644 index 00000000..983765cb --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/vote/adapter/in/dto/response/ResultResponse.java @@ -0,0 +1,31 @@ +package com.kok.kokapi.vote.adapter.in.dto.response; + +import com.kok.kokcore.room.domain.Member; +import com.kok.kokcore.station.domain.entity.Station; +import com.kok.kokcore.vote.domain.VoteResult; +import java.util.List; + +public record ResultResponse( + long stationId, + String stationName, + int votedCount, + List members, + String resultTag +) { + + public static ResultResponse of(Station station, VoteResult voteResult, List members) { + return new ResultResponse( + station.getId(), + station.getName(), + voteResult.getVotedCount(), + getVotedMemberResponses(members), + voteResult.getResultTag().name() + ); + } + + private static List getVotedMemberResponses(List members) { + return members.stream() + .map(VotedMemberResponse::from) + .toList(); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/vote/adapter/in/dto/response/RouteResponse.java b/kok-api/src/main/java/com/kok/kokapi/vote/adapter/in/dto/response/RouteResponse.java new file mode 100644 index 00000000..92c8fd38 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/vote/adapter/in/dto/response/RouteResponse.java @@ -0,0 +1,8 @@ +package com.kok.kokapi.vote.adapter.in.dto.response; + +public record RouteResponse( + String name + //TODO: String color +) { + +} diff --git a/kok-api/src/main/java/com/kok/kokapi/vote/adapter/in/dto/response/VoteCurrentResultResponse.java b/kok-api/src/main/java/com/kok/kokapi/vote/adapter/in/dto/response/VoteCurrentResultResponse.java new file mode 100644 index 00000000..7d42805b --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/vote/adapter/in/dto/response/VoteCurrentResultResponse.java @@ -0,0 +1,10 @@ +package com.kok.kokapi.vote.adapter.in.dto.response; + +import java.util.List; + +public record VoteCurrentResultResponse( + int notVotedCount, + List results +) { + +} diff --git a/kok-api/src/main/java/com/kok/kokapi/vote/adapter/in/dto/response/VoteDeadlineResponse.java b/kok-api/src/main/java/com/kok/kokapi/vote/adapter/in/dto/response/VoteDeadlineResponse.java new file mode 100644 index 00000000..159abbdb --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/vote/adapter/in/dto/response/VoteDeadlineResponse.java @@ -0,0 +1,22 @@ +package com.kok.kokapi.vote.adapter.in.dto.response; + +import com.kok.kokcore.room.domain.Room; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; + +public record VoteDeadlineResponse( + int candidateCount, + LocalDateTime endAt +) { + + public static VoteDeadlineResponse of(Room room, int candidateCount) { + return new VoteDeadlineResponse( + candidateCount, + room.getVoteLimitDateTime() + .atZone(ZoneOffset.UTC) + .withZoneSameInstant(ZoneId.of("Asia/Seoul")) + .toLocalDateTime() + ); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/vote/adapter/in/dto/response/VoteResultStationResponse.java b/kok-api/src/main/java/com/kok/kokapi/vote/adapter/in/dto/response/VoteResultStationResponse.java new file mode 100644 index 00000000..83a87485 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/vote/adapter/in/dto/response/VoteResultStationResponse.java @@ -0,0 +1,27 @@ +package com.kok.kokapi.vote.adapter.in.dto.response; + +import com.kok.kokcore.station.domain.entity.Route; +import com.kok.kokcore.station.domain.entity.Station; +import java.math.BigDecimal; +import java.util.List; + +public record VoteResultStationResponse( + long id, + String name, + BigDecimal latitude, + BigDecimal longitude, + long priority, + List routes +) { + + public static VoteResultStationResponse of(Station station, List routes) { + return new VoteResultStationResponse( + station.getId(), + station.getName(), + station.getLatitude(), + station.getLongitude(), + station.getPriority(), + routes.stream().map(Route::getName).toList() + ); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/vote/adapter/in/dto/response/VotedMemberResponse.java b/kok-api/src/main/java/com/kok/kokapi/vote/adapter/in/dto/response/VotedMemberResponse.java new file mode 100644 index 00000000..a102f0bb --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/vote/adapter/in/dto/response/VotedMemberResponse.java @@ -0,0 +1,12 @@ +package com.kok.kokapi.vote.adapter.in.dto.response; + +import com.kok.kokcore.room.domain.Member; + +public record VotedMemberResponse( + String imageUrl +) { + + public static VotedMemberResponse from(Member member) { + return new VotedMemberResponse(member.getProfile()); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/vote/adapter/in/web/VoteController.java b/kok-api/src/main/java/com/kok/kokapi/vote/adapter/in/web/VoteController.java new file mode 100644 index 00000000..1f2e7d7e --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/vote/adapter/in/web/VoteController.java @@ -0,0 +1,91 @@ +package com.kok.kokapi.vote.adapter.in.web; + +import com.kok.kokapi.common.response.ApiResponseDto; +import com.kok.kokapi.config.annotion.V1Controller; +import com.kok.kokapi.vote.adapter.in.dto.request.VoteRequest; +import com.kok.kokapi.vote.adapter.in.dto.response.CandidateResponse; +import com.kok.kokapi.vote.adapter.in.dto.response.MemberVoteStatusResponse; +import com.kok.kokapi.vote.adapter.in.dto.response.VoteCurrentResultResponse; +import com.kok.kokapi.vote.adapter.in.dto.response.VoteDeadlineResponse; +import com.kok.kokapi.vote.adapter.in.dto.response.VoteResultStationResponse; +import com.kok.kokapi.vote.application.service.VoteFacadeService; +import com.kok.kokcore.room.domain.Room; +import com.kok.kokcore.room.usecase.UpdateRoomUseCase; +import com.kok.kokcore.station.domain.entity.Route; +import com.kok.kokcore.station.domain.entity.Station; +import com.kok.kokcore.station.usecase.RetrieveRouteUseCase; +import com.kok.kokcore.vote.usecase.GetVoteUseCase; +import io.swagger.v3.oas.annotations.Operation; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +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; + +@V1Controller +@RequiredArgsConstructor +public class VoteController { + + private final VoteFacadeService voteFacadeService; + private final GetVoteUseCase getVoteUseCase; + private final RetrieveRouteUseCase retrieveRouteUseCase; + private final UpdateRoomUseCase updateRoomUseCase; + + @Operation(summary = "ํˆฌํ‘œ ํ›„๋ณด์ง€ ๋ชฉ๋ก ์กฐํšŒ", description = "๋ฐฉ ID๊ณผ ์‚ฌ์šฉ์ž ID๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ํˆฌํ‘œ ํ›„๋ณด์ง€ ์ƒ์„ธ ์ •๋ณด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") + @GetMapping("/votes/{roomId}/{memberId}/candidates") + public ResponseEntity>> getCandidates( + @PathVariable String roomId, @PathVariable String memberId) { + List responses = voteFacadeService.getCandidates(roomId, memberId); + return ResponseEntity.ok(ApiResponseDto.success(responses)); + } + + @Operation(summary = "ํˆฌํ‘œํ•˜๊ธฐ", description = "๋ฐฉ ID์™€ ์‚ฌ์šฉ์ž ID์— ๋Œ€ํ•œ ์ฐฌ์„ฑ ํˆฌํ‘œ ๋ชฉ๋ก์„ ๋ฐ›์•„์„œ ํˆฌํ‘œ ์ •๋ณด๋ฅผ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค.") + @PostMapping("/votes/{roomId}/{memberId}") + public ResponseEntity> createVote( + @PathVariable String roomId, + @PathVariable String memberId, + @RequestBody VoteRequest voteRequest + ) { + voteFacadeService.saveVotes(roomId, memberId, voteRequest.agreedStationIds()); + return ResponseEntity.ok(ApiResponseDto.success(null)); + } + + @Operation(summary = "์‚ฌ์šฉ์ž๋ณ„ ํˆฌํ‘œ ์ƒํƒœ ์กฐํšŒ", description = "๋ฐฉ ID์— ๋Œ€ํ•ด ์‚ฌ์šฉ์ž ์ •๋ณด์™€ ํˆฌํ‘œ ์ƒํƒœ(ํˆฌํ‘œ ์ „/ํˆฌํ‘œ ์™„๋ฃŒ)๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") + @GetMapping("/votes/{roomId}/status") + public ResponseEntity>> getMemberVoteStatus( + @PathVariable String roomId) { + List responses = voteFacadeService.getMemberVoteStatus(roomId); + return ResponseEntity.ok(ApiResponseDto.success(responses)); + } + + @Operation(summary = "ํˆฌํ‘œ ๋งˆ๊ฐ ์‹œ๊ฐ„ ์กฐํšŒ", description = "๋ฐฉ ID์— ๋Œ€ํ•œ ํˆฌํ‘œ ๋งˆ๊ฐ ์‹œ๊ฐ„์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") + @GetMapping("/votes/{roomId}/deadline") + public ResponseEntity> getVoteDeadline( + @PathVariable String roomId) { + Room room = updateRoomUseCase.updateRoomStatus(roomId, LocalDateTime.now()); + int candidateCount = voteFacadeService.countCandidates(roomId); + VoteDeadlineResponse response = VoteDeadlineResponse.of(room, candidateCount); + return ResponseEntity.ok(ApiResponseDto.success(response)); + } + + @Operation(summary = "ํˆฌํ‘œ ํ˜„ํ™ฉ ์กฐํšŒ", description = "๋ฐฉ ID์— ๋Œ€ํ•ด ํ˜„์žฌ๊นŒ์ง€ ํˆฌํ‘œ ๊ฒฐ๊ณผ๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") + @GetMapping("/votes/{roomId}/results/current") + public ResponseEntity> getVoteResult( + @PathVariable String roomId) { + VoteCurrentResultResponse response = voteFacadeService.getVoteCurrentResult(roomId); + return ResponseEntity.ok(ApiResponseDto.success(response)); + } + + @Operation(summary = "ํˆฌํ‘œ ์ตœ์ข… ๊ฒฐ๊ณผ ์กฐํšŒ", description = "๋ฐฉ ID์— ๋Œ€ํ•œ ์ตœ์ข… ํˆฌํ‘œ ๊ฒฐ๊ณผ๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") + @GetMapping("/votes/{roomId}/results") + public ResponseEntity> getFinalResult( + @PathVariable String roomId) { + Station station = getVoteUseCase.getVoteFinalResult(roomId); + List routes = retrieveRouteUseCase.retrieveRoutes(station); + VoteResultStationResponse response = VoteResultStationResponse.of(station, routes); + return ResponseEntity.ok(ApiResponseDto.success(response)); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/vote/adapter/out/persistence/CandidateCommandRedisAdapter.java b/kok-api/src/main/java/com/kok/kokapi/vote/adapter/out/persistence/CandidateCommandRedisAdapter.java new file mode 100644 index 00000000..4a8890fa --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/vote/adapter/out/persistence/CandidateCommandRedisAdapter.java @@ -0,0 +1,43 @@ +package com.kok.kokapi.vote.adapter.out.persistence; + +import com.kok.kokapi.common.util.RedisExecutor; +import com.kok.kokcore.vote.domain.Candidate; +import com.kok.kokcore.vote.port.out.SaveCandidatePort; +import java.util.List; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class CandidateCommandRedisAdapter implements SaveCandidatePort { + + private static final String CANDIDATE_KEY_FORMAT = "candidate:%s"; + + private final RedisTemplate redisTemplate; + + @Override + public void saveAll(List candidates) { + validate(candidates); + String key = getCandidateKey(candidates); + Object[] stationIds = candidates.stream() + .map(Candidate::getStationId) + .toArray(); + + RedisExecutor.runOrThrow("saveAll", () -> + redisTemplate.opsForSet().add(key, stationIds) + ); + } + + private void validate(List candidates) { + if (Objects.isNull(candidates) || candidates.isEmpty()) { + throw new IllegalArgumentException("No candidates to save"); + } + } + + private String getCandidateKey(List candidates) { + String roomId = candidates.getFirst().getRoomId(); + return String.format(CANDIDATE_KEY_FORMAT, roomId); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/vote/adapter/out/persistence/CandidateQueryRedisAdapter.java b/kok-api/src/main/java/com/kok/kokapi/vote/adapter/out/persistence/CandidateQueryRedisAdapter.java new file mode 100644 index 00000000..3a425788 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/vote/adapter/out/persistence/CandidateQueryRedisAdapter.java @@ -0,0 +1,54 @@ +package com.kok.kokapi.vote.adapter.out.persistence; + +import com.kok.kokapi.common.util.RedisExecutor; +import com.kok.kokcore.vote.domain.Candidate; +import com.kok.kokcore.vote.port.out.LoadCandidatePort; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +@Repository +@Slf4j +@RequiredArgsConstructor +public class CandidateQueryRedisAdapter implements LoadCandidatePort { + + private static final String CANDIDATE_KEY_FORMAT = "candidate:%s"; + + private final RedisTemplate redisTemplate; + + @Override + public List findByRoomId(String roomId) { + Set stationIds = RedisExecutor.runOrElseGet("findByRoomId", () -> + redisTemplate.opsForSet().members(getCandidateKey(roomId)), Set.of() + ); + + return stationIds.stream() + .map(this::getStationId) + .filter(Objects::nonNull) + .map(stationId -> new Candidate(roomId, stationId)) + .toList(); + } + + private Long getStationId(Object stationId) { + try { + return Long.parseLong(stationId.toString()); + } catch (NumberFormatException e) { + log.warn("Invalid stationId format in Redis: {}", stationId, e); + return null; + } + } + + @Override + public boolean isExistsByRoomId(String roomId) { + return RedisExecutor.runOrElseGet("isExistsByRoomId", () -> + Boolean.TRUE.equals(redisTemplate.hasKey(getCandidateKey(roomId))), false); + } + + private String getCandidateKey(String roomId) { + return String.format(CANDIDATE_KEY_FORMAT, roomId); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/vote/adapter/out/persistence/VoteCommandRedisAdapter.java b/kok-api/src/main/java/com/kok/kokapi/vote/adapter/out/persistence/VoteCommandRedisAdapter.java new file mode 100644 index 00000000..b1a23734 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/vote/adapter/out/persistence/VoteCommandRedisAdapter.java @@ -0,0 +1,116 @@ +package com.kok.kokapi.vote.adapter.out.persistence; + +import com.kok.kokapi.common.util.RedisExecutor; +import com.kok.kokcore.vote.port.out.DeleteVotePort; +import com.kok.kokcore.vote.port.out.SaveVotePort; +import java.time.Duration; +import java.util.List; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +@Slf4j +@Repository +@RequiredArgsConstructor +public class VoteCommandRedisAdapter implements SaveVotePort, DeleteVotePort { + + private static final Duration VOTE_TTL = Duration.ofDays(3); + + private final RedisTemplate redisTemplate; + + @Override + public void saveVotedStationsByRoomIdAndMemberId(List stationIds, String roomId, + String memberId) { + String memberKey = VoteKey.votedStationsByMemberKey(roomId, memberId); + RedisExecutor.runOrThrow("saveVoteMemberHash", () -> { + for (Long stationId : stationIds) { + redisTemplate.opsForSet().add(memberKey, stationId); + } + redisTemplate.expire(memberKey, getTTL(memberKey)); + }); + } + + @Override + public void saveVotedMemberByRoomId(String roomId, String memberId) { + String votedMemberSetKey = VoteKey.voteCompletedMembersKey(roomId); + RedisExecutor.runOrThrow("saveVotedMemberSet", () -> { + redisTemplate.opsForSet().add(votedMemberSetKey, memberId); + redisTemplate.expire(votedMemberSetKey, getTTL(votedMemberSetKey)); + }); + } + + @Override + public void initiateVoteCountByRoomIdAndStationIds(String roomId, List stationIds) { + String key = VoteKey.votedCountOfStationKey(roomId); + RedisExecutor.runOrThrow("initiateVoteCountByRoomIdAndStationIds", () -> { + for (Long stationId : stationIds) { + redisTemplate.opsForZSet().addIfAbsent(key, stationId, 0); + redisTemplate.expire(key, getTTL(key)); + } + }); + } + + @Override + public void saveVotedMemberByRoomIdAndStationId(String memberId, String roomId, + long stationId) { + String key = VoteKey.votedMembersOfStationKey(roomId, stationId); + RedisExecutor.runOrThrow("saveVoteStatusSet", () -> { + redisTemplate.opsForSet().add(key, memberId); + redisTemplate.expire(key, getTTL(key)); + }); + } + + @Override + public void increaseVotedCountByRoomIdAndStationId(String roomId, long stationId) { + String key = VoteKey.votedCountOfStationKey(roomId); + RedisExecutor.runOrThrow("incrementVoteStatusCountZSet", () -> { + redisTemplate.opsForZSet().incrementScore(key, stationId, 1); + redisTemplate.expire(key, getTTL(key)); + }); + } + + @Override + public void deleteVotedMemberByRoomIdAndStationId( + String memberId, String roomId, long stationId + ) { + String key = VoteKey.votedMembersOfStationKey(roomId, stationId); + RedisExecutor.runOrThrow("removeMemberFromVoteStatusSet", () -> + redisTemplate.opsForSet().remove(key, memberId) + ); + } + + @Override + public void decreaseVotedCountByRoomIdAndStationId(String roomId, long stationId) { + String key = VoteKey.votedCountOfStationKey(roomId); + RedisExecutor.runOrThrow("decrementVoteCountInZSet", () -> + redisTemplate.opsForZSet().incrementScore(key, stationId, -1) + ); + } + + @Override + public void deleteVotedStationsByRoomIdAndMemberId(String roomId, String memberId) { + String key = VoteKey.votedStationsByMemberKey(roomId, memberId); + RedisExecutor.runOrThrow("deleteMemberVoteHash", () -> + redisTemplate.delete(key) + ); + } + + @Override + public void deleteVotedMemberByRoomId(String roomId, String memberId) { + String key = VoteKey.voteCompletedMembersKey(roomId); + RedisExecutor.runOrThrow("removeMemberFromVotedSet", () -> + redisTemplate.opsForSet().remove(key, memberId) + ); + } + + private Duration getTTL(String key) { + Long expireSeconds = redisTemplate.getExpire(key); + if (Objects.isNull(expireSeconds) || expireSeconds <= 0) { + log.warn("Cannot find key: {}, initiate expire TTL", key); + return VOTE_TTL; + } + return Duration.ofSeconds(expireSeconds); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/vote/adapter/out/persistence/VoteKey.java b/kok-api/src/main/java/com/kok/kokapi/vote/adapter/out/persistence/VoteKey.java new file mode 100644 index 00000000..f589cc99 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/vote/adapter/out/persistence/VoteKey.java @@ -0,0 +1,41 @@ +package com.kok.kokapi.vote.adapter.out.persistence; + +import com.kok.kokcore.vote.domain.Vote; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum VoteKey { + + VOTE_COMPLETED_MEMBERS_SET("votedDoneMembers:%s"), // Set + VOTED_STATIONS_BY_MEMBER_SET("memberVoted:%s:%s"), // Set + VOTED_COUNT_OF_STATION_ZSET("votedCount:%s"), // ZSET + VOTED_MEMBERS_OF_STATION_SET("votedMembers:%s:%d") // Set per station + ; + + private final String format; + + // ํˆฌํ‘œ ์™„๋ฃŒ์ž ๋ชฉ๋ก key + public static String voteCompletedMembersKey(String roomId) { + return String.format(VOTE_COMPLETED_MEMBERS_SET.format, roomId); + } + + // ๋ฉค๋ฒ„๋ณ„ ํˆฌํ‘œ ์ƒ์„ธ ์ €์žฅ key + public static String votedStationsByMemberKey(String roomId, String memberId) { + return String.format(VOTED_STATIONS_BY_MEMBER_SET.format, roomId, memberId); + } + + // stationId๋ณ„ ํˆฌํ‘œ ์ˆ˜ ์ €์žฅ key + public static String votedCountOfStationKey(String roomId) { + return String.format(VOTED_COUNT_OF_STATION_ZSET.format, roomId); + } + + // stationId๋ณ„ memberId ์ €์žฅ key + public static String votedMembersOfStationKey(Vote vote) { + return String.format(VOTED_MEMBERS_OF_STATION_SET.format, vote.getRoomId(), + vote.getStationId()); + } + + public static String votedMembersOfStationKey(String roomId, long stationId) { + return String.format(VOTED_MEMBERS_OF_STATION_SET.format, roomId, stationId); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/vote/adapter/out/persistence/VoteQueryRedisAdapter.java b/kok-api/src/main/java/com/kok/kokapi/vote/adapter/out/persistence/VoteQueryRedisAdapter.java new file mode 100644 index 00000000..950489ed --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/vote/adapter/out/persistence/VoteQueryRedisAdapter.java @@ -0,0 +1,105 @@ +package com.kok.kokapi.vote.adapter.out.persistence; + +import com.kok.kokapi.common.util.RedisExecutor; +import com.kok.kokcore.vote.domain.Vote; +import com.kok.kokcore.vote.port.out.LoadVotePort; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.data.redis.core.ZSetOperations.TypedTuple; +import org.springframework.stereotype.Repository; + +@Slf4j +@Repository +@RequiredArgsConstructor +public class VoteQueryRedisAdapter implements LoadVotePort { + + private final RedisTemplate redisTemplate; + + @Override + public boolean isExistsByRoomIdAndMemberId(String roomId, String memberId) { + String key = VoteKey.votedStationsByMemberKey(roomId, memberId); + return RedisExecutor.runOrElseGet("isExistsByRoomIdAndMemberId", () -> + !redisTemplate.opsForSet().members(key).isEmpty(), false + ); + } + + @Override + public List findAllByRoomIdAndMemberId(String roomId, String memberId) { + String key = VoteKey.votedStationsByMemberKey(roomId, memberId); + return RedisExecutor.runOrElseGet("findAllByRoomIdAndMemberId", () -> { + Set stationIds = redisTemplate.opsForSet().members(key); + List votes = new ArrayList<>(); + for (Object stationId : stationIds) { + votes.add(new Vote(roomId, getStationId(stationId), memberId)); + } + return votes; + }, List.of()); + } + + @Override + public int countVotedMembersByRoomId(String roomId) { + return RedisExecutor.runOrElseGet("countMembersByRoomId", () -> { + String key = VoteKey.voteCompletedMembersKey(roomId); + Long count = redisTemplate.opsForSet().size(key); + return Objects.nonNull(count) ? count.intValue() : 0; + }, 0); + } + + @Override + public List findMemberIdsByRoomIdAndStationId(String roomId, long stationId) { + String key = VoteKey.votedMembersOfStationKey(roomId, stationId); + Set memberIds = RedisExecutor.runOrElseGet( + "findMembersByRoomIdAndStationIdAndStatus", + () -> redisTemplate.opsForSet().members(key), Set.of()); + return memberIds.stream().map(memberId -> (String) memberId).toList(); + } + + @Override + public long findFirstStationIdByRoomIdOrderByVotedCount(String roomId) { + return RedisExecutor.runOrElseGet("getFirstStationIdByRoomIdAndVoteStatus", () -> { + String key = VoteKey.votedCountOfStationKey(roomId); + Set> sorted = + redisTemplate.opsForZSet().reverseRangeWithScores(key, 0, 0); + + if (Objects.isNull(sorted) || sorted.isEmpty()) { + return -1L; + } + + Object maxScoredStationId = sorted.iterator().next().getValue(); + return getStationId(maxScoredStationId); + }, -1L); + } + + @Override + public List findStationIdsByRoomIdOrderByVotedCount(String roomId) { + return RedisExecutor.runOrElseGet("getStationIdsByRoomIdOrderByVotedCount", () -> { + String key = VoteKey.votedCountOfStationKey(roomId); + Set> sorted = + redisTemplate.opsForZSet().reverseRangeWithScores(key, 0, -1); + + if (Objects.isNull(sorted) || sorted.isEmpty()) { + return List.of(); + } + + return sorted.stream() + .map(TypedTuple::getValue) + .map(this::getStationId) + .toList(); + }, List.of()); + } + + private Long getStationId(Object stationId) { + try { + return Long.valueOf(String.valueOf(stationId)); + } catch (NumberFormatException e) { + log.warn("Invalid stationId format in Redis: {}", stationId, e); + throw new IllegalArgumentException("Invalid stationId format: " + stationId); + } + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/vote/application/service/CandidateService.java b/kok-api/src/main/java/com/kok/kokapi/vote/application/service/CandidateService.java new file mode 100644 index 00000000..11ce85ff --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/vote/application/service/CandidateService.java @@ -0,0 +1,39 @@ +package com.kok.kokapi.vote.application.service; + +import com.kok.kokcore.station.domain.entity.Station; +import com.kok.kokcore.vote.domain.Candidate; +import com.kok.kokcore.vote.port.out.LoadCandidatePort; +import com.kok.kokcore.vote.port.out.SaveCandidatePort; +import com.kok.kokcore.vote.port.out.SaveVotePort; +import com.kok.kokcore.vote.usecase.GetCandidateUseCase; +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CandidateService implements GetCandidateUseCase { + + private final SaveCandidatePort saveCandidatePort; + private final LoadCandidatePort loadCandidatePort; + private final SaveVotePort saveVotePort; + + @Override + public List saveAndGetCandidates(String roomId, Set stations) { + if (!loadCandidatePort.isExistsByRoomId(roomId)) { + List candidates = stations.stream() + .map(station -> new Candidate(roomId, station.getId())) + .toList(); + saveCandidatePort.saveAll(candidates); + saveVotePort.initiateVoteCountByRoomIdAndStationIds(roomId, getStationIds(candidates)); + } + return loadCandidatePort.findByRoomId(roomId); + } + + private List getStationIds(List candidates) { + return candidates.stream() + .map(Candidate::getStationId) + .toList(); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/vote/application/service/VoteFacadeService.java b/kok-api/src/main/java/com/kok/kokapi/vote/application/service/VoteFacadeService.java new file mode 100644 index 00000000..d12d6a5c --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/vote/application/service/VoteFacadeService.java @@ -0,0 +1,143 @@ +package com.kok.kokapi.vote.application.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kok.kokapi.public_transportation.adapter.in.dto.response.TmapPublicTransportationParsedResponse; +import com.kok.kokapi.public_transportation.application.service.TmapPublicTransportationService; +import com.kok.kokapi.vote.adapter.in.dto.response.CandidateResponse; +import com.kok.kokapi.vote.adapter.in.dto.response.MemberVoteStatusResponse; +import com.kok.kokapi.vote.adapter.in.dto.response.ResultResponse; +import com.kok.kokapi.vote.adapter.in.dto.response.VoteCurrentResultResponse; +import com.kok.kokcore.location.domain.Location; +import com.kok.kokcore.location.usecase.ReadLocationUseCase; +import com.kok.kokcore.room.domain.Member; +import com.kok.kokcore.room.domain.Room; +import com.kok.kokcore.room.usecase.GetRoomUseCase; +import com.kok.kokcore.room.usecase.UpdateRoomUseCase; +import com.kok.kokcore.station.domain.entity.Route; +import com.kok.kokcore.station.domain.entity.Station; +import com.kok.kokcore.station.usecase.GetStationUseCase; +import com.kok.kokcore.station.usecase.RetrieveRouteUseCase; +import com.kok.kokcore.station.usecase.SystemRecommendUseCase; +import com.kok.kokcore.station.usecase.UserRecommendUseCase; +import com.kok.kokcore.vote.VoteResults; +import com.kok.kokcore.vote.domain.VoteResult; +import com.kok.kokcore.vote.usecase.GetCandidateUseCase; +import com.kok.kokcore.vote.usecase.GetVoteUseCase; +import com.kok.kokcore.vote.usecase.SaveVoteUseCase; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class VoteFacadeService { + + private final GetCandidateUseCase getCandidateUseCase; + private final RetrieveRouteUseCase retrieveRouteUseCase; + private final GetVoteUseCase getVoteUseCase; + private final GetRoomUseCase getRoomUseCase; + private final TmapPublicTransportationService tmapPublicTransportationService; + private final ObjectMapper objectMapper; + private final SaveVoteUseCase saveVoteUseCase; + private final ReadLocationUseCase readLocationUseCase; + private final SystemRecommendUseCase systemRecommendUseCase; + private final UserRecommendUseCase userRecommendUseCase; + private final GetStationUseCase getStationUseCase; + private final UpdateRoomUseCase updateRoomUseCase; + + public List getCandidates(String roomId, String memberId) { + List recommendedStations = systemRecommendUseCase.systemRecommendStation(roomId); + List customStations = userRecommendUseCase.getUserRecommendStation(roomId); + Set stations = Stream.concat( + recommendedStations.stream(), customStations.stream() + ).collect(Collectors.toSet()); + getCandidateUseCase.saveAndGetCandidates(roomId, stations); + + List responses = new ArrayList<>(); + for (Station station : stations) { + if (recommendedStations.contains(station)) { + responses.add(createCandidateResponse(station, roomId, memberId, true)); + continue; + } + if (customStations.contains(station)) { + responses.add(createCandidateResponse(station, roomId, memberId, false)); + } + } + + return responses; + } + + + private CandidateResponse createCandidateResponse( + Station station, String roomId, String memberId, boolean isRecommended) { + List routes = retrieveRouteUseCase.retrieveRoutes(station); + TmapPublicTransportationParsedResponse transportation = getTransportationParsedResponse( + roomId, memberId, station); + CandidateResponse response = isRecommended + ? CandidateResponse.recommended(station, routes, transportation, List.of()) + : CandidateResponse.custom(station, routes, transportation, List.of()); + return response; + } + + private TmapPublicTransportationParsedResponse getTransportationParsedResponse( + String roomId, String memberId, Station station) { + String content = tmapPublicTransportationService.retrievePublicTransportation( + station.getId(), + roomId, + memberId + ); + try { + return objectMapper.readValue(content, TmapPublicTransportationParsedResponse.class); + } catch (JsonProcessingException e) { + throw new RuntimeException( + "Failed parsing into \"TmapPublicTransportationParsedResponse\" for " + content); + } + } + + public List getMemberVoteStatus(String roomId) { + List members = getRoomUseCase.getParticipants(roomId); + List responses = new ArrayList<>(); + for (Member member : members) { + boolean isVoted = getVoteUseCase.isVotedByMember(roomId, member.getMemberId()); + Location location = readLocationUseCase.readLocation(roomId, member.getMemberId()); + MemberVoteStatusResponse response = MemberVoteStatusResponse.of( + member, location, isVoted); + responses.add(response); + } + return responses; + } + + public VoteCurrentResultResponse getVoteCurrentResult(String roomId) { + List responses = new ArrayList<>(); + Room room = updateRoomUseCase.updateRoomStatus(roomId, LocalDateTime.now()); + int votedCount = getVoteUseCase.countVotedMembers(roomId); + VoteResults voteResults = getVoteUseCase.getVoteResultsByRoomId(roomId); + for (VoteResult voteResult : voteResults.getVoteResults()) { + Station station = getStationUseCase.getStation(voteResult.getStationId()); + List members = getRoomUseCase.getParticipantsByRoomIdInMemberIds( + roomId, voteResult.getMemberIds()); + responses.add(ResultResponse.of(station, voteResult, members)); + } + return new VoteCurrentResultResponse(room.getNotVotedCount(votedCount), responses); + } + + public void saveVotes(String roomId, String memberId, List agreedStationIds) { + saveVoteUseCase.saveVotes(roomId, memberId, agreedStationIds); + updateRoomUseCase.updateRoomStatus(roomId, LocalDateTime.now()); + } + + public int countCandidates(String roomId) { + List recommendedStations = systemRecommendUseCase.systemRecommendStation(roomId); + List customStations = userRecommendUseCase.getUserRecommendStation(roomId); + Set stations = Stream.concat( + recommendedStations.stream(), customStations.stream() + ).collect(Collectors.toSet()); + return getCandidateUseCase.saveAndGetCandidates(roomId, stations).size(); + } +} diff --git a/kok-api/src/main/java/com/kok/kokapi/vote/application/service/VoteService.java b/kok-api/src/main/java/com/kok/kokapi/vote/application/service/VoteService.java new file mode 100644 index 00000000..65596b15 --- /dev/null +++ b/kok-api/src/main/java/com/kok/kokapi/vote/application/service/VoteService.java @@ -0,0 +1,172 @@ +package com.kok.kokapi.vote.application.service; + +import com.kok.kokcore.room.domain.Member; +import com.kok.kokcore.room.domain.Room; +import com.kok.kokcore.room.port.out.LoadRoomParticipantPort; +import com.kok.kokcore.room.port.out.LoadRoomPort; +import com.kok.kokcore.station.domain.entity.Station; +import com.kok.kokcore.station.port.out.RetrieveStationsPort; +import com.kok.kokcore.vote.VoteResults; +import com.kok.kokcore.vote.domain.Vote; +import com.kok.kokcore.vote.domain.VoteResult; +import com.kok.kokcore.vote.port.out.DeleteVotePort; +import com.kok.kokcore.vote.port.out.LoadVotePort; +import com.kok.kokcore.vote.port.out.SaveVotePort; +import com.kok.kokcore.vote.usecase.GetVoteUseCase; +import com.kok.kokcore.vote.usecase.SaveVoteUseCase; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class VoteService implements SaveVoteUseCase, GetVoteUseCase { + + private static final int MINIMUM_VOTED_RATIO = 60; + + private final SaveVotePort saveVotePort; + private final LoadVotePort loadVotePort; + private final DeleteVotePort deleteVotePort; + private final LoadRoomPort loadRoomPort; + private final RetrieveStationsPort retrieveStationsPort; + private final LoadRoomParticipantPort loadRoomParticipantPort; + + @Override + public void saveVotes(String roomId, String memberId, List agreedStationIds) { + validate(roomId, memberId); + validateRoomStatusIfNotOnVote(roomId); + initiate(roomId, memberId); + + // 1. ๋ฉค๋ฒ„๊ฐ€ ํˆฌํ‘œํ•œ stationId set ์ €์žฅ + saveVotePort.saveVotedStationsByRoomIdAndMemberId(agreedStationIds, roomId, memberId); + + // 2. ๊ฐ ํ›„๋ณด์— ๋Œ€ํ•œ ํˆฌํ‘œ์ž Set/ ํˆฌํ‘œ ์ˆ˜ ZSet ๊ฐฑ์‹  + for (Long stationId : agreedStationIds) { + saveVotePort.saveVotedMemberByRoomIdAndStationId(memberId, roomId, stationId); + saveVotePort.increaseVotedCountByRoomIdAndStationId(roomId, stationId); + } + + // 3. ํˆฌํ‘œ ์™„๋ฃŒ Set์— ๋ฉค๋ฒ„ ์ถ”๊ฐ€ + saveVotePort.saveVotedMemberByRoomId(roomId, memberId); + } + + private void initiate(String roomId, String memberId) { + if (loadVotePort.isExistsByRoomIdAndMemberId(roomId, memberId)) { + List votes = loadVotePort.findAllByRoomIdAndMemberId(roomId, memberId); + // 1. ๊ฐ ํ›„๋ณด์— ๋Œ€ํ•œ ํˆฌํ‘œ์ž Set/ ํˆฌํ‘œ ์ˆ˜ ZSet ๊ฐฑ์‹  + for (Vote vote : votes) { + deleteVotePort.deleteVotedMemberByRoomIdAndStationId( + vote.getMemberId(), vote.getRoomId(), vote.getStationId()); + deleteVotePort.decreaseVotedCountByRoomIdAndStationId( + vote.getRoomId(), vote.getStationId()); + } + + // 2. ๋ฉค๋ฒ„๊ฐ€ ํˆฌํ‘œํ•œ stationId set ์ œ๊ฑฐ + deleteVotePort.deleteVotedStationsByRoomIdAndMemberId(roomId, memberId); + + //3. ํˆฌํ‘œ ์™„๋ฃŒ set์—์„œ ๋ฉค๋ฒ„ ์ œ๊ฑฐ + deleteVotePort.deleteVotedMemberByRoomId(roomId, memberId); + } + } + + @Override + public boolean isVotedByMember(String roomId, String memberId) { + return loadVotePort.isExistsByRoomIdAndMemberId(roomId, memberId); + } + + @Override + public int countVotedMembers(String roomId) { + validate(roomId); + validateRoomStatusIfBeforeVote(roomId); + return loadVotePort.countVotedMembersByRoomId(roomId); + } + + @Override + public VoteResults getVoteResultsByRoomId(String roomId) { + validate(roomId); + validateRoomStatusIfBeforeVote(roomId); + Room room = getRoom(roomId); + List stationIds = loadVotePort.findStationIdsByRoomIdOrderByVotedCount(roomId); + VoteResults voteResults = getVoteResults(room, stationIds); + int votedCount = loadVotePort.countVotedMembersByRoomId(roomId); + if (room.getVotedRatio(votedCount) >= MINIMUM_VOTED_RATIO) { + voteResults.applyResultTag(); + } + return voteResults; + } + + private void validateRoomStatusIfBeforeVote(String roomId) { + Room room = getRoom(roomId); + if (room.isBeforeVote()) { + throw new IllegalStateException( + "Room is not on vote yet, but status: " + room.getStatus()); + } + } + + private VoteResults getVoteResults(Room room, List stationIds) { + List voteResults = new ArrayList<>(); + for (Long stationId : stationIds) { + List memberIds = loadVotePort.findMemberIdsByRoomIdAndStationId( + room.getId(), stationId); + Station station = getStation(stationId); + voteResults.add( + new VoteResult(room.getId(), stationId, memberIds, station.getPriority())); + } + return new VoteResults(voteResults); + } + + @Override + public Station getVoteFinalResult(String roomId) { + validate(roomId); + Room room = getRoom(roomId); + validateRoomStatusIfVoteClosed(room); + List stationIds = loadVotePort.findStationIdsByRoomIdOrderByVotedCount(roomId); + VoteResults voteResults = getVoteResults(room, stationIds); + return getStation(voteResults.getFinalResult().getStationId()); + } + + private void validate(String roomId, String memberId) { + validate(roomId); + List memberIds = loadRoomParticipantPort.findMembersByRoomId(roomId).stream() + .map(Member::getMemberId) + .toList(); + if (!memberIds.contains(memberId)) { + throw new IllegalArgumentException( + String.format("Member not found with id: %s, in room with id: %s", + memberId, roomId)); + } + } + + private void validate(String roomId) { + if (!loadRoomPort.isExistsByRoomId(roomId)) { + throw new IllegalArgumentException("Room not found with id: " + roomId); + } + } + + private void validateRoomStatusIfNotOnVote(String roomId) { + Room room = getRoom(roomId); + if (room.isNotOnVote()) { + throw new IllegalStateException( + "Room is not on vote status but status: " + room.getStatus()); + } + } + + private Station getStation(long stationId) { + return retrieveStationsPort.retrieveStation(stationId) + .orElseThrow( + () -> new IllegalArgumentException("Station not found with id " + stationId)); + } + + private Room getRoom(String roomId) { + return loadRoomPort.findRoomById(roomId) + .orElseThrow(() -> new IllegalArgumentException("Room not found with id: " + roomId)); + } + + private static void validateRoomStatusIfVoteClosed(Room room) { + if (!room.isVoteClosed()) { + throw new IllegalArgumentException( + "Vote is not closed for room with id: " + room.getId()); + } + } +} diff --git a/kok-api/src/main/resources/application-dev.yml b/kok-api/src/main/resources/application-dev.yml new file mode 100644 index 00000000..9243fda5 --- /dev/null +++ b/kok-api/src/main/resources/application-dev.yml @@ -0,0 +1,59 @@ +spring: + jackson: + time-zone: Asia/Seoul + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + show_sql: true + format_sql: true + dialect: org.hibernate.spatial.dialect.mysql.MySQLSpatialDialect + open-in-view: false + defer-datasource-initialization: false + flyway: + enabled: true + locations: classpath:db/migration + baseline-on-migrate: true + data: + redis: + host: ${REDIS_HOST} # redis๋„ ์„œ๋ฒ„ ๋‚ด๋ถ€์—์„œ ์ฒ˜๋ฆฌ. + port: ${REDIS_PORT} + timeout: 5000 + +springdoc: + default-consumes-media-type: application/json;charset=UTF-8 + default-produces-media-type: application/json;charset=UTF-8 + swagger-ui: + path: /swagger +# open-api: http://data.seoul.go.kr/dataList/OA-21232/S/1/datasetView.do +station: + base-url: http://openapi.seoul.go.kr:8088 + secret-key: ${STATION_SECRET_KEY} + service: subwayStationMaster + format: json + start-idx: 1 + end-idx: 1000 + +aws: + object-storage-url: ${OBJECT_STORAGE_URL} +google: + places: + api: + key: ${GOOGLE_PLACE_API_KEY} +tmap-sub: + key: ${TMAP_KEY} + url: "https://apis.openapi.sk.com/transit/routes/sub" + keyname: "appKey" +tmap-complex: + key: ${TMAP_KEY} + url: "https://apis.openapi.sk.com/transit/routes" + keyname: "appKey" + +swagger: + appname: "https://dev-api.kokokok.com" diff --git a/kok-api/src/main/resources/application-prod.yml b/kok-api/src/main/resources/application-prod.yml new file mode 100644 index 00000000..16bb00e4 --- /dev/null +++ b/kok-api/src/main/resources/application-prod.yml @@ -0,0 +1,75 @@ +spring: + jackson: + time-zone: Asia/Seoul + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + show_sql: true + format_sql: true + dialect: org.hibernate.spatial.dialect.mysql.MySQLSpatialDialect + open-in-view: false + defer-datasource-initialization: false + flyway: + enabled: true + locations: classpath:db/migration + baseline-on-migrate: true + data: + redis: + host: ${REDIS_HOST} # redis๋„ ์„œ๋ฒ„ ๋‚ด๋ถ€์—์„œ ์ฒ˜๋ฆฌ. + port: ${REDIS_PORT} + timeout: 5000 +# redis: +# cluster: +# nodes: +# - ${REDIS_HOST1}:${REDIS_PORT} +# - ${REDIS_HOST2}:${REDIS_PORT} +# - ${REDIS_HOST3}:${REDIS_PORT} +# max-redirects: 3 +# timeout: 5000 +# sentinel: +# master: ${REDIS_SENTINEL_MASTER} +# nodes: +# - ${REDIS_SENTINEL_HOST1}:${REDIS_SENTINEL_PORT1} +# - ${REDIS_SENTINEL_HOST2}:${REDIS_SENTINEL_PORT2} +# - ${REDIS_SENTINEL_HOST3}:${REDIS_SENTINEL_PORT3} + +springdoc: + default-consumes-media-type: application/json;charset=UTF-8 + default-produces-media-type: application/json;charset=UTF-8 + swagger-ui: + enabled: false + path: /swagger + +# open-api: http://data.seoul.go.kr/dataList/OA-21232/S/1/datasetView.do +station: + base-url: http://openapi.seoul.go.kr:8088 + secret-key: ${STATION_SECRET_KEY} + service: subwayStationMaster + format: json + start-idx: 1 + end-idx: 1000 + +aws: + object-storage-url: ${OBJECT_STORAGE_URL} +google: + places: + api: + key: ${GOOGLE_API_KEY} +tmap-sub: + key: ${TMAP_KEY} + url: "https://apis.openapi.sk.com/transit/routes/sub" + keyname: "appKey" +tmap-complex: + key: ${TMAP_KEY} + url: "https://apis.openapi.sk.com/transit/routes" + keyname: "appKey" + +swagger: + appname: "https://prod-api.kokokok.com" diff --git a/kok-api/src/main/resources/application.properties b/kok-api/src/main/resources/application.properties deleted file mode 100644 index f185fb9d..00000000 --- a/kok-api/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=kok-api diff --git a/kok-api/src/main/resources/db/data/priority.sql b/kok-api/src/main/resources/db/data/priority.sql new file mode 100644 index 00000000..f3710d70 --- /dev/null +++ b/kok-api/src/main/resources/db/data/priority.sql @@ -0,0 +1,104 @@ +-- priority 1 +UPDATE station +SET priority = 1 +WHERE name IN ( + '4.19๋ฏผ์ฃผ๋ฌ˜์ง€', '๊ฐ€์˜ค๋ฆฌ', '๊ฐ•์ผ', '๊ฐœ๋กฑ', '๊ฐœํ™”', '๊ฑฐ์—ฌ', '๊ณ ๋•', '๊ธธ๋™', + '๋‚จํƒœ๋ น', '๋‹น๊ณ ๊ฐœ', '๋Œ€์ฒญ', '๋„๋ด‰์‚ฐ', '๋…๋ฐ”์œ„', '๋‘”์ดŒ๋™', '๋งˆ๋“ค', '๋งˆ์ฒœ', + '๋งค๋ด‰', '๋ช…์ผ', '๋ฌด์•…์žฌ', '๋ฐฉํ™”', '์‚ผ์–‘', '์‚ผ์–‘์‚ฌ๊ฑฐ๋ฆฌ', '์ƒ๊ณ„', '์ƒ์ผ๋™', + '์ƒˆ์ ˆ(์‹ ์‚ฌ)', '์„์ˆ˜', '์†”์ƒ˜', '์ˆ˜๋ฝ์‚ฐ', '์‹ ๋‚ด', '์‹ ์ด๋ฌธ', '์Œ๋ฌธ', '์—ญ์ดŒ', + '์‘๋ด‰', '์‘์•”', '์ผ์›', '์ •๋ฆ‰', '์ค‘๊ณ„', '์ค‘์•™๋ณดํ›ˆ๋ณ‘์›', 'ํ•˜๊ณ„', 'ํ•™์—ฌ์šธ', 'ํ™”๊ณ„' + ); + +-- priority 2 +UPDATE station +SET priority = 2 +WHERE name IN ( + '๊ฐœ๋ด‰', '๊ฐœํ™”์‚ฐ', '๊น€ํฌ๊ณตํ•ญ', '๋‚จ๊ตฌ๋กœ', '๋‚จ์„ฑ', '๋‚ด๋ฐฉ', '๋…น์ฒœ', '๋„๋ฆผ์ฒœ', + '๋Œ๊ณถ์ด', '๋™๋Œ€์ž…๊ตฌ', '๋‘”์ดŒ์˜ค๋ฅœ', '๋จน๊ณจ', '๋ฉด๋ชฉ', '๋ฏธ์•„(์„œ์šธ์‚ฌ์ด๋ฒ„๋Œ€ํ•™)', '๋ด‰์ฒœ', + '์‚ฌ๊ฐ€์ •', '์ƒ๋„', '์ƒ๋ด‰(์‹œ์™ธ๋ฒ„์Šคํ„ฐ๋ฏธ๋„)', '์„œ์›', '์ˆ˜์ƒ‰', '์‹ ๋‹ต', '์‹ ๋Œ€๋ฐฉ์‚ผ๊ฑฐ๋ฆฌ', + '์‹ ์ •๋„ค๊ฑฐ๋ฆฌ', '์‹ ํ’', '์•”์‚ฌ', '์•”์‚ฌ์—ญ์‚ฌ๊ณต์›', '์–‘์ฒœ๊ตฌ์ฒญ', '์˜ค๊ธˆ', '์˜ค๋ฅ˜๋™', + '์˜จ์ˆ˜(์„ฑ๊ณตํšŒ๋Œ€์ž…๊ตฌ)', '์šฉ๋‹ต', '์šฉ๋งˆ์‚ฐ', '์šฐ์žฅ์‚ฐ', '์ด์ˆ˜', '์žฅ์Šน๋ฐฐ๊ธฐ', '์ œ๊ธฐ๋™', + '์ค‘๊ณก', '์ค‘ํ™”', '์ฆ๋ฏธ', '์ฒœ์™•', '์ฒญ๊ณ„์‚ฐ์ž…๊ตฌ', 'ํ•™๋™', 'ํ™์ œ', 'ํ™”๊ณก' + ); + +-- priority 3 +UPDATE station +SET priority = 3 +WHERE name IN ( + '๊ฐ•๋ณ€(๋™์„œ์šธํ„ฐ๋ฏธ๋„)', '๊ตฌ๋กœ', '๊ตฌ๋ฐ˜ํฌ', '๊ตญํšŒ์˜์‚ฌ๋‹น', '๊ตฝ์€๋‹ค๋ฆฌ(๊ฐ•๋™๊ตฌ๋ฏผํšŒ๊ด€์•ž)', '๊ธˆํ˜ธ', + '๊ธธ์Œ', '๊นŒ์น˜์‚ฐ', '๋‹ต์‹ญ๋ฆฌ', '๋Œ€๋ชจ์‚ฐ์ž…๊ตฌ', '๋„๋ด‰', '๋…๋ฆฝ๋ฌธ', '๋งˆ์žฅ', '๋ง์šฐ', '๋ฏธ์•„์‚ฌ๊ฑฐ๋ฆฌ', + '๋ฐœ์‚ฐ', '๋ฐฉํ•™', '๋ณด๋ฌธ', '๋ด‰ํ™”์‚ฐ(์„œ์šธ์˜๋ฃŒ์›)', '๋ถํ•œ์‚ฐ๋ณด๊ตญ๋ฌธ', '์ƒ์›”๊ณก(ํ•œ๊ตญ๊ณผํ•™๊ธฐ์ˆ ์—ฐ๊ตฌ์›)', + '์„œ๋น™๊ณ ', '์†ก์ •', '์ˆ˜์œ (๊ฐ•๋ถ๊ตฌ์ฒญ)', '์‹ ๊ธˆํ˜ธ', '์‹ ๋ฐฉํ™”', '์‹ ์ •(์€ํ–‰์ •)', '์• ์˜ค๊ฐœ', '์–‘์›', + '์–‘์ฒœํ–ฅ๊ต', '์–ธ์ฃผ', '์—ฐ์‹ ๋‚ด', '์—ผ์ฐฝ', '์šฉ๋‘(๋™๋Œ€๋ฌธ๊ตฌ์ฒญ)', '์›”๊ณก(๋™๋•์—ฌ๋Œ€)', '์ž ์‹ค๋‚˜๋ฃจ', + '์ž ์‹ค์ƒˆ๋‚ด', '์žฅ์ง€', '์ฐฝ๋™', 'ํƒœ๋ฆ‰์ž…๊ตฌ', 'ํ•œ์„ฑ๋ฐฑ์ œ', 'ํ–‰๋‹น', 'ํ™”๋ž‘๋Œ€(์„œ์šธ์—ฌ๋Œ€์ž…๊ตฌ)', '๋…น๋ฒˆ' + ); + +-- priority 4 +UPDATE station +SET priority = 4 +WHERE name IN ( + '๊ฐ€๋ฝ์‹œ์žฅ', '๊ด€์•…', '๊ตฌ๋ฃก', '๊ตฌ์˜(๊ด‘์ง„๊ตฌ์ฒญ)', '๊ตฌํŒŒ๋ฐœ', '๊ธˆ์ฒœ๊ตฌ์ฒญ', '๋…ธ์›', '๋ฐฉ๋ฐฐ', + '๋ณต์ •', '๋ถˆ๊ด‘', '์‚ฌํ‰', '์„๊ณ„', '์„ ์œ ๋„', '์†”๋ฐญ๊ณต์›', '์‹ ๋Œ€๋ฐฉ', '์‹ ๋ชฉ๋™', '์‹ ๋ฐ˜ํฌ', + '์‹ ์„ค๋™', '์ค‘๋ž‘' + ); + +-- priority 5 +UPDATE station +SET priority = 5 +WHERE name IN ( + '๊ฐ€์ขŒ', '๊ฐ•๋™', '๊ฐ•๋™๊ตฌ์ฒญ', '๊ณต๋ฆ‰(์„œ์šธ๊ณผํ•™๊ธฐ์ˆ ๋Œ€)', '๊ด€์•…์‚ฐ(์„œ์šธ๋Œ€)', '๊ด‘๋‚˜๋ฃจ(์žฅ์‹ ๋Œ€)', + '๊ด‘ํฅ์ฐฝ(์„œ๊ฐ•)', '๊ตฐ์ž(๋Šฅ๋™)', '๋Œ€๋ฆผ(๊ตฌ๋กœ๊ตฌ์ฒญ)', '๋Œ€์น˜', '๋ชฉ๋™', '๋ชฝ์ดŒํ† ์„ฑ(ํ‰ํ™”์˜๋ฌธ)', + '๋ฌธ์ •', '์‚ผ์ „', '์ƒ์™•์‹ญ๋ฆฌ', '์„์ดŒ๊ณ ๋ถ„', '์ˆญ์‹ค๋Œ€์ž…๊ตฌ(์‚ดํ”ผ์žฌ)', '์‹ ๊ธธ', '์•„์ฐจ์‚ฐ(์–ด๋ฆฐ์ด๋Œ€๊ณต์›ํ›„๋ฌธ)', + '์•„ํ˜„', '์˜ค๋ชฉ๊ต(๋ชฉ๋™์šด๋™์žฅ์•ž)', '์˜ฅ์ˆ˜', '์›”๊ณ„', '์ด์ดŒ(๊ตญ๋ฆฝ์ค‘์•™๋ฐ•๋ฌผ๊ด€)', '์ž ์›', '์žฅํ•œํ‰', + '์ฆ์‚ฐ(๋ช…์ง€๋Œ€์•ž)', '์ฐฝ์‹ ', '์ฒญ๊ตฌ', '์ถฉ์ •๋กœ(๊ฒฝ๊ธฐ๋Œ€์ž…๊ตฌ)', 'ํ•œ์„ฑ๋Œ€์ž…๊ตฌ(์‚ผ์„ ๊ต)', 'ํ•œํ‹ฐ' + ); + +-- priority 6 +UPDATE station +SET priority = 6 +WHERE name IN ( + '๊ฐ€์‚ฐ๋””์ง€ํ„ธ๋‹จ์ง€', '๊ฐœํฌ๋™', '๊ณตํ•ญ์‹œ์žฅ', '๊ตฌ๋กœ๋””์ง€ํ„ธ๋‹จ์ง€', '๋‚™์„ฑ๋Œ€', '๋‹น์‚ฐ', '๋Œ€๋ฐฉ', + '๋„๊ณก', '๋™๋Œ€๋ฌธ', '๋“ฑ์ดŒ', '๋งˆ๊ณก', '๋งˆ๊ณก๋‚˜๋ฃจ', '๋ณด๋ผ๋งค', '๋ณด๋ผ๋งค๊ณต์›', '๋ณด๋ผ๋งค๋ณ‘์›', + '๋ถํ•œ์‚ฐ์šฐ์ด', '์ƒ›๊ฐ•', '์„œ์šธ๋Œ€๋ฒค์ฒ˜ํƒ€์šด', '์„œ์šธ๋Œ€์ž…๊ตฌ(๊ด€์•…๊ตฌ์ฒญ)', '์„œ์šธ์ง€๋ฐฉ๋ณ‘๋ฌด์ฒญ', '์„ ์ •๋ฆ‰', + '์„ฑ์‹ ์—ฌ๋Œ€์ž…๊ตฌ(๋ˆ์•”)', '์‹ ๋‹น', '์•ฝ์ˆ˜', '์ข…๋กœ5๊ฐ€', '์ข…ํ•ฉ์šด๋™์žฅ', '์ด์‹ ๋Œ€์ž…๊ตฌ(์ด์ˆ˜)', 'ํ•œ์–‘๋Œ€', + 'ํšŒํ˜„(๋‚จ๋Œ€๋ฌธ์‹œ์žฅ)' + ); + +-- priority 7 +UPDATE station +SET priority = 7 +WHERE name IN ( + '๊ฐ€์–‘', '๊ฐ•๋‚จ๊ตฌ์ฒญ', '๊ด‘์šด๋Œ€', '๊ต๋Œ€(๋ฒ•์›.๊ฒ€์ฐฐ์ฒญ)', '๋‚จ๋ถ€ํ„ฐ๋ฏธ๋„(์˜ˆ์ˆ ์˜์ „๋‹น)', '๋‹น๊ณก', + '๋™๋ฌ˜์•ž', '๋””์ง€ํ„ธ๋ฏธ๋””์–ด์‹œํ‹ฐ', '๋งˆํฌ', '์‚ฌ๋‹น', '์‚ผ์„ฑ์ค‘์•™', '์„œ๋Œ€๋ฌธ', '์„œ์šธ์ˆฒ', '์†กํŒŒ๋‚˜๋ฃจ', + '์ˆ˜์„œ', '์ˆ™๋Œ€์ž…๊ตฌ(๊ฐˆ์›”)', '์‹ ๋„๋ฆผ', '์—ฌ์˜๋‚˜๋ฃจ', '์˜๋“ฑํฌ๊ตฌ์ฒญ', '์˜๋“ฑํฌ์‹œ์žฅ', '์˜ฌ๋ฆผํ”ฝ๊ณต์›(ํ•œ๊ตญ์ฒด๋Œ€)', + '์ฒญ๋Ÿ‰๋ฆฌ(์„œ์šธ์‹œ๋ฆฝ๋Œ€์ž…๊ตฌ)', 'ํšŒ๊ธฐ', 'ํ‘์„(์ค‘์•™๋Œ€์ž…๊ตฌ)' + ); + +-- priority 8 +UPDATE station +SET priority = 8 +WHERE name IN ( + '๋…ธ๋“ค', '๋…ธ๋Ÿ‰์ง„', '๋™๋Œ€๋ฌธ์—ญ์‚ฌ๋ฌธํ™”๊ณต์›', '๋™์ž‘(ํ˜„์ถฉ์›)', '๋š์„ฌ', '๋ฌธ๋ž˜', '๋ฐ˜ํฌ', '๋ฐฉ์ด', + '์‚ผ์„ฑ', '์‚ผ์„ฑ(๋ฌด์—ญ์„ผํ„ฐ)', '์„œ๊ฐ•๋Œ€', '์„œ์ดˆ', '์„ ๋ฆ‰', '์†กํŒŒ', '์‹ ๋ฆผ', '์–‘์žฌ(์„œ์ดˆ๊ตฌ์ฒญ)', + '์–‘์žฌ์‹œ๋ฏผ์˜์ˆฒ(๋งคํ—Œ)', '์–ด๋ฆฐ์ด๋Œ€๊ณต์›(์„ธ์ข…๋Œ€)', '์™ธ๋Œ€์•ž', '์šฉ์‚ฐ', '์„์ง€๋กœ3๊ฐ€', '์„์ง€๋กœ4๊ฐ€', + '์ฒœํ˜ธ(ํ’๋‚ฉํ† ์„ฑ)', '์ฒญ๋‹ด', '์ถฉ๋ฌด๋กœ' + ); + +-- priority 9 +UPDATE station +SET priority = 9 +WHERE name IN ( + '๊ฒฝ๋ณต๊ถ(์ •๋ถ€์„œ์šธ์ฒญ์‚ฌ)', '๊ณ ์†ํ„ฐ๋ฏธ๋„', '๊ด‘ํ™”๋ฌธ(์„ธ์ข…๋ฌธํ™”ํšŒ๊ด€)', '๋‚จ์˜', '๋ช…๋™', '๋ด‰์€์‚ฌ', + '์‚ผ๊ฐ์ง€', '์„œ์šธ', '์„œ์šธ์—ญ', '์„์ดŒ', '์‹œ์ฒญ', '์‹ ๋…ผํ˜„', '์‹ ์‚ฌ', '์‹ ์šฉ์‚ฐ', '์••๊ตฌ์ •๋กœ๋ฐ์˜ค', + '์—ฌ์˜๋„', '์™•์‹ญ๋ฆฌ(์„ฑ๋™๊ตฌ์ฒญ)', '์ž ์‹ค(์†กํŒŒ๊ตฌ์ฒญ)', '์ข…๊ฐ', '์ข…๋กœ3๊ฐ€', 'ํšจ์ฐฝ๊ณต์›์•ž' + ); + +-- priority 10 +UPDATE station +SET priority = 10 +WHERE name IN ( + '๊ฐ•๋‚จ', '๊ฑด๋Œ€์ž…๊ตฌ', '๊ณ ๋ ค๋Œ€(์ข…์•”)', '๊ณต๋•', '๋…น์‚ฌํ‰(์šฉ์‚ฐ๊ตฌ์ฒญ)', '๋…ผํ˜„', '๋Œ€ํฅ(์„œ๊ฐ•๋Œ€์•ž)', + '๋š์„ฌ์œ ์›์ง€', '๋งˆํฌ๊ตฌ์ฒญ', '๋ง์›', '๋ฒ„ํ‹ฐ๊ณ ๊ฐœ', '์ƒ์ˆ˜', '์„ฑ์ˆ˜', '์‹ ์ดŒ', '์•ˆ๊ตญ', '์•ˆ์•”(๊ณ ๋Œ€๋ณ‘์›์•ž)', + '์••๊ตฌ์ •', '์—ญ์‚ผ', '์˜๋“ฑํฌ', '์›”๋“œ์ปต๊ฒฝ๊ธฐ์žฅ(์„ฑ์‚ฐ)', '์„์ง€๋กœ์ž…๊ตฌ', '์ด๋Œ€', '์ดํƒœ์›', 'ํ•œ๊ฐ•์ง„', + 'ํ•œ๋‚จ', 'ํ•ฉ์ •', 'ํ˜œํ™”', 'ํ™๋Œ€์ž…๊ตฌ' + ); \ No newline at end of file diff --git a/kok-api/src/main/resources/db/migration/V1__init.sql b/kok-api/src/main/resources/db/migration/V1__init.sql new file mode 100644 index 00000000..6c325391 --- /dev/null +++ b/kok-api/src/main/resources/db/migration/V1__init.sql @@ -0,0 +1,29 @@ +CREATE TABLE station +( + id BIGINT NOT NULL AUTO_INCREMENT, + name VARCHAR(20) NOT NULL, + latitude DECIMAL(16, 14) NOT NULL, + longitude DECIMAL(17, 14) NOT NULL, + priority BIGINT NOT NULL, + PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE route +( + id BIGINT NOT NULL AUTO_INCREMENT, + code BIGINT NOT NULL, + station_id BIGINT NOT NULL, + name VARCHAR(20) NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY (station_id) REFERENCES station (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +create table location +( + id bigint auto_increment primary key, + member_id varchar(255) not null, + location_point point not null, + room_id varchar(255) not null, + constraint UKrgpajb4rsivb4gj9xn2qowgw6 + unique (room_id, member_id) +); diff --git a/kok-api/src/main/resources/db/migration/V2__add_location_name.sql b/kok-api/src/main/resources/db/migration/V2__add_location_name.sql new file mode 100644 index 00000000..0cfd43fe --- /dev/null +++ b/kok-api/src/main/resources/db/migration/V2__add_location_name.sql @@ -0,0 +1,5 @@ +ALTER TABLE location + ADD COLUMN name VARCHAR(255) DEFAULT 'unknown'; + +ALTER TABLE location + MODIFY COLUMN name VARCHAR(255) NOT NULL; \ No newline at end of file diff --git a/kok-api/src/main/resources/db/migration/V3__delete_station_code.sql b/kok-api/src/main/resources/db/migration/V3__delete_station_code.sql new file mode 100644 index 00000000..1364f81f --- /dev/null +++ b/kok-api/src/main/resources/db/migration/V3__delete_station_code.sql @@ -0,0 +1 @@ +ALTER TABLE route DROP COLUMN code; diff --git a/kok-api/src/main/resources/db/migration/V4__change_priority_default.sql b/kok-api/src/main/resources/db/migration/V4__change_priority_default.sql new file mode 100644 index 00000000..9ddd7d1e --- /dev/null +++ b/kok-api/src/main/resources/db/migration/V4__change_priority_default.sql @@ -0,0 +1,2 @@ +ALTER TABLE station + MODIFY COLUMN priority BIGINT NOT NULL DEFAULT 1; diff --git a/kok-api/src/test/java/com/kok/kokapi/KokApiApplicationTests.java b/kok-api/src/test/java/com/kok/kokapi/KokApiApplicationTests.java index 0e970898..577fee89 100644 --- a/kok-api/src/test/java/com/kok/kokapi/KokApiApplicationTests.java +++ b/kok-api/src/test/java/com/kok/kokapi/KokApiApplicationTests.java @@ -1,13 +1,14 @@ package com.kok.kokapi; +import com.kok.kokapi.common.template.ServiceTest; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest -class KokApiApplicationTests { +class KokApiApplicationTests extends ServiceTest { - @Test - void contextLoads() { - } + @Test + void contextLoads() { + } } diff --git a/kok-api/src/test/java/com/kok/kokapi/common/template/ContainerBaseTest.java b/kok-api/src/test/java/com/kok/kokapi/common/template/ContainerBaseTest.java new file mode 100644 index 00000000..5d51c7f7 --- /dev/null +++ b/kok-api/src/test/java/com/kok/kokapi/common/template/ContainerBaseTest.java @@ -0,0 +1,38 @@ +package com.kok.kokapi.common.template; + +import com.redis.testcontainers.RedisContainer; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.MySQLContainer; + +public abstract class ContainerBaseTest { + + private static final int REDIS_PORT = 6379; + + private static final MySQLContainer mysqlContainer = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("kok-db") + .withUsername("root") + .withPassword("1234"); + + private static final RedisContainer redisContainer = new RedisContainer("redis:7.0") + .withExposedPorts(REDIS_PORT); + + static { + mysqlContainer.start(); + redisContainer.start(); + } + + @DynamicPropertySource + private static void dynamicProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", mysqlContainer::getJdbcUrl); + registry.add("spring.datasource.driver-class-name", mysqlContainer::getDriverClassName); + registry.add("spring.datasource.username", mysqlContainer::getUsername); + registry.add("spring.datasource.password", mysqlContainer::getPassword); + + registry.add("spring.data.redis.host", redisContainer::getHost); + registry.add("spring.data.redis.port", () -> redisContainer.getMappedPort(REDIS_PORT)); + + registry.add("spring.flyway.enabled", () -> true); + registry.add("spring.flyway.baseline-on-migrate", () -> true); + } +} diff --git a/kok-api/src/test/java/com/kok/kokapi/common/template/IntegrationTest.java b/kok-api/src/test/java/com/kok/kokapi/common/template/IntegrationTest.java new file mode 100644 index 00000000..a3aa046d --- /dev/null +++ b/kok-api/src/test/java/com/kok/kokapi/common/template/IntegrationTest.java @@ -0,0 +1,28 @@ +package com.kok.kokapi.common.template; + +import com.kok.kokapi.common.util.DatabaseCleanerExtension; +import com.kok.kokapi.config.StationTestConfiguration; +import io.restassured.RestAssured; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Profile; +import org.springframework.test.context.TestPropertySource; + +@ExtendWith(DatabaseCleanerExtension.class) +@Import(StationTestConfiguration.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@TestPropertySource(properties = {"spring.config.location = classpath:application-test.yml"}) +@Profile("test") +public abstract class IntegrationTest extends ContainerBaseTest { + + @LocalServerPort + private int port; + + @BeforeEach + void setPort() { + RestAssured.port = port; + } +} diff --git a/kok-api/src/test/java/com/kok/kokapi/common/template/RepositoryTest.java b/kok-api/src/test/java/com/kok/kokapi/common/template/RepositoryTest.java new file mode 100644 index 00000000..b866a12c --- /dev/null +++ b/kok-api/src/test/java/com/kok/kokapi/common/template/RepositoryTest.java @@ -0,0 +1,21 @@ +package com.kok.kokapi.common.template; + +import com.kok.kokapi.common.util.DatabaseCleanerExtension; +import com.kok.kokapi.config.StationTestConfiguration; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Profile; +import org.springframework.test.context.TestPropertySource; + +@ExtendWith(DatabaseCleanerExtension.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +@Import({StationTestConfiguration.class}) +@AutoConfigureTestDatabase(replace = Replace.NONE) +@TestPropertySource(properties = {"spring.config.location = classpath:application-test.yml"}) +@Profile("test") +public abstract class RepositoryTest extends ContainerBaseTest { + +} diff --git a/kok-api/src/test/java/com/kok/kokapi/common/template/ServiceTest.java b/kok-api/src/test/java/com/kok/kokapi/common/template/ServiceTest.java new file mode 100644 index 00000000..706ac3a3 --- /dev/null +++ b/kok-api/src/test/java/com/kok/kokapi/common/template/ServiceTest.java @@ -0,0 +1,25 @@ +package com.kok.kokapi.common.template; + +import com.kok.kokapi.common.util.DatabaseCleanerExtension; +import com.kok.kokapi.config.StationTestConfiguration; +import com.kok.kokapi.public_transportation.adapter.out.external.PublicTransportationClient; +import com.kok.kokapi.public_transportation.adapter.out.external.PublicTransportationComplexClient; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Profile; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +@ExtendWith(DatabaseCleanerExtension.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +@Import({StationTestConfiguration.class}) +@TestPropertySource(properties = {"spring.config.location = classpath:application-test.yml"}) +@Profile("test") +public abstract class ServiceTest extends ContainerBaseTest { + + @MockitoBean + protected PublicTransportationComplexClient publicTransportationComplexClient; + @MockitoBean + protected PublicTransportationClient publicTransportationClient; +} diff --git a/kok-api/src/test/java/com/kok/kokapi/common/util/DatabaseCleaner.java b/kok-api/src/test/java/com/kok/kokapi/common/util/DatabaseCleaner.java new file mode 100644 index 00000000..0ea61763 --- /dev/null +++ b/kok-api/src/test/java/com/kok/kokapi/common/util/DatabaseCleaner.java @@ -0,0 +1,6 @@ +package com.kok.kokapi.common.util; + +public interface DatabaseCleaner { + + void cleanUp(); +} diff --git a/kok-api/src/test/java/com/kok/kokapi/common/util/DatabaseCleanerExtension.java b/kok-api/src/test/java/com/kok/kokapi/common/util/DatabaseCleanerExtension.java new file mode 100644 index 00000000..19b006e7 --- /dev/null +++ b/kok-api/src/test/java/com/kok/kokapi/common/util/DatabaseCleanerExtension.java @@ -0,0 +1,16 @@ +package com.kok.kokapi.common.util; + +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +public class DatabaseCleanerExtension implements BeforeEachCallback { + + @Override + public void beforeEach(ExtensionContext context) { + SpringExtension.getApplicationContext(context) + .getBeansOfType(DatabaseCleaner.class) + .values() + .forEach(DatabaseCleaner::cleanUp); + } +} diff --git a/kok-api/src/test/java/com/kok/kokapi/common/util/MySQLDatabaseCleaner.java b/kok-api/src/test/java/com/kok/kokapi/common/util/MySQLDatabaseCleaner.java new file mode 100644 index 00000000..a1c0376f --- /dev/null +++ b/kok-api/src/test/java/com/kok/kokapi/common/util/MySQLDatabaseCleaner.java @@ -0,0 +1,50 @@ +package com.kok.kokapi.common.util; + +import jakarta.persistence.Entity; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import java.util.List; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +public class MySQLDatabaseCleaner implements DatabaseCleaner { + + @PersistenceContext + private EntityManager entityManager; + + @Override + @Transactional + public void cleanUp() { + entityManager.flush(); + entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 0").executeUpdate(); + for (String tableName : getTableNames()) { + entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate(); + entityManager.createNativeQuery( + "ALTER TABLE " + tableName + " AUTO_INCREMENT = 1") + .executeUpdate(); + } + entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 1").executeUpdate(); + } + + private List getTableNames() { + return entityManager.getMetamodel().getEntities().stream() + .filter(entity -> entity.getJavaType().getAnnotation(Entity.class) != null) + .map(entityType -> camelToSnake(entityType.getName())) + .toList(); + } + + private String camelToSnake(String camel) { + StringBuilder snake = new StringBuilder(); + for (char c : camel.toCharArray()) { + if (Character.isUpperCase(c)) { + snake.append("_"); + } + snake.append(Character.toLowerCase(c)); + } + if (snake.charAt(0) == '_') { + snake.deleteCharAt(0); + } + return snake.toString(); + } +} diff --git a/kok-api/src/test/java/com/kok/kokapi/common/util/RedisDatabaseCleaner.java b/kok-api/src/test/java/com/kok/kokapi/common/util/RedisDatabaseCleaner.java new file mode 100644 index 00000000..c7a8a9de --- /dev/null +++ b/kok-api/src/test/java/com/kok/kokapi/common/util/RedisDatabaseCleaner.java @@ -0,0 +1,29 @@ +package com.kok.kokapi.common.util; + +import java.util.Objects; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +@Component +public class RedisDatabaseCleaner implements DatabaseCleaner { + + private final RedisTemplate redisTemplate; + + public RedisDatabaseCleaner(RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + @Override + public void cleanUp() { + RedisConnectionFactory connectionFactory = Objects.requireNonNull( + redisTemplate.getConnectionFactory(), + "RedisConnectionFactory must not be null" + ); + + try (RedisConnection connection = connectionFactory.getConnection()) { + connection.serverCommands().flushDb(); + } + } +} diff --git a/kok-api/src/test/java/com/kok/kokapi/config/StationTestConfiguration.java b/kok-api/src/test/java/com/kok/kokapi/config/StationTestConfiguration.java new file mode 100644 index 00000000..52ab1321 --- /dev/null +++ b/kok-api/src/test/java/com/kok/kokapi/config/StationTestConfiguration.java @@ -0,0 +1,17 @@ +package com.kok.kokapi.config; + +import com.kok.kokapi.station.adapter.out.external.FakeStationClient; +import com.kok.kokcore.station.port.out.LoadStationsPort; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; + +@TestConfiguration +public class StationTestConfiguration { + + @Bean + @Primary + public LoadStationsPort loadStationsPort() { + return new FakeStationClient(); + } +} diff --git a/kok-api/src/test/java/com/kok/kokapi/fixture/MemberFixture.java b/kok-api/src/test/java/com/kok/kokapi/fixture/MemberFixture.java new file mode 100644 index 00000000..18872ba1 --- /dev/null +++ b/kok-api/src/test/java/com/kok/kokapi/fixture/MemberFixture.java @@ -0,0 +1,15 @@ +package com.kok.kokapi.fixture; + +import com.kok.kokcore.room.domain.Member; +import com.kok.kokcore.room.domain.vo.MemberRole; + +public class MemberFixture { + + public static Member createFollower() { + return new Member("follower", "profile.svg", MemberRole.FOLLOWER); + } + + public static Member createLeader() { + return new Member("leader", "profile.svg", MemberRole.LEADER); + } +} diff --git a/kok-api/src/test/java/com/kok/kokapi/fixture/PointFixture.java b/kok-api/src/test/java/com/kok/kokapi/fixture/PointFixture.java new file mode 100644 index 00000000..5bc11b39 --- /dev/null +++ b/kok-api/src/test/java/com/kok/kokapi/fixture/PointFixture.java @@ -0,0 +1,15 @@ +package com.kok.kokapi.fixture; + +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.Point; + +public class PointFixture { + + public static Point create() { + GeometryFactory geometryFactory = new GeometryFactory(); + Coordinate coordinate = new Coordinate(127.02758, 37.49794); + + return geometryFactory.createPoint(coordinate); + } +} diff --git a/kok-api/src/test/java/com/kok/kokapi/fixture/RoomFixture.java b/kok-api/src/test/java/com/kok/kokapi/fixture/RoomFixture.java new file mode 100644 index 00000000..5fb2a383 --- /dev/null +++ b/kok-api/src/test/java/com/kok/kokapi/fixture/RoomFixture.java @@ -0,0 +1,11 @@ +package com.kok.kokapi.fixture; + +import com.kok.kokcore.room.domain.Member; +import com.kok.kokcore.room.domain.Room; + +public class RoomFixture { + + public static Room create(int capacity, Member member) { + return Room.create("room", capacity, member); + } +} diff --git a/kok-api/src/test/java/com/kok/kokapi/room/adapter/in/web/RoomIntegrationTest.java b/kok-api/src/test/java/com/kok/kokapi/room/adapter/in/web/RoomIntegrationTest.java new file mode 100644 index 00000000..05de3b10 --- /dev/null +++ b/kok-api/src/test/java/com/kok/kokapi/room/adapter/in/web/RoomIntegrationTest.java @@ -0,0 +1,163 @@ +package com.kok.kokapi.room.adapter.in.web; + +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; + +import com.kok.kokapi.centroid.adapter.in.dto.request.LocationRequest; +import com.kok.kokapi.common.template.IntegrationTest; +import com.kok.kokapi.room.adapter.in.dto.request.CreateRoomRequest; +import com.kok.kokapi.room.adapter.in.dto.request.JoinRoomParticipantRequest; +import com.kok.kokapi.room.adapter.in.dto.response.CreateRoomResponse; +import com.kok.kokapi.room.adapter.in.dto.response.JoinRoomResponse; +import com.kok.kokcore.room.domain.vo.RoomStatus; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import java.math.BigDecimal; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; + +class RoomIntegrationTest extends IntegrationTest { + + @DisplayName("์•ฝ์†๋ฐฉ ์‹œ๋‚˜๋ฆฌ์˜ค") + @TestFactory + Stream getRoomDetail() { + AtomicReference createRoomResponse = new AtomicReference<>(); + AtomicReference joinRoomResponse = new AtomicReference<>(); + + return Stream.of( + DynamicTest.dynamicTest("์•ฝ์†๋ฐฉ์„ ์ƒ์„ฑํ•œ๋‹ค.", + () -> createRoomResponse.set(createRoom( + new CreateRoomRequest("room", 2, "hostProfile.svg", "hostNickname")))), + + inputLocation("๋ฐฉ์žฅ์ด ์ถœ๋ฐœ์ง€ ์ •๋ณด๋ฅผ ์ž…๋ ฅํ•œ๋‹ค.", createRoomResponse), + + getRoomDetail("์•ฝ์†๋ฐฉ ์ •๋ณด๋ฅผ ์กฐํšŒํ•ด๋ณด๋ฉด ๋ฏธ์ฐธ์—ฌ์ž๋Š” 1๋ช…์ด๋‹ค", + createRoomResponse, 1, RoomStatus.LOCATION_INPUT.name()), + + DynamicTest.dynamicTest("ํŒ”๋กœ์›Œ๊ฐ€ ์•ฝ์†๋ฐฉ์— ์ฐธ์—ฌํ•œ๋‹ค.", + () -> joinRoomResponse.set(joinRoom(createRoomResponse.get().id(), + new JoinRoomParticipantRequest("profile", "follower")))), + + getRoomDetail("์•ฝ์†๋ฐฉ ์ •๋ณด๋ฅผ ์กฐํšŒํ•ด๋ณด๋ฉด ๋ฏธ์ฐธ์—ฌ์ž๋Š” 1๋ช…์ด๋‹ค.", + createRoomResponse, 1, RoomStatus.LOCATION_INPUT.name()), + + getRoomMembers("์•ฝ์†๋ฐฉ ํ”„๋กœํ•„ ๋ชฉ๋ก์„ ์กฐํšŒํ•˜๋ฉด isFull์€ true์ด๊ณ , 2๋ช…์˜ ํ”„๋กœํ•„์ด ์žˆ๋‹ค", + createRoomResponse, 2, true), + + checkRoomStatus("์•„์ง ์ถœ๋ฐœ์ง€ ์ž…๋ ฅ์„ ์™„๋ฃŒํ•˜์ง€ ์•Š์•˜๊ธฐ์— roomStatuts๋Š” LOCATION_INPUT์ด๋‹ค.", + createRoomResponse, RoomStatus.LOCATION_INPUT.name()), + + inputLocation("ํŒ”๋กœ์›Œ๊ฐ€ ์ถœ๋ฐœ์ง€ ์ •๋ณด๋ฅผ ์ž…๋ ฅํ•œ๋‹ค.", createRoomResponse, joinRoomResponse), + + checkRoomStatus("์•ฝ์†๋ฐฉ ์ •๋ณด๋ฅผ ์กฐํšŒํ•ด๋ณด๋ฉด ๋ชจ๋“  ์ฐธ์—ฌ์ž๊ฐ€ ์ถœ๋ฐœ์ง€ ์ž…๋ ฅ์„ ์™„๋ฃŒํ–ˆ๊ธฐ์— roomStatus๋Š” VOTE์ด๋‹ค.", + createRoomResponse, RoomStatus.VOTE.name()) + ); + } + + private CreateRoomResponse createRoom(CreateRoomRequest request) { + return RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(request) + .when().post("/v1/api/rooms") + .then().log().all() + .assertThat().statusCode(201) + .extract().body().jsonPath().getObject("data", CreateRoomResponse.class); + } + + private JoinRoomResponse joinRoom(String roomId, JoinRoomParticipantRequest request) { + return RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(request) + .when().post("/v1/api/rooms/" + roomId + "/join") + .then().log().all() + .assertThat().statusCode(200) + .extract().body().jsonPath().getObject("data", JoinRoomResponse.class); + } + + private static DynamicTest inputLocation(String message, + AtomicReference createRoomResponse) { + return DynamicTest.dynamicTest(message, + () -> { + String roomId = createRoomResponse.get().id(); + String memberId = createRoomResponse.get().member().id(); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(new LocationRequest(roomId, memberId, new BigDecimal("37"), + new BigDecimal("127"), "test")) + .when().post("/v1/api/locations") + .then().log().all() + .assertThat().statusCode(200); + }); + } + + private static DynamicTest getRoomDetail( + String message, + AtomicReference createRoomResponse, + int nonParticipantCount, + String roomStatus + ) { + return DynamicTest.dynamicTest(message, + () -> { + String roomId = createRoomResponse.get().id(); + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .when().get("/v1/api/rooms/" + roomId) + .then().log().all() + .assertThat().statusCode(200) + .body("data.nonParticipantCount", is(nonParticipantCount)) + .body("data.roomStatus", is(roomStatus)); + }); + } + + private static DynamicTest getRoomMembers(String message, + AtomicReference createRoomResponse, int profileCount, + boolean expectedIsFull) { + return DynamicTest.dynamicTest(message, + () -> { + String roomId = createRoomResponse.get().id(); + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .when().get("/v1/api/rooms/" + roomId + "/participants") + .then().log().all() + .assertThat().statusCode(200) + .body("data.members", hasSize(profileCount)) + .body("data.isFull", is(expectedIsFull)); + }); + } + + private static DynamicTest inputLocation(String message, + AtomicReference createRoomResponse, + AtomicReference joinRoomResponse) { + return DynamicTest.dynamicTest(message, + () -> { + String roomId = createRoomResponse.get().id(); + String memberId = joinRoomResponse.get().id(); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(new LocationRequest(roomId, memberId, new BigDecimal("37"), + new BigDecimal("127"), "test")) + .when().post("/v1/api/locations") + .then().log().all() + .assertThat().statusCode(200); + }); + } + + private static DynamicTest checkRoomStatus(String message, + AtomicReference createRoomResponse, String roomStatus) { + return DynamicTest.dynamicTest(message, + () -> { + String roomId = createRoomResponse.get().id(); + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .when().get("/v1/api/rooms/" + roomId + "/status") + .then().log().all() + .assertThat().statusCode(200) + .body("data.roomStatus", is(roomStatus)); + }); + } +} diff --git a/kok-api/src/test/java/com/kok/kokapi/room/adapter/out/persistence/RoomParticipantQueryRedisAdapterTest.java b/kok-api/src/test/java/com/kok/kokapi/room/adapter/out/persistence/RoomParticipantQueryRedisAdapterTest.java new file mode 100644 index 00000000..08fe6876 --- /dev/null +++ b/kok-api/src/test/java/com/kok/kokapi/room/adapter/out/persistence/RoomParticipantQueryRedisAdapterTest.java @@ -0,0 +1,87 @@ +package com.kok.kokapi.room.adapter.out.persistence; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.kok.kokapi.common.template.RepositoryTest; +import com.kok.kokapi.fixture.MemberFixture; +import com.kok.kokcore.room.domain.Member; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class RoomParticipantQueryRedisAdapterTest extends RepositoryTest { + + @Autowired + private RoomParticipantQueryRedisAdapter roomParticipantQueryRedisAdapter; + + @Autowired + private RoomParticipantSaveAdapter roomParticipantSaveAdapter; + + @DisplayName("๋ฐฉ์˜ ์ฐธ์—ฌ ์ธ์› ์ˆ˜๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void getParticipantCount() { + // given + String roomId = "roomId"; + roomParticipantSaveAdapter.joinRoom(roomId, MemberFixture.createLeader()); + + // when + Long participantCount = roomParticipantQueryRedisAdapter.countParticipantsById(roomId); + + // then + assertThat(participantCount).isEqualTo(1); + } + + @DisplayName("๋ฐฉ์˜ ๋ชจ๋“  ์ฐธ์—ฌ์ž ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void findMembersByRoomId() { + // given + String roomId = "roomId"; + Member member1 = MemberFixture.createLeader(); + Member member2 = MemberFixture.createFollower(); + roomParticipantSaveAdapter.joinRoom(roomId, member1); + roomParticipantSaveAdapter.joinRoom(roomId, member2); + + // when + List members = roomParticipantQueryRedisAdapter.findMembersByRoomId(roomId); + + // then + assertThat(members) + .extracting(Member::getMemberId) + .containsExactlyInAnyOrder(member1.getMemberId(), member2.getMemberId()); + } + + @DisplayName("๋ฐฉ ID์™€ ๋ฉค๋ฒ„ ID๋กœ ํ•ด๋‹น ๋ฉค๋ฒ„๋ฅผ ์กฐํšŒํ•œ๋‹ค.") + @Test + void findByRoomIdAndMemberId() { + // given + String roomId = "roomId"; + Member member = MemberFixture.createLeader(); + roomParticipantSaveAdapter.joinRoom(roomId, member); + + // when + Optional result = roomParticipantQueryRedisAdapter.findByRoomIdAndMemberId( + roomId, member.getMemberId()); + + // then + assertThat(result).isPresent(); + assertThat(result.get().getMemberId()).isEqualTo(member.getMemberId()); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋ฉค๋ฒ„ ID๋กœ ์กฐํšŒํ•˜๋ฉด ๋นˆ Optional์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void notFoundMemberByRoomIdAndMemberId() { + // given + String roomId = "roomId"; + Member member = MemberFixture.createLeader(); + roomParticipantSaveAdapter.joinRoom(roomId, member); + + // when + Optional result = roomParticipantQueryRedisAdapter.findByRoomIdAndMemberId( + roomId, "non-existent-id"); + + // then + assertThat(result).isEmpty(); + } +} diff --git a/kok-api/src/test/java/com/kok/kokapi/room/adapter/out/persistence/RoomSaveRedisAdapterTest.java b/kok-api/src/test/java/com/kok/kokapi/room/adapter/out/persistence/RoomSaveRedisAdapterTest.java new file mode 100644 index 00000000..ee49b3cd --- /dev/null +++ b/kok-api/src/test/java/com/kok/kokapi/room/adapter/out/persistence/RoomSaveRedisAdapterTest.java @@ -0,0 +1,58 @@ +package com.kok.kokapi.room.adapter.out.persistence; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kok.kokapi.common.template.RepositoryTest; +import com.kok.kokapi.fixture.MemberFixture; +import com.kok.kokapi.fixture.RoomFixture; +import com.kok.kokcore.room.domain.Room; +import java.time.LocalDateTime; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; + +class RoomSaveRedisAdapterTest extends RepositoryTest { + + private static final String ROOM_KEY_PREFIX = "room"; + + @Autowired + private RoomSaveRedisAdapter roomSaveRedisAdapter; + @Autowired + private RedisTemplate redisTemplate; + @Autowired + private ObjectMapper objectMapper; + + @Test + void updateRoom() throws Exception { + // given + Room room = RoomFixture.create(3, MemberFixture.createLeader()); + String key = buildKey(room.getId()); + roomSaveRedisAdapter.save(room); + Long ttlBefore = redisTemplate.getExpire(key); + LocalDateTime voteDeadlineBefore = room.getVoteLimitDateTime(); + + Thread.sleep(1000); + + // when + room.updateVoteDeadline(LocalDateTime.now()); + roomSaveRedisAdapter.update(room); + + // then + String result = redisTemplate.opsForValue().get(key); + Long ttlAfter = redisTemplate.getExpire(key); + LocalDateTime voteDeadlineAfter = objectMapper.readValue(result, Room.class) + .getVoteLimitDateTime(); + + assertAll( + () -> assertThat(ttlAfter).isGreaterThan(0L), + () -> assertThat(ttlAfter).isLessThan(ttlBefore), + () -> assertThat(voteDeadlineAfter).isBefore(voteDeadlineBefore) + ); + } + + private String buildKey(String roomId) { + return ROOM_KEY_PREFIX + ":" + roomId; + } +} diff --git a/kok-api/src/test/java/com/kok/kokapi/room/application/service/RoomCommandServiceTest.java b/kok-api/src/test/java/com/kok/kokapi/room/application/service/RoomCommandServiceTest.java new file mode 100644 index 00000000..673d276d --- /dev/null +++ b/kok-api/src/test/java/com/kok/kokapi/room/application/service/RoomCommandServiceTest.java @@ -0,0 +1,170 @@ +package com.kok.kokapi.room.application.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import com.kok.kokapi.centroid.adapter.out.persistence.LocationRepository; +import com.kok.kokapi.common.template.ServiceTest; +import com.kok.kokapi.fixture.MemberFixture; +import com.kok.kokapi.fixture.PointFixture; +import com.kok.kokapi.room.adapter.out.persistence.RoomParticipantSaveAdapter; +import com.kok.kokapi.room.adapter.out.persistence.RoomQueryRedisAdapter; +import com.kok.kokapi.room.adapter.out.persistence.RoomSaveRedisAdapter; +import com.kok.kokcore.location.domain.Location; +import com.kok.kokcore.room.domain.Member; +import com.kok.kokcore.room.domain.Room; +import com.kok.kokcore.room.domain.vo.MemberRole; +import com.kok.kokcore.room.domain.vo.RoomStatus; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class RoomCommandServiceTest extends ServiceTest { + + @Autowired + private RoomCommandService roomCommandService; + @Autowired + private RoomSaveRedisAdapter roomSaveRedisAdapter; + @Autowired + private RoomQueryRedisAdapter roomQueryRedisAdapter; + @Autowired + private LocationRepository locationRepository; + @Autowired + private RoomParticipantSaveAdapter roomParticipantSaveAdapter; + + private Room room; + private Member member; + private Member member2; + + @BeforeEach + void init() { + member = MemberFixture.createLeader(); + member2 = MemberFixture.createFollower(); + room = Room.create("ํˆฌํ‘œ๋ฐฉ", 2, member); + roomSaveRedisAdapter.save(room); + roomParticipantSaveAdapter.joinRoom(room.getId(), member); + roomParticipantSaveAdapter.joinRoom(room.getId(), member2); + } + + @DisplayName("์•ฝ์†๋ฐฉ์ด ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createRoom() { + String roomName = "Test Room"; + int capacity = 4; + String hostNickname = "test"; + String hostProfile = "hostProfile"; + Member host = new Member(hostNickname, hostProfile, MemberRole.LEADER); + + Room createdRoom = roomCommandService.createRoom(roomName, capacity, host); + + assertAll("Room Create Test", + () -> assertNotNull(createdRoom, "Room ๊ฐ์ฒด๋Š” null์ด ์•„๋‹ˆ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."), + () -> assertNotNull(createdRoom.getId(), "์•ฝ์†๋ฐฉ ID๋Š” null์ด ์•„๋‹ˆ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."), + () -> assertEquals(roomName, createdRoom.getRoomName(), "์•ฝ์†๋ฐฉ ์ด๋ฆ„์ด ์ผ์น˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."), + () -> assertEquals(capacity, createdRoom.getCapacity(), "์ฐธ์—ฌ ์ธ์› ์ˆ˜๊ฐ€ ์ผ์น˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."), + () -> assertNotNull(createdRoom.getMember(), "๋ฐฉ์žฅ ์ •๋ณด๋Š” null์ด ์•„๋‹ˆ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."), + () -> assertEquals(hostNickname, createdRoom.getMember().getNickname(), + "๋ฐฉ์žฅ ๋‹‰๋„ค์ž„์ด ์ผ์น˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."), + () -> assertEquals(hostProfile, createdRoom.getMember().getProfile(), + "๋ฐฉ์žฅ ํ”„๋กœํ•„์ด ์ผ์น˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."), + () -> assertEquals(MemberRole.LEADER, createdRoom.getMember().getRole(), + "๋ฐฉ์žฅ ์—ญํ• ์€ Leader์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + ); + } + + @DisplayName("๋ชจ๋“  ์ธ์›์ด ์ถœ๋ฐœ์ง€๋ฅผ ์ž…๋ ฅํ•˜๋ฉด ํˆฌํ‘œ๊ฐ€ ์‹œ์ž‘๋˜๊ณ , ๋งˆ๊ฐ ์‹œ๊ฐ„์ด ๊ฐฑ์‹ ๋œ๋‹ค.") + @Test + void startVote_whenAllLocationsInput() { + // given + Location location = new Location( + room.getId(), + member.getMemberId(), + PointFixture.create(), + "์„œ์šธ์‹œ ๋งˆํฌ๊ตฌ" + ); + Location location2 = new Location( + room.getId(), + member2.getMemberId(), + PointFixture.create(), + "์„œ์šธ์‹œ ๊ฐ•์„œ๊ตฌ" + ); + locationRepository.save(location); + locationRepository.save(location2); + LocalDateTime now = LocalDateTime.now().withNano(0); + + // when + roomCommandService.startVote(room.getId(), now); + + // then + Room updatedRoom = roomQueryRedisAdapter.findRoomById(room.getId()).get(); + + assertAll( + () -> assertThat(updatedRoom.getStatus()).isEqualTo(RoomStatus.VOTE), + () -> assertThat(updatedRoom.getVoteLimitDateTime()).isEqualTo(now.plusHours(12)) + ); + } + + @DisplayName("ํˆฌํ‘œ ์ข…๋ฃŒ ์‹œ ๋ฐฉ ์ƒํƒœ๊ฐ€ VOTE_RESULT๋กœ ๋ณ€๊ฒฝ๋œ๋‹ค.") + @Test + void closeVote() { + // given + room.startVote(); + roomSaveRedisAdapter.update(room); + + // when + roomCommandService.closeVote(room.getId()); + + // then + Room updatedRoom = roomQueryRedisAdapter.findRoomById(room.getId()).get(); + + assertThat(updatedRoom.getStatus()).isEqualTo(RoomStatus.VOTE_RESULT); + } + + @DisplayName("๋ชจ๋“  ์ธ์›์ด ์ถœ๋ฐœ์ง€๋ฅผ ์ž…๋ ฅํ•˜๋ฉด updateRoomStatus๋ฅผ ํ†ตํ•ด ์ƒํƒœ๊ฐ€ VOTE๋กœ ์ „ํ™˜๋œ๋‹ค.") + @Test + void updateRoomStatusChangesToVote() { + // given + Location location1 = new Location(room.getId(), member.getMemberId(), PointFixture.create(), + "์„œ์šธ์‹œ ๋งˆํฌ๊ตฌ"); + Location location2 = new Location(room.getId(), member2.getMemberId(), + PointFixture.create(), "์„œ์šธ์‹œ ๊ฐ•์„œ๊ตฌ"); + locationRepository.save(location1); + locationRepository.save(location2); + + // when + Room updatedRoom = roomCommandService.updateRoomStatus(room.getId(), LocalDateTime.now()); + + // then + assertThat(updatedRoom.getStatus()).isEqualTo(RoomStatus.VOTE); + } + + @DisplayName("๋ชจ๋“  ์ธ์›์ด ํˆฌํ‘œ๋ฅผ ์™„๋ฃŒํ•˜๋ฉด updateRoomStatus๋ฅผ ํ†ตํ•ด ์ƒํƒœ๊ฐ€ VOTE_RESULT๋กœ ์ „ํ™˜๋œ๋‹ค.") + @Test + void updateRoomStatusChangesToVoteResult() { + // given + LocalDateTime now = LocalDateTime.now().withNano(0); + + Location location1 = new Location(room.getId(), member.getMemberId(), PointFixture.create(), + "์„œ์šธ์‹œ ๋งˆํฌ๊ตฌ"); + Location location2 = new Location(room.getId(), member2.getMemberId(), + PointFixture.create(), "์„œ์šธ์‹œ ๊ฐ•์„œ๊ตฌ"); + locationRepository.save(location1); + locationRepository.save(location2); + roomCommandService.updateRoomStatus(room.getId(), now); + + Room voteStartedRoom = roomQueryRedisAdapter.findRoomById(room.getId()).get(); + voteStartedRoom.updateVoteDeadline(now); + voteStartedRoom.startVote(); + roomSaveRedisAdapter.update(voteStartedRoom); + + // when + Room updatedRoom = roomCommandService.updateRoomStatus(room.getId(), now.plusHours(13)); + + // then + assertThat(updatedRoom.getStatus()).isEqualTo(RoomStatus.VOTE_RESULT); + } +} diff --git a/kok-api/src/test/java/com/kok/kokapi/room/application/service/RoomQueryServiceTest.java b/kok-api/src/test/java/com/kok/kokapi/room/application/service/RoomQueryServiceTest.java new file mode 100644 index 00000000..e2f134a7 --- /dev/null +++ b/kok-api/src/test/java/com/kok/kokapi/room/application/service/RoomQueryServiceTest.java @@ -0,0 +1,47 @@ +package com.kok.kokapi.room.application.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.kok.kokapi.common.template.ServiceTest; +import com.kok.kokcore.room.domain.Member; +import com.kok.kokcore.room.domain.Room; +import com.kok.kokcore.room.domain.vo.MemberRole; +import com.kok.kokcore.room.port.out.SaveRoomPort; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class RoomQueryServiceTest extends ServiceTest { + + @Autowired + private SaveRoomPort saveRoomPort; + @Autowired + private RoomQueryService roomQueryService; + + @DisplayName("์กด์žฌํ•˜๋Š” ๋ฐฉ์˜ ์ •๋ณด๋ฅผ ์กฐํšŒํ•œ๋‹ค.") + @Test + void findRoomById() { + // given + Member member = new Member("nickname", "image", MemberRole.LEADER); + Room room = saveRoomPort.save(Room.create("room", 2, member)); + + // when + Room result = roomQueryService.findRoomById(room.getId()); + + // then + assertThat(result).isEqualTo(room); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋ฐฉ์˜ ์ •๋ณด๋ฅผ ์กฐํšŒํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void cannotFindRoomById() { + // given + String roomId = "unknownId"; + + // when & then + assertThatThrownBy(() -> roomQueryService.findRoomById(roomId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Room not found with id: " + roomId); + } +} diff --git a/kok-api/src/test/java/com/kok/kokapi/station/adapter/out/external/FakeStationClient.java b/kok-api/src/test/java/com/kok/kokapi/station/adapter/out/external/FakeStationClient.java new file mode 100644 index 00000000..1bb0b5b2 --- /dev/null +++ b/kok-api/src/test/java/com/kok/kokapi/station/adapter/out/external/FakeStationClient.java @@ -0,0 +1,19 @@ +package com.kok.kokapi.station.adapter.out.external; + +import com.kok.kokcore.station.port.out.LoadStationsPort; +import com.kok.kokcore.station.port.out.dto.StationRouteDto; +import com.kok.kokcore.station.port.out.dto.StationRouteDtos; +import java.util.List; + +public class FakeStationClient implements LoadStationsPort { + + @Override + public StationRouteDtos loadAllStations() { + return new StationRouteDtos( + List.of( + new StationRouteDto("์„œ์šธ์—ญ", "1", "2", 1L, "1ํ˜ธ์„ "), + new StationRouteDto("ํ•ฉ์ •์—ญ", "10", "10", 2L, "2ํ˜ธ์„ ") + ) + ); + } +} diff --git a/kok-api/src/test/java/com/kok/kokapi/station/adapter/out/persistence/StationPersistenceAdapterTest.java b/kok-api/src/test/java/com/kok/kokapi/station/adapter/out/persistence/StationPersistenceAdapterTest.java new file mode 100644 index 00000000..8c05ed50 --- /dev/null +++ b/kok-api/src/test/java/com/kok/kokapi/station/adapter/out/persistence/StationPersistenceAdapterTest.java @@ -0,0 +1,37 @@ +package com.kok.kokapi.station.adapter.out.persistence; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.kok.kokapi.common.template.ServiceTest; +import com.kok.kokcore.station.domain.entity.Station; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class StationPersistenceAdapterTest extends ServiceTest { + + @Autowired + private StationRepository stationRepository; + @Autowired + private StationPersistenceAdapter stationPersistenceAdapter; + + @DisplayName("์ƒˆ๋กœ ์ €์žฅ๋œ ์ง€ํ•˜์ฒ  ๋ชฉ๋ก๋งŒ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void saveStationsAndReturn() { + // given + Station savedStation = new Station("๋ง์›์—ญ", "12.345", "123.456"); + List stations = List.of(new Station("ํ•ฉ์ •์—ญ", "12.345", "123.456")); + stationRepository.save(savedStation); + + // when + List result = stationPersistenceAdapter.saveStations(stations); + + // then + assertAll( + () -> assertThat(result).hasSize(1), + () -> assertThat(result.get(0).getName()).isEqualTo("ํ•ฉ์ •์—ญ") + ); + } +} diff --git a/kok-api/src/test/java/com/kok/kokapi/station/adapter/out/persistence/StationRepositoryTest.java b/kok-api/src/test/java/com/kok/kokapi/station/adapter/out/persistence/StationRepositoryTest.java new file mode 100644 index 00000000..1eee75a2 --- /dev/null +++ b/kok-api/src/test/java/com/kok/kokapi/station/adapter/out/persistence/StationRepositoryTest.java @@ -0,0 +1,39 @@ +package com.kok.kokapi.station.adapter.out.persistence; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +import com.kok.kokapi.common.template.RepositoryTest; +import com.kok.kokcore.station.domain.entity.Station; +import java.math.BigDecimal; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class StationRepositoryTest extends RepositoryTest { + + @Autowired + private StationRepository stationRepository; + + @DisplayName("๋ฐ์ดํ„ฐ๊ฐ€ ํ•˜๋‚˜๋ผ๋„ ์กด์žฌํ•œ๋‹ค๋ฉด, ์ฐธ์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void existsAny() { + // given + stationRepository.save(new Station("์„œ์šธ์—ญ", BigDecimal.ONE, BigDecimal.ONE, 0)); + + // when + boolean result = stationRepository.existsAny(); + + // then + assertThat(result).isTrue(); + } + + @DisplayName("๋ฐ์ดํ„ฐ๊ฐ€ ํ•˜๋‚˜๋„ ์กด์žฌํ•˜์ง€ ์•Š๋Š”๋‹ค๋ฉด, ๊ฑฐ์ง“์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void notExistsAny() { + // when + boolean result = stationRepository.existsAny(); + + // then + assertThat(result).isFalse(); + } +} diff --git a/kok-api/src/test/java/com/kok/kokapi/station/application/service/StationServiceTest.java b/kok-api/src/test/java/com/kok/kokapi/station/application/service/StationServiceTest.java new file mode 100644 index 00000000..0f083958 --- /dev/null +++ b/kok-api/src/test/java/com/kok/kokapi/station/application/service/StationServiceTest.java @@ -0,0 +1,42 @@ +package com.kok.kokapi.station.application.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.kok.kokapi.common.template.ServiceTest; +import com.kok.kokapi.station.adapter.out.persistence.StationRepository; +import com.kok.kokcore.station.domain.entity.Station; +import java.math.BigDecimal; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class StationServiceTest extends ServiceTest { + + @Autowired + private StationRepository stationRepository; + @Autowired + private StationService stationService; + + @DisplayName("์ €์žฅ๋œ ์ง€ํ•˜์ฒ  ์ •๋ณด๊ฐ€ ์—†๋‹ค๋ฉด, ์ง€ํ•˜์ฒ  ์ •๋ณด(์ด 2๊ฐœ)๋ฅผ ๋ถˆ๋Ÿฌ์™€์„œ ์ €์žฅํ•œ๋‹ค.") + @Test + void saveStations() { + // when + stationService.saveStations(); + + // then + assertThat(stationRepository.findAll()).hasSize(2); + } + + @DisplayName("์ด๋ฏธ ์ง€ํ•˜์ฒ  ์ •๋ณด๊ฐ€ ์ €์žฅ๋˜์–ด ์žˆ๋‹ค๋ฉด, ์ €์žฅํ•˜์ง€ ์•Š๋Š”๋‹ค.") + @Test + void doesNotSaveStationsIfAlreadyExists() { + //given + stationRepository.save(new Station("์„œ์šธ์—ญ", BigDecimal.ONE, BigDecimal.ONE, 0)); + + // when + stationService.saveStations(); + + // then + assertThat(stationRepository.findAll()).hasSize(1); + } +} diff --git a/kok-api/src/test/java/com/kok/kokapi/vote/adapter/out/persistence/CandidateCommandRedisAdapterTest.java b/kok-api/src/test/java/com/kok/kokapi/vote/adapter/out/persistence/CandidateCommandRedisAdapterTest.java new file mode 100644 index 00000000..1b432ded --- /dev/null +++ b/kok-api/src/test/java/com/kok/kokapi/vote/adapter/out/persistence/CandidateCommandRedisAdapterTest.java @@ -0,0 +1,45 @@ +package com.kok.kokapi.vote.adapter.out.persistence; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.kok.kokapi.common.template.RepositoryTest; +import com.kok.kokcore.vote.domain.Candidate; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; + +class CandidateCommandRedisAdapterTest extends RepositoryTest { + + private static final String CANDIDATE_KEY_FORMAT = "candidate:%s"; + + @Autowired + private CandidateCommandRedisAdapter candidateCommandRedisAdapter; + @Autowired + private RedisTemplate redisTemplate; + + @DisplayName("Candidate์„ ์ €์žฅํ•œ๋‹ค.") + @Test + void saveAllCandidates() { + // given + String roomId = "roomId"; + String key = getCandidateKey(roomId); + List candidates = List.of( + new Candidate(roomId, 1), + new Candidate(roomId, 2) + ); + + // when + candidateCommandRedisAdapter.saveAll(candidates); + + // then + Set results = redisTemplate.opsForSet().members(key); + assertThat(results).containsExactlyInAnyOrder(1, 2); + } + + private String getCandidateKey(String roomId) { + return String.format(CANDIDATE_KEY_FORMAT, roomId); + } +} diff --git a/kok-api/src/test/java/com/kok/kokapi/vote/adapter/out/persistence/CandidateQueryRedisAdapterTest.java b/kok-api/src/test/java/com/kok/kokapi/vote/adapter/out/persistence/CandidateQueryRedisAdapterTest.java new file mode 100644 index 00000000..a88980c2 --- /dev/null +++ b/kok-api/src/test/java/com/kok/kokapi/vote/adapter/out/persistence/CandidateQueryRedisAdapterTest.java @@ -0,0 +1,84 @@ +package com.kok.kokapi.vote.adapter.out.persistence; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.kok.kokapi.common.template.RepositoryTest; +import com.kok.kokcore.vote.domain.Candidate; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; + +class CandidateQueryRedisAdapterTest extends RepositoryTest { + + private static final String CANDIDATE_KEY_FORMAT = "candidate:%s"; + + @Autowired + private CandidateQueryRedisAdapter candidateQueryRedisAdapter; + @Autowired + private RedisTemplate redisTemplate; + + @DisplayName("roomId๋กœ ํ›„๋ณด์ง€๋ฅผ ์กฐํšŒํ•œ๋‹ค.") + @Test + void findByRoomId() { + // given + String roomId = "roomId"; + String key = getCandidateKey(roomId); + Candidate candidate = new Candidate(roomId, 1); + Candidate candidate2 = new Candidate(roomId, 2); + Candidate candidate3 = new Candidate(roomId, 3); + redisTemplate.opsForSet().add(key, "1", "2", "3"); + + // when + List result = candidateQueryRedisAdapter.findByRoomId(roomId); + + // then + assertThat(result).containsExactlyInAnyOrder(candidate, candidate2, candidate3); + } + + @DisplayName("roomId์— ํ•ด๋‹นํ•˜๋Š” ํ›„๋ณด์ง€๊ฐ€ ์—†์œผ๋ฉด ๋นˆ ๋ฆฌ์ŠคํŠธ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void findByRoomIdWhenNotExist() { + // given + String roomId = "nonexistent"; + + // when + List result = candidateQueryRedisAdapter.findByRoomId(roomId); + + // then + assertThat(result).isEmpty(); + } + + @DisplayName("roomId์— ๋Œ€ํ•œ ํ›„๋ณด์ง€๊ฐ€ ์กด์žฌํ•œ๋‹ค.") + @Test + void isExistsByRoomId() { + // given + String roomId = "roomId"; + String key = getCandidateKey(roomId); + redisTemplate.opsForSet().add(key, "1"); + + // when + boolean result = candidateQueryRedisAdapter.isExistsByRoomId(roomId); + + // then + assertThat(result).isTrue(); + } + + @DisplayName("roomId์— ๋Œ€ํ•œ ํ›„๋ณด์ง€๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š๋Š”๋‹ค.") + @Test + void doesNotExistsByRoomId() { + // given + String roomId = "unknownRoomId"; + + // when + boolean result = candidateQueryRedisAdapter.isExistsByRoomId(roomId); + + // then + assertThat(result).isFalse(); + } + + private String getCandidateKey(String roomId) { + return String.format(CANDIDATE_KEY_FORMAT, roomId); + } +} diff --git a/kok-api/src/test/java/com/kok/kokapi/vote/adapter/out/persistence/VoteCommandRedisAdapterTest.java b/kok-api/src/test/java/com/kok/kokapi/vote/adapter/out/persistence/VoteCommandRedisAdapterTest.java new file mode 100644 index 00000000..bda8e19b --- /dev/null +++ b/kok-api/src/test/java/com/kok/kokapi/vote/adapter/out/persistence/VoteCommandRedisAdapterTest.java @@ -0,0 +1,201 @@ +package com.kok.kokapi.vote.adapter.out.persistence; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.kok.kokapi.common.template.RepositoryTest; +import com.kok.kokcore.vote.domain.Candidate; +import com.kok.kokcore.vote.domain.Vote; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; + +class VoteCommandRedisAdapterTest extends RepositoryTest { + + @Autowired + private VoteCommandRedisAdapter voteCommandRedisAdapter; + + @Autowired + private RedisTemplate redisTemplate; + + @Test + @DisplayName("๋ฉค๋ฒ„๋ณ„๋กœ ํˆฌํ‘œํ•œ ๊ณณ์„ Set์œผ๋กœ ์ €์žฅํ•œ๋‹ค.") + void saveVotes() { + // given + String roomId = "roomId"; + String memberId = "memberId"; + List stationIds = List.of(1L, 2L); + + // when + voteCommandRedisAdapter.saveVotedStationsByRoomIdAndMemberId(stationIds, roomId, memberId); + + // then + String key = VoteKey.votedStationsByMemberKey(roomId, memberId); + Set result = redisTemplate.opsForSet().members(key); + assertThat(result).containsExactlyInAnyOrder(1, 2); + } + + @Test + @DisplayName("ํˆฌํ‘œ ์™„๋ฃŒ์ž Set์— ๋ฉค๋ฒ„๋ฅผ ์ €์žฅํ•œ๋‹ค.") + void saveVotedMemberByRoomId() { + // given + String roomId = "roomId"; + String memberId = "memberId"; + + // when + voteCommandRedisAdapter.saveVotedMemberByRoomId(roomId, memberId); + + // then + Set result = redisTemplate.opsForSet() + .members(VoteKey.voteCompletedMembersKey(roomId)); + assertThat(result).containsExactlyInAnyOrder(memberId); + } + + @Test + @DisplayName("ํŠน์ • ํ›„๋ณด์ง€์— ํˆฌํ‘œํ•œ ๋ฉค๋ฒ„๋ฅผ ์ €์žฅํ•œ๋‹ค.") + void saveVotedMemberByRoomIdByRoomIdAndStationId() { + // given + String roomId = "roomId"; + long stationId = 100; + String memberId = "memberId"; + Vote vote = new Vote(new Candidate(roomId, stationId), memberId); + + // when + voteCommandRedisAdapter.saveVotedMemberByRoomIdAndStationId(memberId, roomId, stationId); + + // then + Set result = redisTemplate.opsForSet() + .members(VoteKey.votedMembersOfStationKey(vote)); + assertThat(result).containsExactlyInAnyOrder("memberId"); + } + + @Test + @DisplayName("ํŠน์ • ํ›„๋ณด์ง€์˜ ๋“ํ‘œ์ˆ˜๋ฅผ 1 ์ฆ๊ฐ€์‹œํ‚จ๋‹ค.") + void incrementVoteStatusCountZSet() { + // given + String roomId = "roomId"; + long stationId = 100; + Vote vote = new Vote(new Candidate(roomId, stationId), "memberId"); + + // when + voteCommandRedisAdapter.increaseVotedCountByRoomIdAndStationId(roomId, stationId); + + // then + Double score = redisTemplate.opsForZSet() + .score(VoteKey.votedCountOfStationKey(roomId), vote.getStationId()); + + assertThat(score).isEqualTo(1.0); + } + + @Test + @DisplayName("ZSet์—์„œ ๋“ํ‘œ์ˆ˜๋ฅผ 1 ๊ฐ์†Œ์‹œํ‚จ๋‹ค.") + void decreaseVotedCountByRoomIdAndStationId() { + // given + String roomId = "roomId"; + long stationId = 100; + Vote vote = new Vote(new Candidate(roomId, stationId), "memberId"); + String key = VoteKey.votedCountOfStationKey(roomId); + redisTemplate.opsForZSet().add(key, vote.getStationId(), 2.0); + + // when + voteCommandRedisAdapter.decreaseVotedCountByRoomIdAndStationId(roomId, stationId); + + // then + Double score = redisTemplate.opsForZSet().score(key, vote.getStationId()); + assertThat(score).isEqualTo(1.0); + } + + @Test + @DisplayName("ํ›„๋ณด์ง€์— ํˆฌํ‘œํ•œ ๋ฉค๋ฒ„ Set์—์„œ ๋ฉค๋ฒ„๋ฅผ ์ œ๊ฑฐํ•œ๋‹ค.") + void deleteVotedMemberByRoomIdAndStationId() { + // given + String roomId = "roomId"; + String memberId = "memberId"; + long stationId = 100; + Vote vote = new Vote(new Candidate(roomId, stationId), memberId); + String key = VoteKey.votedMembersOfStationKey(vote); + redisTemplate.opsForSet().add(key, memberId); + + // when + voteCommandRedisAdapter.deleteVotedMemberByRoomIdAndStationId(memberId, roomId, stationId); + + // then + Set result = redisTemplate.opsForSet().members(key); + assertThat(result).doesNotContain("memberId"); + } + + @Test + @DisplayName("๋ฉค๋ฒ„๊ฐ€ ํˆฌํ‘œํ•œ ํ›„๋ณด์ง€ Set์„ ์‚ญ์ œํ•œ๋‹ค.") + void deleteVotedStationsByRoomIdAndMemberId() { + // given + String roomId = "roomId"; + String memberId = "memberId"; + String key = VoteKey.votedStationsByMemberKey(roomId, memberId); + redisTemplate.opsForHash().put(key, "1", "AGREE"); + + // when + voteCommandRedisAdapter.deleteVotedStationsByRoomIdAndMemberId(roomId, memberId); + + // then + assertAll( + () -> assertThat(redisTemplate.hasKey(key)).isFalse(), + () -> assertThat(redisTemplate.opsForHash().size(key)).isZero() + ); + } + + @Test + @DisplayName("ํˆฌํ‘œ ์™„๋ฃŒ์ž Set์—์„œ ๋ฉค๋ฒ„๋ฅผ ์ œ๊ฑฐํ•œ๋‹ค.") + void deleteVotedMemberByRoomId() { + // given + String roomId = "roomId"; + String memberId = "memberId"; + String key = VoteKey.voteCompletedMembersKey(roomId); + redisTemplate.opsForSet().add(key, memberId); + + // when + voteCommandRedisAdapter.deleteVotedMemberByRoomId(roomId, memberId); + + // then + Set result = redisTemplate.opsForSet().members(key); + assertThat(result).doesNotContain(memberId); + } + + @Test + @DisplayName("์ฃผ์–ด์ง„ stationId์— ๋Œ€ํ•ด ๋“ํ‘œ ์ˆ˜๊ฐ€ 0์œผ๋กœ Zset์„ ์ดˆ๊ธฐํ™”ํ•œ๋‹ค.") + void initiateVoteCountByRoomIdAndStationIds() { + // given + String roomId = "roomId"; + List stationIds = List.of(1L, 2L, 3L); + String key = VoteKey.votedCountOfStationKey(roomId); + + // when + voteCommandRedisAdapter.initiateVoteCountByRoomIdAndStationIds(roomId, stationIds); + + // then + assertAll( + () -> assertThat(redisTemplate.opsForZSet().score(key, 1L)).isEqualTo(0.0), + () -> assertThat(redisTemplate.opsForZSet().score(key, 2L)).isEqualTo(0.0), + () -> assertThat(redisTemplate.opsForZSet().score(key, 3L)).isEqualTo(0.0) + ); + } + + @Test + @DisplayName("์ด๋ฏธ ๋“ํ‘œ ์ˆ˜๊ฐ€ ์กด์žฌํ•˜๋Š” ๊ฒฝ์šฐ ZSet ์ดˆ๊ธฐํ™” ์‹œ ๊ฐ’์„ ๋ฎ์–ด์“ฐ์ง€ ์•Š๋Š”๋‹ค.") + void doesNotInitiateVoteCountDoesNotOverrideExistingScoreIfPresent() { + // given + String roomId = "roomId"; + long stationId = 100; + String key = VoteKey.votedCountOfStationKey(roomId); + redisTemplate.opsForZSet().add(key, stationId, 5.0); + + // when + voteCommandRedisAdapter.initiateVoteCountByRoomIdAndStationIds(roomId, List.of(stationId)); + + // then + Double score = redisTemplate.opsForZSet().score(key, stationId); + assertThat(score).isEqualTo(5.0); + } +} diff --git a/kok-api/src/test/java/com/kok/kokapi/vote/adapter/out/persistence/VoteQueryRedisAdapterTest.java b/kok-api/src/test/java/com/kok/kokapi/vote/adapter/out/persistence/VoteQueryRedisAdapterTest.java new file mode 100644 index 00000000..ac737735 --- /dev/null +++ b/kok-api/src/test/java/com/kok/kokapi/vote/adapter/out/persistence/VoteQueryRedisAdapterTest.java @@ -0,0 +1,141 @@ +package com.kok.kokapi.vote.adapter.out.persistence; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.kok.kokapi.common.template.RepositoryTest; +import com.kok.kokcore.vote.domain.Vote; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; + +class VoteQueryRedisAdapterTest extends RepositoryTest { + + @Autowired + private VoteQueryRedisAdapter voteQueryRedisAdapter; + + @Autowired + private RedisTemplate redisTemplate; + + @Test + @DisplayName("roomId์™€ memberId ์กฐํ•ฉ์œผ๋กœ ํˆฌํ‘œ ์ •๋ณด๊ฐ€ ์กด์žฌํ•˜๋Š”์ง€ ํ™•์ธํ•œ๋‹ค.") + void isExistsByRoomIdAndMemberId() { + // given + String roomId = "room"; + String memberId = "member"; + String key = VoteKey.votedStationsByMemberKey(roomId, memberId); + redisTemplate.opsForSet().add(key, 1); + + // when + boolean result = voteQueryRedisAdapter.isExistsByRoomIdAndMemberId(roomId, memberId); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("ํˆฌํ‘œ ์ •๋ณด๊ฐ€ ์—†์œผ๋ฉด false๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + void isNotExistsByRoomIdAndMemberId() { + assertThat(voteQueryRedisAdapter.isExistsByRoomIdAndMemberId("roomX", "memberY")).isFalse(); + } + + @Test + @DisplayName("roomId, memberId๋กœ ํˆฌํ‘œ ์ „์ฒด ๋ชฉ๋ก์„ ์กฐํšŒํ•œ๋‹ค.") + void findAllByRoomIdAndMemberId() { + // given + String roomId = "room2"; + String memberId = "memberB"; + String key = VoteKey.votedStationsByMemberKey(roomId, memberId); + Vote vote = new Vote(roomId, 1L, memberId); + Vote vote2 = new Vote(roomId, 2L, memberId); + redisTemplate.opsForSet().add(key, 1, 2); + + // when + List result = voteQueryRedisAdapter.findAllByRoomIdAndMemberId(roomId, memberId); + + // then + assertThat(result).hasSize(2) + .containsExactlyInAnyOrder(vote, vote2); + } + + @Test + @DisplayName("roomId์— ๋Œ€ํ•ด ํˆฌํ‘œ ์™„๋ฃŒํ•œ ๋ฉค๋ฒ„ ์ˆ˜๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + void countVotedMembersByRoomId() { + // given + String roomId = "room3"; + redisTemplate.opsForSet() + .add(VoteKey.voteCompletedMembersKey(roomId), "member1", "member2", "member3"); + + // when + int count = voteQueryRedisAdapter.countVotedMembersByRoomId(roomId); + + // then + assertThat(count).isEqualTo(3); + } + + @Test + @DisplayName("ํŠน์ • stationId์— ๋Œ€ํ•ด ํˆฌํ‘œํ•œ memberId ๋ฆฌ์ŠคํŠธ๋ฅผ ์กฐํšŒํ•œ๋‹ค.") + void findMemberIdsByRoomIdAndStationId() { + // given + String roomId = "room4"; + long stationId = 11L; + String key = VoteKey.votedMembersOfStationKey(roomId, stationId); + redisTemplate.opsForSet().add(key, "member1", "member2"); + + // when + List result = voteQueryRedisAdapter.findMemberIdsByRoomIdAndStationId(roomId, + stationId); + + // then + assertThat(result).containsExactlyInAnyOrder("member1", "member2"); + } + + @Test + @DisplayName("์ฐฌ์„ฑ ์ˆ˜๊ฐ€ ๊ฐ€์žฅ ๋งŽ์€ stationId๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + void findFirstStationIdByRoomIdOrderByVotedCount() { + // given + String roomId = "room5"; + String key = VoteKey.votedCountOfStationKey(roomId); + redisTemplate.opsForZSet().add(key, "10", 5.0); + redisTemplate.opsForZSet().add(key, "11", 8.0); + + // when + long result = voteQueryRedisAdapter.findFirstStationIdByRoomIdOrderByVotedCount(roomId); + + // then + assertThat(result).isEqualTo(11); + } + + @Test + @DisplayName("์ฐฌ์„ฑ ํˆฌํ‘œ์ž๊ฐ€ ์•„๋ฌด๋„ ์—†์œผ๋ฉด -1์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + void getFirstStationIdWhenNoVotes() { + // given + String roomId = "room6"; + String key = VoteKey.votedCountOfStationKey(roomId); + redisTemplate.delete(key); + + // when + long result = voteQueryRedisAdapter.findFirstStationIdByRoomIdOrderByVotedCount(roomId); + + // then + assertThat(result).isEqualTo(-1L); + } + + @Test + @DisplayName("roomId์— ๋Œ€ํ•œ stationId๋“ค์„ ์ฐฌ์„ฑ ์ˆ˜ ๋‚ด๋ฆผ์ฐจ์ˆœ์œผ๋กœ ์กฐํšŒํ•œ๋‹ค.") + void findStationIdsByRoomIdOrderByVotedCount() { + // given + String roomId = "room7"; + String key = VoteKey.votedCountOfStationKey(roomId); + redisTemplate.opsForZSet().add(key, "10", 5.0); + redisTemplate.opsForZSet().add(key, "11", 8.0); + redisTemplate.opsForZSet().add(key, "12", 2.0); + + // when + List result = voteQueryRedisAdapter.findStationIdsByRoomIdOrderByVotedCount(roomId); + + // then + assertThat(result).containsExactly(11L, 10L, 12L); + } +} diff --git a/kok-api/src/test/java/com/kok/kokapi/vote/application/service/VoteServiceTest.java b/kok-api/src/test/java/com/kok/kokapi/vote/application/service/VoteServiceTest.java new file mode 100644 index 00000000..fb131213 --- /dev/null +++ b/kok-api/src/test/java/com/kok/kokapi/vote/application/service/VoteServiceTest.java @@ -0,0 +1,188 @@ +package com.kok.kokapi.vote.application.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.kok.kokapi.common.template.ServiceTest; +import com.kok.kokapi.fixture.MemberFixture; +import com.kok.kokapi.room.adapter.out.persistence.RoomParticipantSaveAdapter; +import com.kok.kokapi.room.adapter.out.persistence.RoomSaveRedisAdapter; +import com.kok.kokapi.station.adapter.out.persistence.StationRepository; +import com.kok.kokapi.vote.adapter.out.persistence.CandidateCommandRedisAdapter; +import com.kok.kokapi.vote.adapter.out.persistence.VoteKey; +import com.kok.kokcore.room.domain.Member; +import com.kok.kokcore.room.domain.Room; +import com.kok.kokcore.station.domain.entity.Station; +import com.kok.kokcore.vote.VoteResults; +import com.kok.kokcore.vote.domain.Candidate; +import com.kok.kokcore.vote.domain.Vote; +import java.math.BigDecimal; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; + +class VoteServiceTest extends ServiceTest { + + @Autowired + private VoteService voteService; + @Autowired + private RedisTemplate redisTemplate; + @Autowired + private RoomSaveRedisAdapter roomSaveRedisAdapter; + @Autowired + private RoomParticipantSaveAdapter roomParticipantSaveAdapter; + @Autowired + private CandidateCommandRedisAdapter candidateCommandRedisAdapter; + @Autowired + private StationRepository stationRepository; + + private Room room; + private Member member; + private Member member2; + private Candidate candidate; + private Candidate candidate2; + private Station station; + private Station station2; + + @BeforeEach + void init() { + station = stationRepository.save( + new Station("station", BigDecimal.valueOf(33), BigDecimal.valueOf(127), 3)); + station2 = stationRepository.save( + new Station("station2", BigDecimal.valueOf(32), BigDecimal.valueOf(128), 2)); + member = MemberFixture.createLeader(); + member2 = MemberFixture.createFollower(); + room = Room.create("room", 3, member); + room.startVote(); + roomSaveRedisAdapter.save(room); + roomParticipantSaveAdapter.joinRoom(room.getId(), member); + roomParticipantSaveAdapter.joinRoom(room.getId(), member2); + candidate = new Candidate(room.getId(), station.getId()); + candidate2 = new Candidate(room.getId(), station2.getId()); + candidateCommandRedisAdapter.saveAll(List.of(candidate, candidate2)); + } + + @DisplayName("์‚ฌ์šฉ์ž ํˆฌํ‘œ ์ •๋ณด๋ฅผ ํ›„๋ณด๋ณ„/์‚ฌ์šฉ์ž๋ณ„๋กœ ๋ชจ๋‘ ์ €์žฅํ•œ๋‹ค.") + @Test + void saveVotes() { + // when + voteService.saveVotes(room.getId(), member.getMemberId(), List.of(1L)); + + // then + String memberKey = VoteKey.votedStationsByMemberKey(room.getId(), member.getMemberId()); + String votedMembersKey = VoteKey.votedMembersOfStationKey( + new Vote(candidate, member.getMemberId())); + + Set storedVotes = redisTemplate.opsForSet().members(memberKey); + Set votedMemberIds = redisTemplate.opsForSet().members(votedMembersKey); + + assertAll(() -> assertThat(storedVotes).containsExactlyInAnyOrder(1), + () -> assertThat(votedMemberIds).containsExactlyInAnyOrder(member.getMemberId())); + } + + @DisplayName("์‚ฌ์šฉ์ž ํˆฌํ‘œ ์ •๋ณด๋ฅผ ์ €์žฅํ•˜๊ธฐ ์ „์— ์ด์ „ ํˆฌํ‘œ ๋‚ด์—ญ์„ ์‚ญ์ œํ•œ๋‹ค.") + @Test + void initiateBeforeSaveVote() { + // given + voteService.saveVotes(room.getId(), member.getMemberId(), List.of(1L)); + + // when + voteService.saveVotes(room.getId(), member.getMemberId(), List.of(2L, 3L)); + + // then + String memberKey = VoteKey.votedStationsByMemberKey(room.getId(), member.getMemberId()); + String votedMembersKey = VoteKey.votedMembersOfStationKey( + new Vote(room.getId(), 1L, member.getMemberId())); + + Long memberSize = redisTemplate.opsForSet().size(memberKey); + Long votedMembersSize = redisTemplate.opsForSet().size(votedMembersKey); + + assertAll(() -> assertThat(memberSize).isEqualTo(2), + () -> assertThat(votedMembersSize).isZero()); + } + + @DisplayName("์‚ฌ์šฉ์ž๊ฐ€ ํˆฌํ‘œ๋ฅผ ์™„๋ฃŒํ–ˆ์œผ๋ฉด true๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void isVotedByMember() { + // given + voteService.saveVotes(room.getId(), member.getMemberId(), List.of(1L)); + + // when + boolean result = voteService.isVotedByMember(room.getId(), member.getMemberId()); + + // then + assertThat(result).isTrue(); + } + + @DisplayName("์‚ฌ์šฉ์ž๊ฐ€ ํˆฌํ‘œ๋ฅผ ์™„๋ฃŒํ•˜์ง€ ์•Š์•˜์œผ๋ฉด false๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void isNotVotedByMember() { + // when + boolean result = voteService.isVotedByMember(room.getId(), member.getMemberId()); + + // then + assertThat(result).isFalse(); + } + + @DisplayName("๋ฐฉ์— ํˆฌํ‘œ๋ฅผ ์™„๋ฃŒํ•œ ์ธ์› ์ˆ˜๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void countVotedMembers() { + // given + voteService.saveVotes(room.getId(), member.getMemberId(), List.of(1L)); + voteService.saveVotes(room.getId(), member2.getMemberId(), List.of(2L)); + + // when + int count = voteService.countVotedMembers(room.getId()); + + // then + assertThat(count).isEqualTo(2); + } + + @DisplayName("๋ฐฉ์ด ํˆฌํ‘œ ์ƒํƒœ๊ฐ€ ์•„๋‹ˆ๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwExceptionWhenRoomNotInVoteStatus() { + Room locationInputRoom = Room.create("inputRoom", 3, member); + roomSaveRedisAdapter.save(locationInputRoom); + + assertThatThrownBy( + () -> voteService.countVotedMembers(locationInputRoom.getId())).isInstanceOf( + IllegalStateException.class).hasMessageContaining("Room is not on vote status"); + } + + @DisplayName("๋ฐฉ์— ์†ํ•˜์ง€ ์•Š์€ ๋ฉค๋ฒ„๊ฐ€ ํˆฌํ‘œํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwExceptionWhenMemberNotInRoom() { + String nonParticipantId = "unknown"; + + assertThatThrownBy( + () -> voteService.saveVotes(room.getId(), nonParticipantId, List.of())).isInstanceOf( + IllegalArgumentException.class).hasMessageContaining("Member not found with id"); + } + + @DisplayName("roomId๋กœ ํˆฌํ‘œ ๊ฒฐ๊ณผ๋ฅผ ์กฐํšŒํ•˜๋ฉด ์ฐฌ์„ฑ ์ˆ˜ ๋‚ด๋ฆผ์ฐจ์ˆœ์œผ๋กœ ์ •๋ ฌ๋œ ํ›„๋ณด ์ •๋ณด๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค.") + @Test + void getVoteResultsByRoomId() { + // given + voteService.saveVotes(room.getId(), member.getMemberId(), + List.of(candidate2.getStationId())); + voteService.saveVotes(room.getId(), member2.getMemberId(), + List.of(candidate.getStationId(), candidate2.getStationId())); + + // when + VoteResults results = voteService.getVoteResultsByRoomId(room.getId()); + + // then + assertAll( + () -> assertThat(results.getVoteResults()).hasSize(2), + () -> assertThat(results.getVoteResults().getFirst().getCandidate().getStationId()) + .isEqualTo(station2.getId()), + () -> assertThat(results.getVoteResults().getLast().getCandidate().getStationId()) + .isEqualTo(station.getId()) + ); + } +} diff --git a/kok-api/src/test/java/com/kok/kokcore/vote/VoteResultsTest.java b/kok-api/src/test/java/com/kok/kokcore/vote/VoteResultsTest.java new file mode 100644 index 00000000..52b0d278 --- /dev/null +++ b/kok-api/src/test/java/com/kok/kokcore/vote/VoteResultsTest.java @@ -0,0 +1,81 @@ +package com.kok.kokcore.vote; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.kok.kokcore.vote.domain.VoteResult; +import com.kok.kokcore.vote.domain.vo.ResultTag; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class VoteResultsTest { + + @DisplayName("์ฃผ์–ด์ง„ ํˆฌํ‘œ ๊ฒฐ๊ณผ๋ฅผ ํˆฌํ‘œ ์ˆ˜๊ฐ€ ๊ฐ™๋‹ค๋ฉด, ๊ฐ€์ค‘์น˜ ์ˆœ์œผ๋กœ ์ •๋ ฌํ•œ๋‹ค.") + @Test + void orderByPriorityIfVoteCountSame() { + VoteResult voteResult = new VoteResult("roomId", 1, List.of("1"), 3); + VoteResult voteResult2 = new VoteResult("roomId", 1, List.of("1"), 4); + VoteResults voteResults = new VoteResults(List.of(voteResult, voteResult2)); + + assertThat(voteResults.getVoteResults()).containsExactly(voteResult2, voteResult); + } + + @DisplayName("์ตœ๊ณ  ๋“ํ‘œ ํ›„๋ณด์ง€๊ฐ€ 1๊ฐœ์ธ ๊ฒฝ์šฐ TOP ํƒœ๊ทธ๊ฐ€ ๋ถ€์—ฌ๋œ๋‹ค.") + @Test + void applyResultTagWithSingleTopVoted() { + // given + VoteResult voteResult = new VoteResult("roomId", 1, List.of("a", "b", "c"), 1); + VoteResult voteResult2 = new VoteResult("roomId", 2, List.of("a", "b"), 2); + VoteResult voteResult3 = new VoteResult("roomId", 3, List.of("a"), 3); + VoteResults voteResults = new VoteResults(List.of(voteResult, voteResult2, voteResult3)); + + // when + voteResults.applyResultTag(); + + // then + assertAll( + () -> assertThat(voteResults.getVoteResults().get(0).getVotedCount()) + .isEqualTo(3), + () -> assertThat(voteResults.getVoteResults().get(0).getResultTag()) + .isEqualTo(ResultTag.TOP), + () -> assertThat(voteResults.getVoteResults().get(1).getVotedCount()) + .isEqualTo(2), + () -> assertThat(voteResults.getVoteResults().get(1).getResultTag()) + .isEqualTo(ResultTag.NONE), + () -> assertThat(voteResults.getVoteResults().get(2).getVotedCount()) + .isEqualTo(1), + () -> assertThat(voteResults.getVoteResults().get(2).getResultTag()) + .isEqualTo(ResultTag.NONE) + ); + } + + @DisplayName("์ตœ๊ณ  ๋“ํ‘œ ํ›„๋ณด์ง€๊ฐ€ ์—ฌ๋Ÿฌ ๊ฐœ์ด๋ฉด ๋ชจ๋‘์—๊ฒŒ CLOSE ํƒœ๊ทธ๊ฐ€ ๋ถ€์—ฌ๋œ๋‹ค.") + @Test + void applyResultTagWithTiedTopVotes() { + // given + VoteResult close = new VoteResult("roomId", 1, List.of("a", "b"), 1); + VoteResult close2 = new VoteResult("roomId", 2, List.of("a", "b"), 2); + VoteResult low = new VoteResult("roomId", 3, List.of("a"), 3); + VoteResults voteResults = new VoteResults(List.of(close, close2, low)); + + // when + voteResults.applyResultTag(); + + // then + assertAll( + () -> assertThat(voteResults.getVoteResults().get(0).getVotedCount()) + .isEqualTo(2), + () -> assertThat(voteResults.getVoteResults().get(0).getResultTag()) + .isEqualTo(ResultTag.CLOSE), + () -> assertThat(voteResults.getVoteResults().get(1).getVotedCount()) + .isEqualTo(2), + () -> assertThat(voteResults.getVoteResults().get(1).getResultTag()) + .isEqualTo(ResultTag.CLOSE), + () -> assertThat(voteResults.getVoteResults().get(2).getVotedCount()) + .isEqualTo(1), + () -> assertThat(voteResults.getVoteResults().get(2).getResultTag()) + .isEqualTo(ResultTag.NONE) + ); + } +} diff --git a/kok-api/src/test/resources/application-test.yml b/kok-api/src/test/resources/application-test.yml new file mode 100644 index 00000000..eb963837 --- /dev/null +++ b/kok-api/src/test/resources/application-test.yml @@ -0,0 +1,36 @@ +spring: + profiles: + active: test + flyway: + enabled: true + connect-retries: 30 + locations: classpath:db/migration + baseline-on-migrate: true + jpa: + show-sql: true + properties: + hibernate: + show_sql: true + format_sql: true + dialect: org.hibernate.spatial.dialect.mysql.MySQLSpatialDialect + defer-datasource-initialization: false + open-in-view: false + hibernate: + ddl-auto: validate +### ์‹ค์ œ ์™ธ๋ถ€ API ํ˜ธ์ถœ์„ ํ”ผํ•˜๊ธฐ ์œ„ํ•ด ๊ฐ€์งœ ๊ฐ’์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. +ncp: + object-storage-url: mock +tmap-sub: + key: mock + url: mock + keyname: mock +tmap-complex: + key: mock + url: mock + keyname: mock +google: + places: + api: + key: mock +swagger: + appname: mock diff --git a/kok-core/build.gradle b/kok-core/build.gradle index c58c530f..9892958b 100644 --- a/kok-core/build.gradle +++ b/kok-core/build.gradle @@ -1,28 +1,28 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.4.2' - id 'io.spring.dependency-management' version '1.1.7' + id 'java' + id 'org.springframework.boot' version '3.4.2' + id 'io.spring.dependency-management' version '1.1.7' } group = 'com.kok' version = '0.0.1-SNAPSHOT' java { - toolchain { - languageVersion = JavaLanguageVersion.of(21) - } + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } } repositories { - mavenCentral() + mavenCentral() } dependencies { - implementation 'org.springframework.boot:spring-boot-starter' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + implementation 'org.springframework.boot:spring-boot-starter' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform() } diff --git a/kok-core/src/main/java/com/kok/kokcore/KokCoreApplication.java b/kok-core/src/main/java/com/kok/kokcore/KokCoreApplication.java index a11e019f..a3f2659b 100644 --- a/kok-core/src/main/java/com/kok/kokcore/KokCoreApplication.java +++ b/kok-core/src/main/java/com/kok/kokcore/KokCoreApplication.java @@ -6,8 +6,8 @@ @SpringBootApplication public class KokCoreApplication { - public static void main(String[] args) { - SpringApplication.run(KokCoreApplication.class, args); - } + public static void main(String[] args) { + SpringApplication.run(KokCoreApplication.class, args); + } } diff --git a/kok-core/src/main/java/com/kok/kokcore/location/domain/Location.java b/kok-core/src/main/java/com/kok/kokcore/location/domain/Location.java new file mode 100644 index 00000000..17c3f337 --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/location/domain/Location.java @@ -0,0 +1,57 @@ +package com.kok.kokcore.location.domain; + +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 jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.locationtech.jts.geom.Point; + + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "location", + uniqueConstraints = { + @UniqueConstraint(columnNames = {"room_id", "member_id"}) + }) + +public class Location { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String roomId; + + @Column(nullable = false) + private String memberId; + + @Column(nullable = false, columnDefinition = "POINT SRID 4326") + private Point location_point; + + @Column(nullable = false) + private String name; + + public Location(String roomId, String memberId, Point location_point, String name) { + this.roomId = roomId; + this.memberId = memberId; + this.location_point = location_point; + this.name = name; + } + + // ๋”ํ‹ฐ์ฒดํ‚น + public void changePoint(Point point) { + this.location_point = point; + } + + public void changeName(String name) { + this.name = name; + } +} diff --git a/kok-core/src/main/java/com/kok/kokcore/location/port/out/ReadCentroidPort.java b/kok-core/src/main/java/com/kok/kokcore/location/port/out/ReadCentroidPort.java new file mode 100644 index 00000000..0b659b96 --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/location/port/out/ReadCentroidPort.java @@ -0,0 +1,8 @@ +package com.kok.kokcore.location.port.out; + +import org.locationtech.jts.geom.Point; + +public interface ReadCentroidPort { + + Point findCentroidByRoomId(String roomId); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/location/port/out/ReadLocationPort.java b/kok-core/src/main/java/com/kok/kokcore/location/port/out/ReadLocationPort.java new file mode 100644 index 00000000..46d9fb5b --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/location/port/out/ReadLocationPort.java @@ -0,0 +1,18 @@ +package com.kok.kokcore.location.port.out; + +import com.kok.kokcore.location.domain.Location; +import java.util.List; +import java.util.Optional; + +public interface ReadLocationPort { + + Optional findLocationByRoomIdAndMemberId(String roomId, String memberId); + + List findLocationsByRoomId(String roomId); + + List findInsideConvexHull(String roomId); + + List findConvexHull(String roomId); + + long countParticipantsById(String roomId); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/location/port/out/SaveLocationPort.java b/kok-core/src/main/java/com/kok/kokcore/location/port/out/SaveLocationPort.java new file mode 100644 index 00000000..8d5e5d25 --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/location/port/out/SaveLocationPort.java @@ -0,0 +1,9 @@ +package com.kok.kokcore.location.port.out; + +import com.kok.kokcore.location.domain.Location; +import org.locationtech.jts.geom.Point; + +public interface SaveLocationPort { + + Location saveLocation(String roomId, String memberId, Point point, String name); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/location/usecase/CreateLocationUseCase.java b/kok-core/src/main/java/com/kok/kokcore/location/usecase/CreateLocationUseCase.java new file mode 100644 index 00000000..2a4df22c --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/location/usecase/CreateLocationUseCase.java @@ -0,0 +1,14 @@ +package com.kok.kokcore.location.usecase; + +import com.kok.kokcore.location.domain.Location; +import java.math.BigDecimal; + + +public interface CreateLocationUseCase { + + Location createLocation(String roomId, String memberId, BigDecimal latitude, + BigDecimal longitude, String name); + + Location updateLocation(String roomId, String memberId, BigDecimal latitude, + BigDecimal longitude, String name); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/location/usecase/LoadCentroidUseCase.java b/kok-core/src/main/java/com/kok/kokcore/location/usecase/LoadCentroidUseCase.java new file mode 100644 index 00000000..bba313b4 --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/location/usecase/LoadCentroidUseCase.java @@ -0,0 +1,12 @@ +package com.kok.kokcore.location.usecase; + +import java.math.BigDecimal; +import org.locationtech.jts.geom.Point; +import org.springframework.data.util.Pair; + +public interface LoadCentroidUseCase { + + Point readCentroid(String roomId); + + Pair readCentroidCoordinates(String roomId); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/location/usecase/ReadLocationUseCase.java b/kok-core/src/main/java/com/kok/kokcore/location/usecase/ReadLocationUseCase.java new file mode 100644 index 00000000..9cb06333 --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/location/usecase/ReadLocationUseCase.java @@ -0,0 +1,15 @@ +package com.kok.kokcore.location.usecase; + +import com.kok.kokcore.location.domain.Location; +import java.util.List; + +public interface ReadLocationUseCase { + + Location readLocation(String roomId, String memberId); + + List readLocations(String roomId); + + List readInsideConvexHull(String roomId); + + List readConvexHull(String roomId); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/places/domain/model/Place.java b/kok-core/src/main/java/com/kok/kokcore/places/domain/model/Place.java new file mode 100644 index 00000000..a707f45f --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/places/domain/model/Place.java @@ -0,0 +1,17 @@ +package com.kok.kokcore.places.domain.model; + +import com.kok.kokcore.places.domain.model.vo.PlaceType; +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; + +@Getter +@Builder +@ToString +public class Place { + private final String name; + private final String address; + private final double latitude; + private final double longitude; + private final PlaceType placeType; +} \ No newline at end of file diff --git a/kok-core/src/main/java/com/kok/kokcore/places/domain/model/PlacesResult.java b/kok-core/src/main/java/com/kok/kokcore/places/domain/model/PlacesResult.java new file mode 100644 index 00000000..7e8aaa4d --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/places/domain/model/PlacesResult.java @@ -0,0 +1,7 @@ +package com.kok.kokcore.places.domain.model; + +import java.util.List; + +public record PlacesResult( + List places +) { } \ No newline at end of file diff --git a/kok-core/src/main/java/com/kok/kokcore/places/domain/model/vo/PlaceType.java b/kok-core/src/main/java/com/kok/kokcore/places/domain/model/vo/PlaceType.java new file mode 100644 index 00000000..9e9dd1ec --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/places/domain/model/vo/PlaceType.java @@ -0,0 +1,42 @@ +package com.kok.kokcore.places.domain.model.vo; + +import java.util.List; +import lombok.Getter; + +/** + * Place ์œ ํ˜• Enum + */ +@Getter +public enum PlaceType { + ACTIVITY(List.of( + "amusement_park", "aquarium", "art_gallery", "bowling_alley", "gym", "movie_theater", + "museum", "night_club", "spa", "stadium", "tourist_attraction", "zoo" + )), + RESTAURANT(List.of( + "restaurant", "meal_delivery", "meal_takeaway" + )), + CAFE(List.of( + "cafe", "bakery" + )), + BAR(List.of("bar")), + PARK(List.of( + "park", "campground", "rv_park" + )), + CULTURE(List.of( + "library", "church", "hindu_temple", "mosque", "synagogue" + )), + EVENT(List.of( + "casino", "movie_theater", "stadium" + )), + SHOPPING(List.of( + "shopping_mall", "clothing_store", "convenience_store", "department_store", + "electronics_store", "furniture_store", "hardware_store", "home_goods_store", + "jewelry_store", "liquor_store", "shoe_store", "store", "supermarket" + )); + + private final List placeCategories; + + PlaceType(List placeCategories) { + this.placeCategories = placeCategories; + } +} \ No newline at end of file diff --git a/kok-core/src/main/java/com/kok/kokcore/places/port/in/PlaceInput.java b/kok-core/src/main/java/com/kok/kokcore/places/port/in/PlaceInput.java new file mode 100644 index 00000000..ce6501ec --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/places/port/in/PlaceInput.java @@ -0,0 +1,14 @@ +package com.kok.kokcore.places.port.in; + +import com.kok.kokcore.places.domain.model.vo.PlaceType; + +public record PlaceInput( + PlaceType placeType, + double latitude, + double longitude, + Integer maxCount +) { + public PlaceInput { + maxCount = (maxCount == null) ? 20 : maxCount; + } +} \ No newline at end of file diff --git a/kok-core/src/main/java/com/kok/kokcore/places/port/out/LoadPlacesPort.java b/kok-core/src/main/java/com/kok/kokcore/places/port/out/LoadPlacesPort.java new file mode 100644 index 00000000..65ead4f4 --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/places/port/out/LoadPlacesPort.java @@ -0,0 +1,9 @@ +package com.kok.kokcore.places.port.out; + + +import com.kok.kokcore.places.port.in.PlaceInput; +import com.kok.kokcore.places.domain.model.PlacesResult; + +public interface LoadPlacesPort { + PlacesResult getPlaces(PlaceInput input); +} \ No newline at end of file diff --git a/kok-core/src/main/java/com/kok/kokcore/places/usecase/SearchPlaceUseCase.java b/kok-core/src/main/java/com/kok/kokcore/places/usecase/SearchPlaceUseCase.java new file mode 100644 index 00000000..19e2e3ee --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/places/usecase/SearchPlaceUseCase.java @@ -0,0 +1,12 @@ +package com.kok.kokcore.places.usecase; + +import com.kok.kokcore.places.port.in.PlaceInput; +import com.kok.kokcore.places.domain.model.PlacesResult; + +/** + * ์ฃผ๋ณ€ ์žฅ์†Œ ๊ฒ€์ƒ‰ ์œ ์Šค์ผ€์ด์Šค + * PlaceInput์„ ๋ฐ›์•„ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ(PlacesResult)๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + */ +public interface SearchPlaceUseCase { + PlacesResult getPlaces(PlaceInput input) throws Exception; +} \ No newline at end of file diff --git a/kok-core/src/main/java/com/kok/kokcore/public_transportation/usecase/RetrievePublicTransportationUseCase.java b/kok-core/src/main/java/com/kok/kokcore/public_transportation/usecase/RetrievePublicTransportationUseCase.java new file mode 100644 index 00000000..0d292eb1 --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/public_transportation/usecase/RetrievePublicTransportationUseCase.java @@ -0,0 +1,8 @@ +package com.kok.kokcore.public_transportation.usecase; + +public interface RetrievePublicTransportationUseCase { + + String retrievePublicTransportation(Long stationId, String roomId, String memberId); + + String retrieveComplexPublicTransportation(Long stationId, String roomId, String memberId); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/room/domain/Member.java b/kok-core/src/main/java/com/kok/kokcore/room/domain/Member.java new file mode 100644 index 00000000..c8a9e5b7 --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/room/domain/Member.java @@ -0,0 +1,31 @@ +package com.kok.kokcore.room.domain; + +import com.kok.kokcore.room.domain.vo.MemberRole; +import java.util.UUID; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; + +@Getter +@ToString +@EqualsAndHashCode +public class Member { + + private final String memberId; + private final String nickname; + private final String profile; + private final MemberRole role; + + public Member(String nickname, String profile, MemberRole role) { + if (nickname == null || nickname.isEmpty()) { + throw new IllegalArgumentException("Nickname is required."); + } + if (profile == null || profile.isEmpty()) { + throw new IllegalArgumentException("Profile is required."); + } + this.memberId = UUID.randomUUID().toString(); + this.nickname = nickname.trim(); + this.profile = profile.trim(); + this.role = role; + } +} diff --git a/kok-core/src/main/java/com/kok/kokcore/room/domain/Profile.java b/kok-core/src/main/java/com/kok/kokcore/room/domain/Profile.java new file mode 100644 index 00000000..1ec61e9f --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/room/domain/Profile.java @@ -0,0 +1,19 @@ +package com.kok.kokcore.room.domain; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; + +@Getter +@ToString +@EqualsAndHashCode +public class Profile { + + private final String imageUrl; + private final String nickname; + + public Profile(String imageUrl, String nickname) { + this.imageUrl = imageUrl; + this.nickname = nickname; + } +} diff --git a/kok-core/src/main/java/com/kok/kokcore/room/domain/Room.java b/kok-core/src/main/java/com/kok/kokcore/room/domain/Room.java new file mode 100644 index 00000000..5d514f8d --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/room/domain/Room.java @@ -0,0 +1,115 @@ +package com.kok.kokcore.room.domain; + +import com.kok.kokcore.room.domain.vo.RoomStatus; +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.UUID; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; + +@Getter +@ToString +@EqualsAndHashCode +public class Room implements Serializable { + + private static final int REQUIRED_CAPACITY = 2; + private static final long LOCATION_INPUT_TIME_LIMIT = 6; + private static final long VOTE_TIME_LIMIT = 12; + + private final String id; // ์•ฝ์†๋ฐฉ ID (UUID) + private final String roomName; // ์•ฝ์†๋ฐฉ ์ด๋ฆ„ + private final int capacity; // ์ฐธ์—ฌ์ธ์› ์ˆ˜ (์ตœ์†Œ 2๋ช… ์ด์ƒ) + private final Member member; // ๋ฐฉ ์ฐธ์—ฌ์ž + private final LocalDateTime createdDateTime; // ๋ฐฉ ์ƒ์„ฑ์ผ์‹œ + private final LocalDateTime locationInputLimitDateTime; // ์ถœ๋ฐœ์ง€ ์ž…๋ ฅ ๋งˆ๊ฐ์ผ์‹œ + private LocalDateTime voteLimitDateTime; // ํˆฌํ‘œ ๋งˆ๊ฐ์ผ์‹œ + private RoomStatus status; + + private Room(String id, String roomName, int capacity, Member member) { + this.id = id; + this.roomName = roomName; + this.capacity = capacity; + this.member = member; + this.createdDateTime = LocalDateTime.now().withNano(0); + this.locationInputLimitDateTime = createdDateTime.plusHours(LOCATION_INPUT_TIME_LIMIT) + .withNano(0); + this.voteLimitDateTime = locationInputLimitDateTime.plusHours(VOTE_TIME_LIMIT) + .withNano(0); + this.status = RoomStatus.LOCATION_INPUT; + } + + public static Room create(String roomName, int capacity, Member member) { + validateParameter(roomName, capacity); + + String roomId = UUID.randomUUID().toString(); + + return new Room(roomId, roomName, capacity, member); + } + + private static void validateParameter(String roomName, int capacity) { + if (roomName == null || roomName.trim().isEmpty()) { + throw new IllegalArgumentException("Room name is required"); + } + if (capacity < REQUIRED_CAPACITY) { + throw new IllegalArgumentException("At least 2 participants are required"); + } + } + + public boolean shouldEndLocationInput(long locationInputCount, LocalDateTime current) { + return this.status.isLocationInput() && (isAllLocationInput(locationInputCount) + || current.isAfter(locationInputLimitDateTime)); + } + + public boolean isBeforeVote() { + return this.status.isLocationInput(); + } + + public boolean isNotOnVote() { + return this.status.isLocationInput() || this.status.isVoteResult(); + } + + public boolean isVoteClosed() { + return this.status.isVoteResult(); + } + + private boolean isAllLocationInput(long participantCount) { + return participantCount == capacity; + } + + public boolean isFull(int participantCount) { + return capacity == participantCount; + } + + public void updateVoteDeadline(LocalDateTime current) { + this.voteLimitDateTime = current.plusHours(VOTE_TIME_LIMIT); + } + + public void startVote() { + this.status = RoomStatus.VOTE; + } + + public void closeVote() { + this.status = RoomStatus.VOTE_RESULT; + } + + public int getNotVotedCount(int votedCount) { + return capacity - votedCount; + } + + public int getVotedRatio(int votedCount) { + if (capacity == 0) { + return 0; + } + return (int) ((double) votedCount / capacity * 100); + } + + public boolean shouldEndVote(int votedCount, LocalDateTime current) { + return this.status.isVote() && (isAllVoted(votedCount) + || current.isAfter(voteLimitDateTime)); + } + + private boolean isAllVoted(int votedCount) { + return votedCount == capacity; + } +} diff --git a/kok-core/src/main/java/com/kok/kokcore/room/domain/vo/MemberRole.java b/kok-core/src/main/java/com/kok/kokcore/room/domain/vo/MemberRole.java new file mode 100644 index 00000000..40cbf6e5 --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/room/domain/vo/MemberRole.java @@ -0,0 +1,6 @@ +package com.kok.kokcore.room.domain.vo; + +public enum MemberRole { + LEADER, + FOLLOWER +} diff --git a/kok-core/src/main/java/com/kok/kokcore/room/domain/vo/RoomStatus.java b/kok-core/src/main/java/com/kok/kokcore/room/domain/vo/RoomStatus.java new file mode 100644 index 00000000..b3a14a43 --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/room/domain/vo/RoomStatus.java @@ -0,0 +1,20 @@ +package com.kok.kokcore.room.domain.vo; + +public enum RoomStatus { + + LOCATION_INPUT, + VOTE, + VOTE_RESULT; + + public boolean isLocationInput() { + return this.equals(LOCATION_INPUT); + } + + public boolean isVoteResult() { + return this.equals(VOTE_RESULT); + } + + public boolean isVote() { + return this.equals(VOTE); + } +} diff --git a/kok-core/src/main/java/com/kok/kokcore/room/port/out/LoadRoomParticipantPort.java b/kok-core/src/main/java/com/kok/kokcore/room/port/out/LoadRoomParticipantPort.java new file mode 100644 index 00000000..7ceb9260 --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/room/port/out/LoadRoomParticipantPort.java @@ -0,0 +1,14 @@ +package com.kok.kokcore.room.port.out; + +import com.kok.kokcore.room.domain.Member; +import java.util.List; +import java.util.Optional; + +public interface LoadRoomParticipantPort { + + Long countParticipantsById(String roomId); + + List findMembersByRoomId(String roomId); + + Optional findByRoomIdAndMemberId(String roomId, String memberId); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/room/port/out/LoadRoomPort.java b/kok-core/src/main/java/com/kok/kokcore/room/port/out/LoadRoomPort.java new file mode 100644 index 00000000..5ecdf9ad --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/room/port/out/LoadRoomPort.java @@ -0,0 +1,11 @@ +package com.kok.kokcore.room.port.out; + +import com.kok.kokcore.room.domain.Room; +import java.util.Optional; + +public interface LoadRoomPort { + + Optional findRoomById(String roomId); + + boolean isExistsByRoomId(String roomId); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/room/port/out/SaveRoomParticipantsPort.java b/kok-core/src/main/java/com/kok/kokcore/room/port/out/SaveRoomParticipantsPort.java new file mode 100644 index 00000000..8f4de49a --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/room/port/out/SaveRoomParticipantsPort.java @@ -0,0 +1,8 @@ +package com.kok.kokcore.room.port.out; + +import com.kok.kokcore.room.domain.Member; + +public interface SaveRoomParticipantsPort { + + int joinRoom(String roomId, Member member); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/room/port/out/SaveRoomPort.java b/kok-core/src/main/java/com/kok/kokcore/room/port/out/SaveRoomPort.java new file mode 100644 index 00000000..1cbeeb64 --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/room/port/out/SaveRoomPort.java @@ -0,0 +1,8 @@ +package com.kok.kokcore.room.port.out; + +import com.kok.kokcore.room.domain.Room; + +public interface SaveRoomPort { + + Room save(Room room); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/room/port/out/UpdateRoomPort.java b/kok-core/src/main/java/com/kok/kokcore/room/port/out/UpdateRoomPort.java new file mode 100644 index 00000000..ba6625b1 --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/room/port/out/UpdateRoomPort.java @@ -0,0 +1,8 @@ +package com.kok.kokcore.room.port.out; + +import com.kok.kokcore.room.domain.Room; + +public interface UpdateRoomPort { + + void update(Room room); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/room/usecase/CreateRandomProfileUseCase.java b/kok-core/src/main/java/com/kok/kokcore/room/usecase/CreateRandomProfileUseCase.java new file mode 100644 index 00000000..6cbecdc1 --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/room/usecase/CreateRandomProfileUseCase.java @@ -0,0 +1,8 @@ +package com.kok.kokcore.room.usecase; + +import com.kok.kokcore.room.domain.Profile; + +public interface CreateRandomProfileUseCase { + + Profile createProfile(); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/room/usecase/CreateRoomUseCase.java b/kok-core/src/main/java/com/kok/kokcore/room/usecase/CreateRoomUseCase.java new file mode 100644 index 00000000..b686dc74 --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/room/usecase/CreateRoomUseCase.java @@ -0,0 +1,9 @@ +package com.kok.kokcore.room.usecase; + +import com.kok.kokcore.room.domain.Member; +import com.kok.kokcore.room.domain.Room; + +public interface CreateRoomUseCase { + + Room createRoom(String roomName, int capacity, Member host); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/room/usecase/GetRoomUseCase.java b/kok-core/src/main/java/com/kok/kokcore/room/usecase/GetRoomUseCase.java new file mode 100644 index 00000000..6b27b1d8 --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/room/usecase/GetRoomUseCase.java @@ -0,0 +1,18 @@ +package com.kok.kokcore.room.usecase; + +import com.kok.kokcore.room.domain.Member; +import com.kok.kokcore.room.domain.Room; +import java.util.List; + +public interface GetRoomUseCase { + + Room findRoomById(String roomId); + + List getParticipants(String roomId); + + Member getParticipant(String roomId, String memberId); + + long getParticipantsCount(String roomId); + + List getParticipantsByRoomIdInMemberIds(String roomId, List memberIds); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/room/usecase/JoinRoomUseCase.java b/kok-core/src/main/java/com/kok/kokcore/room/usecase/JoinRoomUseCase.java new file mode 100644 index 00000000..cce7b952 --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/room/usecase/JoinRoomUseCase.java @@ -0,0 +1,8 @@ +package com.kok.kokcore.room.usecase; + +import com.kok.kokcore.room.domain.Member; + +public interface JoinRoomUseCase { + + int joinRoom(String roomId, Member member); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/room/usecase/UpdateRoomUseCase.java b/kok-core/src/main/java/com/kok/kokcore/room/usecase/UpdateRoomUseCase.java new file mode 100644 index 00000000..82430e29 --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/room/usecase/UpdateRoomUseCase.java @@ -0,0 +1,13 @@ +package com.kok.kokcore.room.usecase; + +import com.kok.kokcore.room.domain.Room; +import java.time.LocalDateTime; + +public interface UpdateRoomUseCase { + + void startVote(String roomId, LocalDateTime current); + + void closeVote(String roomId); + + Room updateRoomStatus(String roomId, LocalDateTime current); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/station/domain/entity/Route.java b/kok-core/src/main/java/com/kok/kokcore/station/domain/entity/Route.java new file mode 100644 index 00000000..8cb2c1ce --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/station/domain/entity/Route.java @@ -0,0 +1,60 @@ +package com.kok.kokcore.station.domain.entity; + +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 java.util.Map; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode +public class Route { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 20) + private String name; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "station_id", nullable = false) + private Station station; + + private static final Map NORMALIZED_NAME_MAP = Map.ofEntries( + Map.entry("๊ฒฝ๋ถ€์„ ", "1ํ˜ธ์„ "), + Map.entry("๊ฒฝ์ธ์„ ", "1ํ˜ธ์„ "), + Map.entry("๊ฒฝ์›์„ ", "1ํ˜ธ์„ "), + Map.entry("์žฅํ•ญ์„ ", "1ํ˜ธ์„ "), + Map.entry("๊ณผ์ฒœ์„ ", "4ํ˜ธ์„ "), + Map.entry("7ํ˜ธ์„ (์ธ์ฒœ)", "7ํ˜ธ์„ "), + Map.entry("9ํ˜ธ์„ (์—ฐ์žฅ)", "9ํ˜ธ์„ "), + Map.entry("์‹ ๋ถ„๋‹น์„ (์—ฐ์žฅ)", "์‹ ๋ถ„๋‹น์„ "), + Map.entry("์‹ ๋ถ„๋‹น์„ (์—ฐ์žฅ2)", "์‹ ๋ถ„๋‹น์„ "), + Map.entry("์ˆ˜์ธ์„ ", "์ˆ˜์ธ๋ถ„๋‹น์„ "), + Map.entry("๋ถ„๋‹น์„ ", "์ˆ˜์ธ๋ถ„๋‹น์„ "), + Map.entry("์•ˆ์‚ฐ์„ ", "์ˆ˜์ธ๋ถ„๋‹น์„ "), + Map.entry("์ˆ˜๋„๊ถŒ ๊ด‘์—ญ๊ธ‰ํ–‰์ฒ ๋„", "GTX"), + Map.entry("๊ณตํ•ญ์ฒ ๋„1ํ˜ธ์„ ", "๊ณตํ•ญ์ฒ ๋„") + ); + + public static Route create(String rawName, Station station) { + String normalized = NORMALIZED_NAME_MAP.getOrDefault(rawName, rawName); + return new Route(normalized, station); + } + + private Route(String name, Station station) { + this.name = name; + this.station = station; + } +} diff --git a/kok-core/src/main/java/com/kok/kokcore/station/domain/entity/Station.java b/kok-core/src/main/java/com/kok/kokcore/station/domain/entity/Station.java new file mode 100644 index 00000000..e613edf3 --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/station/domain/entity/Station.java @@ -0,0 +1,42 @@ +package com.kok.kokcore.station.domain.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import java.math.BigDecimal; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode +public class Station { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(nullable = false, length = 20) + private String name; + @Column(nullable = false, columnDefinition = "DECIMAL(16, 14)") + private BigDecimal latitude; + @Column(nullable = false, columnDefinition = "DECIMAL(17, 14)") + private BigDecimal longitude; + @Column(nullable = false) + private Long priority; + + public Station(String name, BigDecimal latitude, BigDecimal longitude, long priority) { + this.name = name; + this.latitude = latitude; + this.longitude = longitude; + this.priority = priority; + } + + public Station(String name, String latitude, String longitude) { + this(name, new BigDecimal(latitude), new BigDecimal(longitude), 0); + } +} diff --git a/kok-core/src/main/java/com/kok/kokcore/station/port/out/LoadRecommendedStationsPort.java b/kok-core/src/main/java/com/kok/kokcore/station/port/out/LoadRecommendedStationsPort.java new file mode 100644 index 00000000..92e9e57d --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/station/port/out/LoadRecommendedStationsPort.java @@ -0,0 +1,9 @@ +package com.kok.kokcore.station.port.out; + +import com.kok.kokcore.station.domain.entity.Station; +import java.util.List; + +public interface LoadRecommendedStationsPort { + + List getStationsByRoomId(String roomId); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/station/port/out/LoadStationsPort.java b/kok-core/src/main/java/com/kok/kokcore/station/port/out/LoadStationsPort.java new file mode 100644 index 00000000..4b6bbcfb --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/station/port/out/LoadStationsPort.java @@ -0,0 +1,8 @@ +package com.kok.kokcore.station.port.out; + +import com.kok.kokcore.station.port.out.dto.StationRouteDtos; + +public interface LoadStationsPort { + + StationRouteDtos loadAllStations(); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/station/port/out/ReadStationsPort.java b/kok-core/src/main/java/com/kok/kokcore/station/port/out/ReadStationsPort.java new file mode 100644 index 00000000..e173fd00 --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/station/port/out/ReadStationsPort.java @@ -0,0 +1,6 @@ +package com.kok.kokcore.station.port.out; + +public interface ReadStationsPort { + + boolean hasNoStations(); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/station/port/out/ReadUserRecommendStationsPort.java b/kok-core/src/main/java/com/kok/kokcore/station/port/out/ReadUserRecommendStationsPort.java new file mode 100644 index 00000000..b97c4478 --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/station/port/out/ReadUserRecommendStationsPort.java @@ -0,0 +1,9 @@ +package com.kok.kokcore.station.port.out; + +import com.kok.kokcore.station.domain.entity.Station; +import java.util.List; + +public interface ReadUserRecommendStationsPort { + + List findUserRecommendedStationsByRoomId(String roomId); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/station/port/out/RetrieveRoutePort.java b/kok-core/src/main/java/com/kok/kokcore/station/port/out/RetrieveRoutePort.java new file mode 100644 index 00000000..f2e82a77 --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/station/port/out/RetrieveRoutePort.java @@ -0,0 +1,10 @@ +package com.kok.kokcore.station.port.out; + +import com.kok.kokcore.station.domain.entity.Route; +import com.kok.kokcore.station.domain.entity.Station; +import java.util.List; + +public interface RetrieveRoutePort { + + List retrieveRoutes(Station station); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/station/port/out/RetrieveStationsPort.java b/kok-core/src/main/java/com/kok/kokcore/station/port/out/RetrieveStationsPort.java new file mode 100644 index 00000000..6ae6b0be --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/station/port/out/RetrieveStationsPort.java @@ -0,0 +1,15 @@ +package com.kok.kokcore.station.port.out; + +import com.kok.kokcore.station.domain.entity.Station; +import java.util.List; +import java.util.Optional; +import org.locationtech.jts.geom.Point; + +public interface RetrieveStationsPort { + + Optional retrieveStation(Long stationId); + + List retrieveInRangeStations(Point centroid, double dist); + + List retrieveStationsByKeyword(String keyword); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/station/port/out/SaveRoutePort.java b/kok-core/src/main/java/com/kok/kokcore/station/port/out/SaveRoutePort.java new file mode 100644 index 00000000..a7024765 --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/station/port/out/SaveRoutePort.java @@ -0,0 +1,9 @@ +package com.kok.kokcore.station.port.out; + +import com.kok.kokcore.station.domain.entity.Route; +import java.util.List; + +public interface SaveRoutePort { + + void saveRoutes(List routes); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/station/port/out/SaveStationsPort.java b/kok-core/src/main/java/com/kok/kokcore/station/port/out/SaveStationsPort.java new file mode 100644 index 00000000..c773107d --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/station/port/out/SaveStationsPort.java @@ -0,0 +1,9 @@ +package com.kok.kokcore.station.port.out; + +import com.kok.kokcore.station.domain.entity.Station; +import java.util.List; + +public interface SaveStationsPort { + + List saveStations(List stations); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/station/port/out/SaveUserRecommendStationsPort.java b/kok-core/src/main/java/com/kok/kokcore/station/port/out/SaveUserRecommendStationsPort.java new file mode 100644 index 00000000..4ce7cd7f --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/station/port/out/SaveUserRecommendStationsPort.java @@ -0,0 +1,8 @@ +package com.kok.kokcore.station.port.out; + +import com.kok.kokcore.station.domain.entity.Station; + +public interface SaveUserRecommendStationsPort { + + Station addUserRecommendStation(String roomId, Station station); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/station/port/out/dto/StationRouteDto.java b/kok-core/src/main/java/com/kok/kokcore/station/port/out/dto/StationRouteDto.java new file mode 100644 index 00000000..65a519cb --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/station/port/out/dto/StationRouteDto.java @@ -0,0 +1,25 @@ +package com.kok.kokcore.station.port.out.dto; + +import com.kok.kokcore.station.domain.entity.Route; +import com.kok.kokcore.station.domain.entity.Station; + +public record StationRouteDto( + String name, + String latitude, + String longitude, + Long stationId, + String route +) { + + public boolean hasName(Station station) { + return name.equals(station.getName()); + } + + public Station toStation() { + return new Station(name, latitude, longitude); + } + + public Route toRouteByStation(Station station) { + return Route.create(route, station); + } +} diff --git a/kok-core/src/main/java/com/kok/kokcore/station/port/out/dto/StationRouteDtos.java b/kok-core/src/main/java/com/kok/kokcore/station/port/out/dto/StationRouteDtos.java new file mode 100644 index 00000000..4d37f810 --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/station/port/out/dto/StationRouteDtos.java @@ -0,0 +1,45 @@ +package com.kok.kokcore.station.port.out.dto; + +import com.kok.kokcore.station.domain.entity.Route; +import com.kok.kokcore.station.domain.entity.Station; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +public record StationRouteDtos( + List stationRouteDtos +) { + + public boolean isEmpty() { + return stationRouteDtos().isEmpty(); + } + + public List toStations() { + return distinctByName().stream() + .map(StationRouteDto::toStation) + .toList(); + } + + private List distinctByName() { + return new ArrayList<>(stationRouteDtos.stream() + .collect(Collectors.toMap( + StationRouteDto::name, + dto -> dto, + (existing, replacement) -> existing + )) + .values()); + } + + public List toRoutesByStations(List stations) { + List routes = new ArrayList<>(); + for (Station station : stations) { + Set routesOfStation = stationRouteDtos.stream() + .filter(stationRouteDto -> stationRouteDto.hasName(station)) + .map(stationRouteDto -> stationRouteDto.toRouteByStation(station)) + .collect(Collectors.toSet()); + routes.addAll(routesOfStation); + } + return routes; + } +} diff --git a/kok-core/src/main/java/com/kok/kokcore/station/usecase/DeleteRecommendStationUseCase.java b/kok-core/src/main/java/com/kok/kokcore/station/usecase/DeleteRecommendStationUseCase.java new file mode 100644 index 00000000..c0ecfa88 --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/station/usecase/DeleteRecommendStationUseCase.java @@ -0,0 +1,6 @@ +package com.kok.kokcore.station.usecase; + +public interface DeleteRecommendStationUseCase { + + void deleteRecommendedStations(String roomId); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/station/usecase/GetRecommendStationUseCase.java b/kok-core/src/main/java/com/kok/kokcore/station/usecase/GetRecommendStationUseCase.java new file mode 100644 index 00000000..6594279c --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/station/usecase/GetRecommendStationUseCase.java @@ -0,0 +1,9 @@ +package com.kok.kokcore.station.usecase; + +import com.kok.kokcore.station.domain.entity.Station; +import java.util.List; + +public interface GetRecommendStationUseCase { + + List getRecommendedStations(String roomId); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/station/usecase/GetStationUseCase.java b/kok-core/src/main/java/com/kok/kokcore/station/usecase/GetStationUseCase.java new file mode 100644 index 00000000..325b37e4 --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/station/usecase/GetStationUseCase.java @@ -0,0 +1,8 @@ +package com.kok.kokcore.station.usecase; + +import com.kok.kokcore.station.domain.entity.Station; + +public interface GetStationUseCase { + + Station getStation(long stationId); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/station/usecase/RetrieveRouteUseCase.java b/kok-core/src/main/java/com/kok/kokcore/station/usecase/RetrieveRouteUseCase.java new file mode 100644 index 00000000..d8bc8b4a --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/station/usecase/RetrieveRouteUseCase.java @@ -0,0 +1,10 @@ +package com.kok.kokcore.station.usecase; + +import com.kok.kokcore.station.domain.entity.Route; +import com.kok.kokcore.station.domain.entity.Station; +import java.util.List; + +public interface RetrieveRouteUseCase { + + List retrieveRoutes(Station station); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/station/usecase/SaveStationUseCase.java b/kok-core/src/main/java/com/kok/kokcore/station/usecase/SaveStationUseCase.java new file mode 100644 index 00000000..5aa616bd --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/station/usecase/SaveStationUseCase.java @@ -0,0 +1,6 @@ +package com.kok.kokcore.station.usecase; + +public interface SaveStationUseCase { + + void saveStations(); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/station/usecase/SystemRecommendUseCase.java b/kok-core/src/main/java/com/kok/kokcore/station/usecase/SystemRecommendUseCase.java new file mode 100644 index 00000000..88795083 --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/station/usecase/SystemRecommendUseCase.java @@ -0,0 +1,10 @@ +package com.kok.kokcore.station.usecase; + +import com.kok.kokcore.station.domain.entity.Station; +import java.util.List; + + +public interface SystemRecommendUseCase { + + List systemRecommendStation(String roomId); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/station/usecase/UserRecommendUseCase.java b/kok-core/src/main/java/com/kok/kokcore/station/usecase/UserRecommendUseCase.java new file mode 100644 index 00000000..683f8491 --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/station/usecase/UserRecommendUseCase.java @@ -0,0 +1,14 @@ +package com.kok.kokcore.station.usecase; + +import com.kok.kokcore.station.domain.entity.Station; +import java.util.List; + +public interface UserRecommendUseCase { + + Station addUserRecommendStation(String roomId, Long stationId); + + List getUserRecommendStation(String roomId); + + List searchStations(String keyword); + +} diff --git a/kok-core/src/main/java/com/kok/kokcore/vote/VoteResults.java b/kok-core/src/main/java/com/kok/kokcore/vote/VoteResults.java new file mode 100644 index 00000000..bbfa7c1c --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/vote/VoteResults.java @@ -0,0 +1,41 @@ +package com.kok.kokcore.vote; + +import com.kok.kokcore.vote.domain.VoteResult; +import java.util.Comparator; +import java.util.List; +import lombok.Getter; + +@Getter +public class VoteResults { + + private final List voteResults; + + public VoteResults(List voteResults) { + this.voteResults = voteResults.stream() + .sorted(Comparator + .comparing(VoteResult::getVotedCount, Comparator.reverseOrder()) + .thenComparing(VoteResult::getPriority, Comparator.reverseOrder())) + .toList(); + } + + public void applyResultTag() { + int topVotedCount = voteResults.getFirst().getVotedCount(); + + List topResults = voteResults.stream() + .filter(voteResult -> voteResult.getVotedCount() == topVotedCount) + .toList(); + + if (topResults.size() == 1) { + topResults.getFirst().markTop(); + return; + } + + for (VoteResult topResult : topResults) { + topResult.markClose(); + } + } + + public VoteResult getFinalResult() { + return voteResults.getFirst(); + } +} diff --git a/kok-core/src/main/java/com/kok/kokcore/vote/domain/Candidate.java b/kok-core/src/main/java/com/kok/kokcore/vote/domain/Candidate.java new file mode 100644 index 00000000..c821e2b5 --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/vote/domain/Candidate.java @@ -0,0 +1,17 @@ +package com.kok.kokcore.vote.domain; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@Getter +@EqualsAndHashCode +public class Candidate { + + private final String roomId; + private final long stationId; + + public Candidate(String roomId, long stationId) { + this.roomId = roomId; + this.stationId = stationId; + } +} diff --git a/kok-core/src/main/java/com/kok/kokcore/vote/domain/Vote.java b/kok-core/src/main/java/com/kok/kokcore/vote/domain/Vote.java new file mode 100644 index 00000000..36f26e11 --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/vote/domain/Vote.java @@ -0,0 +1,29 @@ +package com.kok.kokcore.vote.domain; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@Getter +@EqualsAndHashCode +public class Vote { + + private final Candidate candidate; + private final String memberId; + + public Vote(Candidate candidate, String memberId) { + this.candidate = candidate; + this.memberId = memberId; + } + + public Vote(String roomId, long stationId, String memberId) { + this(new Candidate(roomId, stationId), memberId); + } + + public String getRoomId() { + return candidate.getRoomId(); + } + + public long getStationId() { + return candidate.getStationId(); + } +} diff --git a/kok-core/src/main/java/com/kok/kokcore/vote/domain/VoteResult.java b/kok-core/src/main/java/com/kok/kokcore/vote/domain/VoteResult.java new file mode 100644 index 00000000..222026e9 --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/vote/domain/VoteResult.java @@ -0,0 +1,44 @@ +package com.kok.kokcore.vote.domain; + +import com.kok.kokcore.vote.domain.vo.ResultTag; +import java.util.List; +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@Getter +@EqualsAndHashCode +public class VoteResult { + + private final Candidate candidate; + private final List memberIds; + private final long priority; + private ResultTag resultTag; + + public VoteResult(String roomId, long stationId, List memberIds, long priority) { + this(new Candidate(roomId, stationId), memberIds, priority, ResultTag.NONE); + } + + public VoteResult( + Candidate candidate, List memberIds, long priority, ResultTag resultTag) { + this.candidate = candidate; + this.memberIds = memberIds; + this.priority = priority; + this.resultTag = resultTag; + } + + public int getVotedCount() { + return memberIds.size(); + } + + public void markTop() { + resultTag = ResultTag.TOP; + } + + public void markClose() { + resultTag = ResultTag.CLOSE; + } + + public long getStationId() { + return candidate.getStationId(); + } +} diff --git a/kok-core/src/main/java/com/kok/kokcore/vote/domain/vo/ResultTag.java b/kok-core/src/main/java/com/kok/kokcore/vote/domain/vo/ResultTag.java new file mode 100644 index 00000000..aab2fb30 --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/vote/domain/vo/ResultTag.java @@ -0,0 +1,8 @@ +package com.kok.kokcore.vote.domain.vo; + +public enum ResultTag { + + TOP, + CLOSE, + NONE; +} diff --git a/kok-core/src/main/java/com/kok/kokcore/vote/port/out/DeleteVotePort.java b/kok-core/src/main/java/com/kok/kokcore/vote/port/out/DeleteVotePort.java new file mode 100644 index 00000000..33ca6e4f --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/vote/port/out/DeleteVotePort.java @@ -0,0 +1,24 @@ +package com.kok.kokcore.vote.port.out; + +public interface DeleteVotePort { + + /** + * ํˆฌํ‘œํ•œ ๋ฉค๋ฒ„ Set์—์„œ memberId ์ œ๊ฑฐ votedMember:{roomId}:{stationId} + */ + void deleteVotedMemberByRoomIdAndStationId(String memberId, String roomId, long stationId); + + /** + * ํ›„๋ณด์ง€ ํˆฌํ‘œ ์ˆ˜ ZSet์˜ ํˆฌํ‘œ ์ˆ˜(score)๋ฅผ -1 votedCount:{roomId} + */ + void decreaseVotedCountByRoomIdAndStationId(String roomId, long stationId); + + /** + * member์˜ ๊ฐœ์ธ ํˆฌํ‘œ ๊ธฐ๋ก ์‚ญ์ œ member:{roomId}:{memberId} + */ + void deleteVotedStationsByRoomIdAndMemberId(String roomId, String memberId); + + /** + * vote ์™„๋ฃŒ Set์—์„œ memberId ์ œ๊ฑฐ ex) vote:{roomId} + */ + void deleteVotedMemberByRoomId(String roomId, String memberId); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/vote/port/out/LoadCandidatePort.java b/kok-core/src/main/java/com/kok/kokcore/vote/port/out/LoadCandidatePort.java new file mode 100644 index 00000000..437a6971 --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/vote/port/out/LoadCandidatePort.java @@ -0,0 +1,11 @@ +package com.kok.kokcore.vote.port.out; + +import com.kok.kokcore.vote.domain.Candidate; +import java.util.List; + +public interface LoadCandidatePort { + + List findByRoomId(String roomId); + + boolean isExistsByRoomId(String roomId); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/vote/port/out/LoadVotePort.java b/kok-core/src/main/java/com/kok/kokcore/vote/port/out/LoadVotePort.java new file mode 100644 index 00000000..5e4f3321 --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/vote/port/out/LoadVotePort.java @@ -0,0 +1,19 @@ +package com.kok.kokcore.vote.port.out; + +import com.kok.kokcore.vote.domain.Vote; +import java.util.List; + +public interface LoadVotePort { + + boolean isExistsByRoomIdAndMemberId(String roomId, String memberId); + + List findAllByRoomIdAndMemberId(String roomId, String memberId); + + int countVotedMembersByRoomId(String roomId); + + List findMemberIdsByRoomIdAndStationId(String roomId, long stationId); + + long findFirstStationIdByRoomIdOrderByVotedCount(String roomId); + + List findStationIdsByRoomIdOrderByVotedCount(String roomId); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/vote/port/out/SaveCandidatePort.java b/kok-core/src/main/java/com/kok/kokcore/vote/port/out/SaveCandidatePort.java new file mode 100644 index 00000000..8be0c0ea --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/vote/port/out/SaveCandidatePort.java @@ -0,0 +1,9 @@ +package com.kok.kokcore.vote.port.out; + +import com.kok.kokcore.vote.domain.Candidate; +import java.util.List; + +public interface SaveCandidatePort { + + void saveAll(List candidates); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/vote/port/out/SaveVotePort.java b/kok-core/src/main/java/com/kok/kokcore/vote/port/out/SaveVotePort.java new file mode 100644 index 00000000..17107f58 --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/vote/port/out/SaveVotePort.java @@ -0,0 +1,17 @@ +package com.kok.kokcore.vote.port.out; + +import java.util.List; + +public interface SaveVotePort { + + void saveVotedStationsByRoomIdAndMemberId(List stationIds, String roomId, + String memberId); + + void saveVotedMemberByRoomIdAndStationId(String memberId, String roomId, long stationId); + + void increaseVotedCountByRoomIdAndStationId(String roomId, long stationId); + + void saveVotedMemberByRoomId(String roomId, String memberId); + + void initiateVoteCountByRoomIdAndStationIds(String roomId, List stationIds); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/vote/usecase/GetCandidateUseCase.java b/kok-core/src/main/java/com/kok/kokcore/vote/usecase/GetCandidateUseCase.java new file mode 100644 index 00000000..88f90b63 --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/vote/usecase/GetCandidateUseCase.java @@ -0,0 +1,11 @@ +package com.kok.kokcore.vote.usecase; + +import com.kok.kokcore.station.domain.entity.Station; +import com.kok.kokcore.vote.domain.Candidate; +import java.util.List; +import java.util.Set; + +public interface GetCandidateUseCase { + + List saveAndGetCandidates(String roomId, Set stations); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/vote/usecase/GetVoteUseCase.java b/kok-core/src/main/java/com/kok/kokcore/vote/usecase/GetVoteUseCase.java new file mode 100644 index 00000000..990f61ca --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/vote/usecase/GetVoteUseCase.java @@ -0,0 +1,15 @@ +package com.kok.kokcore.vote.usecase; + +import com.kok.kokcore.station.domain.entity.Station; +import com.kok.kokcore.vote.VoteResults; + +public interface GetVoteUseCase { + + boolean isVotedByMember(String roomId, String memberId); + + Station getVoteFinalResult(String roomId); + + int countVotedMembers(String roomId); + + VoteResults getVoteResultsByRoomId(String roomId); +} diff --git a/kok-core/src/main/java/com/kok/kokcore/vote/usecase/SaveVoteUseCase.java b/kok-core/src/main/java/com/kok/kokcore/vote/usecase/SaveVoteUseCase.java new file mode 100644 index 00000000..7680eb53 --- /dev/null +++ b/kok-core/src/main/java/com/kok/kokcore/vote/usecase/SaveVoteUseCase.java @@ -0,0 +1,8 @@ +package com.kok.kokcore.vote.usecase; + +import java.util.List; + +public interface SaveVoteUseCase { + + void saveVotes(String roomId, String memberId, List agreedStationIds); +} diff --git a/kok-core/src/main/resources/application.properties b/kok-core/src/main/resources/application-prod.yml similarity index 100% rename from kok-core/src/main/resources/application.properties rename to kok-core/src/main/resources/application-prod.yml diff --git a/kok-core/src/test/java/com/kok/kokcore/KokCoreApplicationTests.java b/kok-core/src/test/java/com/kok/kokcore/KokCoreApplicationTests.java index ea8c4192..47c1898f 100644 --- a/kok-core/src/test/java/com/kok/kokcore/KokCoreApplicationTests.java +++ b/kok-core/src/test/java/com/kok/kokcore/KokCoreApplicationTests.java @@ -6,8 +6,8 @@ @SpringBootTest class KokCoreApplicationTests { - @Test - void contextLoads() { - } + @Test + void contextLoads() { + } } diff --git a/kok-core/src/test/java/com/kok/kokcore/location/domain/LocationTest.java b/kok-core/src/test/java/com/kok/kokcore/location/domain/LocationTest.java new file mode 100644 index 00000000..3c4b252f --- /dev/null +++ b/kok-core/src/test/java/com/kok/kokcore/location/domain/LocationTest.java @@ -0,0 +1,5 @@ +package com.kok.kokcore.location.domain; + +public class LocationTest { + +} diff --git a/kok-core/src/test/java/com/kok/kokcore/room/domain/RoomTest.java b/kok-core/src/test/java/com/kok/kokcore/room/domain/RoomTest.java new file mode 100644 index 00000000..765eeb4c --- /dev/null +++ b/kok-core/src/test/java/com/kok/kokcore/room/domain/RoomTest.java @@ -0,0 +1,255 @@ +package com.kok.kokcore.room.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.kok.kokcore.room.domain.vo.MemberRole; +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class RoomTest { + + @DisplayName("์•ฝ์†๋ฐฉ์ด ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createRoom() { + // Given + String roomName = "Test Room"; + int capacity = 4; + String hostProfile = "hostProfile"; + String hostNickname = "test"; + LocalDateTime locationInputDeadline = LocalDateTime.now().withNano(0).plusHours(6); + LocalDateTime voteDeadline = locationInputDeadline.plusHours(12); + Member host = new Member(hostNickname, hostProfile, MemberRole.LEADER); + + // When + Room room = Room.create(roomName, capacity, host); + + // Then + assertAll("์•ฝ์†๋ฐฉ ์ƒ์„ฑ", + () -> assertNotNull(room.getId(), "ID๋Š” null์ด ์•„๋‹ˆ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."), + () -> assertEquals(roomName, room.getRoomName(), "๋ฐฉ ์ด๋ฆ„์ด ์ผ์น˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."), + () -> assertEquals(capacity, room.getCapacity(), "์ฐธ์—ฌ ์ธ์› ์ˆ˜๊ฐ€ ์ผ์น˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."), + () -> assertEquals(locationInputDeadline.getHour(), + room.getLocationInputLimitDateTime().getHour(), + "์ถœ๋ฐœ์ง€ ์ž…๋ ฅ ๋งˆ๊ฐ ์‹œ๊ฐ„ ๋ฐฉ ์ƒ์„ฑ ์‹œ์ ์œผ๋กœ๋ถ€ํ„ฐ 6์‹œ๊ฐ„ ๋’ค์ž…๋‹ˆ๋‹ค."), + () -> assertEquals(voteDeadline.getHour(), + room.getVoteLimitDateTime().getHour(), + "ํˆฌํ‘œ ๋งˆ๊ฐ ์‹œ๊ฐ„์€ ์ฒ˜์Œ์—๋Š” ์ถœ๋ฐœ์ง€ ์ž…๋ ฅ ๋งˆ๊ฐ ์‹œ์ ์œผ๋กœ๋ถ€ํ„ฐ 12์‹œ๊ฐ„ ๋’ค์ž…๋‹ˆ๋‹ค."), + () -> assertEquals(hostNickname, room.getMember().getNickname(), "๋ฐฉ์žฅ ๋‹‰๋„ค์ž„์ด ์ผ์น˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."), + () -> assertEquals(hostProfile, room.getMember().getProfile(), "๋ฐฉ์žฅ ํ”„๋กœํ•„์ด ์ผ์น˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."), + () -> assertEquals(MemberRole.LEADER, room.getMember().getRole(), + "๋ฐฉ์žฅ ์—ญํ• ์€ Leader์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + ); + } + + @DisplayName("์•ฝ์†๋ฐฉ ์ƒ์„ฑ ์‹คํŒจ - ์•ฝ์†๋ฐฉ ์ด๋ฆ„์ด ์—†๋Š” ๊ฒฝ์šฐ ์•ฝ์†๋ฐฉ์ด ์ƒ์„ฑ๋˜์ง€ ์•Š๋Š”๋‹ค.") + @Test + void createRoomWithEmptyRoomName() { + // Given + String roomName = " "; + int capacity = 4; + String hostNickname = "hostNickname"; + String hostProfile = "hostProfile"; + Member host = new Member(hostNickname, hostProfile, MemberRole.LEADER); + + // When & Then + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, + () -> Room.create(roomName, capacity, host)); + assertTrue(exception.getMessage().contains("Room name is required")); + } + + @DisplayName("์•ฝ์†๋ฐฉ ์ƒ์„ฑ ์‹คํŒจ - ์ตœ์†Œ ์š”๊ตฌ ์ธ์›(2๋ช…) ๋ฏธ๋งŒ") + @Test + void createRoomWithInvalidCapacity() { + // Given + String roomName = "Test Room"; + int capacity = 1; + String hostNickname = "hostNickname"; + String hostProfile = "hostProfile"; + Member host = new Member(hostNickname, hostProfile, MemberRole.LEADER); + + // When & Then + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, + () -> Room.create(roomName, capacity, host)); + assertTrue(exception.getMessage().contains("At least 2 participants are required")); + } + + @DisplayName("์ถœ๋ฐœ์ง€ ์ž…๋ ฅ ๋งˆ๊ฐ ์‹œ๊ฐ„์„ ์ดˆ๊ณผํ•˜๋ฉด true๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void shouldEndLocationInputByDeadlineExceeded() { + // given + Room room = Room.create("room", 2, new Member("member", "profile.svg", MemberRole.LEADER)); + + // when + boolean result = room.shouldEndLocationInput(1, LocalDateTime.now().plusHours(6)); + + // then + assertThat(result).isTrue(); + } + + @DisplayName("๋ชจ๋“  ์ฐธ๊ฐ€์ž๊ฐ€ ์ถœ๋ฐœ์ง€ ์ž…๋ ฅ์„ ์™„๋ฃŒํ•˜๋ฉด true๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void shouldEndLocationInputByAllParticipantCompleted() { + // given + Room room = Room.create("room", 2, new Member("member", "profile.svg", MemberRole.LEADER)); + + // when + boolean result = room.shouldEndLocationInput(2, LocalDateTime.now().plusHours(5)); + + // then + assertThat(result).isTrue(); + } + + @DisplayName("์ถœ๋ฐœ์ง€ ์ž…๋ ฅ ๋งˆ๊ฐ ์‹œ๊ฐ„์„ ์ดˆ๊ณผํ•˜์ง€๋„, ์ถœ๋ฐœ์ง€ ์ž…๋ ฅ์„ ์™„๋ฃŒํ•˜์ง€๋„ ์•Š์•˜์œผ๋ฉด false๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void hasNotLocationInputEnded() { + // given + Room room = Room.create("room", 2, new Member("member", "profile.svg", MemberRole.LEADER)); + + // when + boolean result = room.shouldEndLocationInput(1, LocalDateTime.now().plusHours(5)); + + // then + assertThat(result).isFalse(); + } + + @DisplayName("ํ˜„์žฌ ์ƒํƒœ๊ฐ€ LOCATION_INPUT์ด๋ฉด true๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void isLocationInputStatus() { + // given + Room room = Room.create("room", 3, new Member("member", "profile", MemberRole.LEADER)); + + // when + boolean result = room.isNotOnVote(); + + // then + assertThat(result).isTrue(); + } + + @DisplayName("ํ˜„์žฌ ์ƒํƒœ๊ฐ€ VOTE_RESULT์ด๋ฉด true๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void isVoteClosed() { + // given + Room room = Room.create("room", 3, new Member("member", "profile", MemberRole.LEADER)); + room.closeVote(); // ์ƒํƒœ๋ฅผ VOTE_RESULT๋กœ ๋ณ€๊ฒฝ + + // when + boolean result = room.isVoteClosed(); + + // then + assertThat(result).isTrue(); + } + + @DisplayName("์ฐธ๊ฐ€ ์ธ์›์ด ๊ฝ‰ ์ฐผ์„ ๊ฒฝ์šฐ true๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void isFull() { + // given + Room room = Room.create("room", 3, new Member("member", "profile", MemberRole.LEADER)); + + // when + boolean result = room.isFull(3); + + // then + assertThat(result).isTrue(); + } + + @DisplayName("ํˆฌํ‘œ ๋งˆ๊ฐ ์‹œ๊ฐ„์ด ํ˜„์žฌ๋กœ๋ถ€ํ„ฐ 12์‹œ๊ฐ„ ๋’ค๋กœ ๊ฐฑ์‹ ๋œ๋‹ค.") + @Test + void updateVoteDeadline() { + // given + Room room = Room.create("room", 3, new Member("member", "profile", MemberRole.LEADER)); + LocalDateTime now = LocalDateTime.now().withNano(0); + + // when + room.updateVoteDeadline(now); + + // then + assertThat(room.getVoteLimitDateTime()).isEqualTo(now.plusHours(12)); + } + + @DisplayName("startVote ํ˜ธ์ถœ ์‹œ ์ƒํƒœ๊ฐ€ VOTE๋กœ ๋ณ€๊ฒฝ๋œ๋‹ค.") + @Test + void startVote() { + // given + Room room = Room.create("room", 3, new Member("member", "profile", MemberRole.LEADER)); + + // when + room.startVote(); + + // then + assertThat(room.isNotOnVote()).isFalse(); + } + + @DisplayName("closeVote ํ˜ธ์ถœ ์‹œ ์ƒํƒœ๊ฐ€ VOTE_RESULT๋กœ ๋ณ€๊ฒฝ๋œ๋‹ค.") + @Test + void closeVote() { + // given + Room room = Room.create("room", 3, new Member("member", "profile", MemberRole.LEADER)); + room.startVote(); + + // when + room.closeVote(); + + // then + assertThat(room.isVoteClosed()).isTrue(); + } + + @DisplayName("ํˆฌํ‘œํ•˜์ง€ ์•Š์€ ์ธ์› ์ˆ˜๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void getNotVotedCount() { + // given + Room room = Room.create("room", 5, new Member("member", "profile", MemberRole.LEADER)); + + // when + int notVoted = room.getNotVotedCount(2); + + // then + assertThat(notVoted).isEqualTo(3); + } + + @DisplayName("์ถœ๋ฐœ์ง€ ์ž…๋ ฅ ๋งˆ๊ฐ ์‹œ๊ฐ„์„ ์ดˆ๊ณผํ•˜๋ฉด true๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void shouldEndVoteByDeadlineExceeded() { + // given + Room room = Room.create("room", 2, new Member("member", "profile.svg", MemberRole.LEADER)); + + // when + boolean result = room.shouldEndVote(1, LocalDateTime.now().plusHours(18)); + + // then + assertThat(result).isTrue(); + } + + @DisplayName("๋ชจ๋“  ์ฐธ๊ฐ€์ž๊ฐ€ ํˆฌํ‘œ๋ฅผ ์™„๋ฃŒํ•˜๋ฉด true๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void shouldEndVoteByAllParticipantCompleted() { + // given + Room room = Room.create("room", 2, new Member("member", "profile.svg", MemberRole.LEADER)); + + // when + boolean result = room.shouldEndVote(2, LocalDateTime.now().plusHours(17)); + + // then + assertThat(result).isTrue(); + } + + @DisplayName("์ถœ๋ฐœ์ง€ ์ž…๋ ฅ ๋งˆ๊ฐ ์‹œ๊ฐ„์„ ์ดˆ๊ณผํ•˜์ง€๋„, ์ถœ๋ฐœ์ง€ ์ž…๋ ฅ์„ ์™„๋ฃŒํ•˜์ง€๋„ ์•Š์•˜์œผ๋ฉด false๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void hasNotVoteEnded() { + // given + Room room = Room.create("room", 2, new Member("member", "profile.svg", MemberRole.LEADER)); + + // when + boolean result = room.shouldEndVote(1, LocalDateTime.now().plusHours(17)); + + // then + assertThat(result).isFalse(); + } +} diff --git a/kok-core/src/test/java/com/kok/kokcore/station/port/out/dto/StationRouteDtosTest.java b/kok-core/src/test/java/com/kok/kokcore/station/port/out/dto/StationRouteDtosTest.java new file mode 100644 index 00000000..7eb33333 --- /dev/null +++ b/kok-core/src/test/java/com/kok/kokcore/station/port/out/dto/StationRouteDtosTest.java @@ -0,0 +1,97 @@ +package com.kok.kokcore.station.port.out.dto; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.kok.kokcore.station.domain.entity.Route; +import com.kok.kokcore.station.domain.entity.Station; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class StationRouteDtosTest { + + @DisplayName("stationRouteDtos๊ฐ€ ๋น„์–ด ์žˆ์œผ๋ฉด ์ฐธ์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void isEmpty() { + // given + StationRouteDtos stationRouteDtos = new StationRouteDtos(List.of()); + + // when + boolean result = stationRouteDtos.isEmpty(); + + // then + assertThat(result).isTrue(); + } + + @DisplayName("stationRouteDtos๊ฐ€ ๋น„์–ด ์žˆ์ง€ ์•Š์œผ๋ฉด ๊ฑฐ์ง“์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void isNotEmpty() { + // given + StationRouteDto stationRouteDto = new StationRouteDto("์„œ์šธ์—ญ", "37.556", "126.972", 1L, + "1ํ˜ธ์„ "); + StationRouteDtos stationRouteDtos = new StationRouteDtos(List.of(stationRouteDto)); + + // when + boolean result = stationRouteDtos.isEmpty(); + + // then + assertThat(result).isFalse(); + } + + @DisplayName("stationRouteDtos๋ฅผ Station ๋ฆฌ์ŠคํŠธ๋กœ ์ค‘๋ณต ์—†์ด ๋ณ€ํ™˜ํ•œ๋‹ค.") + @Test + void toStations() { + // given + StationRouteDto stationRouteDto1 = new StationRouteDto("์„œ์šธ์—ญ", "37.556", "126.972", 1L, + "1ํ˜ธ์„ "); + StationRouteDto stationRouteDto2 = new StationRouteDto("๊ฐ•๋‚จ์—ญ", "37.497", "127.028", 2L, + "2ํ˜ธ์„ "); + StationRouteDto stationRouteDto3 = new StationRouteDto("๊ฐ•๋‚จ์—ญ", "37.496", "127.029", 3L, + "์‹ ๋ถ„๋‹น์„ "); + StationRouteDtos stationRouteDtos = new StationRouteDtos( + List.of(stationRouteDto1, stationRouteDto2, stationRouteDto3) + ); + + // when + List stations = stationRouteDtos.toStations(); + + // then + List names = stations.stream().map(Station::getName).toList(); + + assertAll( + () -> assertThat(stations).hasSize(2), + () -> assertThat(names).containsExactlyInAnyOrder("์„œ์šธ์—ญ", "๊ฐ•๋‚จ์—ญ") + ); + } + + @DisplayName("stationRouteDtos๋ฅผ Route ๋ฆฌ์ŠคํŠธ๋กœ ๋ณ€ํ™˜ํ•œ๋‹ค.") + @Test + void toRoutesByStations() { + // given + StationRouteDto stationRouteDto1 = new StationRouteDto("์„œ์šธ์—ญ", "37.556", "126.972", 1L, + "1ํ˜ธ์„ "); + StationRouteDto stationRouteDto2 = new StationRouteDto("๊ฐ•๋‚จ์—ญ", "37.497", "127.028", 2L, + "2ํ˜ธ์„ "); + StationRouteDto stationRouteDto3 = new StationRouteDto("๊ฐ•๋‚จ์—ญ", "37.497", "127.028", 3L, + "์‹ ๋ถ„๋‹น์„ "); + StationRouteDtos stationRouteDtos = new StationRouteDtos( + List.of(stationRouteDto1, stationRouteDto2, stationRouteDto3) + ); + Station station1 = new Station("์„œ์šธ์—ญ", "37.556", "126.972"); + Station station2 = new Station("๊ฐ•๋‚จ์—ญ", "37.497", "127.028"); + + // when + List routes = stationRouteDtos.toRoutesByStations(List.of(station1, station2)); + + // then + List names = routes.stream().map(Route::getName).toList(); + List stations = routes.stream().map(Route::getStation).distinct().toList(); + + assertAll( + () -> assertThat(routes).hasSize(3), + () -> assertThat(names).containsExactlyInAnyOrder("1ํ˜ธ์„ ", "2ํ˜ธ์„ ", "์‹ ๋ถ„๋‹น์„ "), + () -> assertThat(stations).containsExactlyInAnyOrder(station1, station2) + ); + } +}