From c0af401777fa37e6ba00d486d12f296bae073ba3 Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Tue, 17 Feb 2026 11:40:09 +0900 Subject: [PATCH 01/73] =?UTF-8?q?[WTH-137]=20weeth=20server=20cicd=20?= =?UTF-8?q?=EA=B5=AC=EC=B6=95=20(#4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: 깃 이그노어 업데이트 * deploy: Dockerfile 통합 * deploy: main, dev 통합 CI 워크플로우 설정 * deploy: docker ignore 설정 * deploy: 개발서버 CICD 적용 * deploy: 운영서버 CICD 적용 * chore: caddy 리버스 프록시 정보 추가 * chore: 릴리즈 드래프터 적용 * chore: lint 수정 * chore: PR 템플릿 수정 * deploy: key 볼륨 마운트 설정 * deploy: 워크플로우 의존성 설정 * deploy: ci job 이름 변경 * deploy: wrapping 제거 * deploy: 홈 디렉토리 설정 변경 * chore: jdk 21로 업데이트 --- .dockerignore | 12 +++ .github/pull_request_template.md | 7 ++ .github/release-drafter.yml | 55 ++++++++++++ .github/workflows/ci.yml | 31 +++++++ .github/workflows/deploy-dev.yml | 90 +++++++++++++++++++ .github/workflows/deploy-prod.yml | 82 +++++++++++++++++ .github/workflows/release-drafter.yml | 19 ++++ .gitignore | 2 +- CLAUDE.md | 4 +- Dockerfile | 7 ++ Dockerfile-dev | 12 --- Dockerfile-prod | 12 --- README.md | 4 +- build.gradle.kts | 8 +- infra/dev/caddy/Caddyfile | 28 ++++++ infra/dev/caddy/upstream.conf | 1 + infra/dev/docker-compose.yml | 84 +++++++++++++++++ infra/dev/scripts/deploy.sh | 61 +++++++++++++ infra/prod/caddy/Caddyfile | 28 ++++++ infra/prod/caddy/upstream.conf | 1 + infra/prod/docker-compose.yml | 61 +++++++++++++ infra/prod/scripts/deploy.sh | 61 +++++++++++++ .../usecase/NoticeUsecaseImplTest.kt | 4 +- 23 files changed, 641 insertions(+), 33 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/release-drafter.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/deploy-dev.yml create mode 100644 .github/workflows/deploy-prod.yml create mode 100644 .github/workflows/release-drafter.yml create mode 100644 Dockerfile delete mode 100644 Dockerfile-dev delete mode 100644 Dockerfile-prod create mode 100644 infra/dev/caddy/Caddyfile create mode 100644 infra/dev/caddy/upstream.conf create mode 100644 infra/dev/docker-compose.yml create mode 100755 infra/dev/scripts/deploy.sh create mode 100644 infra/prod/caddy/Caddyfile create mode 100644 infra/prod/caddy/upstream.conf create mode 100644 infra/prod/docker-compose.yml create mode 100755 infra/prod/scripts/deploy.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..a90b37f0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.github +.idea +.gradle +.claude +out +docs +infra + +**/Dockerfile* +**/*.iml +**/.DS_Store \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 0ce50809..28f267fd 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,20 +1,27 @@ ## 📌 Summary > 어떤 작업인지 한 줄 요약해 주세요. + ## 📝 Changes > 변경사항을 what, why, how로 구분해 작성해 주세요. + ### What + ### Why + ### How + ## 📸 Screenshots / Logs > 필요시 스크린샷 or 로그를 첨부해주세요. + ## 💡 Reviewer 참고사항 > 리뷰에 참고할 내용을 작성해주세요. + ## ✅ Checklist - [ ] PR 제목 설정 완료 ([WTH-123] 인증 필터 설정) - [ ] 테스트 구현 완료 diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 00000000..2235b905 --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,55 @@ +name-template: "v$RESOLVED_VERSION" +tag-template: "v$RESOLVED_VERSION" + +categories: + - title: "✨ Features" + labels: ["✨ Feature"] + - title: "🐞 Bug Fixes" + labels: ["🐞 BugFix"] + - title: "🔐 Security" + labels: ["🔐 Security"] + - title: "🔨 Refactors" + labels: ["🔨 Refactor"] + - title: "✅ Tests" + labels: ["✅ Test"] + - title: "📃 Docs" + labels: ["📃 Docs"] + - title: "🌏 Deploy" + labels: ["🌏 Deploy"] + - title: "⚙ Settings" + labels: ["⚙ Setting"] + - title: "💻 Cross Browsing" + labels: ["💻 CrossBrowsing"] + - title: "📬 API" + labels: ["📬 API"] + - title: "🤖 Agent" + labels: ["🤖 Agent"] + +change-template: "- $TITLE (#$NUMBER) @$AUTHOR" +change-title-escapes: "<*_&" + +version-resolver: + major: + labels: ["💥 Breaking"] + + minor: + labels: ["✨ Feature"] + + patch: + labels: + - "🐞 BugFix" + - "🔐 Security" + - "🔨 Refactor" + - "✅ Test" + - "📃 Docs" + - "🌏 Deploy" + - "⚙ Setting" + - "💻 CrossBrowsing" + - "📬 API" + - "🤖 Agent" + + default: patch + +template: | + ## Changes + $CHANGES diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..cb0cfdd4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + pull_request: + branches: [dev, main] + push: + branches: [dev, main] + +jobs: + ci: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "21" + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Check ktlint + run: ./gradlew ktlintCheck --no-daemon + + - name: Build and test + run: ./gradlew clean test --no-daemon diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml new file mode 100644 index 00000000..73b7bcba --- /dev/null +++ b/.github/workflows/deploy-dev.yml @@ -0,0 +1,90 @@ +name: Deploy Dev + +on: + workflow_run: + workflows: ["CI"] + branches: [dev] + types: [completed] + +permissions: + contents: read + +env: + IMAGE_REPOSITORY: ${{ secrets.DOCKERHUB_REPOSITORY }} + +jobs: + deploy: + runs-on: ubuntu-latest + if: > + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'dev' + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.event.workflow_run.head_sha }} + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "21" + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build JAR + run: ./gradlew clean build -x test --no-daemon + + # ARM Docker Build 설정 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push dev image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + platforms: linux/arm64 # t4g small 배포를 위한 arm platform 설정 + push: true + tags: | + ${{ env.IMAGE_REPOSITORY }}:dev-latest + ${{ env.IMAGE_REPOSITORY }}:dev-${{ github.event.workflow_run.head_sha }} + + # /infra/dev 하위 파일 EC2로 복사 + - name: Upload deployment files to EC2 + uses: appleboy/scp-action@v1 + with: + host: ${{ secrets.DEV_EC2_HOST }} + username: ${{ secrets.DEV_EC2_USER }} + key: ${{ secrets.DEV_EC2_SSH_KEY }} + source: "infra/dev" + target: "${{ secrets.DEV_DEPLOY_DIR }}" + + - name: Deploy to dev server + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.DEV_EC2_HOST }} + username: ${{ secrets.DEV_EC2_USER }} + key: ${{ secrets.DEV_EC2_SSH_KEY }} + script: | + set -euo pipefail + DEPLOY_DIR="${{ secrets.DEV_DEPLOY_DIR }}/infra/dev" + chmod +x "$DEPLOY_DIR/scripts/deploy.sh" + + APP_IMAGE="${{ env.IMAGE_REPOSITORY }}:dev-${{ github.event.workflow_run.head_sha }}" \ + DOMAIN="${{ secrets.DEV_DOMAIN }}" \ + DEPLOY_DIR="$DEPLOY_DIR" \ + "$DEPLOY_DIR/scripts/deploy.sh" diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml new file mode 100644 index 00000000..9dce6fcb --- /dev/null +++ b/.github/workflows/deploy-prod.yml @@ -0,0 +1,82 @@ +name: Deploy Prod + +on: + release: + types: [published] + +permissions: + contents: read + +env: + IMAGE_REPOSITORY: ${{ secrets.DOCKERHUB_REPOSITORY }} + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "21" + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build JAR + run: ./gradlew clean build -x test --no-daemon + + # ARM Docker Build 설정 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push prod image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + platforms: linux/arm64 # t4g small 배포를 위한 arm platform 설정 + push: true + tags: | + ${{ env.IMAGE_REPOSITORY }}:${{ github.event.release.tag_name }} + ${{ env.IMAGE_REPOSITORY }}:prod-latest + + # /infra/prod 하위 파일 EC2로 복사 + - name: Upload deployment files to EC2 + uses: appleboy/scp-action@v1 + with: + host: ${{ secrets.PROD_EC2_HOST }} + username: ${{ secrets.PROD_EC2_USER }} + key: ${{ secrets.PROD_EC2_SSH_KEY }} + source: "infra/prod" + target: "${{ secrets.PROD_DEPLOY_DIR }}" + + - name: Deploy to prod server + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.PROD_EC2_HOST }} + username: ${{ secrets.PROD_EC2_USER }} + key: ${{ secrets.PROD_EC2_SSH_KEY }} + script: | + set -euo pipefail + DEPLOY_DIR="${{ secrets.PROD_DEPLOY_DIR }}/infra/prod" + chmod +x "$DEPLOY_DIR/scripts/deploy.sh" + + APP_IMAGE="${{ env.IMAGE_REPOSITORY }}:${{ github.event.release.tag_name }}" \ + DOMAIN="${{ secrets.PROD_DOMAIN }}" \ + DEPLOY_DIR="$DEPLOY_DIR" \ + "$DEPLOY_DIR/scripts/deploy.sh" diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml new file mode 100644 index 00000000..bf4d23a3 --- /dev/null +++ b/.github/workflows/release-drafter.yml @@ -0,0 +1,19 @@ +name: Release Drafter + +on: + push: + branches: [main] + +permissions: + contents: write + pull-requests: read + +jobs: + update_release_draft: + runs-on: ubuntu-latest + steps: + - uses: release-drafter/release-drafter@v6 + with: + config-name: release-drafter.yml + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 9414d074..784ceabb 100644 --- a/.gitignore +++ b/.gitignore @@ -39,7 +39,7 @@ out/ /.idea/ ### Environment Variables ### -src/main/resources/.env +src/main/resources/*.env src/main/resources/*.p8 .env.local .env.*.local diff --git a/CLAUDE.md b/CLAUDE.md index 5f4c558d..1b029baa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ Weeth Server is a community platform backend built with Spring Boot 3.5.10. The ./gradlew bootRun --args='--spring.profiles.active=dev' # Run with specific profile ``` -**Prerequisites:** JDK 17, MySQL 8.0, Redis 7.0+, environment variables configured in `.env` +**Prerequisites:** JDK 21, MySQL 8.0, Redis 7.0+, environment variables configured in `.env` **Profiles:** `local` (default dev), `dev` (dev server, ddl-auto: update), `prod` (Swagger disabled, ddl-auto: validate), `test` @@ -101,4 +101,4 @@ JWT with symmetric key (JJWT 0.13.0), OAuth2 via Kakao and Apple. `@CurrentUser` ## Detailed Rules -Architecture, code style, testing, API design, exception handling, transactions, git conventions, and logging rules are documented in `.claude/rules/`. Refer to those files for comprehensive guidance on each topic. \ No newline at end of file +Architecture, code style, testing, API design, exception handling, transactions, git conventions, and logging rules are documented in `.claude/rules/`. Refer to those files for comprehensive guidance on each topic. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..afebd65d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM eclipse-temurin:21-jre-alpine + +WORKDIR /app + +COPY build/libs/*.jar app.jar + +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/Dockerfile-dev b/Dockerfile-dev deleted file mode 100644 index 0e8aefbd..00000000 --- a/Dockerfile-dev +++ /dev/null @@ -1,12 +0,0 @@ -# eclipse-temurin 17 버전의 alpine 리눅스 환경을 구성 -FROM eclipse-temurin:17-jdk-alpine - -# build가 되는 시점에 JAR_FILE이라는 변수 명에 build/libs/*.jar 선언 -# build/libs - gradle로 빌드했을 때 jar 파일이 생성되는 경로 -ARG JAR_FILE=build/libs/*.jar - -# JAR_FILE을 app.jar로 복사 -COPY ${JAR_FILE} docker-springboot.jar - -# 운영 및 개발에서 사용되는 환경 설정을 분리 -ENTRYPOINT ["java", "-Dspring.profiles.active=dev", "-jar", "/docker-springboot.jar"] diff --git a/Dockerfile-prod b/Dockerfile-prod deleted file mode 100644 index f2026e6a..00000000 --- a/Dockerfile-prod +++ /dev/null @@ -1,12 +0,0 @@ -# eclipse-temurin 17 버전의 alpine 리눅스 환경을 구성 -FROM eclipse-temurin:17-jdk-alpine - -# build가 되는 시점에 JAR_FILE이라는 변수 명에 build/libs/*.jar 선언 -# build/libs - gradle로 빌드했을 때 jar 파일이 생성되는 경로 -ARG JAR_FILE=build/libs/*.jar - -# JAR_FILE을 app.jar로 복사 -COPY ${JAR_FILE} docker-springboot.jar - -# 운영 및 개발에서 사용되는 환경 설정을 분리 -ENTRYPOINT ["java", "-Dspring.profiles.active=prod", "-jar", "/docker-springboot.jar"] diff --git a/README.md b/README.md index 6beb7c52..485a5d04 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ ## 🛠 기술 스택 ### Core -- **Language**: Kotlin 2.1.0 (Java 17에서 점진적 마이그레이션) +- **Language**: Kotlin 2.1.0 (Java 코드에서 점진적 마이그레이션) - **Framework**: Spring Boot 3.5.10 - **Build**: Gradle 8.12 (Kotlin DSL) @@ -45,7 +45,7 @@ ### 사전 요구사항 -- JDK 17 +- JDK 21 - MySQL 8.0 - Redis 7.0+ - Gradle 8.12 (Wrapper 포함) diff --git a/build.gradle.kts b/build.gradle.kts index 57ef4aa0..6b528cbf 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -17,10 +17,14 @@ version = "0.0.1-SNAPSHOT" java { toolchain { - languageVersion.set(JavaLanguageVersion.of(17)) + languageVersion.set(JavaLanguageVersion.of(21)) } } +kotlin { + jvmToolchain(21) +} + repositories { mavenCentral() } @@ -112,7 +116,7 @@ tasks.withType().configureEach { "-Xjsr305=strict", "-Xjvm-default=all", ) - jvmTarget.set(JvmTarget.JVM_17) + jvmTarget.set(JvmTarget.JVM_21) } } diff --git a/infra/dev/caddy/Caddyfile b/infra/dev/caddy/Caddyfile new file mode 100644 index 00000000..6f84aa2d --- /dev/null +++ b/infra/dev/caddy/Caddyfile @@ -0,0 +1,28 @@ +# {$DOMAIN} 은 Github Action에서 주입한다. +{$DOMAIN} { + + # 응답 압축 활성화 + # zstd: 최신 브라우저에서 더 효율적인 압축 + # gzip: 대부분 클라이언트와 호환되는 기본 압축 + encode zstd gzip + + # 보안 관련 HTTP 헤더 설정 + header { + + # HSTS (HTTP Strict Transport Security) + Strict-Transport-Security "max-age=31536000" + + # MIME 타입 스니핑 방지 + # 브라우저가 응답 타입을 추측하지 못하도록 하여 XSS 위험 감소 + X-Content-Type-Options "nosniff" + + # 클릭재킹 방지 + X-Frame-Options "DENY" + + # Referrer 정책 설정 + Referrer-Policy "strict-origin-when-cross-origin" + } + + # 실제 reverse_proxy 설정은 upstream.conf 파일에서 불러옴 + import /etc/caddy/upstream.conf +} diff --git a/infra/dev/caddy/upstream.conf b/infra/dev/caddy/upstream.conf new file mode 100644 index 00000000..74cf5084 --- /dev/null +++ b/infra/dev/caddy/upstream.conf @@ -0,0 +1 @@ +reverse_proxy weeth-dev-app-blue:8080 diff --git a/infra/dev/docker-compose.yml b/infra/dev/docker-compose.yml new file mode 100644 index 00000000..bfef6324 --- /dev/null +++ b/infra/dev/docker-compose.yml @@ -0,0 +1,84 @@ +name: weeth-dev + +services: + caddy: + image: caddy:2.8 + container_name: weeth-dev-caddy + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro + - ./caddy/upstream.conf:/etc/caddy/upstream.conf + - caddy_data:/data + - caddy_config:/config + environment: + DOMAIN: ${DOMAIN} + networks: + - web + + mysql: + image: mysql:8.0 + container_name: weeth-dev-mysql + restart: unless-stopped + env_file: + - .env + environment: + TZ: Asia/Seoul + ports: + - "127.0.0.1:3306:3306" + volumes: + - mysql_data:/var/lib/mysql + command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci + networks: + - web + + app-blue: + image: ${APP_IMAGE} + container_name: weeth-dev-app-blue + restart: unless-stopped + profiles: ["blue"] + env_file: + - .env + environment: + SPRING_PROFILES_ACTIVE: dev + TZ: Asia/Seoul + volumes: + - ${HOME}/keys:/app/keys:ro + ports: + - "127.0.0.1:18081:8080" + depends_on: + mysql: + condition: service_started + networks: + - web + + app-green: + image: ${APP_IMAGE} + container_name: weeth-dev-app-green + restart: unless-stopped + profiles: ["green"] + env_file: + - .env + environment: + SPRING_PROFILES_ACTIVE: dev + TZ: Asia/Seoul + volumes: + - ${HOME}/keys:/app/keys:ro + ports: + - "127.0.0.1:18082:8080" + depends_on: + mysql: + condition: service_started + networks: + - web + +networks: + web: + driver: bridge + +volumes: + caddy_data: + caddy_config: + mysql_data: diff --git a/infra/dev/scripts/deploy.sh b/infra/dev/scripts/deploy.sh new file mode 100755 index 00000000..bef723d8 --- /dev/null +++ b/infra/dev/scripts/deploy.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Github Action에서 주입한 환경변수 사용 +: "${APP_IMAGE:?APP_IMAGE is required}" +: "${DOMAIN:?DOMAIN is required}" +: "${DEPLOY_DIR:=/opt/weeth/dev}" + +cd "$DEPLOY_DIR" + +export APP_IMAGE DOMAIN + +# EC2 홈 디렉토리의 .env를 심링크 +ln -sf "$HOME/.env" "$DEPLOY_DIR/.env" + +if [ ! -f ./caddy/upstream.conf ]; then + echo "reverse_proxy weeth-dev-app-blue:8080" > ./caddy/upstream.conf +fi + +if grep -q "app-blue" ./caddy/upstream.conf; then + NEW_COLOR="green" + NEW_HEALTH_PORT="18082" + OLD_COLOR="blue" +else + NEW_COLOR="blue" + NEW_HEALTH_PORT="18081" + OLD_COLOR="green" +fi + +echo "[deploy] image=$APP_IMAGE new_color=$NEW_COLOR old_color=$OLD_COLOR" + +docker compose --profile "$NEW_COLOR" -f docker-compose.yml pull "app-$NEW_COLOR" +docker compose --profile "$NEW_COLOR" -f docker-compose.yml up -d "app-$NEW_COLOR" + +for i in {1..20}; do + if curl -fsS "http://127.0.0.1:${NEW_HEALTH_PORT}/actuator/health" >/dev/null; then + echo "[deploy] new app is healthy" + break + fi + + if [ "$i" -eq 20 ]; then + echo "[deploy] health check failed" + exit 1 + fi + + sleep 3 +done + +echo "reverse_proxy weeth-dev-app-${NEW_COLOR}:8080" > ./caddy/upstream.conf + +# Caddy가 실행 중이면 reload, 아니면 시작 +if docker compose ps caddy --format '{{.State}}' 2>/dev/null | grep -q running; then + docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile +else + docker compose up -d caddy +fi + +docker compose --profile "$OLD_COLOR" -f docker-compose.yml stop "app-$OLD_COLOR" || true +docker compose --profile "$OLD_COLOR" -f docker-compose.yml rm -f "app-$OLD_COLOR" || true + +echo "[deploy] completed" diff --git a/infra/prod/caddy/Caddyfile b/infra/prod/caddy/Caddyfile new file mode 100644 index 00000000..6f84aa2d --- /dev/null +++ b/infra/prod/caddy/Caddyfile @@ -0,0 +1,28 @@ +# {$DOMAIN} 은 Github Action에서 주입한다. +{$DOMAIN} { + + # 응답 압축 활성화 + # zstd: 최신 브라우저에서 더 효율적인 압축 + # gzip: 대부분 클라이언트와 호환되는 기본 압축 + encode zstd gzip + + # 보안 관련 HTTP 헤더 설정 + header { + + # HSTS (HTTP Strict Transport Security) + Strict-Transport-Security "max-age=31536000" + + # MIME 타입 스니핑 방지 + # 브라우저가 응답 타입을 추측하지 못하도록 하여 XSS 위험 감소 + X-Content-Type-Options "nosniff" + + # 클릭재킹 방지 + X-Frame-Options "DENY" + + # Referrer 정책 설정 + Referrer-Policy "strict-origin-when-cross-origin" + } + + # 실제 reverse_proxy 설정은 upstream.conf 파일에서 불러옴 + import /etc/caddy/upstream.conf +} diff --git a/infra/prod/caddy/upstream.conf b/infra/prod/caddy/upstream.conf new file mode 100644 index 00000000..7a77edec --- /dev/null +++ b/infra/prod/caddy/upstream.conf @@ -0,0 +1 @@ +reverse_proxy weeth-prod-app-blue:8080 diff --git a/infra/prod/docker-compose.yml b/infra/prod/docker-compose.yml new file mode 100644 index 00000000..f6e0df55 --- /dev/null +++ b/infra/prod/docker-compose.yml @@ -0,0 +1,61 @@ +name: weeth-prod + +services: + caddy: + image: caddy:2.8 + container_name: weeth-prod-caddy + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro + - ./caddy/upstream.conf:/etc/caddy/upstream.conf + - caddy_data:/data + - caddy_config:/config + environment: + DOMAIN: ${DOMAIN} + networks: + - web + + app-blue: + image: ${APP_IMAGE} + container_name: weeth-prod-app-blue + restart: unless-stopped + profiles: ["blue"] + env_file: + - .env + environment: + SPRING_PROFILES_ACTIVE: prod + TZ: Asia/Seoul + volumes: + - ${HOME}/keys:/app/keys:ro + ports: + - "127.0.0.1:18081:8080" + networks: + - web + + app-green: + image: ${APP_IMAGE} + container_name: weeth-prod-app-green + restart: unless-stopped + profiles: ["green"] + env_file: + - .env + environment: + SPRING_PROFILES_ACTIVE: prod + TZ: Asia/Seoul + volumes: + - ${HOME}/keys:/app/keys:ro + ports: + - "127.0.0.1:18082:8080" + networks: + - web + +networks: + web: + driver: bridge + +volumes: + caddy_data: + caddy_config: diff --git a/infra/prod/scripts/deploy.sh b/infra/prod/scripts/deploy.sh new file mode 100755 index 00000000..2033b362 --- /dev/null +++ b/infra/prod/scripts/deploy.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Github Action에서 주입한 환경변수 사용 +: "${APP_IMAGE:?APP_IMAGE is required}" +: "${DOMAIN:?DOMAIN is required}" +: "${DEPLOY_DIR:=/opt/weeth/prod}" + +cd "$DEPLOY_DIR" + +export APP_IMAGE DOMAIN + +# EC2 홈 디렉토리의 .env를 심링크 +ln -sf "$HOME/.env" "$DEPLOY_DIR/.env" + +if [ ! -f ./caddy/upstream.conf ]; then + echo "reverse_proxy weeth-prod-app-blue:8080" > ./caddy/upstream.conf +fi + +if grep -q "app-blue" ./caddy/upstream.conf; then + NEW_COLOR="green" + NEW_HEALTH_PORT="18082" + OLD_COLOR="blue" +else + NEW_COLOR="blue" + NEW_HEALTH_PORT="18081" + OLD_COLOR="green" +fi + +echo "[deploy] image=$APP_IMAGE new_color=$NEW_COLOR old_color=$OLD_COLOR" + +docker compose --profile "$NEW_COLOR" -f docker-compose.yml pull "app-$NEW_COLOR" +docker compose --profile "$NEW_COLOR" -f docker-compose.yml up -d "app-$NEW_COLOR" + +for i in {1..20}; do + if curl -fsS "http://127.0.0.1:${NEW_HEALTH_PORT}/actuator/health" >/dev/null; then + echo "[deploy] new app is healthy" + break + fi + + if [ "$i" -eq 20 ]; then + echo "[deploy] health check failed" + exit 1 + fi + + sleep 3 +done + +echo "reverse_proxy weeth-prod-app-${NEW_COLOR}:8080" > ./caddy/upstream.conf + +# Caddy가 실행 중이면 reload, 아니면 시작 +if docker compose ps caddy --format '{{.State}}' 2>/dev/null | grep -q running; then + docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile +else + docker compose up -d caddy +fi + +docker compose --profile "$OLD_COLOR" -f docker-compose.yml stop "app-$OLD_COLOR" || true +docker compose --profile "$OLD_COLOR" -f docker-compose.yml rm -f "app-$OLD_COLOR" || true + +echo "[deploy] completed" diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/NoticeUsecaseImplTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/NoticeUsecaseImplTest.kt index ad55bd62..f5d83e1e 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/NoticeUsecaseImplTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/NoticeUsecaseImplTest.kt @@ -7,9 +7,8 @@ import com.weeth.domain.board.domain.service.NoticeDeleteService import com.weeth.domain.board.domain.service.NoticeFindService import com.weeth.domain.board.domain.service.NoticeSaveService import com.weeth.domain.board.domain.service.NoticeUpdateService -import com.weeth.domain.comment.application.mapper.CommentMapper -import com.weeth.domain.user.domain.service.UserGetService import com.weeth.domain.board.fixture.NoticeTestFixture +import com.weeth.domain.comment.application.mapper.CommentMapper import com.weeth.domain.file.application.dto.request.FileSaveRequest import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.entity.File @@ -21,6 +20,7 @@ import com.weeth.domain.user.domain.entity.User import com.weeth.domain.user.domain.entity.enums.Department import com.weeth.domain.user.domain.entity.enums.Position import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.service.UserGetService import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.booleans.shouldBeFalse From f9836b87c7004eebb97a41a2973d8e5bb2c485b4 Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Tue, 17 Feb 2026 12:23:02 +0900 Subject: [PATCH 02/73] =?UTF-8?q?[WTH-140]=20Deploy=20Job=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20=20(#5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * deploy: job 분리 * chore: 개행 추가 --- .github/workflows/deploy-dev.yml | 19 +++++++++++++++---- .github/workflows/deploy-prod.yml | 11 ++++++++++- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index 73b7bcba..9fd57bee 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -2,9 +2,9 @@ name: Deploy Dev on: workflow_run: - workflows: ["CI"] - branches: [dev] - types: [completed] + workflows: [ "CI" ] + branches: [ dev ] + types: [ completed ] permissions: contents: read @@ -13,7 +13,7 @@ env: IMAGE_REPOSITORY: ${{ secrets.DOCKERHUB_REPOSITORY }} jobs: - deploy: + build: runs-on: ubuntu-latest if: > github.event.workflow_run.conclusion == 'success' && @@ -63,6 +63,17 @@ jobs: ${{ env.IMAGE_REPOSITORY }}:dev-latest ${{ env.IMAGE_REPOSITORY }}:dev-${{ github.event.workflow_run.head_sha }} + deploy: + needs: build + runs-on: ubuntu-latest + if: needs.build.result == 'success' + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.event.workflow_run.head_sha }} + # /infra/dev 하위 파일 EC2로 복사 - name: Upload deployment files to EC2 uses: appleboy/scp-action@v1 diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 9dce6fcb..a89d0ac5 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -11,7 +11,7 @@ env: IMAGE_REPOSITORY: ${{ secrets.DOCKERHUB_REPOSITORY }} jobs: - deploy: + build: runs-on: ubuntu-latest steps: @@ -55,6 +55,15 @@ jobs: ${{ env.IMAGE_REPOSITORY }}:${{ github.event.release.tag_name }} ${{ env.IMAGE_REPOSITORY }}:prod-latest + deploy: + needs: build + runs-on: ubuntu-latest + if: needs.build.result == 'success' + + steps: + - name: Checkout + uses: actions/checkout@v6 + # /infra/prod 하위 파일 EC2로 복사 - name: Upload deployment files to EC2 uses: appleboy/scp-action@v1 From 828596a3fae364fa807e7bd364a643aa8bb09aea Mon Sep 17 00:00:00 2001 From: hyxklee Date: Tue, 17 Feb 2026 12:48:16 +0900 Subject: [PATCH 03/73] =?UTF-8?q?hotfix:=20=EB=A0=88=EB=94=94=EC=8A=A4=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/dev/docker-compose.yml | 16 ++++++++++++++++ infra/prod/docker-compose.yml | 18 ++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/infra/dev/docker-compose.yml b/infra/dev/docker-compose.yml index bfef6324..4a209d75 100644 --- a/infra/dev/docker-compose.yml +++ b/infra/dev/docker-compose.yml @@ -34,6 +34,17 @@ services: networks: - web + redis: + image: redis:7.0 + container_name: weeth-dev-redis + restart: unless-stopped + ports: + - "127.0.0.1:6379:6379" + volumes: + - redis_data:/data + networks: + - web + app-blue: image: ${APP_IMAGE} container_name: weeth-dev-app-blue @@ -51,6 +62,8 @@ services: depends_on: mysql: condition: service_started + redis: + condition: service_started networks: - web @@ -71,6 +84,8 @@ services: depends_on: mysql: condition: service_started + redis: + condition: service_started networks: - web @@ -82,3 +97,4 @@ volumes: caddy_data: caddy_config: mysql_data: + redis_data: diff --git a/infra/prod/docker-compose.yml b/infra/prod/docker-compose.yml index f6e0df55..0c779c5b 100644 --- a/infra/prod/docker-compose.yml +++ b/infra/prod/docker-compose.yml @@ -18,6 +18,17 @@ services: networks: - web + redis: + image: redis:7.0 + container_name: weeth-prod-redis + restart: unless-stopped + ports: + - "127.0.0.1:6379:6379" + volumes: + - redis_data:/data + networks: + - web + app-blue: image: ${APP_IMAGE} container_name: weeth-prod-app-blue @@ -32,6 +43,9 @@ services: - ${HOME}/keys:/app/keys:ro ports: - "127.0.0.1:18081:8080" + depends_on: + redis: + condition: service_started networks: - web @@ -49,6 +63,9 @@ services: - ${HOME}/keys:/app/keys:ro ports: - "127.0.0.1:18082:8080" + depends_on: + redis: + condition: service_started networks: - web @@ -59,3 +76,4 @@ networks: volumes: caddy_data: caddy_config: + redis_data: From 57b2cc173f6775099519ed193de5a3eda3a5e97a Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:37:44 +0900 Subject: [PATCH 04/73] =?UTF-8?q?[WTH-143]=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=B0=8F=20=EC=98=88=EC=99=B8=20=EC=98=88?= =?UTF-8?q?=EC=8B=9C=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95=20(#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 커스텀 예외 코드 반환 * fix: 그룹 페이지에 각각 커스터마이저 등록 --- .../weeth/global/common/exception/CommonExceptionHandler.java | 4 +++- .../java/com/weeth/global/config/swagger/SwaggerConfig.java | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/weeth/global/common/exception/CommonExceptionHandler.java b/src/main/java/com/weeth/global/common/exception/CommonExceptionHandler.java index e711dc88..394779a3 100644 --- a/src/main/java/com/weeth/global/common/exception/CommonExceptionHandler.java +++ b/src/main/java/com/weeth/global/common/exception/CommonExceptionHandler.java @@ -24,7 +24,9 @@ public ResponseEntity> handle(BaseException ex) { log.warn("구체로그: ", ex); log.warn(LOG_FORMAT, ex.getClass().getSimpleName(), ex.getStatusCode(), ex.getMessage()); - CommonResponse response = CommonResponse.createFailure(ex.getStatusCode(), ex.getMessage()); + CommonResponse response = ex.getErrorCode() != null + ? CommonResponse.error(ex.getErrorCode()) + : CommonResponse.createFailure(ex.getStatusCode(), ex.getMessage()); return ResponseEntity .status(ex.getStatusCode()) diff --git a/src/main/java/com/weeth/global/config/swagger/SwaggerConfig.java b/src/main/java/com/weeth/global/config/swagger/SwaggerConfig.java index 971955f7..afd607e6 100644 --- a/src/main/java/com/weeth/global/config/swagger/SwaggerConfig.java +++ b/src/main/java/com/weeth/global/config/swagger/SwaggerConfig.java @@ -84,6 +84,7 @@ public GroupedOpenApi adminApi() { return GroupedOpenApi.builder() .group("admin") .pathsToMatch("/api/v1/admin/**") + .addOperationCustomizer(operationCustomizer()) .build(); } @@ -91,8 +92,8 @@ public GroupedOpenApi adminApi() { public GroupedOpenApi publicApi() { return GroupedOpenApi.builder() .group("public") - .pathsToMatch("/api/v1/**") .pathsToExclude("/api/v1/admin/**") + .addOperationCustomizer(operationCustomizer()) .build(); } From 734e2ae01af0b9a45afb69cc2ff9204e9e3f1890 Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Wed, 18 Feb 2026 10:07:32 +0900 Subject: [PATCH 05/73] =?UTF-8?q?[WTH-139]=20file=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=BD=94=ED=8B=80=EB=A6=B0=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20(#6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: File 엔티티 구조 변경 및 마이그레이션 * refactor: 기존 서비스 제거 * refactor: FileRepository 마이그레이션 * refactor: 예외 마이그레이션 * refactor: 매퍼 마이그레이션 * refactor: dto 마이그레이션 * refactor: 파일 엔티티 개선을 위한 Enum Class 추가 * refactor: 타 도메인 조회를 위한 Reader 인터페이스 추가 * refactor: 파일 업로드 URL 생성을 위한 Port/Adapter 구현 * refactor: 기존 유스케이스를 QueryService로 이전 * refactor: Controller 마이그레이션 * refactor: File 도메인 변경에 따른 기존 코드 수정 * refactor: File 도메인 변경에 따른 기존 코드 수정 * refactor: File 도메인 변경에 따른 기존 코드 수정 * refactor: port/adapter 컨벤션 수정 및 스웨거 노출 API prefix 제거 * refactor: 예외 발생 원인 유지 * test: 테스트 추가 * feat: S3/CDN url 반환 로직 구현 * refactor: 주석 추가 * feat: S3/CDN url 반환 로직 구현 * feat: S3/CDN url 반환 환경변수 설정 * refactor: 주석 위치 수정 * refactor: 하위호환 * test: s3 presigned url 테스트 추가 * feat: VO 분리 * refactor: 미사용 객체 제거 * refactor: VO 규격에 맞게 테스트 대응 * refactor: lint 설정 * refactor: Repository 테스트 구현 및 불필요 코드 정리 * refactor: code-review skill 오류 해결 * refactor: 단일 enum 파일로 확장자 관리 * refactor: lint 설정 * refactor: 수정시 null check 추가 * refactor: command로 이동 * refactor: 입력 검증 수정 --- .claude/rules/architecture.md | 4 +- .claude/skills/code-review/SKILL.md | 16 +- .../usecase/AccountUseCaseImpl.java | 7 +- .../usecase/ReceiptUseCaseImpl.java | 27 ++-- .../usecase/NoticeUsecaseImpl.java | 33 +++-- .../application/usecase/PostUseCaseImpl.java | 39 +++-- .../usecase/NoticeCommentUsecaseImpl.java | 25 ++-- .../usecase/PostCommentUsecaseImpl.java | 25 ++-- .../dto/request/FileSaveRequest.java | 10 -- .../dto/request/FileUpdateRequest.java | 12 -- .../dto/response/FileResponse.java | 8 - .../application/dto/response/UrlResponse.java | 7 - .../file/application/mapper/FileMapper.java | 72 --------- .../usecase/FileManageUseCase.java | 22 --- .../weeth/domain/file/domain/entity/File.java | 50 ------- .../domain/repository/FileRepository.java | 17 --- .../domain/service/FileDeleteService.java | 23 --- .../file/domain/service/FileGetService.java | 31 ---- .../file/domain/service/FileSaveService.java | 22 --- .../file/domain/service/PreSignedService.java | 51 ------- .../file/presentation/FileController.java | 29 ---- .../file/presentation/FileResponseCode.java | 22 --- .../common/exception/BaseException.java | 14 +- .../dto/request/FileSaveRequest.kt | 20 +++ .../application/dto/response/FileResponse.kt | 24 +++ .../application/dto/response/UrlResponse.kt | 12 ++ .../application/exception/FileErrorCode.kt | 30 ++++ .../exception/FileNotFoundException.kt | 5 + .../PresignedUrlGenerationException.kt | 7 + .../UnsupportedFileContentTypeException.kt | 5 + .../UnsupportedFileExtensionException.kt | 5 + .../file/application/mapper/FileMapper.kt | 52 +++++++ .../usecase/command/GenerateFileUrlUsecase.kt | 21 +++ .../weeth/domain/file/domain/entity/File.kt | 73 +++++++++ .../file/domain/entity/FileOwnerType.kt | 8 + .../domain/file/domain/entity/FileStatus.kt | 6 + .../file/domain/port/FileAccessUrlPort.kt | 12 ++ .../file/domain/port/FileUploadUrlPort.kt | 17 +++ .../file/domain/repository/FileReader.kt | 19 +++ .../file/domain/repository/FileRepository.kt | 48 ++++++ .../domain/file/domain/vo/FileContentType.kt | 20 +++ .../domain/file/domain/vo/FileExtension.kt | 15 ++ .../weeth/domain/file/domain/vo/FileName.kt | 19 +++ .../weeth/domain/file/domain/vo/FileType.kt | 29 ++++ .../weeth/domain/file/domain/vo/StorageKey.kt | 23 +++ .../infrastructure/CdnFileAccessUrlAdapter.kt | 26 ++++ .../infrastructure/S3FileAccessUrlAdapter.kt | 24 +++ .../infrastructure/S3FileUploadUrlAdapter.kt | 66 +++++++++ .../file/presentation/FileController.kt | 39 +++++ .../file/presentation/FileResponseCode.kt | 12 ++ src/main/resources/application.yml | 5 + .../usecase/ReceiptUseCaseImplTest.kt | 97 ++++++++++++ .../usecase/NoticeUsecaseImplTest.kt | 69 +++++---- .../usecase/PostUseCaseImplTest.kt | 38 +++-- .../usecase/NoticeCommentUsecaseImplTest.kt | 20 ++- .../file/application/mapper/FileMapperTest.kt | 47 ++++++ .../command/GenerateFileUrlUsecaseTest.kt | 63 ++++++++ .../domain/file/domain/entity/FileTest.kt | 138 ++++++++++++++++++ .../domain/repository/FileRepositoryTest.kt | 109 ++++++++++++++ .../domain/file/domain/vo/FileNameTest.kt | 25 ++++ .../domain/file/domain/vo/FileTypeTest.kt | 20 +++ .../domain/file/fixture/FileTestFixture.kt | 27 ++-- .../FileAccessUrlAdapterTest.kt | 42 ++++++ .../S3FileUploadUrlAdapterTest.kt | 65 +++++++++ 64 files changed, 1433 insertions(+), 535 deletions(-) delete mode 100644 src/main/java/com/weeth/domain/file/application/dto/request/FileSaveRequest.java delete mode 100644 src/main/java/com/weeth/domain/file/application/dto/request/FileUpdateRequest.java delete mode 100644 src/main/java/com/weeth/domain/file/application/dto/response/FileResponse.java delete mode 100644 src/main/java/com/weeth/domain/file/application/dto/response/UrlResponse.java delete mode 100644 src/main/java/com/weeth/domain/file/application/mapper/FileMapper.java delete mode 100644 src/main/java/com/weeth/domain/file/application/usecase/FileManageUseCase.java delete mode 100644 src/main/java/com/weeth/domain/file/domain/entity/File.java delete mode 100644 src/main/java/com/weeth/domain/file/domain/repository/FileRepository.java delete mode 100644 src/main/java/com/weeth/domain/file/domain/service/FileDeleteService.java delete mode 100644 src/main/java/com/weeth/domain/file/domain/service/FileGetService.java delete mode 100644 src/main/java/com/weeth/domain/file/domain/service/FileSaveService.java delete mode 100644 src/main/java/com/weeth/domain/file/domain/service/PreSignedService.java delete mode 100644 src/main/java/com/weeth/domain/file/presentation/FileController.java delete mode 100644 src/main/java/com/weeth/domain/file/presentation/FileResponseCode.java create mode 100644 src/main/kotlin/com/weeth/domain/file/application/dto/request/FileSaveRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/file/application/dto/response/FileResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/file/application/dto/response/UrlResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/file/application/exception/FileErrorCode.kt create mode 100644 src/main/kotlin/com/weeth/domain/file/application/exception/FileNotFoundException.kt create mode 100644 src/main/kotlin/com/weeth/domain/file/application/exception/PresignedUrlGenerationException.kt create mode 100644 src/main/kotlin/com/weeth/domain/file/application/exception/UnsupportedFileContentTypeException.kt create mode 100644 src/main/kotlin/com/weeth/domain/file/application/exception/UnsupportedFileExtensionException.kt create mode 100644 src/main/kotlin/com/weeth/domain/file/application/mapper/FileMapper.kt create mode 100644 src/main/kotlin/com/weeth/domain/file/application/usecase/command/GenerateFileUrlUsecase.kt create mode 100644 src/main/kotlin/com/weeth/domain/file/domain/entity/File.kt create mode 100644 src/main/kotlin/com/weeth/domain/file/domain/entity/FileOwnerType.kt create mode 100644 src/main/kotlin/com/weeth/domain/file/domain/entity/FileStatus.kt create mode 100644 src/main/kotlin/com/weeth/domain/file/domain/port/FileAccessUrlPort.kt create mode 100644 src/main/kotlin/com/weeth/domain/file/domain/port/FileUploadUrlPort.kt create mode 100644 src/main/kotlin/com/weeth/domain/file/domain/repository/FileReader.kt create mode 100644 src/main/kotlin/com/weeth/domain/file/domain/repository/FileRepository.kt create mode 100644 src/main/kotlin/com/weeth/domain/file/domain/vo/FileContentType.kt create mode 100644 src/main/kotlin/com/weeth/domain/file/domain/vo/FileExtension.kt create mode 100644 src/main/kotlin/com/weeth/domain/file/domain/vo/FileName.kt create mode 100644 src/main/kotlin/com/weeth/domain/file/domain/vo/FileType.kt create mode 100644 src/main/kotlin/com/weeth/domain/file/domain/vo/StorageKey.kt create mode 100644 src/main/kotlin/com/weeth/domain/file/infrastructure/CdnFileAccessUrlAdapter.kt create mode 100644 src/main/kotlin/com/weeth/domain/file/infrastructure/S3FileAccessUrlAdapter.kt create mode 100644 src/main/kotlin/com/weeth/domain/file/infrastructure/S3FileUploadUrlAdapter.kt create mode 100644 src/main/kotlin/com/weeth/domain/file/presentation/FileController.kt create mode 100644 src/main/kotlin/com/weeth/domain/file/presentation/FileResponseCode.kt create mode 100644 src/test/kotlin/com/weeth/domain/account/application/usecase/ReceiptUseCaseImplTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/file/application/mapper/FileMapperTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/file/application/usecase/command/GenerateFileUrlUsecaseTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/file/domain/entity/FileTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/file/domain/repository/FileRepositoryTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/file/domain/vo/FileNameTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/file/domain/vo/FileTypeTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/file/infrastructure/FileAccessUrlAdapterTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/file/infrastructure/S3FileUploadUrlAdapterTest.kt diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index bd296c71..0df5ad09 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -154,8 +154,8 @@ class User( ## Port-Adapter Pattern -- **Port** (`domain/port/`): interface in domain language, no tech names → `FileStorage`, `PushNotificationSender` -- **Adapter** (`infrastructure/`): implementation with tech prefix → `S3FileStorage`, `FcmPushNotificationSender` +- **Port** (`domain/port/`): interface in domain language → `FileStoragePort`, `PushNotificationSenderPort` +- **Adapter** (`infrastructure/`): implementation with tech prefix → `S3FileStorageAdapter`, `FcmPushNotificationSenderAdapter` - UseCase depends on Port interface only → swappable, testable ## Core Principles diff --git a/.claude/skills/code-review/SKILL.md b/.claude/skills/code-review/SKILL.md index 134a4eb0..5e5d9739 100644 --- a/.claude/skills/code-review/SKILL.md +++ b/.claude/skills/code-review/SKILL.md @@ -36,16 +36,16 @@ For each issue provide: ## Review Checklist ### Bug/Logic -- Null safety (no `!!`, use nullable types) +- Null safety (avoid "!!", use nullable types) - Edge case handling -- Exception handling (must extend `BaseException`) +- Exception handling (must extend BaseException) - Concurrency issues (race conditions) ### Security - SQL Injection (raw queries, string concatenation) - Sensitive data exposure (logs, responses) -- Missing auth (`@CurrentUser` usage) -- Input validation (`@Valid`, `@NotNull`, `@NotBlank`) +- Missing auth (@CurrentUser usage) +- Input validation (@Valid, @NotNull, @NotBlank) ### Performance - N+1 query (repository calls inside loops) @@ -56,17 +56,17 @@ For each issue provide: - Layer adherence: Controller → UseCase → Repository (UseCase uses Repository directly) - Rich Domain Model: business logic in Entity, not UseCase - No thin wrapper services (GetService/SaveService) — Domain Service only for multi-entity logic -- `@Transactional` only on UseCase methods +- @Transactional only on UseCase methods - Port-Adapter: UseCase depends on Port interface, not infrastructure directly - Cross-domain read via Reader interface, cross-domain write via Repository directly - No layer skipping (Controller → Repository is forbidden) ### Kotlin-specific -- `val` over `var` +- val over var - Nullable type overuse -- Scope function opportunities (`let`, `apply`, `also`) +- Scope function opportunities (let, apply, also) - data class for DTOs -- `when` expression over if-else chains +- when expression over if-else chains ## Output Format diff --git a/src/main/java/com/weeth/domain/account/application/usecase/AccountUseCaseImpl.java b/src/main/java/com/weeth/domain/account/application/usecase/AccountUseCaseImpl.java index 179f0c09..d2d9ca00 100644 --- a/src/main/java/com/weeth/domain/account/application/usecase/AccountUseCaseImpl.java +++ b/src/main/java/com/weeth/domain/account/application/usecase/AccountUseCaseImpl.java @@ -12,7 +12,8 @@ import com.weeth.domain.account.domain.service.ReceiptGetService; import com.weeth.domain.file.application.dto.response.FileResponse; import com.weeth.domain.file.application.mapper.FileMapper; -import com.weeth.domain.file.domain.service.FileGetService; +import com.weeth.domain.file.domain.entity.FileOwnerType; +import com.weeth.domain.file.domain.repository.FileReader; import com.weeth.domain.user.domain.service.CardinalGetService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -27,7 +28,7 @@ public class AccountUseCaseImpl implements AccountUseCase { private final AccountGetService accountGetService; private final AccountSaveService accountSaveService; private final ReceiptGetService receiptGetService; - private final FileGetService fileGetService; + private final FileReader fileReader; private final CardinalGetService cardinalGetService; private final AccountMapper accountMapper; @@ -60,7 +61,7 @@ private void validate(AccountDTO.Save dto) { } private List getFiles(Long receiptId) { - return fileGetService.findAllByReceipt(receiptId).stream() + return fileReader.findAll(FileOwnerType.RECEIPT, receiptId, null).stream() .map(fileMapper::toFileResponse) .toList(); } diff --git a/src/main/java/com/weeth/domain/account/application/usecase/ReceiptUseCaseImpl.java b/src/main/java/com/weeth/domain/account/application/usecase/ReceiptUseCaseImpl.java index f755585f..0a646340 100644 --- a/src/main/java/com/weeth/domain/account/application/usecase/ReceiptUseCaseImpl.java +++ b/src/main/java/com/weeth/domain/account/application/usecase/ReceiptUseCaseImpl.java @@ -8,9 +8,9 @@ import com.weeth.domain.account.domain.service.*; import com.weeth.domain.file.application.mapper.FileMapper; import com.weeth.domain.file.domain.entity.File; -import com.weeth.domain.file.domain.service.FileDeleteService; -import com.weeth.domain.file.domain.service.FileGetService; -import com.weeth.domain.file.domain.service.FileSaveService; +import com.weeth.domain.file.domain.entity.FileOwnerType; +import com.weeth.domain.file.domain.repository.FileReader; +import com.weeth.domain.file.domain.repository.FileRepository; import com.weeth.domain.user.domain.service.CardinalGetService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -27,9 +27,8 @@ public class ReceiptUseCaseImpl implements ReceiptUseCase { private final ReceiptUpdateService receiptUpdateService; private final AccountGetService accountGetService; - private final FileGetService fileGetService; - private final FileSaveService fileSaveService; - private final FileDeleteService fileDeleteService; + private final FileReader fileReader; + private final FileRepository fileRepository; private final CardinalGetService cardinalGetService; @@ -46,8 +45,8 @@ public void save(ReceiptDTO.Save dto) { Receipt receipt = receiptSaveService.save(mapper.from(dto, account)); account.spend(receipt); - List files = fileMapper.toFileList(dto.files(), receipt); - fileSaveService.save(files); + List files = fileMapper.toFileList(dto.files(), FileOwnerType.RECEIPT, receipt.getId()); + fileRepository.saveAll(files); } @Override @@ -59,28 +58,28 @@ public void update(Long receiptId, ReceiptDTO.Update dto){ if(!dto.files().isEmpty()){ // 업데이트하려는 파일이 있다면 파일을 전체 삭제한 뒤 저장 List fileList = getFiles(receiptId); - fileDeleteService.delete(fileList); + fileRepository.deleteAll(fileList); - List files = fileMapper.toFileList(dto.files(), receipt); - fileSaveService.save(files); + List files = fileMapper.toFileList(dto.files(), FileOwnerType.RECEIPT, receipt.getId()); + fileRepository.saveAll(files); } receiptUpdateService.update(receipt, dto); account.spend(receipt); } private List getFiles(Long receiptId) { - return fileGetService.findAllByReceipt(receiptId); + return fileReader.findAll(FileOwnerType.RECEIPT, receiptId, null); } @Override @Transactional public void delete(Long id) { Receipt receipt = receiptGetService.find(id); - List fileList = fileGetService.findAllByReceipt(id); + List fileList = fileReader.findAll(FileOwnerType.RECEIPT, id, null); receipt.getAccount().cancel(receipt); - fileDeleteService.delete(fileList); + fileRepository.deleteAll(fileList); receiptDeleteService.delete(receipt); } } diff --git a/src/main/java/com/weeth/domain/board/application/usecase/NoticeUsecaseImpl.java b/src/main/java/com/weeth/domain/board/application/usecase/NoticeUsecaseImpl.java index a9d2fce4..ac5d5bf9 100644 --- a/src/main/java/com/weeth/domain/board/application/usecase/NoticeUsecaseImpl.java +++ b/src/main/java/com/weeth/domain/board/application/usecase/NoticeUsecaseImpl.java @@ -15,9 +15,9 @@ import com.weeth.domain.file.application.dto.response.FileResponse; import com.weeth.domain.file.application.mapper.FileMapper; import com.weeth.domain.file.domain.entity.File; -import com.weeth.domain.file.domain.service.FileDeleteService; -import com.weeth.domain.file.domain.service.FileGetService; -import com.weeth.domain.file.domain.service.FileSaveService; +import com.weeth.domain.file.domain.entity.FileOwnerType; +import com.weeth.domain.file.domain.repository.FileReader; +import com.weeth.domain.file.domain.repository.FileRepository; import com.weeth.domain.user.application.exception.UserNotMatchException; import com.weeth.domain.user.domain.entity.User; import com.weeth.domain.user.domain.service.UserGetService; @@ -45,9 +45,8 @@ public class NoticeUsecaseImpl implements NoticeUsecase { private final UserGetService userGetService; - private final FileSaveService fileSaveService; - private final FileGetService fileGetService; - private final FileDeleteService fileDeleteService; + private final FileRepository fileRepository; + private final FileReader fileReader; private final NoticeMapper mapper; private final CommentMapper commentMapper; @@ -61,8 +60,8 @@ public NoticeDTO.SaveResponse save(NoticeDTO.Save request, Long userId) { Notice notice = mapper.fromNoticeDto(request, user); Notice savedNotice = noticeSaveService.save(notice); - List files = fileMapper.toFileList(request.files(), notice); - fileSaveService.save(files); + List files = fileMapper.toFileList(request.files(), FileOwnerType.NOTICE, savedNotice.getId()); + fileRepository.saveAll(files); return mapper.toSaveResponse(savedNotice); } @@ -109,11 +108,13 @@ public Slice searchNotice(String keyword, int pageNumber, public NoticeDTO.SaveResponse update(Long noticeId, NoticeDTO.Update dto, Long userId) { Notice notice = validateOwner(noticeId, userId); - List fileList = getFiles(noticeId); - fileDeleteService.delete(fileList); + if (dto.files() != null) { + List fileList = getFiles(noticeId); + fileRepository.deleteAll(fileList); - List files = fileMapper.toFileList(dto.files(), notice); - fileSaveService.save(files); + List files = fileMapper.toFileList(dto.files(), FileOwnerType.NOTICE, notice.getId()); + fileRepository.saveAll(files); + } noticeUpdateService.update(notice, dto); @@ -126,13 +127,13 @@ public void delete(Long noticeId, Long userId) { validateOwner(noticeId, userId); List fileList = getFiles(noticeId); - fileDeleteService.delete(fileList); + fileRepository.deleteAll(fileList); noticeDeleteService.delete(noticeId); } private List getFiles(Long noticeId) { - return fileGetService.findAllByNotice(noticeId); + return fileReader.findAll(FileOwnerType.NOTICE, noticeId, null); } private Notice validateOwner(Long noticeId, Long userId) { @@ -144,7 +145,7 @@ private Notice validateOwner(Long noticeId, Long userId) { } private boolean checkFileExistsByNotice(Long noticeId){ - return !fileGetService.findAllByNotice(noticeId).isEmpty(); + return fileReader.exists(FileOwnerType.NOTICE, noticeId, null); } private List filterParentComments(List comments) { @@ -164,7 +165,7 @@ private CommentDTO.Response mapToDtoWithChildren(Comment comment, Map mapToDtoWithChildren(child, commentMap)) .collect(Collectors.toList()); - List files = fileGetService.findAllByComment(comment.getId()).stream() + List files = fileReader.findAll(FileOwnerType.COMMENT, comment.getId(), null).stream() .map(fileMapper::toFileResponse) .toList(); diff --git a/src/main/java/com/weeth/domain/board/application/usecase/PostUseCaseImpl.java b/src/main/java/com/weeth/domain/board/application/usecase/PostUseCaseImpl.java index ab8016db..0c25b13f 100644 --- a/src/main/java/com/weeth/domain/board/application/usecase/PostUseCaseImpl.java +++ b/src/main/java/com/weeth/domain/board/application/usecase/PostUseCaseImpl.java @@ -19,9 +19,9 @@ import com.weeth.domain.file.application.dto.response.FileResponse; import com.weeth.domain.file.application.mapper.FileMapper; import com.weeth.domain.file.domain.entity.File; -import com.weeth.domain.file.domain.service.FileDeleteService; -import com.weeth.domain.file.domain.service.FileGetService; -import com.weeth.domain.file.domain.service.FileSaveService; +import com.weeth.domain.file.domain.entity.FileOwnerType; +import com.weeth.domain.file.domain.repository.FileReader; +import com.weeth.domain.file.domain.repository.FileRepository; import com.weeth.domain.user.application.exception.UserNotMatchException; import com.weeth.domain.user.domain.entity.User; import com.weeth.domain.user.domain.entity.enums.Role; @@ -51,9 +51,8 @@ public class PostUseCaseImpl implements PostUsecase { private final UserCardinalGetService userCardinalGetService; private final CardinalGetService cardinalGetService; - private final FileSaveService fileSaveService; - private final FileGetService fileGetService; - private final FileDeleteService fileDeleteService; + private final FileRepository fileRepository; + private final FileReader fileReader; private final PostMapper mapper; private final FileMapper fileMapper; @@ -73,8 +72,8 @@ public PostDTO.SaveResponse save(PostDTO.Save request, Long userId) { Post post = mapper.fromPostDto(request, user); Post savedPost = postSaveService.save(post); - List files = fileMapper.toFileList(request.files(), post); - fileSaveService.save(files); + List files = fileMapper.toFileList(request.files(), FileOwnerType.POST, savedPost.getId()); + fileRepository.saveAll(files); return mapper.toSaveResponse(savedPost); } @@ -87,8 +86,8 @@ public PostDTO.SaveResponse saveEducation(PostDTO.SaveEducation request, Long us Post post = mapper.fromEducationDto(request, user); Post saverPost = postSaveService.save(post); - List files = fileMapper.toFileList(request.files(), post); - fileSaveService.save(files); + List files = fileMapper.toFileList(request.files(), FileOwnerType.POST, saverPost.getId()); + fileRepository.saveAll(files); return mapper.toSaveResponse(saverPost); } @@ -198,10 +197,10 @@ public PostDTO.SaveResponse update(Long postId, PostDTO.Update dto, Long userId) if (dto.files() != null) { List fileList = getFiles(postId); - fileDeleteService.delete(fileList); + fileRepository.deleteAll(fileList); - List files = fileMapper.toFileList(dto.files(), post); - fileSaveService.save(files); + List files = fileMapper.toFileList(dto.files(), FileOwnerType.POST, post.getId()); + fileRepository.saveAll(files); } postUpdateService.update(post, dto); @@ -216,10 +215,10 @@ public PostDTO.SaveResponse updateEducation(Long postId, PostDTO.UpdateEducation if (dto.files() != null) { List fileList = getFiles(postId); - fileDeleteService.delete(fileList); + fileRepository.deleteAll(fileList); - List files = fileMapper.toFileList(dto.files(), post); - fileSaveService.save(files); + List files = fileMapper.toFileList(dto.files(), FileOwnerType.POST, post.getId()); + fileRepository.saveAll(files); } postUpdateService.updateEducation(post, dto); @@ -233,13 +232,13 @@ public void delete(Long postId, Long userId) { validateOwner(postId, userId); List fileList = getFiles(postId); - fileDeleteService.delete(fileList); + fileRepository.deleteAll(fileList); postDeleteService.delete(postId); } private List getFiles(Long postId) { - return fileGetService.findAllByPost(postId); + return fileReader.findAll(FileOwnerType.POST, postId, null); } private Post validateOwner(Long postId, Long userId) { @@ -252,7 +251,7 @@ private Post validateOwner(Long postId, Long userId) { } public boolean checkFileExistsByPost(Long postId){ - return !fileGetService.findAllByPost(postId).isEmpty(); + return fileReader.exists(FileOwnerType.POST, postId, null); } private List filterParentComments(List comments) { @@ -272,7 +271,7 @@ private CommentDTO.Response mapToDtoWithChildren(Comment comment, Map mapToDtoWithChildren(child, commentMap)) .collect(Collectors.toList()); - List files = fileGetService.findAllByComment(comment.getId()).stream() + List files = fileReader.findAll(FileOwnerType.COMMENT, comment.getId(), null).stream() .map(fileMapper::toFileResponse) .toList(); diff --git a/src/main/java/com/weeth/domain/comment/application/usecase/NoticeCommentUsecaseImpl.java b/src/main/java/com/weeth/domain/comment/application/usecase/NoticeCommentUsecaseImpl.java index e0137b98..e67de961 100644 --- a/src/main/java/com/weeth/domain/comment/application/usecase/NoticeCommentUsecaseImpl.java +++ b/src/main/java/com/weeth/domain/comment/application/usecase/NoticeCommentUsecaseImpl.java @@ -11,9 +11,9 @@ import com.weeth.domain.comment.domain.service.CommentSaveService; import com.weeth.domain.file.application.mapper.FileMapper; import com.weeth.domain.file.domain.entity.File; -import com.weeth.domain.file.domain.service.FileDeleteService; -import com.weeth.domain.file.domain.service.FileGetService; -import com.weeth.domain.file.domain.service.FileSaveService; +import com.weeth.domain.file.domain.entity.FileOwnerType; +import com.weeth.domain.file.domain.repository.FileReader; +import com.weeth.domain.file.domain.repository.FileRepository; import com.weeth.domain.user.application.exception.UserNotMatchException; import com.weeth.domain.user.domain.entity.User; import com.weeth.domain.user.domain.service.UserGetService; @@ -31,9 +31,8 @@ public class NoticeCommentUsecaseImpl implements NoticeCommentUsecase { private final CommentFindService commentFindService; private final CommentDeleteService commentDeleteService; - private final FileSaveService fileSaveService; - private final FileGetService fileGetService; - private final FileDeleteService fileDeleteService; + private final FileRepository fileRepository; + private final FileReader fileReader; private final FileMapper fileMapper; private final NoticeFindService noticeFindService; @@ -54,8 +53,8 @@ public void saveNoticeComment(CommentDTO.Save dto, Long noticeId, Long userId) { Comment comment = commentMapper.fromCommentDto(dto, notice, user, parentComment); commentSaveService.save(comment); - List files = fileMapper.toFileList(dto.files(), comment); - fileSaveService.save(files); + List files = fileMapper.toFileList(dto.files(), FileOwnerType.COMMENT, comment.getId()); + fileRepository.saveAll(files); // 부모 댓글이 없다면 새 댓글로 추가 if(parentComment == null) { @@ -75,10 +74,10 @@ public void updateNoticeComment(CommentDTO.Update dto, Long noticeId, Long comme Comment comment = validateOwner(commentId, userId); List fileList = getFiles(commentId); - fileDeleteService.delete(fileList); + fileRepository.deleteAll(fileList); - List files = fileMapper.toFileList(dto.files(), comment); - fileSaveService.save(files); + List files = fileMapper.toFileList(dto.files(), FileOwnerType.COMMENT, comment.getId()); + fileRepository.saveAll(files); comment.update(dto); } @@ -91,7 +90,7 @@ public void deleteNoticeComment(Long commentId, Long userId) throws UserNotMatch Notice notice = comment.getNotice(); List fileList = getFiles(commentId); - fileDeleteService.delete(fileList); + fileRepository.deleteAll(fileList); if (comment.getChildren().isEmpty()) { Comment parentComment = findParentComment(commentId); @@ -132,6 +131,6 @@ private Comment validateOwner(Long commentId, Long userId) throws UserNotMatchEx } private List getFiles(Long commentId) { - return fileGetService.findAllByComment(commentId); + return fileReader.findAll(FileOwnerType.COMMENT, commentId, null); } } diff --git a/src/main/java/com/weeth/domain/comment/application/usecase/PostCommentUsecaseImpl.java b/src/main/java/com/weeth/domain/comment/application/usecase/PostCommentUsecaseImpl.java index 3a26ba64..39dd4898 100644 --- a/src/main/java/com/weeth/domain/comment/application/usecase/PostCommentUsecaseImpl.java +++ b/src/main/java/com/weeth/domain/comment/application/usecase/PostCommentUsecaseImpl.java @@ -11,9 +11,9 @@ import com.weeth.domain.comment.domain.service.CommentSaveService; import com.weeth.domain.file.application.mapper.FileMapper; import com.weeth.domain.file.domain.entity.File; -import com.weeth.domain.file.domain.service.FileDeleteService; -import com.weeth.domain.file.domain.service.FileGetService; -import com.weeth.domain.file.domain.service.FileSaveService; +import com.weeth.domain.file.domain.entity.FileOwnerType; +import com.weeth.domain.file.domain.repository.FileReader; +import com.weeth.domain.file.domain.repository.FileRepository; import com.weeth.domain.user.application.exception.UserNotMatchException; import com.weeth.domain.user.domain.entity.User; import com.weeth.domain.user.domain.service.UserGetService; @@ -31,9 +31,8 @@ public class PostCommentUsecaseImpl implements PostCommentUsecase { private final CommentFindService commentFindService; private final CommentDeleteService commentDeleteService; - private final FileSaveService fileSaveService; - private final FileGetService fileGetService; - private final FileDeleteService fileDeleteService; + private final FileRepository fileRepository; + private final FileReader fileReader; private final FileMapper fileMapper; private final UserGetService userGetService; @@ -55,8 +54,8 @@ public void savePostComment(CommentDTO.Save dto, Long postId, Long userId) { Comment comment = commentMapper.fromCommentDto(dto, post, user, parentComment); commentSaveService.save(comment); - List files = fileMapper.toFileList(dto.files(), comment); - fileSaveService.save(files); + List files = fileMapper.toFileList(dto.files(), FileOwnerType.COMMENT, comment.getId()); + fileRepository.saveAll(files); // 부모 댓글이 없다면 새 댓글로 추가 if (parentComment == null) { @@ -76,10 +75,10 @@ public void updatePostComment(CommentDTO.Update dto, Long postId, Long commentId Comment comment = validateOwner(commentId, userId); List fileList = getFiles(commentId); - fileDeleteService.delete(fileList); + fileRepository.deleteAll(fileList); - List files = fileMapper.toFileList(dto.files(), comment); - fileSaveService.save(files); + List files = fileMapper.toFileList(dto.files(), FileOwnerType.COMMENT, comment.getId()); + fileRepository.saveAll(files); comment.update(dto); } @@ -93,7 +92,7 @@ public void deletePostComment(Long commentId, Long userId) throws UserNotMatchEx Post post = comment.getPost(); List fileList = getFiles(commentId); - fileDeleteService.delete(fileList); + fileRepository.deleteAll(fileList); /* 1. 지우고자 하는 댓글이 맨 아래층인 경우(child, child가 없는 댓글 @@ -143,7 +142,7 @@ private Comment validateOwner(Long commentId, Long userId) throws UserNotMatchEx } private List getFiles(Long commentId) { - return fileGetService.findAllByComment(commentId); + return fileReader.findAll(FileOwnerType.COMMENT, commentId, null); } } diff --git a/src/main/java/com/weeth/domain/file/application/dto/request/FileSaveRequest.java b/src/main/java/com/weeth/domain/file/application/dto/request/FileSaveRequest.java deleted file mode 100644 index dda1a2ca..00000000 --- a/src/main/java/com/weeth/domain/file/application/dto/request/FileSaveRequest.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.weeth.domain.file.application.dto.request; - -import jakarta.validation.constraints.NotBlank; -import org.hibernate.validator.constraints.URL; - -public record FileSaveRequest( - @NotBlank String fileName, - @NotBlank @URL String fileUrl -) { -} diff --git a/src/main/java/com/weeth/domain/file/application/dto/request/FileUpdateRequest.java b/src/main/java/com/weeth/domain/file/application/dto/request/FileUpdateRequest.java deleted file mode 100644 index 2aa000ca..00000000 --- a/src/main/java/com/weeth/domain/file/application/dto/request/FileUpdateRequest.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.weeth.domain.file.application.dto.request; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import org.hibernate.validator.constraints.URL; - -public record FileUpdateRequest( - @NotNull Long fileId, - @NotBlank String fileName, - @NotBlank @URL String fileUrl -) { -} diff --git a/src/main/java/com/weeth/domain/file/application/dto/response/FileResponse.java b/src/main/java/com/weeth/domain/file/application/dto/response/FileResponse.java deleted file mode 100644 index 480de7ef..00000000 --- a/src/main/java/com/weeth/domain/file/application/dto/response/FileResponse.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.weeth.domain.file.application.dto.response; - -public record FileResponse( - long fileId, - String fileName, - String fileUrl -) { -} diff --git a/src/main/java/com/weeth/domain/file/application/dto/response/UrlResponse.java b/src/main/java/com/weeth/domain/file/application/dto/response/UrlResponse.java deleted file mode 100644 index 0c55cb13..00000000 --- a/src/main/java/com/weeth/domain/file/application/dto/response/UrlResponse.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.weeth.domain.file.application.dto.response; - -public record UrlResponse( - String fileName, - String putUrl -) { -} diff --git a/src/main/java/com/weeth/domain/file/application/mapper/FileMapper.java b/src/main/java/com/weeth/domain/file/application/mapper/FileMapper.java deleted file mode 100644 index 80727aed..00000000 --- a/src/main/java/com/weeth/domain/file/application/mapper/FileMapper.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.weeth.domain.file.application.mapper; - -import com.weeth.domain.account.domain.entity.Receipt; -import com.weeth.domain.board.domain.entity.Notice; -import com.weeth.domain.board.domain.entity.Post; -import com.weeth.domain.comment.application.mapper.CommentMapper; -import com.weeth.domain.comment.domain.entity.Comment; -import com.weeth.domain.file.application.dto.request.FileSaveRequest; -import com.weeth.domain.file.application.dto.response.FileResponse; -import com.weeth.domain.file.application.dto.response.UrlResponse; -import com.weeth.domain.file.domain.entity.File; -import org.mapstruct.Mapper; -import org.mapstruct.Mapping; -import org.mapstruct.MappingConstants; -import org.mapstruct.ReportingPolicy; - -import java.util.Collections; -import java.util.List; -import java.util.function.Function; -import java.util.stream.Collectors; - -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, uses = CommentMapper.class, unmappedTargetPolicy = ReportingPolicy.IGNORE) -public interface FileMapper { - - @Mapping(target = "id", ignore = true) - @Mapping(target = "post", source = "post") - File toFileWithPost(String fileName, String fileUrl, Post post); - - @Mapping(target = "id", ignore = true) - @Mapping(target = "notice", source = "notice") - File toFileWithNotice(String fileName, String fileUrl, Notice notice); - - @Mapping(target = "id", ignore = true) - @Mapping(target = "receipt", source = "receipt") - File toFileWithReceipt(String fileName, String fileUrl, Receipt receipt); - - @Mapping(target = "id", ignore = true) - @Mapping(target = "comment", source = "comment") - @Mapping(target = "notice", ignore = true) // notice 필드는 매핑하지 않도록 명시 - @Mapping(target = "post", ignore = true) // post 필드는 매핑하지 않도록 명시 - File toFileWithComment(String fileName, String fileUrl, Comment comment); - - @Mapping(target = "fileId", source = "file.id") - FileResponse toFileResponse(File file); - - UrlResponse toUrlResponse(String fileName, String putUrl); - - private List mapRequestsToFiles(List requests, Function mapper) { - if (requests == null || requests.isEmpty()) { - return Collections.emptyList(); - } - return requests.stream() - .map(mapper) - .collect(Collectors.toList()); - } - - default List toFileList(List requests, Post post) { - return mapRequestsToFiles(requests, request -> toFileWithPost(request.fileName(), request.fileUrl(), post)); - } - - default List toFileList(List requests, Notice notice) { - return mapRequestsToFiles(requests, request -> toFileWithNotice(request.fileName(), request.fileUrl(), notice)); - } - - default List toFileList(List requests, Receipt receipt) { - return mapRequestsToFiles(requests, request -> toFileWithReceipt(request.fileName(), request.fileUrl(), receipt)); - } - - default List toFileList(List requests, Comment comment) { - return mapRequestsToFiles(requests, request -> toFileWithComment(request.fileName(), request.fileUrl(), comment)); - } -} diff --git a/src/main/java/com/weeth/domain/file/application/usecase/FileManageUseCase.java b/src/main/java/com/weeth/domain/file/application/usecase/FileManageUseCase.java deleted file mode 100644 index ff94dd8e..00000000 --- a/src/main/java/com/weeth/domain/file/application/usecase/FileManageUseCase.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.weeth.domain.file.application.usecase; - -import jakarta.transaction.Transactional; -import com.weeth.domain.file.application.dto.response.UrlResponse; -import com.weeth.domain.file.domain.service.PreSignedService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class FileManageUseCase { - - private final PreSignedService preSignedService; - - public List getUrl(List fileNames) { - return fileNames.stream() - .map(preSignedService::generateUrl) - .toList(); - } -} diff --git a/src/main/java/com/weeth/domain/file/domain/entity/File.java b/src/main/java/com/weeth/domain/file/domain/entity/File.java deleted file mode 100644 index fcce771a..00000000 --- a/src/main/java/com/weeth/domain/file/domain/entity/File.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.weeth.domain.file.domain.entity; - -import jakarta.persistence.*; -import com.weeth.domain.account.domain.entity.Receipt; -import com.weeth.domain.board.domain.entity.Notice; -import com.weeth.domain.board.domain.entity.Post; -import com.weeth.domain.comment.domain.entity.Comment; -import com.weeth.global.common.entity.BaseEntity; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.experimental.SuperBuilder; - -@Getter -@SuperBuilder -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@Entity -public class File extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - private String fileName; - - private String fileUrl; - - @ManyToOne - @JoinColumn(name = "post_id") - private Post post; - - @ManyToOne - @JoinColumn(name = "notice_id") - private Notice notice; - - @ManyToOne - @JoinColumn(name = "receipt_id") - private Receipt receipt; - - @ManyToOne - @JoinColumn(name = "comment_id") - private Comment comment; - - public void update(String fileName, String fileUrl) { - this.fileName = fileName; - this.fileUrl = fileUrl; - } -} diff --git a/src/main/java/com/weeth/domain/file/domain/repository/FileRepository.java b/src/main/java/com/weeth/domain/file/domain/repository/FileRepository.java deleted file mode 100644 index de0b53ac..00000000 --- a/src/main/java/com/weeth/domain/file/domain/repository/FileRepository.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.weeth.domain.file.domain.repository; - -import com.weeth.domain.file.domain.entity.File; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.List; - -public interface FileRepository extends JpaRepository { - - List findAllByPostId(Long postId); - - List findAllByNoticeId(Long noticeId); - - List findAllByReceiptId(Long receiptId); - - List findAllByCommentId(Long commentId); -} diff --git a/src/main/java/com/weeth/domain/file/domain/service/FileDeleteService.java b/src/main/java/com/weeth/domain/file/domain/service/FileDeleteService.java deleted file mode 100644 index 9cb1168c..00000000 --- a/src/main/java/com/weeth/domain/file/domain/service/FileDeleteService.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.weeth.domain.file.domain.service; - -import com.weeth.domain.file.domain.entity.File; -import com.weeth.domain.file.domain.repository.FileRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class FileDeleteService { - - private final FileRepository fileRepository; - - public void delete(File file) { - fileRepository.delete(file); - } - - public void delete(List files) { - fileRepository.deleteAll(files); - } -} diff --git a/src/main/java/com/weeth/domain/file/domain/service/FileGetService.java b/src/main/java/com/weeth/domain/file/domain/service/FileGetService.java deleted file mode 100644 index c145ea49..00000000 --- a/src/main/java/com/weeth/domain/file/domain/service/FileGetService.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.weeth.domain.file.domain.service; - -import com.weeth.domain.file.domain.entity.File; -import com.weeth.domain.file.domain.repository.FileRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class FileGetService { - - private final FileRepository fileRepository; - - public List findAllByPost(Long postId) { - return fileRepository.findAllByPostId(postId); - } - - public List findAllByNotice(Long noticeId) { - return fileRepository.findAllByNoticeId(noticeId); - } - - public List findAllByReceipt(Long receiptId) { - return fileRepository.findAllByReceiptId(receiptId); - } - - public List findAllByComment(Long commentId) { - return fileRepository.findAllByCommentId(commentId); - } -} diff --git a/src/main/java/com/weeth/domain/file/domain/service/FileSaveService.java b/src/main/java/com/weeth/domain/file/domain/service/FileSaveService.java deleted file mode 100644 index 8b4cfffa..00000000 --- a/src/main/java/com/weeth/domain/file/domain/service/FileSaveService.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.weeth.domain.file.domain.service; - -import com.weeth.domain.file.domain.entity.File; -import com.weeth.domain.file.domain.repository.FileRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class FileSaveService { - private final FileRepository fileRepository; - - public void save(File file) { - fileRepository.save(file); - } - - public void save(List files) { - fileRepository.saveAll(files); - } -} diff --git a/src/main/java/com/weeth/domain/file/domain/service/PreSignedService.java b/src/main/java/com/weeth/domain/file/domain/service/PreSignedService.java deleted file mode 100644 index 62c0c7c3..00000000 --- a/src/main/java/com/weeth/domain/file/domain/service/PreSignedService.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.weeth.domain.file.domain.service; - -import com.weeth.domain.file.application.dto.response.UrlResponse; -import com.weeth.domain.file.application.mapper.FileMapper; -import com.weeth.global.config.properties.AwsS3Properties; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import software.amazon.awssdk.services.s3.model.PutObjectRequest; -import software.amazon.awssdk.services.s3.presigner.S3Presigner; -import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; -import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; - -import java.time.Duration; -import java.util.UUID; - -@Service -@RequiredArgsConstructor -public class PreSignedService { - - private final S3Presigner s3Presigner; - private final FileMapper fileMapper; - private final AwsS3Properties awsS3Properties; - - public UrlResponse generateUrl(String fileName) { - String key = generateKey(fileName); - - PutObjectRequest putObjectRequest = PutObjectRequest.builder() - .bucket(awsS3Properties.getS3().getBucket()) - .key(key) - .build(); - - PutObjectPresignRequest request = PutObjectPresignRequest.builder() - .signatureDuration(Duration.ofMinutes(5)) - .putObjectRequest(putObjectRequest) - .build(); - - PresignedPutObjectRequest presignedUrlRequest = s3Presigner.presignPutObject(request); - - String putUrl = presignedUrlRequest.url().toString(); - - return fileMapper.toUrlResponse(fileName, putUrl); - } - - // 파일 이름을 고유하게 생성하는 메서드(확장자 포함) - private String generateKey(String fileName) { - String key = UUID.randomUUID().toString(); - String extension = fileName.substring(fileName.lastIndexOf(".") + 1); - - return key + "." + extension; - } -} diff --git a/src/main/java/com/weeth/domain/file/presentation/FileController.java b/src/main/java/com/weeth/domain/file/presentation/FileController.java deleted file mode 100644 index ad7ad701..00000000 --- a/src/main/java/com/weeth/domain/file/presentation/FileController.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.weeth.domain.file.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import com.weeth.domain.file.application.dto.response.UrlResponse; -import com.weeth.domain.file.application.usecase.FileManageUseCase; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -import java.util.List; - -@Tag(name = "FILE") -@RestController -@RequiredArgsConstructor -@RequestMapping("/files") -public class FileController { - - private final FileManageUseCase fileManageUseCase; - - @GetMapping("/") - @Operation(summary = "파일 업로드를 위한 presigned url을 요청하는 API 입니다.") - public CommonResponse> getUrl(@RequestParam(required = false) List fileName) { - return CommonResponse.success(FileResponseCode.PRESIGNED_URL_GET_SUCCESS, fileManageUseCase.getUrl(fileName)); - } -} diff --git a/src/main/java/com/weeth/domain/file/presentation/FileResponseCode.java b/src/main/java/com/weeth/domain/file/presentation/FileResponseCode.java deleted file mode 100644 index 93058d56..00000000 --- a/src/main/java/com/weeth/domain/file/presentation/FileResponseCode.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.weeth.domain.file.presentation; - -import com.weeth.global.common.response.ResponseCodeInterface; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -public enum FileResponseCode implements ResponseCodeInterface { - - PRESIGNED_URL_GET_SUCCESS(1500, HttpStatus.OK, "Presigned Url 반환에 성공했습니다"); - - private final int code; - private final HttpStatus status; - private final String message; - - FileResponseCode(int code, HttpStatus status, String message) { - this.code = code; - this.status = status; - this.message = message; - } - -} diff --git a/src/main/java/com/weeth/global/common/exception/BaseException.java b/src/main/java/com/weeth/global/common/exception/BaseException.java index 9d810822..c93f459a 100644 --- a/src/main/java/com/weeth/global/common/exception/BaseException.java +++ b/src/main/java/com/weeth/global/common/exception/BaseException.java @@ -14,9 +14,19 @@ public BaseException(int code, String message) { this.errorCode = null; } - public BaseException(ErrorCodeInterface errorCode) { - super(errorCode.getMessage()); + public BaseException(int code, String message, Throwable cause) { + super(message, cause); + this.statusCode = code; + this.errorCode = null; + } + + public BaseException(ErrorCodeInterface errorCode, Throwable cause) { + super(errorCode.getMessage(), cause); this.statusCode = errorCode.getStatus().value(); this.errorCode = errorCode; } + + public BaseException(ErrorCodeInterface errorCode) { + this(errorCode, null); + } } diff --git a/src/main/kotlin/com/weeth/domain/file/application/dto/request/FileSaveRequest.kt b/src/main/kotlin/com/weeth/domain/file/application/dto/request/FileSaveRequest.kt new file mode 100644 index 00000000..92041cd8 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/application/dto/request/FileSaveRequest.kt @@ -0,0 +1,20 @@ +package com.weeth.domain.file.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Positive + +data class FileSaveRequest( + @field:Schema(description = "원본 파일명", example = "profile-image.png") + @field:NotBlank + val fileName: String, + @field:Schema(description = "저장소 키. `Type/YY-MM/UUID_원본파일명` 형식", example = "POST/2026-02/58400-e29b-44-a716-44665000_profile-image.png") + @field:NotBlank + val storageKey: String, + @field:Schema(description = "파일 크기(bytes)", example = "102400") + @field:Positive + val fileSize: Long, + @field:Schema(description = "파일 Content-Type. `image/png, image/jpeg, application/pdf` 지원", example = "image/png") + @field:NotBlank + val contentType: String, +) diff --git a/src/main/kotlin/com/weeth/domain/file/application/dto/response/FileResponse.kt b/src/main/kotlin/com/weeth/domain/file/application/dto/response/FileResponse.kt new file mode 100644 index 00000000..7a4d286b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/application/dto/response/FileResponse.kt @@ -0,0 +1,24 @@ +package com.weeth.domain.file.application.dto.response + +import com.weeth.domain.file.domain.entity.FileStatus +import io.swagger.v3.oas.annotations.media.Schema + +data class FileResponse( + @field:Schema(description = "파일 ID", example = "1") + val fileId: Long, + @field:Schema(description = "원본 파일명", example = "profile-image.png") + val fileName: String, + @field:Schema( + description = "조회용 파일 URL", + example = "https://bucket.s3.ap-northeast-2.amazonaws.com/POST/2026-02/uuid_profile-image.png", + ) + val fileUrl: String, + @field:Schema(description = "저장소 키", example = "POST/2026-02/uuid_profile-image.png") + val storageKey: String, + @field:Schema(description = "파일 크기(bytes)", example = "102400") + val fileSize: Long, + @field:Schema(description = "파일 Content-Type", example = "image/png") + val contentType: String, + @field:Schema(description = "파일 상태", example = "UPLOADED") + val status: FileStatus, +) diff --git a/src/main/kotlin/com/weeth/domain/file/application/dto/response/UrlResponse.kt b/src/main/kotlin/com/weeth/domain/file/application/dto/response/UrlResponse.kt new file mode 100644 index 00000000..3653cacc --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/application/dto/response/UrlResponse.kt @@ -0,0 +1,12 @@ +package com.weeth.domain.file.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class UrlResponse( + @field:Schema(description = "원본 파일명", example = "profile-image.png") + val fileName: String, + @field:Schema(description = "Presigned PUT URL", example = "https://bucket.s3.amazonaws.com/TEMP/2026-02/uuid_profile-image.png") + val putUrl: String, + @field:Schema(description = "저장소 키", example = "TEMP/2026-02/uuid_profile-image.png") + val storageKey: String, +) diff --git a/src/main/kotlin/com/weeth/domain/file/application/exception/FileErrorCode.kt b/src/main/kotlin/com/weeth/domain/file/application/exception/FileErrorCode.kt new file mode 100644 index 00000000..b7483b7d --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/application/exception/FileErrorCode.kt @@ -0,0 +1,30 @@ +package com.weeth.domain.file.application.exception + +import com.weeth.global.common.exception.ErrorCodeInterface +import com.weeth.global.common.exception.ExplainError +import org.springframework.http.HttpStatus + +enum class FileErrorCode( + private val code: Int, + private val status: HttpStatus, + private val message: String, +) : ErrorCodeInterface { + @ExplainError("파일 ID로 조회했으나 해당 파일이 존재하지 않을 때 발생합니다.") + FILE_NOT_FOUND(2500, HttpStatus.NOT_FOUND, "존재하지 않는 파일입니다."), + + @ExplainError("Presigned URL 생성 중 S3 연결 오류가 발생했을 때 발생합니다.") + PRESIGNED_URL_GENERATION_FAILED(2501, HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드 URL 생성에 실패했습니다."), + + @ExplainError("허용되지 않은 Content-Type으로 파일 업로드를 시도했을 때 발생합니다.") + UNSUPPORTED_CONTENT_TYPE(2502, HttpStatus.BAD_REQUEST, "지원하지 않는 파일 형식입니다."), + + @ExplainError("허용되지 않은 확장자로 파일 업로드를 시도했을 때 발생합니다.") + UNSUPPORTED_FILE_EXTENSION(2503, HttpStatus.BAD_REQUEST, "지원하지 않는 파일 확장자입니다."), + ; + + override fun getCode(): Int = code + + override fun getStatus(): HttpStatus = status + + override fun getMessage(): String = message +} diff --git a/src/main/kotlin/com/weeth/domain/file/application/exception/FileNotFoundException.kt b/src/main/kotlin/com/weeth/domain/file/application/exception/FileNotFoundException.kt new file mode 100644 index 00000000..7579f51f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/application/exception/FileNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.file.application.exception + +import com.weeth.global.common.exception.BaseException + +class FileNotFoundException : BaseException(FileErrorCode.FILE_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/file/application/exception/PresignedUrlGenerationException.kt b/src/main/kotlin/com/weeth/domain/file/application/exception/PresignedUrlGenerationException.kt new file mode 100644 index 00000000..a8056516 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/application/exception/PresignedUrlGenerationException.kt @@ -0,0 +1,7 @@ +package com.weeth.domain.file.application.exception + +import com.weeth.global.common.exception.BaseException + +class PresignedUrlGenerationException( + cause: Throwable? = null, +) : BaseException(FileErrorCode.PRESIGNED_URL_GENERATION_FAILED, cause) diff --git a/src/main/kotlin/com/weeth/domain/file/application/exception/UnsupportedFileContentTypeException.kt b/src/main/kotlin/com/weeth/domain/file/application/exception/UnsupportedFileContentTypeException.kt new file mode 100644 index 00000000..6d72f025 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/application/exception/UnsupportedFileContentTypeException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.file.application.exception + +import com.weeth.global.common.exception.BaseException + +class UnsupportedFileContentTypeException : BaseException(FileErrorCode.UNSUPPORTED_CONTENT_TYPE) diff --git a/src/main/kotlin/com/weeth/domain/file/application/exception/UnsupportedFileExtensionException.kt b/src/main/kotlin/com/weeth/domain/file/application/exception/UnsupportedFileExtensionException.kt new file mode 100644 index 00000000..6aca29be --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/application/exception/UnsupportedFileExtensionException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.file.application.exception + +import com.weeth.global.common.exception.BaseException + +class UnsupportedFileExtensionException : BaseException(FileErrorCode.UNSUPPORTED_FILE_EXTENSION) diff --git a/src/main/kotlin/com/weeth/domain/file/application/mapper/FileMapper.kt b/src/main/kotlin/com/weeth/domain/file/application/mapper/FileMapper.kt new file mode 100644 index 00000000..12a38ba1 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/application/mapper/FileMapper.kt @@ -0,0 +1,52 @@ +package com.weeth.domain.file.application.mapper + +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import com.weeth.domain.file.application.dto.response.FileResponse +import com.weeth.domain.file.application.dto.response.UrlResponse +import com.weeth.domain.file.domain.entity.File +import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.port.FileAccessUrlPort +import org.springframework.stereotype.Component + +@Component +class FileMapper( + private val fileAccessUrlPort: FileAccessUrlPort, +) { + fun toFileList( + requests: List?, + ownerType: FileOwnerType, + ownerId: Long, + ): List { + if (requests.isNullOrEmpty()) { + return emptyList() + } + + return requests.map { + File.createUploaded( + fileName = it.fileName, + storageKey = it.storageKey, + fileSize = it.fileSize, + contentType = it.contentType, + ownerType = ownerType, + ownerId = ownerId, + ) + } + } + + fun toFileResponse(file: File) = + FileResponse( + fileId = file.id, + fileName = file.fileName, + fileUrl = fileAccessUrlPort.resolve(file.storageKey.value), + storageKey = file.storageKey.value, + fileSize = file.fileSize, + contentType = file.contentType.value, + status = file.status, + ) + + fun toUrlResponse( + fileName: String, + putUrl: String, + storageKey: String, + ) = UrlResponse(fileName = fileName, putUrl = putUrl, storageKey = storageKey) +} diff --git a/src/main/kotlin/com/weeth/domain/file/application/usecase/command/GenerateFileUrlUsecase.kt b/src/main/kotlin/com/weeth/domain/file/application/usecase/command/GenerateFileUrlUsecase.kt new file mode 100644 index 00000000..207df82b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/application/usecase/command/GenerateFileUrlUsecase.kt @@ -0,0 +1,21 @@ +package com.weeth.domain.file.application.usecase.command + +import com.weeth.domain.file.application.dto.response.UrlResponse +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.port.FileUploadUrlPort +import org.springframework.stereotype.Service + +@Service +class GenerateFileUrlUsecase( + private val fileUploadUrlPort: FileUploadUrlPort, + private val fileMapper: FileMapper, +) { + fun generateFileUploadUrls( + ownerType: FileOwnerType, + fileNames: List, + ): List = + fileNames + .map { fileUploadUrlPort.generateUploadUrl(ownerType, it) } + .map { fileMapper.toUrlResponse(it.fileName, it.url, it.storageKey) } +} diff --git a/src/main/kotlin/com/weeth/domain/file/domain/entity/File.kt b/src/main/kotlin/com/weeth/domain/file/domain/entity/File.kt new file mode 100644 index 00000000..1d6089cb --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/domain/entity/File.kt @@ -0,0 +1,73 @@ +package com.weeth.domain.file.domain.entity + +import com.weeth.domain.file.domain.vo.FileContentType +import com.weeth.domain.file.domain.vo.StorageKey +import com.weeth.global.common.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Index +import jakarta.persistence.Table + +@Entity +@Table( + name = "file", + indexes = [ + Index(name = "idx_file_owner_type_owner_id", columnList = "owner_type, owner_id"), + ], +) +class File( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + val id: Long = 0, + @Column(nullable = false) + var fileName: String, + @Column(nullable = false, length = 500, unique = true) + val storageKey: StorageKey, + @Column(nullable = false) + val fileSize: Long, + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + val ownerType: FileOwnerType, + @Column(nullable = false) + val ownerId: Long, + @Column(nullable = false, length = 100) + val contentType: FileContentType, + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + var status: FileStatus = FileStatus.UPLOADED, +) : BaseEntity() { + fun markDeleted() { + status = FileStatus.DELETED + } + + companion object { + fun createUploaded( + fileName: String, + storageKey: String, + fileSize: Long, + contentType: String, + ownerType: FileOwnerType, + ownerId: Long, + ): File { + require(fileName.isNotBlank()) { "fileName은 비어 있을 수 없습니다." } + require(fileSize > 0) { "fileSize는 0보다 커야 합니다." } + require(ownerId > 0) { "ownerId는 0보다 커야 합니다." } + + return File( + fileName = fileName, + storageKey = StorageKey(storageKey), + fileSize = fileSize, + contentType = FileContentType(contentType), + ownerType = ownerType, + ownerId = ownerId, + status = FileStatus.UPLOADED, + ) + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/file/domain/entity/FileOwnerType.kt b/src/main/kotlin/com/weeth/domain/file/domain/entity/FileOwnerType.kt new file mode 100644 index 00000000..a8028a3a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/domain/entity/FileOwnerType.kt @@ -0,0 +1,8 @@ +package com.weeth.domain.file.domain.entity + +enum class FileOwnerType { + POST, + NOTICE, + COMMENT, + RECEIPT, +} diff --git a/src/main/kotlin/com/weeth/domain/file/domain/entity/FileStatus.kt b/src/main/kotlin/com/weeth/domain/file/domain/entity/FileStatus.kt new file mode 100644 index 00000000..6d1287d3 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/domain/entity/FileStatus.kt @@ -0,0 +1,6 @@ +package com.weeth.domain.file.domain.entity + +enum class FileStatus { + UPLOADED, + DELETED, +} diff --git a/src/main/kotlin/com/weeth/domain/file/domain/port/FileAccessUrlPort.kt b/src/main/kotlin/com/weeth/domain/file/domain/port/FileAccessUrlPort.kt new file mode 100644 index 00000000..ca1bba30 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/domain/port/FileAccessUrlPort.kt @@ -0,0 +1,12 @@ +package com.weeth.domain.file.domain.port + +/** + * 저장된 storageKey를 조회 가능한 URL로 변환하는 포트입니다. + */ +interface FileAccessUrlPort { + /** + * storageKey를 조회용 URL로 변환합니다. + * 기본 구현은 S3 공개 URL을 사용하고, 설정에 따라 CDN URL로 교체될 수 있습니다. + */ + fun resolve(storageKey: String): String +} diff --git a/src/main/kotlin/com/weeth/domain/file/domain/port/FileUploadUrlPort.kt b/src/main/kotlin/com/weeth/domain/file/domain/port/FileUploadUrlPort.kt new file mode 100644 index 00000000..fe92d777 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/domain/port/FileUploadUrlPort.kt @@ -0,0 +1,17 @@ +package com.weeth.domain.file.domain.port + +import com.weeth.domain.file.domain.entity.FileOwnerType + +/** [FileUploadUrlPort.generateUploadUrl] 반환 타입 */ +data class FileUploadUrl( + val fileName: String, + val storageKey: String, + val url: String, +) + +interface FileUploadUrlPort { + fun generateUploadUrl( + ownerType: FileOwnerType, + fileName: String, + ): FileUploadUrl +} diff --git a/src/main/kotlin/com/weeth/domain/file/domain/repository/FileReader.kt b/src/main/kotlin/com/weeth/domain/file/domain/repository/FileReader.kt new file mode 100644 index 00000000..b979e3a1 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/domain/repository/FileReader.kt @@ -0,0 +1,19 @@ +package com.weeth.domain.file.domain.repository + +import com.weeth.domain.file.domain.entity.File +import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.entity.FileStatus + +interface FileReader { + fun findAll( + ownerType: FileOwnerType, + ownerId: Long, + status: FileStatus? = FileStatus.UPLOADED, + ): List + + fun exists( + ownerType: FileOwnerType, + ownerId: Long, + status: FileStatus? = FileStatus.UPLOADED, + ): Boolean +} diff --git a/src/main/kotlin/com/weeth/domain/file/domain/repository/FileRepository.kt b/src/main/kotlin/com/weeth/domain/file/domain/repository/FileRepository.kt new file mode 100644 index 00000000..8c1c4f19 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/domain/repository/FileRepository.kt @@ -0,0 +1,48 @@ +package com.weeth.domain.file.domain.repository + +import com.weeth.domain.file.domain.entity.File +import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.entity.FileStatus +import org.springframework.data.jpa.repository.JpaRepository + +interface FileRepository : + JpaRepository, + FileReader { + fun findAllByOwnerTypeAndOwnerId( + ownerType: FileOwnerType, + ownerId: Long, + ): List + + fun findAllByOwnerTypeAndOwnerIdAndStatus( + ownerType: FileOwnerType, + ownerId: Long, + status: FileStatus, + ): List + + fun existsByOwnerTypeAndOwnerId( + ownerType: FileOwnerType, + ownerId: Long, + ): Boolean + + fun existsByOwnerTypeAndOwnerIdAndStatus( + ownerType: FileOwnerType, + ownerId: Long, + status: FileStatus, + ): Boolean + + override fun findAll( + ownerType: FileOwnerType, + ownerId: Long, + status: FileStatus?, + ): List = + status?.let { findAllByOwnerTypeAndOwnerIdAndStatus(ownerType, ownerId, it) } + ?: findAllByOwnerTypeAndOwnerId(ownerType, ownerId) + + override fun exists( + ownerType: FileOwnerType, + ownerId: Long, + status: FileStatus?, + ): Boolean = + status?.let { existsByOwnerTypeAndOwnerIdAndStatus(ownerType, ownerId, it) } + ?: existsByOwnerTypeAndOwnerId(ownerType, ownerId) +} diff --git a/src/main/kotlin/com/weeth/domain/file/domain/vo/FileContentType.kt b/src/main/kotlin/com/weeth/domain/file/domain/vo/FileContentType.kt new file mode 100644 index 00000000..e002ec43 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/domain/vo/FileContentType.kt @@ -0,0 +1,20 @@ +package com.weeth.domain.file.domain.vo + +import com.weeth.domain.file.application.exception.UnsupportedFileContentTypeException + +@JvmInline +value class FileContentType( + val value: String, +) { + val normalized: String + get() = value.lowercase() + + val fileType: FileType + get() = FileType.fromContentType(normalized) ?: throw UnsupportedFileContentTypeException() + + init { + if (FileType.fromContentType(normalized) == null) { + throw UnsupportedFileContentTypeException() + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/file/domain/vo/FileExtension.kt b/src/main/kotlin/com/weeth/domain/file/domain/vo/FileExtension.kt new file mode 100644 index 00000000..67a560d6 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/domain/vo/FileExtension.kt @@ -0,0 +1,15 @@ +package com.weeth.domain.file.domain.vo + +import com.weeth.domain.file.application.exception.UnsupportedFileExtensionException + +class FileExtension( + value: String, +) { + val normalized: String = value.lowercase() + val fileType: FileType + + init { + val resolvedType = FileType.fromExtension(normalized) ?: throw UnsupportedFileExtensionException() + fileType = resolvedType + } +} diff --git a/src/main/kotlin/com/weeth/domain/file/domain/vo/FileName.kt b/src/main/kotlin/com/weeth/domain/file/domain/vo/FileName.kt new file mode 100644 index 00000000..8290fed3 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/domain/vo/FileName.kt @@ -0,0 +1,19 @@ +package com.weeth.domain.file.domain.vo + +class FileName( + value: String, +) { + val sanitized: String + val extension: FileExtension + + init { + val normalized = value.trim() + require(normalized.isNotBlank()) { "fileName은 비어 있을 수 없습니다." } + + val ext = normalized.substringAfterLast('.', "") + require(ext.isNotBlank()) { "fileName에는 확장자가 포함되어야 합니다." } + + extension = FileExtension(ext) + sanitized = normalized.replace(Regex("""[\\/:*?"<>|]"""), "_") + } +} diff --git a/src/main/kotlin/com/weeth/domain/file/domain/vo/FileType.kt b/src/main/kotlin/com/weeth/domain/file/domain/vo/FileType.kt new file mode 100644 index 00000000..e2e2f025 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/domain/vo/FileType.kt @@ -0,0 +1,29 @@ +package com.weeth.domain.file.domain.vo + +enum class FileType( + val contentType: String, + val extensions: Set, +) { + JPEG("image/jpeg", setOf("jpg", "jpeg")), + PNG("image/png", setOf("png")), + WEBP("image/webp", setOf("webp")), + PDF("application/pdf", setOf("pdf")), + ; + + companion object { + private val BY_CONTENT_TYPE = entries.associateBy { it.contentType } + private val BY_EXTENSION = entries.flatMap { type -> type.extensions.map { ext -> ext to type } }.toMap() + + /** + * API 요청의 contentType 검증 시 사용 + * image/png -> FileType.PNG 반환 + * */ + fun fromContentType(contentType: String): FileType? = BY_CONTENT_TYPE[contentType.trim().lowercase()] + + /** + * 파일명 확장자 검증 시 사용 + * png -> FileType.PNG 반환 + * */ + fun fromExtension(extension: String): FileType? = BY_EXTENSION[extension.trim().lowercase()] + } +} diff --git a/src/main/kotlin/com/weeth/domain/file/domain/vo/StorageKey.kt b/src/main/kotlin/com/weeth/domain/file/domain/vo/StorageKey.kt new file mode 100644 index 00000000..79a2d708 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/domain/vo/StorageKey.kt @@ -0,0 +1,23 @@ +package com.weeth.domain.file.domain.vo + +import com.weeth.domain.file.domain.entity.FileOwnerType + +@JvmInline +value class StorageKey( + val value: String, +) { + init { + require(value.isNotBlank()) { "storageKey는 비어 있을 수 없습니다." } + require(STORAGE_KEY_PATTERN.matches(value)) { + "storageKey 형식이 올바르지 않습니다. 형식: {OWNER_TYPE}/{yyyy-MM}/{uuid}_{fileName}" + } + } + + companion object { + private val OWNER_TYPE_PATTERN = FileOwnerType.entries.joinToString("|") { it.name } + private val STORAGE_KEY_PATTERN = + Regex( + pattern = "^($OWNER_TYPE_PATTERN)/(\\d{4}-(0[1-9]|1[0-2]))/([0-9a-fA-F-]{36})_.+$", + ) + } +} diff --git a/src/main/kotlin/com/weeth/domain/file/infrastructure/CdnFileAccessUrlAdapter.kt b/src/main/kotlin/com/weeth/domain/file/infrastructure/CdnFileAccessUrlAdapter.kt new file mode 100644 index 00000000..d9fcddb0 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/infrastructure/CdnFileAccessUrlAdapter.kt @@ -0,0 +1,26 @@ +package com.weeth.domain.file.infrastructure + +import com.weeth.domain.file.domain.port.FileAccessUrlPort +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.stereotype.Component + +@Component +@ConditionalOnProperty( + prefix = "app.file", + name = ["url-provider"], + havingValue = "CDN", // CDN으로 설정된 경우 이 어댑터를 사용합니다. +) +class CdnFileAccessUrlAdapter( + @Value("\${app.file.cdn-base-url:}") private val cdnBaseUrl: String, +) : FileAccessUrlPort { + /** storageKey를 CDN 조회 URL로 변환합니다. */ + override fun resolve(storageKey: String): String { + val normalizedBaseUrl = cdnBaseUrl.trimEnd('/') + return if (normalizedBaseUrl.isBlank()) { + storageKey + } else { + "$normalizedBaseUrl/$storageKey" + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/file/infrastructure/S3FileAccessUrlAdapter.kt b/src/main/kotlin/com/weeth/domain/file/infrastructure/S3FileAccessUrlAdapter.kt new file mode 100644 index 00000000..b11dd43d --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/infrastructure/S3FileAccessUrlAdapter.kt @@ -0,0 +1,24 @@ +package com.weeth.domain.file.infrastructure + +import com.weeth.domain.file.domain.port.FileAccessUrlPort +import com.weeth.global.config.properties.AwsS3Properties +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.stereotype.Component + +@Component +@ConditionalOnProperty( + prefix = "app.file", + name = ["url-provider"], + havingValue = "S3", // S3로 설정된 경우 이 어댑터를 사용합니다. + matchIfMissing = true, +) +class S3FileAccessUrlAdapter( + private val awsS3Properties: AwsS3Properties, +) : FileAccessUrlPort { + /** storageKey를 S3 공개 조회 URL로 변환합니다. */ + override fun resolve(storageKey: String): String { + val bucket = awsS3Properties.s3.bucket + val region = awsS3Properties.region.static + return "https://$bucket.s3.$region.amazonaws.com/$storageKey" + } +} diff --git a/src/main/kotlin/com/weeth/domain/file/infrastructure/S3FileUploadUrlAdapter.kt b/src/main/kotlin/com/weeth/domain/file/infrastructure/S3FileUploadUrlAdapter.kt new file mode 100644 index 00000000..1db243d3 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/infrastructure/S3FileUploadUrlAdapter.kt @@ -0,0 +1,66 @@ +package com.weeth.domain.file.infrastructure + +import com.weeth.domain.file.application.exception.PresignedUrlGenerationException +import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.port.FileUploadUrl +import com.weeth.domain.file.domain.port.FileUploadUrlPort +import com.weeth.domain.file.domain.vo.FileName +import com.weeth.global.common.exception.BaseException +import com.weeth.global.config.properties.AwsS3Properties +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import software.amazon.awssdk.services.s3.model.PutObjectRequest +import software.amazon.awssdk.services.s3.presigner.S3Presigner +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest +import java.time.Duration +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.UUID + +/** S3 기반 업로드용 presigned URL 생성 어댑터입니다. */ +@Component +class S3FileUploadUrlAdapter( + private val s3Presigner: S3Presigner, + private val awsS3Properties: AwsS3Properties, + @param:Value("\${app.file.presigned-url-expiration-minutes:5}") + private val presignedUrlExpirationMinutes: Long, +) : FileUploadUrlPort { + override fun generateUploadUrl( + ownerType: FileOwnerType, + fileName: String, + ): FileUploadUrl = + runCatching { + val validatedFileName = FileName(fileName) + val storageKey = generateStorageKey(ownerType, validatedFileName.sanitized) + val putObjectRequest = + PutObjectRequest + .builder() + .bucket(awsS3Properties.s3.bucket) + .key(storageKey) + .build() + + val request = + PutObjectPresignRequest + .builder() + .signatureDuration(Duration.ofMinutes(presignedUrlExpirationMinutes)) + .putObjectRequest(putObjectRequest) + .build() + + val presigned = s3Presigner.presignPutObject(request) + FileUploadUrl(fileName = fileName, storageKey = storageKey, url = presigned.url().toString()) + }.getOrElse { e -> + if (e is BaseException) { + throw e + } + throw PresignedUrlGenerationException(cause = e) + } + + private fun generateStorageKey( + ownerType: FileOwnerType, + sanitizedFileName: String, + ): String { + val month = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM")) + val uuid = UUID.randomUUID().toString() + return "${ownerType.name}/$month/${uuid}_$sanitizedFileName" + } +} diff --git a/src/main/kotlin/com/weeth/domain/file/presentation/FileController.kt b/src/main/kotlin/com/weeth/domain/file/presentation/FileController.kt new file mode 100644 index 00000000..5e2d26a7 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/presentation/FileController.kt @@ -0,0 +1,39 @@ +package com.weeth.domain.file.presentation + +import com.weeth.domain.file.application.dto.response.UrlResponse +import com.weeth.domain.file.application.exception.FileErrorCode +import com.weeth.domain.file.application.usecase.command.GenerateFileUrlUsecase +import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotEmpty +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "FILE") +@Validated +@RestController +@RequestMapping("/api/v4/files") +@ApiErrorCodeExample(FileErrorCode::class) +class FileController( + private val generateFileUrlUsecase: GenerateFileUrlUsecase, +) { + @GetMapping + @Operation(summary = "파일 업로드를 위한 presigned url을 요청하는 API 입니다.") + fun getUrl( + @Parameter(description = "파일 소유 타입", example = "POST") + @RequestParam ownerType: FileOwnerType, + @RequestParam @NotEmpty fileNames: List<@NotBlank String>, + ): CommonResponse> = + CommonResponse.success( + FileResponseCode.PRESIGNED_URL_GET_SUCCESS, + generateFileUrlUsecase.generateFileUploadUrls(ownerType, fileNames), + ) +} diff --git a/src/main/kotlin/com/weeth/domain/file/presentation/FileResponseCode.kt b/src/main/kotlin/com/weeth/domain/file/presentation/FileResponseCode.kt new file mode 100644 index 00000000..eadec132 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/file/presentation/FileResponseCode.kt @@ -0,0 +1,12 @@ +package com.weeth.domain.file.presentation + +import com.weeth.global.common.response.ResponseCodeInterface +import org.springframework.http.HttpStatus + +enum class FileResponseCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ResponseCodeInterface { + PRESIGNED_URL_GET_SUCCESS(1500, HttpStatus.OK, "Presigned Url 반환에 성공했습니다"), +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9c079427..fb06d574 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -38,3 +38,8 @@ management: metrics: export: enabled: true + +app: + file: + cdn-base-url: ${CDN_BASE_URL:} + presigned-url-expiration-minutes: 5 diff --git a/src/test/kotlin/com/weeth/domain/account/application/usecase/ReceiptUseCaseImplTest.kt b/src/test/kotlin/com/weeth/domain/account/application/usecase/ReceiptUseCaseImplTest.kt new file mode 100644 index 00000000..b3d0853f --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/account/application/usecase/ReceiptUseCaseImplTest.kt @@ -0,0 +1,97 @@ +package com.weeth.domain.account.application.usecase + +import com.weeth.domain.account.application.dto.ReceiptDTO +import com.weeth.domain.account.application.mapper.ReceiptMapper +import com.weeth.domain.account.domain.entity.Account +import com.weeth.domain.account.domain.entity.Receipt +import com.weeth.domain.account.domain.service.AccountGetService +import com.weeth.domain.account.domain.service.ReceiptDeleteService +import com.weeth.domain.account.domain.service.ReceiptGetService +import com.weeth.domain.account.domain.service.ReceiptSaveService +import com.weeth.domain.account.domain.service.ReceiptUpdateService +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.entity.File +import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.repository.FileReader +import com.weeth.domain.file.domain.repository.FileRepository +import com.weeth.domain.user.domain.service.CardinalGetService +import io.kotest.core.spec.style.DescribeSpec +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import java.time.LocalDate + +class ReceiptUseCaseImplTest : + DescribeSpec({ + val receiptGetService = mockk() + val receiptDeleteService = mockk() + val receiptSaveService = mockk() + val receiptUpdateService = mockk(relaxUnitFun = true) + val accountGetService = mockk() + val fileReader = mockk() + val fileRepository = mockk(relaxed = true) + val cardinalGetService = mockk() + val receiptMapper = mockk() + val fileMapper = mockk() + + val useCase = + ReceiptUseCaseImpl( + receiptGetService, + receiptDeleteService, + receiptSaveService, + receiptUpdateService, + accountGetService, + fileReader, + fileRepository, + cardinalGetService, + receiptMapper, + fileMapper, + ) + + describe("update") { + it("업데이트 파일이 있으면 기존 파일을 삭제 후 새 파일을 저장한다") { + val receiptId = 10L + val account = + Account + .builder() + .id(1L) + .totalAmount(10000) + .currentAmount(10000) + .cardinal(40) + .receipts(mutableListOf()) + .build() + val receipt = + Receipt + .builder() + .id(receiptId) + .amount(1000) + .account(account) + .build() + + val dto = + ReceiptDTO.Update( + "desc", + "source", + 2000, + LocalDate.of(2026, 1, 1), + 40, + listOf(FileSaveRequest("new.png", "TEMP/2026-02/new.png", 100L, "image/png")), + ) + + val oldFiles = listOf(mockk()) + val newFiles = listOf(mockk()) + + every { accountGetService.find(dto.cardinal()) } returns account + every { receiptGetService.find(receiptId) } returns receipt + every { fileReader.findAll(FileOwnerType.RECEIPT, receiptId, null) } returns oldFiles + every { fileMapper.toFileList(dto.files(), FileOwnerType.RECEIPT, receiptId) } returns newFiles + + useCase.update(receiptId, dto) + + verify(exactly = 1) { fileRepository.deleteAll(oldFiles) } + verify(exactly = 1) { fileRepository.saveAll(newFiles) } + verify(exactly = 1) { receiptUpdateService.update(receipt, dto) } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/NoticeUsecaseImplTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/NoticeUsecaseImplTest.kt index f5d83e1e..e12f7ac9 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/NoticeUsecaseImplTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/NoticeUsecaseImplTest.kt @@ -12,9 +12,11 @@ import com.weeth.domain.comment.application.mapper.CommentMapper import com.weeth.domain.file.application.dto.request.FileSaveRequest import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.entity.File -import com.weeth.domain.file.domain.service.FileDeleteService -import com.weeth.domain.file.domain.service.FileGetService -import com.weeth.domain.file.domain.service.FileSaveService +import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.repository.FileReader +import com.weeth.domain.file.domain.repository.FileRepository +import com.weeth.domain.file.domain.vo.FileContentType +import com.weeth.domain.file.domain.vo.StorageKey import com.weeth.domain.file.fixture.FileTestFixture import com.weeth.domain.user.domain.entity.User import com.weeth.domain.user.domain.entity.enums.Department @@ -45,9 +47,8 @@ class NoticeUsecaseImplTest : val noticeUpdateService = mockk(relaxUnitFun = true) val noticeDeleteService = mockk(relaxUnitFun = true) val userGetService = mockk() - val fileSaveService = mockk(relaxUnitFun = true) - val fileGetService = mockk() - val fileDeleteService = mockk(relaxUnitFun = true) + val fileRepository = mockk(relaxed = true) + val fileReader = mockk() val noticeMapper = mockk() val commentMapper = mockk() val fileMapper = mockk() @@ -59,9 +60,8 @@ class NoticeUsecaseImplTest : noticeUpdateService, noticeDeleteService, userGetService, - fileSaveService, - fileGetService, - fileDeleteService, + fileRepository, + fileReader, noticeMapper, commentMapper, fileMapper, @@ -90,7 +90,7 @@ class NoticeUsecaseImplTest : val slice = SliceImpl(listOf(notices[4], notices[3], notices[2]), pageable, true) every { noticeFindService.findRecentNotices(any()) } returns slice - every { fileGetService.findAllByNotice(any()) } returns listOf() + every { fileReader.exists(FileOwnerType.NOTICE, any(), null) } returns false every { noticeMapper.toAll(any(), any()) } answers { val notice = firstArg() NoticeDTO.ResponseAll( @@ -146,18 +146,9 @@ class NoticeUsecaseImplTest : val slice = SliceImpl(listOf(notices[5], notices[4], notices[3]), pageable, false) every { noticeFindService.search(any(), any()) } returns slice - every { fileGetService.findAllByNotice(any()) } answers { - val noticeId = firstArg() - if (noticeId % 2 == 0L) { - listOf( - File - .builder() - .notice(notices[(noticeId - 1).toInt()]) - .build(), - ) - } else { - listOf() - } + every { fileReader.exists(FileOwnerType.NOTICE, any(), null) } answers { + val noticeId = secondArg() + noticeId % 2 == 0L } every { noticeMapper.toAll(any(), any()) } answers { val notice = firstArg() @@ -198,24 +189,40 @@ class NoticeUsecaseImplTest : val user = UserTestFixture.createActiveUser1(userId) val notice = NoticeTestFixture.createNotice(id = noticeId, title = "기존 제목", user = user) - val oldFile = FileTestFixture.createFile(1L, "old.pdf", "https://example.com/old.pdf", notice) + val oldFile = + FileTestFixture.createFile( + 1L, + "old.pdf", + storageKey = StorageKey("NOTICE/2026-02/00000000-0000-0000-0000-000000000000_old.pdf"), + ownerType = FileOwnerType.NOTICE, + ownerId = noticeId, + contentType = FileContentType("application/pdf"), + ) val oldFiles = listOf(oldFile) val dto = NoticeDTO.Update( "수정된 제목", "수정된 내용", - listOf(FileSaveRequest("new.pdf", "https://example.com/new.pdf")), + listOf(FileSaveRequest("new.pdf", "NOTICE/2026-02/new.pdf", 100L, "application/pdf")), ) - val newFile = FileTestFixture.createFile(2L, "new.pdf", "https://example.com/new.pdf", notice) + val newFile = + FileTestFixture.createFile( + 2L, + "new.pdf", + storageKey = StorageKey("NOTICE/2026-02/00000000-0000-0000-0000-000000000000_old.pdf"), + ownerType = FileOwnerType.NOTICE, + ownerId = noticeId, + contentType = FileContentType("application/pdf"), + ) val newFiles = listOf(newFile) val expectedResponse = NoticeDTO.SaveResponse(noticeId) every { noticeFindService.find(noticeId) } returns notice - every { fileGetService.findAllByNotice(noticeId) } returns oldFiles - every { fileMapper.toFileList(dto.files(), notice) } returns newFiles + every { fileReader.findAll(FileOwnerType.NOTICE, noticeId, null) } returns oldFiles + every { fileMapper.toFileList(dto.files(), FileOwnerType.NOTICE, noticeId) } returns newFiles every { noticeMapper.toSaveResponse(notice) } returns expectedResponse val response = noticeUsecase.update(noticeId, dto, userId) @@ -223,10 +230,10 @@ class NoticeUsecaseImplTest : response shouldBe expectedResponse verify { noticeFindService.find(noticeId) } - verify { fileGetService.findAllByNotice(noticeId) } - verify { fileDeleteService.delete(oldFiles) } - verify { fileMapper.toFileList(dto.files(), notice) } - verify { fileSaveService.save(newFiles) } + verify { fileReader.findAll(FileOwnerType.NOTICE, noticeId, null) } + verify { fileRepository.deleteAll(oldFiles) } + verify { fileMapper.toFileList(dto.files(), FileOwnerType.NOTICE, noticeId) } + verify { fileRepository.saveAll(newFiles) } verify { noticeUpdateService.update(notice, dto) } } diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/PostUseCaseImplTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/PostUseCaseImplTest.kt index 200629e4..de6aa350 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/PostUseCaseImplTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/PostUseCaseImplTest.kt @@ -13,9 +13,10 @@ import com.weeth.domain.board.domain.service.PostUpdateService import com.weeth.domain.board.fixture.PostTestFixture import com.weeth.domain.comment.application.mapper.CommentMapper import com.weeth.domain.file.application.mapper.FileMapper -import com.weeth.domain.file.domain.service.FileDeleteService -import com.weeth.domain.file.domain.service.FileGetService -import com.weeth.domain.file.domain.service.FileSaveService +import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.repository.FileReader +import com.weeth.domain.file.domain.repository.FileRepository +import com.weeth.domain.file.domain.vo.StorageKey import com.weeth.domain.file.fixture.FileTestFixture import com.weeth.domain.user.domain.service.CardinalGetService import com.weeth.domain.user.domain.service.UserCardinalGetService @@ -50,9 +51,8 @@ class PostUseCaseImplTest : val userGetService = mockk() val userCardinalGetService = mockk() val cardinalGetService = mockk() - val fileSaveService = mockk(relaxUnitFun = true) - val fileGetService = mockk() - val fileDeleteService = mockk() + val fileRepository = mockk(relaxed = true) + val fileReader = mockk() val mapper = mockk() val fileMapper = mockk() val commentMapper = mockk() @@ -66,9 +66,8 @@ class PostUseCaseImplTest : userGetService, userCardinalGetService, cardinalGetService, - fileSaveService, - fileGetService, - fileDeleteService, + fileRepository, + fileReader, mapper, fileMapper, commentMapper, @@ -86,7 +85,7 @@ class PostUseCaseImplTest : every { userGetService.find(userId) } returns user every { mapper.fromEducationDto(request, user) } returns post every { postSaveService.save(post) } returns post - every { fileMapper.toFileList(request.files(), post) } returns listOf() + every { fileMapper.toFileList(request.files(), FileOwnerType.POST, postId) } returns listOf() every { mapper.toSaveResponse(post) } returns PostDTO.SaveResponse(postId) val response = postUseCase.saveEducation(request, userId) @@ -146,7 +145,7 @@ class PostUseCaseImplTest : } returns postSlice every { mapper.toAll(post2, false) } returns response2 - every { fileGetService.findAllByPost(post2.id) } returns emptyList() + every { fileReader.exists(FileOwnerType.POST, post2.id, null) } returns false val result = postUseCase.findPartPosts(dto, pageNumber, pageSize) @@ -207,8 +206,8 @@ class PostUseCaseImplTest : every { postFindService.findByCategory(part, Category.Education, cardinalNumber, pageNumber, pageSize) } returns postSlice every { mapper.toEducationAll(post1, false) } returns response1 every { mapper.toEducationAll(post2, false) } returns response2 - every { fileGetService.findAllByPost(post1.id) } returns emptyList() - every { fileGetService.findAllByPost(post2.id) } returns emptyList() + every { fileReader.exists(FileOwnerType.POST, post1.id, null) } returns false + every { fileReader.exists(FileOwnerType.POST, post2.id, null) } returns false val result = postUseCase.findEducationPosts(userId, part, cardinalNumber, pageNumber, pageSize) @@ -269,14 +268,21 @@ class PostUseCaseImplTest : describe("checkFileExistsByPost") { it("파일이 존재하는 경우 true를 반환한다") { val postId = 1L - val file = FileTestFixture.createFile(postId, "파일1", "url1") + val file = + FileTestFixture.createFile( + postId, + "파일1", + storageKey = StorageKey("POST/2026-02/00000000-0000-0000-0000-000000000000_url1"), + ownerType = FileOwnerType.POST, + ownerId = postId, + ) - every { fileGetService.findAllByPost(postId) } returns listOf(file) + every { fileReader.exists(FileOwnerType.POST, postId, null) } returns true val fileExists = postUseCase.checkFileExistsByPost(postId) fileExists.shouldBeTrue() - verify { fileGetService.findAllByPost(postId) } + verify { fileReader.exists(FileOwnerType.POST, postId, null) } } } }) diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/NoticeCommentUsecaseImplTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/NoticeCommentUsecaseImplTest.kt index 337afc82..034b5a88 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/NoticeCommentUsecaseImplTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/NoticeCommentUsecaseImplTest.kt @@ -9,9 +9,9 @@ import com.weeth.domain.comment.domain.service.CommentFindService import com.weeth.domain.comment.domain.service.CommentSaveService import com.weeth.domain.comment.fixture.CommentTestFixture import com.weeth.domain.file.application.mapper.FileMapper -import com.weeth.domain.file.domain.service.FileDeleteService -import com.weeth.domain.file.domain.service.FileGetService -import com.weeth.domain.file.domain.service.FileSaveService +import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.repository.FileReader +import com.weeth.domain.file.domain.repository.FileRepository import com.weeth.domain.user.application.exception.UserNotMatchException import com.weeth.domain.user.domain.service.UserGetService import com.weeth.domain.user.fixture.UserTestFixture @@ -28,9 +28,8 @@ class NoticeCommentUsecaseImplTest : val commentSaveService = mockk(relaxUnitFun = true) val commentFindService = mockk() val commentDeleteService = mockk(relaxUnitFun = true) - val fileSaveService = mockk(relaxUnitFun = true) - val fileGetService = mockk() - val fileDeleteService = mockk(relaxUnitFun = true) + val fileRepository = mockk(relaxed = true) + val fileReader = mockk() val fileMapper = mockk() val noticeFindService = mockk() val userGetService = mockk() @@ -41,9 +40,8 @@ class NoticeCommentUsecaseImplTest : commentSaveService, commentFindService, commentDeleteService, - fileSaveService, - fileGetService, - fileDeleteService, + fileRepository, + fileReader, fileMapper, noticeFindService, userGetService, @@ -66,7 +64,7 @@ class NoticeCommentUsecaseImplTest : every { commentMapper.fromCommentDto(dto, notice, user, null) } returns comment every { userGetService.find(user.id) } returns user every { noticeFindService.find(notice.id) } returns notice - every { fileMapper.toFileList(dto.files(), comment) } returns listOf() + every { fileMapper.toFileList(dto.files(), FileOwnerType.COMMENT, commentId) } returns listOf() noticeCommentUsecase.saveNoticeComment(dto, noticeId, userId) @@ -95,7 +93,7 @@ class NoticeCommentUsecaseImplTest : every { userGetService.find(user.id) } returns user every { commentFindService.find(parentComment.id) } returns parentComment every { noticeFindService.find(notice.id) } returns notice - every { fileMapper.toFileList(childCommentDTO.files(), childComment) } returns listOf() + every { fileMapper.toFileList(childCommentDTO.files(), FileOwnerType.COMMENT, childCommentId) } returns listOf() noticeCommentUsecase.saveNoticeComment(childCommentDTO, noticeId, userId) diff --git a/src/test/kotlin/com/weeth/domain/file/application/mapper/FileMapperTest.kt b/src/test/kotlin/com/weeth/domain/file/application/mapper/FileMapperTest.kt new file mode 100644 index 00000000..b459d6e9 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/file/application/mapper/FileMapperTest.kt @@ -0,0 +1,47 @@ +package com.weeth.domain.file.application.mapper + +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.port.FileAccessUrlPort +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.mockk.mockk + +class FileMapperTest : + DescribeSpec({ + val fileAccessUrlPort = mockk(relaxed = true) + val fileMapper = FileMapper(fileAccessUrlPort) + + describe("toFileList") { + it("요청이 null이면 빈 리스트를 반환한다") { + val result = fileMapper.toFileList(null, FileOwnerType.POST, 1L) + + result shouldBe emptyList() + } + + it("요청이 비어있으면 빈 리스트를 반환한다") { + val result = fileMapper.toFileList(emptyList(), FileOwnerType.POST, 1L) + + result shouldBe emptyList() + } + + it("요청 리스트를 ownerType/ownerId를 포함한 File 리스트로 매핑한다") { + val requests = + listOf( + FileSaveRequest("a.png", "POST/2026-02/550e8400-e29b-41d4-a716-446655440000_a.png", 100L, "image/png"), + FileSaveRequest("b.pdf", "POST/2026-02/550e8400-e29b-41d4-a716-446655440001_b.pdf", 200L, "application/pdf"), + ) + + val result = fileMapper.toFileList(requests, FileOwnerType.POST, 99L) + + result shouldHaveSize 2 + result[0].fileName shouldBe "a.png" + result[0].ownerType shouldBe FileOwnerType.POST + result[0].ownerId shouldBe 99L + result[1].fileName shouldBe "b.pdf" + result[1].ownerType shouldBe FileOwnerType.POST + result[1].ownerId shouldBe 99L + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/file/application/usecase/command/GenerateFileUrlUsecaseTest.kt b/src/test/kotlin/com/weeth/domain/file/application/usecase/command/GenerateFileUrlUsecaseTest.kt new file mode 100644 index 00000000..3601b57d --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/file/application/usecase/command/GenerateFileUrlUsecaseTest.kt @@ -0,0 +1,63 @@ +package com.weeth.domain.file.application.usecase.command + +import com.weeth.domain.file.application.dto.response.UrlResponse +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.port.FileUploadUrl +import com.weeth.domain.file.domain.port.FileUploadUrlPort +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.collections.shouldContainExactly +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify + +class GenerateFileUrlUsecaseTest : + DescribeSpec({ + val preSignedService = mockk() + val fileMapper = mockk() + val useCase = GenerateFileUrlUsecase(preSignedService, fileMapper) + + beforeEach { + clearMocks(preSignedService, fileMapper) + } + + describe("getUrl") { + it("요청한 파일명 순서대로 presigned URL을 반환한다") { + val fileNames = listOf("a.png", "b.pdf") + val ownerType = FileOwnerType.POST + val responses = + listOf( + UrlResponse("a.png", "https://presigned/a", "POST/2026-02/a.png"), + UrlResponse("b.pdf", "https://presigned/b", "POST/2026-02/b.pdf"), + ) + val firstPresigned = FileUploadUrl("a.png", "POST/2026-02/a.png", "https://presigned/a") + val secondPresigned = FileUploadUrl("b.pdf", "POST/2026-02/b.pdf", "https://presigned/b") + + every { preSignedService.generateUploadUrl(ownerType, "a.png") } returns firstPresigned + every { preSignedService.generateUploadUrl(ownerType, "b.pdf") } returns secondPresigned + every { fileMapper.toUrlResponse("a.png", "https://presigned/a", "POST/2026-02/a.png") } returns responses[0] + every { fileMapper.toUrlResponse("b.pdf", "https://presigned/b", "POST/2026-02/b.pdf") } returns responses[1] + + val result = useCase.generateFileUploadUrls(ownerType, fileNames) + + result shouldContainExactly responses + verify(exactly = 1) { preSignedService.generateUploadUrl(ownerType, "a.png") } + verify(exactly = 1) { preSignedService.generateUploadUrl(ownerType, "b.pdf") } + } + + it("Presigned URL 생성 포트에서 예외가 나면 그대로 전파한다") { + val ownerType = FileOwnerType.POST + val fileNames = listOf("a.png") + + every { preSignedService.generateUploadUrl(ownerType, "a.png") } throws RuntimeException("s3 error") + + shouldThrow { + useCase.generateFileUploadUrls(ownerType, fileNames) + } + + verify(exactly = 0) { fileMapper.toUrlResponse(any(), any(), any()) } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/file/domain/entity/FileTest.kt b/src/test/kotlin/com/weeth/domain/file/domain/entity/FileTest.kt new file mode 100644 index 00000000..c30dfba0 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/file/domain/entity/FileTest.kt @@ -0,0 +1,138 @@ +package com.weeth.domain.file.domain.entity + +import com.weeth.domain.file.application.exception.UnsupportedFileContentTypeException +import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.entity.FileStatus +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe + +class FileTest : + DescribeSpec({ + describe("createUploaded") { + it("유효한 입력이면 업로드 상태 파일을 생성한다") { + val file = + File.createUploaded( + fileName = "image.png", + storageKey = "POST/2026-02/550e8400-e29b-41d4-a716-446655440000_image.png", + fileSize = 1024, + contentType = "image/png", + ownerType = FileOwnerType.POST, + ownerId = 1L, + ) + + file.fileName shouldBe "image.png" + file.ownerType shouldBe FileOwnerType.POST + file.status shouldBe FileStatus.UPLOADED + } + + it("fileName이 blank면 예외가 발생한다") { + shouldThrow { + File.createUploaded( + fileName = " ", + storageKey = "POST/2026-02/550e8400-e29b-41d4-a716-446655440000_image.png", + fileSize = 1024, + contentType = "image/png", + ownerType = FileOwnerType.POST, + ownerId = 1L, + ) + } + } + + it("storageKey가 blank면 예외가 발생한다") { + shouldThrow { + File.createUploaded( + fileName = "image.png", + storageKey = " ", + fileSize = 1024, + contentType = "image/png", + ownerType = FileOwnerType.POST, + ownerId = 1L, + ) + } + } + + it("storageKey ownerType 형식이 잘못되면 예외가 발생한다") { + shouldThrow { + File.createUploaded( + fileName = "image.png", + storageKey = "INVALID/2026-02/550e8400-e29b-41d4-a716-446655440000_image.png", + fileSize = 1024, + contentType = "image/png", + ownerType = FileOwnerType.POST, + ownerId = 1L, + ) + } + } + + it("storageKey uuid 형식이 잘못되면 예외가 발생한다") { + shouldThrow { + File.createUploaded( + fileName = "image.png", + storageKey = "POST/2026-02/not-uuid_image.png", + fileSize = 1024, + contentType = "image/png", + ownerType = FileOwnerType.POST, + ownerId = 1L, + ) + } + } + + it("fileSize가 0 이하이면 예외가 발생한다") { + shouldThrow { + File.createUploaded( + fileName = "image.png", + storageKey = "POST/2026-02/550e8400-e29b-41d4-a716-446655440000_image.png", + fileSize = 0, + contentType = "image/png", + ownerType = FileOwnerType.POST, + ownerId = 1L, + ) + } + } + + it("ownerId가 0 이하이면 예외가 발생한다") { + shouldThrow { + File.createUploaded( + fileName = "image.png", + storageKey = "POST/2026-02/550e8400-e29b-41d4-a716-446655440000_image.png", + fileSize = 1024, + contentType = "image/png", + ownerType = FileOwnerType.POST, + ownerId = 0, + ) + } + } + + it("허용되지 않은 contentType이면 예외가 발생한다") { + shouldThrow { + File.createUploaded( + fileName = "file.exe", + storageKey = "POST/2026-02/550e8400-e29b-41d4-a716-446655440000_file.exe", + fileSize = 100, + contentType = "application/octet-stream", + ownerType = FileOwnerType.POST, + ownerId = 1L, + ) + } + } + } + + describe("markDeleted") { + it("파일 상태를 DELETED로 변경한다") { + val file = + File.createUploaded( + fileName = "doc.pdf", + storageKey = "NOTICE/2026-02/550e8400-e29b-41d4-a716-446655440000_doc.pdf", + fileSize = 100, + contentType = "application/pdf", + ownerType = FileOwnerType.NOTICE, + ownerId = 2L, + ) + + file.markDeleted() + + file.status shouldBe FileStatus.DELETED + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/file/domain/repository/FileRepositoryTest.kt b/src/test/kotlin/com/weeth/domain/file/domain/repository/FileRepositoryTest.kt new file mode 100644 index 00000000..1983e70f --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/file/domain/repository/FileRepositoryTest.kt @@ -0,0 +1,109 @@ +package com.weeth.domain.file.domain.repository + +import com.weeth.config.TestContainersConfig +import com.weeth.domain.file.domain.entity.File +import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.entity.FileStatus +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.booleans.shouldBeFalse +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import org.springframework.jdbc.core.JdbcTemplate +import java.util.UUID + +@DataJpaTest +@Import(TestContainersConfig::class) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class FileRepositoryTest( + private val fileRepository: FileRepository, + private val jdbcTemplate: JdbcTemplate, +) : DescribeSpec({ + describe("save") { + it("파일 정보를 저장하고 조회한다") { + val saved = + fileRepository.save( + createTestFile( + fileName = "notice-image.png", + ownerType = FileOwnerType.NOTICE, + ownerId = 101L, + status = FileStatus.UPLOADED, + ), + ) + + val found = fileRepository.findById(saved.id).orElseThrow() + + found.fileName shouldBe "notice-image.png" + found.ownerType shouldBe FileOwnerType.NOTICE + found.ownerId shouldBe 101L + found.status shouldBe FileStatus.UPLOADED + } + } + + describe("findAll/exists") { + it("ownerType + ownerId + status 조건에 맞는 데이터만 조회한다") { + fileRepository.save(createTestFile("target-1.png", FileOwnerType.POST, 77L, FileStatus.UPLOADED)) + fileRepository.save(createTestFile("target-2.png", FileOwnerType.POST, 77L, FileStatus.UPLOADED)) + fileRepository.save(createTestFile("deleted.png", FileOwnerType.POST, 77L, FileStatus.DELETED)) + fileRepository.save(createTestFile("other-owner.png", FileOwnerType.POST, 78L, FileStatus.UPLOADED)) + fileRepository.save(createTestFile("other-type.png", FileOwnerType.NOTICE, 77L, FileStatus.UPLOADED)) + + val uploaded = fileRepository.findAll(FileOwnerType.POST, 77L, FileStatus.UPLOADED) + val allStatus = fileRepository.findAll(FileOwnerType.POST, 77L, null) + + uploaded.map { it.fileName }.sorted() shouldContainExactly listOf("target-1.png", "target-2.png") + allStatus.map { it.fileName }.sorted() shouldContainExactly + listOf("deleted.png", "target-1.png", "target-2.png") + + fileRepository.exists(FileOwnerType.POST, 77L, FileStatus.UPLOADED).shouldBeTrue() + fileRepository.exists(FileOwnerType.POST, 77L, FileStatus.DELETED).shouldBeTrue() + fileRepository.exists(FileOwnerType.POST, 99L, FileStatus.UPLOADED).shouldBeFalse() + } + } + + describe("index usage") { + it("owner_type + owner_id 조건 조회 시 복합 인덱스를 사용한다") { + fileRepository.save(createTestFile("index-target.png", FileOwnerType.RECEIPT, 55L, FileStatus.UPLOADED)) + + val explain = + jdbcTemplate + .queryForList( + "EXPLAIN SELECT id FROM `file` WHERE owner_type = ? AND owner_id = ?", + FileOwnerType.RECEIPT.name, + 55L, + ).first() + + val possibleKeys = explain.valueBy("possible_keys") + val selectedKey = explain.valueBy("key") + + possibleKeys shouldContain "idx_file_owner_type_owner_id" + selectedKey shouldBe "idx_file_owner_type_owner_id" + } + } + }) + +private fun createTestFile( + fileName: String, + ownerType: FileOwnerType, + ownerId: Long, + status: FileStatus, +): File = + File + .createUploaded( + fileName = fileName, + storageKey = "${ownerType.name}/2026-02/${UUID.randomUUID()}_$fileName", + fileSize = 1024L, + contentType = "image/png", + ownerType = ownerType, + ownerId = ownerId, + ).also { + if (status == FileStatus.DELETED) { + it.markDeleted() + } + } + +private fun Map.valueBy(key: String): String = entries.first { it.key.equals(key, ignoreCase = true) }.value.toString() diff --git a/src/test/kotlin/com/weeth/domain/file/domain/vo/FileNameTest.kt b/src/test/kotlin/com/weeth/domain/file/domain/vo/FileNameTest.kt new file mode 100644 index 00000000..0052c501 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/file/domain/vo/FileNameTest.kt @@ -0,0 +1,25 @@ +package com.weeth.domain.file.domain.vo + +import com.weeth.domain.file.application.exception.UnsupportedFileExtensionException +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe + +class FileNameTest : + DescribeSpec({ + describe("FileName") { + it("파일명 sanitize와 확장자 검증을 수행한다") { + val fileName = FileName(" report:2026.PDF ") + + fileName.sanitized shouldBe "report_2026.PDF" + fileName.extension.normalized shouldBe "pdf" + fileName.extension.fileType shouldBe FileType.PDF + } + + it("허용되지 않은 확장자는 예외를 던진다") { + shouldThrow { + FileName("payload.exe") + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/file/domain/vo/FileTypeTest.kt b/src/test/kotlin/com/weeth/domain/file/domain/vo/FileTypeTest.kt new file mode 100644 index 00000000..8c376512 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/file/domain/vo/FileTypeTest.kt @@ -0,0 +1,20 @@ +package com.weeth.domain.file.domain.vo + +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe + +class FileTypeTest : + DescribeSpec({ + describe("FileType") { + it("contentType으로 타입을 조회한다") { + FileType.fromContentType("image/png") shouldBe FileType.PNG + FileType.fromContentType("application/pdf") shouldBe FileType.PDF + } + + it("extension으로 타입을 조회한다") { + FileType.fromExtension("jpg") shouldBe FileType.JPEG + FileType.fromExtension("jpeg") shouldBe FileType.JPEG + FileType.fromExtension("webp") shouldBe FileType.WEBP + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/file/fixture/FileTestFixture.kt b/src/test/kotlin/com/weeth/domain/file/fixture/FileTestFixture.kt index a42c852c..c7865ed1 100644 --- a/src/test/kotlin/com/weeth/domain/file/fixture/FileTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/file/fixture/FileTestFixture.kt @@ -1,20 +1,27 @@ package com.weeth.domain.file.fixture -import com.weeth.domain.board.domain.entity.Notice import com.weeth.domain.file.domain.entity.File +import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.vo.FileContentType +import com.weeth.domain.file.domain.vo.StorageKey object FileTestFixture { fun createFile( id: Long, fileName: String, - fileUrl: String, - notice: Notice? = null, + storageKey: StorageKey = StorageKey("NOTICE/2026-02/00000000-0000-0000-0000-000000000000_test.png"), + fileSize: Long = 1024, + ownerType: FileOwnerType = FileOwnerType.NOTICE, + ownerId: Long = 1L, + contentType: FileContentType = FileContentType("image/png"), ): File = - File - .builder() - .id(id) - .fileName(fileName) - .fileUrl(fileUrl) - .notice(notice) - .build() + File( + id = id, + fileName = fileName, + storageKey = storageKey, + fileSize = fileSize, + ownerType = ownerType, + ownerId = ownerId, + contentType = contentType, + ) } diff --git a/src/test/kotlin/com/weeth/domain/file/infrastructure/FileAccessUrlAdapterTest.kt b/src/test/kotlin/com/weeth/domain/file/infrastructure/FileAccessUrlAdapterTest.kt new file mode 100644 index 00000000..31e4520c --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/file/infrastructure/FileAccessUrlAdapterTest.kt @@ -0,0 +1,42 @@ +package com.weeth.domain.file.infrastructure + +import com.weeth.global.config.properties.AwsS3Properties +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe + +class FileAccessUrlAdapterTest : + DescribeSpec({ + describe("S3FileAccessUrlAdapter") { + it("storageKey를 S3 URL로 변환한다") { + val awsS3Properties = + AwsS3Properties( + s3 = AwsS3Properties.S3Properties(bucket = "weeth-bucket"), + credentials = AwsS3Properties.CredentialsProperties(accessKey = "test", secretKey = "test"), + region = AwsS3Properties.RegionProperties(static = "ap-northeast-2"), + ) + val adapter = S3FileAccessUrlAdapter(awsS3Properties) + + val result = adapter.resolve("POST/2026-02/file.png") + + result shouldBe "https://weeth-bucket.s3.ap-northeast-2.amazonaws.com/POST/2026-02/file.png" + } + } + + describe("CdnFileAccessUrlAdapter") { + it("cdn base url이 있으면 CDN URL로 변환한다") { + val adapter = CdnFileAccessUrlAdapter("https://cdn.example.com") + + val result = adapter.resolve("POST/2026-02/file.png") + + result shouldBe "https://cdn.example.com/POST/2026-02/file.png" + } + + it("cdn base url이 없으면 storageKey를 그대로 반환한다") { + val adapter = CdnFileAccessUrlAdapter("") + + val result = adapter.resolve("POST/2026-02/file.png") + + result shouldBe "POST/2026-02/file.png" + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/file/infrastructure/S3FileUploadUrlAdapterTest.kt b/src/test/kotlin/com/weeth/domain/file/infrastructure/S3FileUploadUrlAdapterTest.kt new file mode 100644 index 00000000..f54d313b --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/file/infrastructure/S3FileUploadUrlAdapterTest.kt @@ -0,0 +1,65 @@ +package com.weeth.domain.file.infrastructure + +import com.weeth.domain.file.application.exception.PresignedUrlGenerationException +import com.weeth.domain.file.application.exception.UnsupportedFileExtensionException +import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.global.config.properties.AwsS3Properties +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldStartWith +import io.mockk.every +import io.mockk.mockk +import software.amazon.awssdk.services.s3.presigner.S3Presigner +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest +import java.net.URL + +class S3FileUploadUrlAdapterTest : + DescribeSpec({ + describe("generateUploadUrl") { + val awsS3Properties = + AwsS3Properties( + s3 = AwsS3Properties.S3Properties(bucket = "weeth-bucket"), + credentials = AwsS3Properties.CredentialsProperties(accessKey = "test", secretKey = "test"), + region = AwsS3Properties.RegionProperties(static = "ap-northeast-2"), + ) + + it("ownerType/fileName 기반 storageKey와 presigned URL을 반환한다") { + val s3Presigner = mockk() + val presignedRequest = mockk() + val adapter = S3FileUploadUrlAdapter(s3Presigner, awsS3Properties, 5) + + every { s3Presigner.presignPutObject(any()) } returns presignedRequest + every { presignedRequest.url() } returns URL("https://presigned.example.com/upload") + + val result = adapter.generateUploadUrl(FileOwnerType.POST, "file.png") + + result.fileName shouldBe "file.png" + result.storageKey shouldStartWith "POST/" + result.storageKey shouldContain "_file.png" + result.url shouldBe "https://presigned.example.com/upload" + } + + it("presigner 오류가 발생하면 PresignedUrlGenerationException으로 변환한다") { + val s3Presigner = mockk() + val adapter = S3FileUploadUrlAdapter(s3Presigner, awsS3Properties, 5) + + every { s3Presigner.presignPutObject(any()) } throws RuntimeException("s3 unavailable") + + shouldThrow { + adapter.generateUploadUrl(FileOwnerType.POST, "file.png") + } + } + + it("허용되지 않은 확장자는 URL 생성 전에 차단한다") { + val s3Presigner = mockk() + val adapter = S3FileUploadUrlAdapter(s3Presigner, awsS3Properties, 5) + + shouldThrow { + adapter.generateUploadUrl(FileOwnerType.POST, "file.exe") + } + } + } + }) From 05340f5b78337cbf998ce2fa3967adfa6a8fab0e Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Wed, 18 Feb 2026 19:24:18 +0900 Subject: [PATCH 06/73] =?UTF-8?q?[WTH-144]=20comment=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=BD=94=ED=8B=80=EB=A6=B0=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: Comment 엔티티 마이그레이션 * refactor: 기존 서비스 제거 * refactor: Repository 마이그레이션 * refactor: Dto 마이그레이션 및 분리 * refactor: 예외 마이그레이션 * refactor: mapper 마이그레이션 * refactor: 유스케이스 마이그레이션 및 통합 * refactor: 컨트롤러 마이그레이션 * refactor: Comment Test Fixture 추가 * refactor: Comment 변경으로 인한 notice 도메인 수정 * refactor: Comment 변경으로 인한 post 도메인 수정 * refactor: 주석 추가 * refactor: 롬복 어노테이션 추가 * refactor: Increse 메서드 추가 * feat: 테스트 시 쿼리 측정을 위한 설정 추가 * feat: 댓글 조회 서비스 구현 * refactor: 댓글 조회 시 파일도 bulk로 조회해오도록 수저 * chore: 컨텍스트 업데이트 * refactor: 자체 리뷰 사항 반영 * feat: 레거시 vs 개선 로직 비교 테스트 추가 * chore: lint 처리 --- .claude/rules/testing.md | 24 + build.gradle.kts | 9 +- .../board/application/dto/NoticeDTO.java | 10 +- .../domain/board/application/dto/PostDTO.java | 12 +- .../application/mapper/NoticeMapper.java | 4 +- .../board/application/mapper/PostMapper.java | 4 +- .../usecase/NoticeUsecaseImpl.java | 33 +- .../application/usecase/PostUseCaseImpl.java | 32 +- .../domain/board/domain/entity/Board.java | 4 + .../domain/board/domain/entity/Notice.java | 1 + .../domain/repository/NoticeRepository.java | 9 + .../domain/repository/PostRepository.java | 13 +- .../comment/application/dto/CommentDTO.java | 43 -- .../exception/CommentErrorCode.java | 19 - .../exception/CommentNotFoundException.java | 9 - .../application/mapper/CommentMapper.java | 49 -- .../usecase/NoticeCommentUsecase.java | 13 - .../usecase/NoticeCommentUsecaseImpl.java | 136 ----- .../usecase/PostCommentUsecase.java | 14 - .../usecase/PostCommentUsecaseImpl.java | 148 ----- .../domain/comment/domain/entity/Comment.java | 78 --- .../domain/repository/CommentRepository.java | 7 - .../domain/service/CommentDeleteService.java | 19 - .../domain/service/CommentFindService.java | 26 - .../domain/service/CommentSaveService.java | 20 - .../domain/service/CommentUpdateService.java | 14 - .../presentation/NoticeCommentController.java | 52 -- .../presentation/PostCommentController.java | 51 -- .../global/common/entity/BaseEntity.java | 2 +- .../dto/request/CommentSaveRequest.kt | 20 + .../dto/request/CommentUpdateRequest.kt | 21 + .../dto/response/CommentResponse.kt | 17 + .../CommentAlreadyDeletedException.kt | 5 + .../application/exception/CommentErrorCode.kt | 27 + .../exception/CommentNotFoundException.kt | 5 + .../exception/CommentNotOwnedException.kt | 5 + .../application/mapper/CommentMapper.kt | 25 + .../usecase/command/ManageCommentUseCase.kt | 224 ++++++++ .../usecase/command/NoticeCommentUsecase.kt | 25 + .../usecase/command/PostCommentUsecase.kt | 25 + .../usecase/query/GetCommentQueryService.kt | 61 ++ .../domain/comment/domain/entity/Comment.kt | 96 ++++ .../domain/repository/CommentRepository.kt | 16 + .../comment/domain/vo/CommentContent.kt | 18 + .../presentation/CommentResponseCode.kt} | 28 +- .../presentation/NoticeCommentController.kt | 65 +++ .../presentation/PostCommentController.kt | 65 +++ .../file/domain/repository/FileReader.kt | 11 + .../file/domain/repository/FileRepository.kt | 23 + .../kotlin/com/weeth/config/QueryCountUtil.kt | 56 ++ .../usecase/NoticeUsecaseImplTest.kt | 6 +- .../usecase/PostUseCaseImplTest.kt | 6 +- .../usecase/NoticeCommentUsecaseImplTest.kt | 129 ----- .../command/ManageCommentUseCaseTest.kt | 523 ++++++++++++++++++ .../query/CommentQueryPerformanceTest.kt | 213 +++++++ .../query/GetCommentQueryServiceTest.kt | 109 ++++ .../domain/entity/CommentEntityTest.kt | 114 ++++ .../comment/domain/vo/CommentContentTest.kt | 38 ++ .../comment/fixture/CommentTestFixture.kt | 43 +- src/test/resources/application-test.yml | 1 + 60 files changed, 1936 insertions(+), 939 deletions(-) delete mode 100644 src/main/java/com/weeth/domain/comment/application/dto/CommentDTO.java delete mode 100644 src/main/java/com/weeth/domain/comment/application/exception/CommentErrorCode.java delete mode 100644 src/main/java/com/weeth/domain/comment/application/exception/CommentNotFoundException.java delete mode 100644 src/main/java/com/weeth/domain/comment/application/mapper/CommentMapper.java delete mode 100644 src/main/java/com/weeth/domain/comment/application/usecase/NoticeCommentUsecase.java delete mode 100644 src/main/java/com/weeth/domain/comment/application/usecase/NoticeCommentUsecaseImpl.java delete mode 100644 src/main/java/com/weeth/domain/comment/application/usecase/PostCommentUsecase.java delete mode 100644 src/main/java/com/weeth/domain/comment/application/usecase/PostCommentUsecaseImpl.java delete mode 100644 src/main/java/com/weeth/domain/comment/domain/entity/Comment.java delete mode 100644 src/main/java/com/weeth/domain/comment/domain/repository/CommentRepository.java delete mode 100644 src/main/java/com/weeth/domain/comment/domain/service/CommentDeleteService.java delete mode 100644 src/main/java/com/weeth/domain/comment/domain/service/CommentFindService.java delete mode 100644 src/main/java/com/weeth/domain/comment/domain/service/CommentSaveService.java delete mode 100644 src/main/java/com/weeth/domain/comment/domain/service/CommentUpdateService.java delete mode 100644 src/main/java/com/weeth/domain/comment/presentation/NoticeCommentController.java delete mode 100644 src/main/java/com/weeth/domain/comment/presentation/PostCommentController.java create mode 100644 src/main/kotlin/com/weeth/domain/comment/application/dto/request/CommentSaveRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/comment/application/dto/request/CommentUpdateRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/comment/application/dto/response/CommentResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/comment/application/exception/CommentAlreadyDeletedException.kt create mode 100644 src/main/kotlin/com/weeth/domain/comment/application/exception/CommentErrorCode.kt create mode 100644 src/main/kotlin/com/weeth/domain/comment/application/exception/CommentNotFoundException.kt create mode 100644 src/main/kotlin/com/weeth/domain/comment/application/exception/CommentNotOwnedException.kt create mode 100644 src/main/kotlin/com/weeth/domain/comment/application/mapper/CommentMapper.kt create mode 100644 src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt create mode 100644 src/main/kotlin/com/weeth/domain/comment/application/usecase/command/NoticeCommentUsecase.kt create mode 100644 src/main/kotlin/com/weeth/domain/comment/application/usecase/command/PostCommentUsecase.kt create mode 100644 src/main/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryService.kt create mode 100644 src/main/kotlin/com/weeth/domain/comment/domain/entity/Comment.kt create mode 100644 src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentRepository.kt create mode 100644 src/main/kotlin/com/weeth/domain/comment/domain/vo/CommentContent.kt rename src/main/{java/com/weeth/domain/comment/presentation/CommentResponseCode.java => kotlin/com/weeth/domain/comment/presentation/CommentResponseCode.kt} (50%) create mode 100644 src/main/kotlin/com/weeth/domain/comment/presentation/NoticeCommentController.kt create mode 100644 src/main/kotlin/com/weeth/domain/comment/presentation/PostCommentController.kt create mode 100644 src/test/kotlin/com/weeth/config/QueryCountUtil.kt delete mode 100644 src/test/kotlin/com/weeth/domain/comment/application/usecase/NoticeCommentUsecaseImplTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/comment/domain/entity/CommentEntityTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/comment/domain/vo/CommentContentTest.kt diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 9585f2b6..963e9c3c 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -93,6 +93,30 @@ object UserTestFixture { - Getter/setter, trivial DTO mapping - Framework-provided functionality +## Mock Lifecycle in DescribeSpec + +MockK mocks are **not** automatically cleared between `it` blocks. Without clearing, accumulated invocations cause `verify(exactly = N)` to fail in subsequent tests. + +Always add `beforeTest { clearMocks(...) }` when mocks are shared: + +```kotlin +class SomeUseCaseTest : DescribeSpec({ + val repository = mockk() + val useCase = SomeUseCase(repository) + + beforeTest { + clearMocks(repository) + // Re-stub defaults after clearing + every { repository.save(any()) } answers { firstArg() } + } + + describe("someMethod") { + it("case 1") { verify(exactly = 1) { repository.save(any()) } } + it("case 2") { verify(exactly = 1) { repository.save(any()) } } // OK - count reset + } +}) +``` + ## Running Tests ```bash diff --git a/build.gradle.kts b/build.gradle.kts index 6b528cbf..638e9945 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,6 +8,7 @@ plugins { kotlin("jvm") version "2.1.0" kotlin("plugin.spring") version "2.1.0" kotlin("plugin.jpa") version "2.1.0" + kotlin("plugin.lombok") version "2.1.0" id("org.jlleitschuh.gradle.ktlint") version "12.1.2" } @@ -121,7 +122,13 @@ tasks.withType().configureEach { } tasks.test { - useJUnitPlatform() + val runPerformanceTests = (findProperty("runPerformanceTests") as String?)?.toBoolean() ?: false + systemProperty("runPerformanceTests", runPerformanceTests.toString()) + useJUnitPlatform { + if (!runPerformanceTests) { + excludeTags("performance") + } + } } // plain jar 파일 생성 방지 (bootJar는 그대로) diff --git a/src/main/java/com/weeth/domain/board/application/dto/NoticeDTO.java b/src/main/java/com/weeth/domain/board/application/dto/NoticeDTO.java index 0d01cdad..4c6ed47e 100644 --- a/src/main/java/com/weeth/domain/board/application/dto/NoticeDTO.java +++ b/src/main/java/com/weeth/domain/board/application/dto/NoticeDTO.java @@ -1,13 +1,13 @@ package com.weeth.domain.board.application.dto; -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotNull; -import com.weeth.domain.comment.application.dto.CommentDTO; +import com.weeth.domain.comment.application.dto.response.CommentResponse; import com.weeth.domain.file.application.dto.request.FileSaveRequest; import com.weeth.domain.file.application.dto.response.FileResponse; import com.weeth.domain.user.domain.entity.enums.Position; import com.weeth.domain.user.domain.entity.enums.Role; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; import lombok.Builder; import java.time.LocalDateTime; @@ -41,7 +41,7 @@ public record Response( String content, LocalDateTime time, //createdAt Integer commentCount, - List comments, + List comments, List fileUrls ) { } diff --git a/src/main/java/com/weeth/domain/board/application/dto/PostDTO.java b/src/main/java/com/weeth/domain/board/application/dto/PostDTO.java index 885d6905..c0f26dd2 100644 --- a/src/main/java/com/weeth/domain/board/application/dto/PostDTO.java +++ b/src/main/java/com/weeth/domain/board/application/dto/PostDTO.java @@ -1,16 +1,16 @@ package com.weeth.domain.board.application.dto; -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; import com.weeth.domain.board.domain.entity.enums.Category; import com.weeth.domain.board.domain.entity.enums.Part; -import com.weeth.domain.comment.application.dto.CommentDTO; +import com.weeth.domain.comment.application.dto.response.CommentResponse; import com.weeth.domain.file.application.dto.request.FileSaveRequest; import com.weeth.domain.file.application.dto.response.FileResponse; import com.weeth.domain.user.domain.entity.enums.Position; import com.weeth.domain.user.domain.entity.enums.Role; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import lombok.Builder; import java.time.LocalDateTime; @@ -85,7 +85,7 @@ public record Response( List parts, LocalDateTime time, Integer commentCount, - List comments, + List comments, List fileUrls ) { } diff --git a/src/main/java/com/weeth/domain/board/application/mapper/NoticeMapper.java b/src/main/java/com/weeth/domain/board/application/mapper/NoticeMapper.java index 413a126d..4acc286a 100644 --- a/src/main/java/com/weeth/domain/board/application/mapper/NoticeMapper.java +++ b/src/main/java/com/weeth/domain/board/application/mapper/NoticeMapper.java @@ -2,7 +2,7 @@ import com.weeth.domain.board.application.dto.NoticeDTO; import com.weeth.domain.board.domain.entity.Notice; -import com.weeth.domain.comment.application.dto.CommentDTO; +import com.weeth.domain.comment.application.dto.response.CommentResponse; import com.weeth.domain.comment.application.mapper.CommentMapper; import com.weeth.domain.file.application.dto.response.FileResponse; import com.weeth.domain.user.domain.entity.User; @@ -35,7 +35,7 @@ public interface NoticeMapper { @Mapping(target = "time", source = "notice.createdAt"), @Mapping(target = "comments", source = "comments") }) - NoticeDTO.Response toNoticeDto(Notice notice, List fileUrls, List comments); + NoticeDTO.Response toNoticeDto(Notice notice, List fileUrls, List comments); NoticeDTO.SaveResponse toSaveResponse(Notice notice); diff --git a/src/main/java/com/weeth/domain/board/application/mapper/PostMapper.java b/src/main/java/com/weeth/domain/board/application/mapper/PostMapper.java index 45aa58f0..db3924b5 100644 --- a/src/main/java/com/weeth/domain/board/application/mapper/PostMapper.java +++ b/src/main/java/com/weeth/domain/board/application/mapper/PostMapper.java @@ -2,7 +2,7 @@ import com.weeth.domain.board.application.dto.PostDTO; import com.weeth.domain.board.domain.entity.Post; -import com.weeth.domain.comment.application.dto.CommentDTO; +import com.weeth.domain.comment.application.dto.response.CommentResponse; import com.weeth.domain.comment.application.mapper.CommentMapper; import com.weeth.domain.file.application.dto.response.FileResponse; import com.weeth.domain.user.domain.entity.User; @@ -66,7 +66,7 @@ public interface PostMapper { @Mapping(target = "time", source = "post.createdAt"), @Mapping(target = "comments", source = "comments") }) - PostDTO.Response toPostDto(Post post, List fileUrls, List comments); + PostDTO.Response toPostDto(Post post, List fileUrls, List comments); default PostDTO.ResponseStudyNames toStudyNames(List studyNames) { return new PostDTO.ResponseStudyNames(studyNames); diff --git a/src/main/java/com/weeth/domain/board/application/usecase/NoticeUsecaseImpl.java b/src/main/java/com/weeth/domain/board/application/usecase/NoticeUsecaseImpl.java index ac5d5bf9..ea1e6ceb 100644 --- a/src/main/java/com/weeth/domain/board/application/usecase/NoticeUsecaseImpl.java +++ b/src/main/java/com/weeth/domain/board/application/usecase/NoticeUsecaseImpl.java @@ -9,8 +9,8 @@ import com.weeth.domain.board.domain.service.NoticeFindService; import com.weeth.domain.board.domain.service.NoticeSaveService; import com.weeth.domain.board.domain.service.NoticeUpdateService; -import com.weeth.domain.comment.application.dto.CommentDTO; -import com.weeth.domain.comment.application.mapper.CommentMapper; +import com.weeth.domain.comment.application.dto.response.CommentResponse; +import com.weeth.domain.comment.application.usecase.query.GetCommentQueryService; import com.weeth.domain.comment.domain.entity.Comment; import com.weeth.domain.file.application.dto.response.FileResponse; import com.weeth.domain.file.application.mapper.FileMapper; @@ -29,10 +29,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.Collections; import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -49,7 +46,7 @@ public class NoticeUsecaseImpl implements NoticeUsecase { private final FileReader fileReader; private final NoticeMapper mapper; - private final CommentMapper commentMapper; + private final GetCommentQueryService getCommentQueryService; private final FileMapper fileMapper; @Override @@ -148,28 +145,8 @@ private boolean checkFileExistsByNotice(Long noticeId){ return fileReader.exists(FileOwnerType.NOTICE, noticeId, null); } - private List filterParentComments(List comments) { - Map> commentMap = comments.stream() - .filter(comment -> comment.getParent() != null) - .collect(Collectors.groupingBy(comment -> comment.getParent().getId())); - - return comments.stream() - .filter(comment -> comment.getParent() == null) // 부모 댓글만 가져오기 - .map(parent -> mapToDtoWithChildren(parent, commentMap)) - .toList(); - } - - private CommentDTO.Response mapToDtoWithChildren(Comment comment, Map> commentMap) { - List children = commentMap.getOrDefault(comment.getId(), Collections.emptyList()) - .stream() - .map(child -> mapToDtoWithChildren(child, commentMap)) - .collect(Collectors.toList()); - - List files = fileReader.findAll(FileOwnerType.COMMENT, comment.getId(), null).stream() - .map(fileMapper::toFileResponse) - .toList(); - - return commentMapper.toCommentDto(comment, children, files); + private List filterParentComments(List comments) { + return getCommentQueryService.toCommentTreeResponses(comments); } private void validatePageNumber(int pageNumber){ diff --git a/src/main/java/com/weeth/domain/board/application/usecase/PostUseCaseImpl.java b/src/main/java/com/weeth/domain/board/application/usecase/PostUseCaseImpl.java index 0c25b13f..e0546d38 100644 --- a/src/main/java/com/weeth/domain/board/application/usecase/PostUseCaseImpl.java +++ b/src/main/java/com/weeth/domain/board/application/usecase/PostUseCaseImpl.java @@ -13,8 +13,8 @@ import com.weeth.domain.board.domain.service.PostFindService; import com.weeth.domain.board.domain.service.PostSaveService; import com.weeth.domain.board.domain.service.PostUpdateService; -import com.weeth.domain.comment.application.dto.CommentDTO; -import com.weeth.domain.comment.application.mapper.CommentMapper; +import com.weeth.domain.comment.application.dto.response.CommentResponse; +import com.weeth.domain.comment.application.usecase.query.GetCommentQueryService; import com.weeth.domain.comment.domain.entity.Comment; import com.weeth.domain.file.application.dto.response.FileResponse; import com.weeth.domain.file.application.mapper.FileMapper; @@ -35,8 +35,6 @@ import java.util.Collections; import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -56,7 +54,7 @@ public class PostUseCaseImpl implements PostUsecase { private final PostMapper mapper; private final FileMapper fileMapper; - private final CommentMapper commentMapper; + private final GetCommentQueryService getCommentQueryService; @Override @Transactional @@ -254,28 +252,8 @@ public boolean checkFileExistsByPost(Long postId){ return fileReader.exists(FileOwnerType.POST, postId, null); } - private List filterParentComments(List comments) { - Map> commentMap = comments.stream() - .filter(comment -> comment.getParent() != null) - .collect(Collectors.groupingBy(comment -> comment.getParent().getId())); - - return comments.stream() - .filter(comment -> comment.getParent() == null) // 부모 댓글만 가져오기 - .map(parent -> mapToDtoWithChildren(parent, commentMap)) - .toList(); - } - - private CommentDTO.Response mapToDtoWithChildren(Comment comment, Map> commentMap) { - List children = commentMap.getOrDefault(comment.getId(), Collections.emptyList()) - .stream() - .map(child -> mapToDtoWithChildren(child, commentMap)) - .collect(Collectors.toList()); - - List files = fileReader.findAll(FileOwnerType.COMMENT, comment.getId(), null).stream() - .map(fileMapper::toFileResponse) - .toList(); - - return commentMapper.toCommentDto(comment, children, files); + private List filterParentComments(List comments) { + return getCommentQueryService.toCommentTreeResponses(comments); } private void validatePageNumber(int pageNumber){ diff --git a/src/main/java/com/weeth/domain/board/domain/entity/Board.java b/src/main/java/com/weeth/domain/board/domain/entity/Board.java index 181412df..37ea7de0 100644 --- a/src/main/java/com/weeth/domain/board/domain/entity/Board.java +++ b/src/main/java/com/weeth/domain/board/domain/entity/Board.java @@ -56,6 +56,10 @@ public void decreaseCommentCount() { } } + public void increaseCommentCount() { + commentCount++; + } + public void updateCommentCount(List comments) { this.commentCount = (int) comments.stream() .filter(comment -> !comment.getIsDeleted()) diff --git a/src/main/java/com/weeth/domain/board/domain/entity/Notice.java b/src/main/java/com/weeth/domain/board/domain/entity/Notice.java index f63fd4ff..0477a32a 100644 --- a/src/main/java/com/weeth/domain/board/domain/entity/Notice.java +++ b/src/main/java/com/weeth/domain/board/domain/entity/Notice.java @@ -18,6 +18,7 @@ @SuperBuilder public class Notice extends Board { + // Todo: OneToMany 매핑 제거 @OneToMany(mappedBy = "notice", orphanRemoval = true) @JsonManagedReference private List comments; diff --git a/src/main/java/com/weeth/domain/board/domain/repository/NoticeRepository.java b/src/main/java/com/weeth/domain/board/domain/repository/NoticeRepository.java index 3def6036..42c615a9 100644 --- a/src/main/java/com/weeth/domain/board/domain/repository/NoticeRepository.java +++ b/src/main/java/com/weeth/domain/board/domain/repository/NoticeRepository.java @@ -3,13 +3,22 @@ import com.weeth.domain.board.domain.entity.Notice; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.QueryHints; import org.springframework.data.repository.query.Param; +import jakarta.persistence.LockModeType; +import jakarta.persistence.QueryHint; public interface NoticeRepository extends JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query("select n from Notice n where n.id = :id") + Notice findByIdWithLock(@Param("id") Long id); + Slice findPageBy(Pageable page); @Query(""" diff --git a/src/main/java/com/weeth/domain/board/domain/repository/PostRepository.java b/src/main/java/com/weeth/domain/board/domain/repository/PostRepository.java index fff5b870..20e2f949 100644 --- a/src/main/java/com/weeth/domain/board/domain/repository/PostRepository.java +++ b/src/main/java/com/weeth/domain/board/domain/repository/PostRepository.java @@ -1,19 +1,28 @@ package com.weeth.domain.board.domain.repository; -import java.util.Collection; -import java.util.List; import com.weeth.domain.board.domain.entity.Post; import com.weeth.domain.board.domain.entity.enums.Category; import com.weeth.domain.board.domain.entity.enums.Part; +import jakarta.persistence.LockModeType; +import jakarta.persistence.QueryHint; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.QueryHints; import org.springframework.data.repository.query.Param; +import java.util.Collection; +import java.util.List; public interface PostRepository extends JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query("select p from Post p where p.id = :id") + Post findByIdWithLock(@Param("id") Long id); + @Query(""" SELECT p FROM Post p WHERE p.category IN ( diff --git a/src/main/java/com/weeth/domain/comment/application/dto/CommentDTO.java b/src/main/java/com/weeth/domain/comment/application/dto/CommentDTO.java deleted file mode 100644 index 94938d6c..00000000 --- a/src/main/java/com/weeth/domain/comment/application/dto/CommentDTO.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.weeth.domain.comment.application.dto; - -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; -import com.weeth.domain.file.application.dto.request.FileSaveRequest; -import com.weeth.domain.file.application.dto.response.FileResponse; -import com.weeth.domain.user.domain.entity.enums.Position; -import com.weeth.domain.user.domain.entity.enums.Role; -import lombok.Builder; - -import java.time.LocalDateTime; -import java.util.List; - -public class CommentDTO { - - @Builder - public record Save( - Long parentCommentId, - @NotBlank @Size(max=300, message = "댓글은 최대 300자까지 가능합니다.") String content, - @Valid List<@NotNull FileSaveRequest> files - ){} - - @Builder - public record Update( - @NotBlank @Size(max=300, message = "댓글은 최대 300자까지 가능합니다.") String content, - @Valid List<@NotNull FileSaveRequest> files - ){} - - @Builder - public record Response( - Long id, - String name, - Position position, - Role role, - String content, - LocalDateTime time, //modifiedAt - List fileUrls, - List children - ){} - -} diff --git a/src/main/java/com/weeth/domain/comment/application/exception/CommentErrorCode.java b/src/main/java/com/weeth/domain/comment/application/exception/CommentErrorCode.java deleted file mode 100644 index 4fdf58b0..00000000 --- a/src/main/java/com/weeth/domain/comment/application/exception/CommentErrorCode.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.weeth.domain.comment.application.exception; - -import com.weeth.global.common.exception.ErrorCodeInterface; -import com.weeth.global.common.exception.ExplainError; -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@AllArgsConstructor -public enum CommentErrorCode implements ErrorCodeInterface { - - @ExplainError("요청한 댓글 ID에 해당하는 댓글이 존재하지 않을 때 발생합니다.") - COMMENT_NOT_FOUND(2400, HttpStatus.NOT_FOUND, "존재하지 않는 댓글입니다."); - - private final int code; - private final HttpStatus status; - private final String message; -} diff --git a/src/main/java/com/weeth/domain/comment/application/exception/CommentNotFoundException.java b/src/main/java/com/weeth/domain/comment/application/exception/CommentNotFoundException.java deleted file mode 100644 index c99942a3..00000000 --- a/src/main/java/com/weeth/domain/comment/application/exception/CommentNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.comment.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class CommentNotFoundException extends BaseException { - public CommentNotFoundException() { - super(CommentErrorCode.COMMENT_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/domain/comment/application/mapper/CommentMapper.java b/src/main/java/com/weeth/domain/comment/application/mapper/CommentMapper.java deleted file mode 100644 index 757fd587..00000000 --- a/src/main/java/com/weeth/domain/comment/application/mapper/CommentMapper.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.weeth.domain.comment.application.mapper; - -import com.weeth.domain.board.domain.entity.Notice; -import com.weeth.domain.board.domain.entity.Post; -import com.weeth.domain.comment.application.dto.CommentDTO; -import com.weeth.domain.comment.domain.entity.Comment; -import com.weeth.domain.file.application.dto.response.FileResponse; -import com.weeth.domain.user.domain.entity.User; -import org.mapstruct.*; - -import java.util.List; - -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, unmappedTargetPolicy = ReportingPolicy.IGNORE) -public interface CommentMapper { - - @Mappings({ - @Mapping(target = "id", ignore = true), - @Mapping(target = "createdAt", ignore = true), - @Mapping(target = "modifiedAt", ignore = true), - @Mapping(target = "isDeleted", ignore = true), - @Mapping(target = "notice", ignore = true), - @Mapping(target = "user", source = "user"), - @Mapping(target = "parent", source = "parent"), - @Mapping(target = "content", source = "dto.content"), - @Mapping(target = "post", source = "post") - }) - Comment fromCommentDto(CommentDTO.Save dto, Post post, User user, Comment parent); - - @Mappings({ - @Mapping(target = "id", ignore = true), - @Mapping(target = "createdAt", ignore = true), - @Mapping(target = "modifiedAt", ignore = true), - @Mapping(target = "isDeleted", ignore = true), - @Mapping(target = "post", ignore = true), - @Mapping(target = "user", source = "user"), - @Mapping(target = "parent", source = "parent"), - @Mapping(target = "content", source = "dto.content"), - @Mapping(target = "notice", source = "notice") - }) - Comment fromCommentDto(CommentDTO.Save dto, Notice notice, User user, Comment parent); - - - @Mapping(target = "name", source = "comment.user.name") - @Mapping(target = "position", source = "comment.user.position") - @Mapping(target = "role", source = "comment.user.role") - @Mapping(target = "time", source = "comment.modifiedAt") - @Mapping(target = "children", source = "children") - CommentDTO.Response toCommentDto(Comment comment, List children, List fileUrls); -} diff --git a/src/main/java/com/weeth/domain/comment/application/usecase/NoticeCommentUsecase.java b/src/main/java/com/weeth/domain/comment/application/usecase/NoticeCommentUsecase.java deleted file mode 100644 index 4a142a59..00000000 --- a/src/main/java/com/weeth/domain/comment/application/usecase/NoticeCommentUsecase.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.weeth.domain.comment.application.usecase; - -import com.weeth.domain.comment.application.dto.CommentDTO; -import com.weeth.domain.user.application.exception.UserNotMatchException; - -public interface NoticeCommentUsecase { - - void saveNoticeComment(CommentDTO.Save dto, Long noticeId, Long userId); - - void updateNoticeComment(CommentDTO.Update dto, Long noticeId, Long commentId, Long userId) throws UserNotMatchException; - - void deleteNoticeComment(Long commentId, Long userId) throws UserNotMatchException; -} diff --git a/src/main/java/com/weeth/domain/comment/application/usecase/NoticeCommentUsecaseImpl.java b/src/main/java/com/weeth/domain/comment/application/usecase/NoticeCommentUsecaseImpl.java deleted file mode 100644 index e67de961..00000000 --- a/src/main/java/com/weeth/domain/comment/application/usecase/NoticeCommentUsecaseImpl.java +++ /dev/null @@ -1,136 +0,0 @@ -package com.weeth.domain.comment.application.usecase; - -import com.weeth.domain.board.domain.entity.Notice; -import com.weeth.domain.board.domain.service.NoticeFindService; -import com.weeth.domain.comment.application.dto.CommentDTO; -import com.weeth.domain.comment.application.exception.CommentNotFoundException; -import com.weeth.domain.comment.application.mapper.CommentMapper; -import com.weeth.domain.comment.domain.entity.Comment; -import com.weeth.domain.comment.domain.service.CommentDeleteService; -import com.weeth.domain.comment.domain.service.CommentFindService; -import com.weeth.domain.comment.domain.service.CommentSaveService; -import com.weeth.domain.file.application.mapper.FileMapper; -import com.weeth.domain.file.domain.entity.File; -import com.weeth.domain.file.domain.entity.FileOwnerType; -import com.weeth.domain.file.domain.repository.FileReader; -import com.weeth.domain.file.domain.repository.FileRepository; -import com.weeth.domain.user.application.exception.UserNotMatchException; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.service.UserGetService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class NoticeCommentUsecaseImpl implements NoticeCommentUsecase { - - private final CommentSaveService commentSaveService; - private final CommentFindService commentFindService; - private final CommentDeleteService commentDeleteService; - - private final FileRepository fileRepository; - private final FileReader fileReader; - private final FileMapper fileMapper; - - private final NoticeFindService noticeFindService; - - private final UserGetService userGetService; - private final CommentMapper commentMapper; - - @Override - @Transactional - public void saveNoticeComment(CommentDTO.Save dto, Long noticeId, Long userId) { - User user = userGetService.find(userId); - Notice notice = noticeFindService.find(noticeId); - Comment parentComment = null; - - if(!(dto.parentCommentId() == null)) { - parentComment = commentFindService.find(dto.parentCommentId()); - } - Comment comment = commentMapper.fromCommentDto(dto, notice, user, parentComment); - commentSaveService.save(comment); - - List files = fileMapper.toFileList(dto.files(), FileOwnerType.COMMENT, comment.getId()); - fileRepository.saveAll(files); - - // 부모 댓글이 없다면 새 댓글로 추가 - if(parentComment == null) { - notice.addComment(comment); - } else { - // 부모 댓글이 있다면 자녀 댓글로 추가 - parentComment.addChild(comment); - } - notice.updateCommentCount(); - } - - @Override - @Transactional - public void updateNoticeComment(CommentDTO.Update dto, Long noticeId, Long commentId, Long userId) throws UserNotMatchException { - User user = userGetService.find(userId); - Notice notice = noticeFindService.find(noticeId); - Comment comment = validateOwner(commentId, userId); - - List fileList = getFiles(commentId); - fileRepository.deleteAll(fileList); - - List files = fileMapper.toFileList(dto.files(), FileOwnerType.COMMENT, comment.getId()); - fileRepository.saveAll(files); - - comment.update(dto); - } - - @Override - @Transactional - public void deleteNoticeComment(Long commentId, Long userId) throws UserNotMatchException { - User user = userGetService.find(userId); - Comment comment = validateOwner(commentId, userId); - Notice notice = comment.getNotice(); - - List fileList = getFiles(commentId); - fileRepository.deleteAll(fileList); - - if (comment.getChildren().isEmpty()) { - Comment parentComment = findParentComment(commentId); - commentDeleteService.delete(commentId); - if (parentComment != null) { - parentComment.getChildren().remove(comment); - if (parentComment.getIsDeleted() && parentComment.getChildren().isEmpty()) { - notice.getComments().remove(parentComment); - commentDeleteService.delete(parentComment.getId()); - } - } - } else if (comment.getIsDeleted()) { // 삭제된 대댓글인 경우 예외 - throw new CommentNotFoundException(); - } else { - comment.markAsDeleted(); - commentSaveService.save(comment); - } - notice.decreaseCommentCount(); - } - - private Comment findParentComment(Long commentId) { - List comments = commentFindService.find(); - for (Comment comment : comments) { - if (comment.getChildren().stream().anyMatch(child -> child.getId().equals(commentId))) { - return comment; - } - } - return null; // 부모 댓글을 찾지 못한 경우 - } - - private Comment validateOwner(Long commentId, Long userId) throws UserNotMatchException { - Comment comment = commentFindService.find(commentId); - - if (!comment.getUser().getId().equals(userId)) { - throw new UserNotMatchException(); - } - return comment; - } - - private List getFiles(Long commentId) { - return fileReader.findAll(FileOwnerType.COMMENT, commentId, null); - } -} diff --git a/src/main/java/com/weeth/domain/comment/application/usecase/PostCommentUsecase.java b/src/main/java/com/weeth/domain/comment/application/usecase/PostCommentUsecase.java deleted file mode 100644 index 50e0633e..00000000 --- a/src/main/java/com/weeth/domain/comment/application/usecase/PostCommentUsecase.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.weeth.domain.comment.application.usecase; - -import com.weeth.domain.comment.application.dto.CommentDTO; -import com.weeth.domain.user.application.exception.UserNotMatchException; - -public interface PostCommentUsecase { - - void savePostComment(CommentDTO.Save dto, Long postId, Long userId); - - void updatePostComment(CommentDTO.Update dto, Long postId, Long commentId, Long userId) throws UserNotMatchException; - - void deletePostComment(Long commentId, Long userId) throws UserNotMatchException; - -} diff --git a/src/main/java/com/weeth/domain/comment/application/usecase/PostCommentUsecaseImpl.java b/src/main/java/com/weeth/domain/comment/application/usecase/PostCommentUsecaseImpl.java deleted file mode 100644 index 39dd4898..00000000 --- a/src/main/java/com/weeth/domain/comment/application/usecase/PostCommentUsecaseImpl.java +++ /dev/null @@ -1,148 +0,0 @@ -package com.weeth.domain.comment.application.usecase; - -import com.weeth.domain.board.domain.entity.Post; -import com.weeth.domain.board.domain.service.PostFindService; -import com.weeth.domain.comment.application.dto.CommentDTO; -import com.weeth.domain.comment.application.exception.CommentNotFoundException; -import com.weeth.domain.comment.application.mapper.CommentMapper; -import com.weeth.domain.comment.domain.entity.Comment; -import com.weeth.domain.comment.domain.service.CommentDeleteService; -import com.weeth.domain.comment.domain.service.CommentFindService; -import com.weeth.domain.comment.domain.service.CommentSaveService; -import com.weeth.domain.file.application.mapper.FileMapper; -import com.weeth.domain.file.domain.entity.File; -import com.weeth.domain.file.domain.entity.FileOwnerType; -import com.weeth.domain.file.domain.repository.FileReader; -import com.weeth.domain.file.domain.repository.FileRepository; -import com.weeth.domain.user.application.exception.UserNotMatchException; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.service.UserGetService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class PostCommentUsecaseImpl implements PostCommentUsecase { - - private final CommentSaveService commentSaveService; - private final CommentFindService commentFindService; - private final CommentDeleteService commentDeleteService; - - private final FileRepository fileRepository; - private final FileReader fileReader; - private final FileMapper fileMapper; - - private final UserGetService userGetService; - - private final PostFindService postFindService; - - private final CommentMapper commentMapper; - - @Override - @Transactional - public void savePostComment(CommentDTO.Save dto, Long postId, Long userId) { - User user = userGetService.find(userId); - Post post = postFindService.find(postId); - Comment parentComment = null; - - if (!(dto.parentCommentId() == null)) { - parentComment = commentFindService.find(dto.parentCommentId()); - } - Comment comment = commentMapper.fromCommentDto(dto, post, user, parentComment); - commentSaveService.save(comment); - - List files = fileMapper.toFileList(dto.files(), FileOwnerType.COMMENT, comment.getId()); - fileRepository.saveAll(files); - - // 부모 댓글이 없다면 새 댓글로 추가 - if (parentComment == null) { - post.addComment(comment); - } else { - // 부모 댓글이 있다면 자녀 댓글로 추가 - parentComment.addChild(comment); - } - post.updateCommentCount(); - } - - @Override - @Transactional - public void updatePostComment(CommentDTO.Update dto, Long postId, Long commentId, Long userId) throws UserNotMatchException { - User user = userGetService.find(userId); - Post post = postFindService.find(postId); - Comment comment = validateOwner(commentId, userId); - - List fileList = getFiles(commentId); - fileRepository.deleteAll(fileList); - - List files = fileMapper.toFileList(dto.files(), FileOwnerType.COMMENT, comment.getId()); - fileRepository.saveAll(files); - - comment.update(dto); - } - - - @Override - @Transactional - public void deletePostComment(Long commentId, Long userId) throws UserNotMatchException { - User user = userGetService.find(userId); - Comment comment = validateOwner(commentId, userId); - Post post = comment.getPost(); - - List fileList = getFiles(commentId); - fileRepository.deleteAll(fileList); - - /* - 1. 지우고자 하는 댓글이 맨 아래층인 경우(child, child가 없는 댓글 - - 현재 댓글.getChildren이 NULL 이면 해당 - - 내가 child인지 child가 없는 댓글인지 구분해야함 - - child인 경우 -> 부모가 있음. 하지만 부모를 삭제하는게 아니라 나만 삭제함, 부모의 childern에서 나를 제거해야함 - - child가 없는 댓글인 경우 -> 자식이 없기 떄문에 나만 삭제함 - */ - // 현재 삭제하고자 하는 댓글이 자식이 없는 경우 - if (comment.getChildren().isEmpty()) { - Comment parentComment = findParentComment(commentId); - commentDeleteService.delete(commentId); - if (parentComment != null) { - parentComment.getChildren().remove(comment); - if (parentComment.getIsDeleted() && parentComment.getChildren().isEmpty()) { - post.getComments().remove(parentComment); - commentDeleteService.delete(parentComment.getId()); - } - } - } else if (comment.getIsDeleted()) { // 삭제된 대댓글인 경우 예외 - throw new CommentNotFoundException(); - } else { - comment.markAsDeleted(); - commentSaveService.save(comment); - } - post.decreaseCommentCount(); - } - - private Comment findParentComment(Long commentId) { - List comments = commentFindService.find(); - for (Comment comment : comments) { - if (comment.getChildren().stream().anyMatch(child -> child.getId().equals(commentId))) { - return comment; - } - } - return null; // 부모 댓글을 찾지 못한 경우 - } - - // 업데이트 메소드를 엔티티 안에서 변경감지로 사용하기로 했기 때문에, 반환 값이 필요 없짐 -> 나머지도 다 수정 - private Comment validateOwner(Long commentId, Long userId) throws UserNotMatchException { - Comment comment = commentFindService.find(commentId); - - if (!comment.getUser().getId().equals(userId)) { - throw new UserNotMatchException(); - } - return comment; - } - - private List getFiles(Long commentId) { - return fileReader.findAll(FileOwnerType.COMMENT, commentId, null); - } - -} diff --git a/src/main/java/com/weeth/domain/comment/domain/entity/Comment.java b/src/main/java/com/weeth/domain/comment/domain/entity/Comment.java deleted file mode 100644 index a3beeb59..00000000 --- a/src/main/java/com/weeth/domain/comment/domain/entity/Comment.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.weeth.domain.comment.domain.entity; - - -import com.fasterxml.jackson.annotation.JsonBackReference; -import jakarta.persistence.*; -import com.weeth.domain.board.domain.entity.Notice; -import com.weeth.domain.board.domain.entity.Post; -import com.weeth.domain.comment.application.dto.CommentDTO; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.global.common.entity.BaseEntity; -import lombok.*; -import lombok.experimental.SuperBuilder; -import org.hibernate.annotations.ColumnDefault; - -import java.util.ArrayList; -import java.util.List; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@SuperBuilder -public class Comment extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "comment_id") - private Long id; - - @Column(length = 300) - private String content; - - @Column(nullable = false) - private Boolean isDeleted; - - @ManyToOne - @JoinColumn(name="post_id") - @JsonBackReference - private Post post; - - @ManyToOne - @JoinColumn(name="notice_id") - @JsonBackReference - private Notice notice; - - @ManyToOne - @JoinColumn(name = "user_id") - private User user; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "parent_id") - private Comment parent; - - @OneToMany(mappedBy = "parent", cascade = CascadeType.REMOVE, fetch = FetchType.LAZY) - private List children = new ArrayList<>(); - - public void addChild(Comment child) { - this.children.add(child); - } - - @PrePersist - public void prePersist() { - if (isDeleted == null) { - isDeleted = false; - } - } - - //TODO 문자열 상수처리 - public void markAsDeleted() { - this.isDeleted = true; - this.content = "삭제된 댓글입니다."; - } - - public void update(CommentDTO.Update dto) { - this.content = dto.content(); - } - -} diff --git a/src/main/java/com/weeth/domain/comment/domain/repository/CommentRepository.java b/src/main/java/com/weeth/domain/comment/domain/repository/CommentRepository.java deleted file mode 100644 index d37043be..00000000 --- a/src/main/java/com/weeth/domain/comment/domain/repository/CommentRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.weeth.domain.comment.domain.repository; - -import com.weeth.domain.comment.domain.entity.Comment; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface CommentRepository extends JpaRepository { -} diff --git a/src/main/java/com/weeth/domain/comment/domain/service/CommentDeleteService.java b/src/main/java/com/weeth/domain/comment/domain/service/CommentDeleteService.java deleted file mode 100644 index 4f02b73e..00000000 --- a/src/main/java/com/weeth/domain/comment/domain/service/CommentDeleteService.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.weeth.domain.comment.domain.service; - -import com.weeth.domain.comment.domain.repository.CommentRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -public class CommentDeleteService { - - private final CommentRepository commentRepository; - - @Transactional - public void delete(Long commentId) { - commentRepository.deleteById(commentId); - } - -} diff --git a/src/main/java/com/weeth/domain/comment/domain/service/CommentFindService.java b/src/main/java/com/weeth/domain/comment/domain/service/CommentFindService.java deleted file mode 100644 index 1d455677..00000000 --- a/src/main/java/com/weeth/domain/comment/domain/service/CommentFindService.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.weeth.domain.comment.domain.service; - -import com.weeth.domain.comment.domain.entity.Comment; -import com.weeth.domain.comment.domain.repository.CommentRepository; -import com.weeth.domain.comment.application.exception.CommentNotFoundException; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class CommentFindService { - - private final CommentRepository commentRepository; - - public Comment find(Long commentId) { - return commentRepository.findById(commentId) - .orElseThrow(CommentNotFoundException::new); - } - - public List find() { - return commentRepository.findAll(); - } - -} diff --git a/src/main/java/com/weeth/domain/comment/domain/service/CommentSaveService.java b/src/main/java/com/weeth/domain/comment/domain/service/CommentSaveService.java deleted file mode 100644 index 7712d1b0..00000000 --- a/src/main/java/com/weeth/domain/comment/domain/service/CommentSaveService.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.weeth.domain.comment.domain.service; - -import com.weeth.domain.comment.domain.entity.Comment; -import com.weeth.domain.comment.domain.repository.CommentRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -public class CommentSaveService { - - private final CommentRepository commentRepository; - - @Transactional - public void save(Comment comment){ - commentRepository.save(comment); - } - -} diff --git a/src/main/java/com/weeth/domain/comment/domain/service/CommentUpdateService.java b/src/main/java/com/weeth/domain/comment/domain/service/CommentUpdateService.java deleted file mode 100644 index 3580ff76..00000000 --- a/src/main/java/com/weeth/domain/comment/domain/service/CommentUpdateService.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.weeth.domain.comment.domain.service; - -import com.weeth.domain.comment.domain.repository.CommentRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -public class CommentUpdateService { - - private final CommentRepository commentRepository; - -} diff --git a/src/main/java/com/weeth/domain/comment/presentation/NoticeCommentController.java b/src/main/java/com/weeth/domain/comment/presentation/NoticeCommentController.java deleted file mode 100644 index e69a0033..00000000 --- a/src/main/java/com/weeth/domain/comment/presentation/NoticeCommentController.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.weeth.domain.comment.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import com.weeth.domain.comment.application.dto.CommentDTO; -import com.weeth.domain.comment.application.exception.CommentErrorCode; -import com.weeth.domain.comment.application.usecase.NoticeCommentUsecase; -import com.weeth.domain.user.application.exception.UserNotMatchException; -import com.weeth.global.auth.annotation.CurrentUser; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import static com.weeth.domain.comment.presentation.CommentResponseCode.*; - -@Tag(name = "COMMENT-NOTICE", description = "공지사항 댓글 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/notices/{noticeId}/comments") -@ApiErrorCodeExample(CommentErrorCode.class) -public class NoticeCommentController { - - private final NoticeCommentUsecase noticeCommentUsecase; - - @PostMapping - @Operation(summary="공지사항 댓글 작성") - public CommonResponse saveNoticeComment(@RequestBody @Valid CommentDTO.Save dto, @PathVariable Long noticeId, - @Parameter(hidden = true) @CurrentUser Long userId) { - noticeCommentUsecase.saveNoticeComment(dto, noticeId, userId); - return CommonResponse.success(COMMENT_CREATED_SUCCESS); - } - - @PatchMapping("{commentId}") - @Operation(summary="공지사항 댓글 수정") - public CommonResponse updateNoticeComment(@RequestBody @Valid CommentDTO.Update dto, @PathVariable Long noticeId, - @PathVariable Long commentId,@Parameter(hidden = true) @CurrentUser Long userId) throws UserNotMatchException { - noticeCommentUsecase.updateNoticeComment(dto, noticeId, commentId, userId); - return CommonResponse.success(COMMENT_UPDATED_SUCCESS); - } - - @DeleteMapping("{commentId}") - @Operation(summary="공지사항 댓글 삭제") - public CommonResponse deleteNoticeComment(@PathVariable Long commentId, - @Parameter(hidden = true) @CurrentUser Long userId) throws UserNotMatchException { - noticeCommentUsecase.deleteNoticeComment(commentId, userId); - return CommonResponse.success(COMMENT_DELETED_SUCCESS); - } - -} diff --git a/src/main/java/com/weeth/domain/comment/presentation/PostCommentController.java b/src/main/java/com/weeth/domain/comment/presentation/PostCommentController.java deleted file mode 100644 index e6da122a..00000000 --- a/src/main/java/com/weeth/domain/comment/presentation/PostCommentController.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.weeth.domain.comment.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import com.weeth.domain.comment.application.dto.CommentDTO; -import com.weeth.domain.comment.application.exception.CommentErrorCode; -import com.weeth.domain.comment.application.usecase.PostCommentUsecase; -import com.weeth.domain.user.application.exception.UserNotMatchException; -import com.weeth.global.auth.annotation.CurrentUser; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import static com.weeth.domain.comment.presentation.CommentResponseCode.*; - -@Tag(name = "COMMENT-BOARD", description = "게시판 댓글 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/board/{boardId}/comments") -@ApiErrorCodeExample(CommentErrorCode.class) -public class PostCommentController { - - private final PostCommentUsecase postCommentUsecase; - - @PostMapping - @Operation(summary="게시글 댓글 작성") - public CommonResponse savePostComment(@RequestBody @Valid CommentDTO.Save dto, @PathVariable Long boardId, - @Parameter(hidden = true) @CurrentUser Long userId) { - postCommentUsecase.savePostComment(dto, boardId, userId); - return CommonResponse.success(POST_COMMENT_CREATED_SUCCESS); - } - - @PatchMapping("/{commentId}") - @Operation(summary="게시글 댓글 수정") - public CommonResponse updatePostComment(@RequestBody @Valid CommentDTO.Update dto, @PathVariable Long boardId, @PathVariable Long commentId, - @Parameter(hidden = true) @CurrentUser Long userId) throws UserNotMatchException { - postCommentUsecase.updatePostComment(dto, boardId, commentId, userId); - return CommonResponse.success(POST_COMMENT_UPDATED_SUCCESS); - } - - @DeleteMapping("{commentId}") - @Operation(summary="게시글 댓글 삭제") - public CommonResponse deletePostComment(@PathVariable Long commentId, @Parameter(hidden = true) @CurrentUser Long userId) throws UserNotMatchException { - postCommentUsecase.deletePostComment(commentId, userId); - return CommonResponse.success(POST_COMMENT_DELETED_SUCCESS); - } - -} diff --git a/src/main/java/com/weeth/global/common/entity/BaseEntity.java b/src/main/java/com/weeth/global/common/entity/BaseEntity.java index a92a6187..3e8a520a 100644 --- a/src/main/java/com/weeth/global/common/entity/BaseEntity.java +++ b/src/main/java/com/weeth/global/common/entity/BaseEntity.java @@ -25,4 +25,4 @@ public class BaseEntity { private LocalDateTime createdAt; @LastModifiedDate private LocalDateTime modifiedAt; -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/weeth/domain/comment/application/dto/request/CommentSaveRequest.kt b/src/main/kotlin/com/weeth/domain/comment/application/dto/request/CommentSaveRequest.kt new file mode 100644 index 00000000..e698dccd --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/comment/application/dto/request/CommentSaveRequest.kt @@ -0,0 +1,20 @@ +package com.weeth.domain.comment.application.dto.request + +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size + +data class CommentSaveRequest( + @field:Schema(description = "부모 댓글 ID (대댓글인 경우)", example = "1", nullable = true) + val parentCommentId: Long? = null, + @field:Schema(description = "댓글 내용", example = "댓글입니다.") + @field:NotBlank + @field:Size(max = 300, message = "댓글은 최대 300자까지 가능합니다.") + val content: String, + @field:Schema(description = "첨부 파일 목록", nullable = true) + @field:Valid + val files: List<@NotNull FileSaveRequest>? = null, +) diff --git a/src/main/kotlin/com/weeth/domain/comment/application/dto/request/CommentUpdateRequest.kt b/src/main/kotlin/com/weeth/domain/comment/application/dto/request/CommentUpdateRequest.kt new file mode 100644 index 00000000..4d1e916a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/comment/application/dto/request/CommentUpdateRequest.kt @@ -0,0 +1,21 @@ +package com.weeth.domain.comment.application.dto.request + +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size + +data class CommentUpdateRequest( + @field:Schema(description = "댓글 내용", example = "댓글입니다.") + @field:NotBlank + @field:Size(max = 300, message = "댓글은 최대 300자까지 가능합니다.") + val content: String, + @field:Schema( + description = "첨부 파일 변경 규약: null=변경 안 함, []=전체 삭제, 배열 전달=해당 목록으로 교체", + nullable = true, + ) + @field:Valid + val files: List<@NotNull FileSaveRequest>? = null, +) diff --git a/src/main/kotlin/com/weeth/domain/comment/application/dto/response/CommentResponse.kt b/src/main/kotlin/com/weeth/domain/comment/application/dto/response/CommentResponse.kt new file mode 100644 index 00000000..0a30e1d6 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/comment/application/dto/response/CommentResponse.kt @@ -0,0 +1,17 @@ +package com.weeth.domain.comment.application.dto.response + +import com.weeth.domain.file.application.dto.response.FileResponse +import com.weeth.domain.user.domain.entity.enums.Position +import com.weeth.domain.user.domain.entity.enums.Role +import java.time.LocalDateTime + +data class CommentResponse( + val id: Long, + val name: String, + val position: Position, + val role: Role, + val content: String, + val time: LocalDateTime, + val fileUrls: List, + val children: List, +) diff --git a/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentAlreadyDeletedException.kt b/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentAlreadyDeletedException.kt new file mode 100644 index 00000000..f318e479 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentAlreadyDeletedException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.comment.application.exception + +import com.weeth.global.common.exception.BaseException + +class CommentAlreadyDeletedException : BaseException(CommentErrorCode.COMMENT_ALREADY_DELETED) diff --git a/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentErrorCode.kt b/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentErrorCode.kt new file mode 100644 index 00000000..253a6155 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentErrorCode.kt @@ -0,0 +1,27 @@ +package com.weeth.domain.comment.application.exception + +import com.weeth.global.common.exception.ErrorCodeInterface +import com.weeth.global.common.exception.ExplainError +import org.springframework.http.HttpStatus + +enum class CommentErrorCode( + private val code: Int, + private val status: HttpStatus, + private val message: String, +) : ErrorCodeInterface { + @ExplainError("요청한 댓글 ID에 해당하는 댓글이 존재하지 않을 때 발생합니다.") + COMMENT_NOT_FOUND(2400, HttpStatus.NOT_FOUND, "존재하지 않는 댓글입니다."), + + @ExplainError("댓글 작성자가 아닌 사용자가 수정/삭제를 시도할 때 발생합니다.") + COMMENT_NOT_OWNED(2401, HttpStatus.FORBIDDEN, "댓글 작성자만 수정/삭제할 수 있습니다."), + + @ExplainError("이미 삭제된 댓글에 대해 삭제를 재시도할 때 발생합니다.") + COMMENT_ALREADY_DELETED(2402, HttpStatus.BAD_REQUEST, "이미 삭제된 댓글입니다."), + ; + + override fun getCode(): Int = code + + override fun getStatus(): HttpStatus = status + + override fun getMessage(): String = message +} diff --git a/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentNotFoundException.kt b/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentNotFoundException.kt new file mode 100644 index 00000000..b6866b39 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.comment.application.exception + +import com.weeth.global.common.exception.BaseException + +class CommentNotFoundException : BaseException(CommentErrorCode.COMMENT_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentNotOwnedException.kt b/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentNotOwnedException.kt new file mode 100644 index 00000000..63483f9b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentNotOwnedException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.comment.application.exception + +import com.weeth.global.common.exception.BaseException + +class CommentNotOwnedException : BaseException(CommentErrorCode.COMMENT_NOT_OWNED) diff --git a/src/main/kotlin/com/weeth/domain/comment/application/mapper/CommentMapper.kt b/src/main/kotlin/com/weeth/domain/comment/application/mapper/CommentMapper.kt new file mode 100644 index 00000000..74007626 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/comment/application/mapper/CommentMapper.kt @@ -0,0 +1,25 @@ +package com.weeth.domain.comment.application.mapper + +import com.weeth.domain.comment.application.dto.response.CommentResponse +import com.weeth.domain.comment.domain.entity.Comment +import com.weeth.domain.file.application.dto.response.FileResponse +import org.springframework.stereotype.Component + +@Component +class CommentMapper { + fun toCommentDto( + comment: Comment, + children: List, + fileUrls: List, + ): CommentResponse = + CommentResponse( + id = comment.id, + name = comment.user.name, + position = comment.user.position, + role = comment.user.role, + content = comment.content, + time = comment.modifiedAt, + fileUrls = fileUrls, + children = children, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt b/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt new file mode 100644 index 00000000..03fde804 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt @@ -0,0 +1,224 @@ +package com.weeth.domain.comment.application.usecase.command + +import com.weeth.domain.board.application.exception.NoticeNotFoundException +import com.weeth.domain.board.application.exception.PostNotFoundException +import com.weeth.domain.board.domain.entity.Notice +import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.board.domain.repository.NoticeRepository +import com.weeth.domain.board.domain.repository.PostRepository +import com.weeth.domain.comment.application.dto.request.CommentSaveRequest +import com.weeth.domain.comment.application.dto.request.CommentUpdateRequest +import com.weeth.domain.comment.application.exception.CommentAlreadyDeletedException +import com.weeth.domain.comment.application.exception.CommentNotFoundException +import com.weeth.domain.comment.application.exception.CommentNotOwnedException +import com.weeth.domain.comment.domain.entity.Comment +import com.weeth.domain.comment.domain.repository.CommentRepository +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.repository.FileReader +import com.weeth.domain.file.domain.repository.FileRepository +import com.weeth.domain.user.domain.service.UserGetService +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class ManageCommentUseCase( + private val commentRepository: CommentRepository, + private val postRepository: PostRepository, + private val noticeRepository: NoticeRepository, + private val userGetService: UserGetService, + private val fileReader: FileReader, + private val fileRepository: FileRepository, + private val fileMapper: FileMapper, +) : PostCommentUsecase, + NoticeCommentUsecase { + // Todo: Board 도메인 리팩토링 후 단일 Post 대응으로 수정. + @Transactional + override fun savePostComment( + dto: CommentSaveRequest, + postId: Long, + userId: Long, + ) { + val user = userGetService.find(userId) + val post = findPostWithLock(postId) + val parent = + dto.parentCommentId?.let { parentId -> + commentRepository.findByIdAndPostId(parentId, postId) ?: throw CommentNotFoundException() + } + + val comment = + Comment.createForPost( + content = dto.content, + post = post, + user = user, + parent = parent, + ) + val savedComment = commentRepository.save(comment) + saveCommentFiles(savedComment, dto.files) + post.increaseCommentCount() + } + + @Transactional + override fun updatePostComment( + dto: CommentUpdateRequest, + postId: Long, + commentId: Long, + userId: Long, + ) { + val comment = commentRepository.findByIdAndPostId(commentId, postId) ?: throw CommentNotFoundException() + ensureOwner(comment, userId) + ensureNotDeleted(comment) + + comment.updateContent(dto.content) + replaceCommentFiles(comment, dto.files) + } + + @Transactional + override fun deletePostComment( + postId: Long, + commentId: Long, + userId: Long, + ) { + val post = findPostWithLock(postId) + val comment = commentRepository.findByIdAndPostId(commentId, postId) ?: throw CommentNotFoundException() + ensureOwner(comment, userId) + + deleteComment(comment) + post.decreaseCommentCount() + } + + @Transactional + override fun saveNoticeComment( + dto: CommentSaveRequest, + noticeId: Long, + userId: Long, + ) { + val user = userGetService.find(userId) + val notice = findNoticeWithLock(noticeId) + val parent = + dto.parentCommentId?.let { parentId -> + commentRepository.findByIdAndNoticeId(parentId, noticeId) ?: throw CommentNotFoundException() + } + + val comment = + Comment.createForNotice( + content = dto.content, + notice = notice, + user = user, + parent = parent, + ) + val savedComment = commentRepository.save(comment) + saveCommentFiles(savedComment, dto.files) + notice.increaseCommentCount() + } + + @Transactional + override fun updateNoticeComment( + dto: CommentUpdateRequest, + noticeId: Long, + commentId: Long, + userId: Long, + ) { + val comment = commentRepository.findByIdAndNoticeId(commentId, noticeId) ?: throw CommentNotFoundException() + ensureOwner(comment, userId) + ensureNotDeleted(comment) + + comment.updateContent(dto.content) + replaceCommentFiles(comment, dto.files) + } + + @Transactional + override fun deleteNoticeComment( + noticeId: Long, + commentId: Long, + userId: Long, + ) { + val notice = findNoticeWithLock(noticeId) + val comment = + commentRepository.findByIdAndNoticeId(commentId, noticeId) ?: throw CommentNotFoundException() + ensureOwner(comment, userId) + + deleteComment(comment) + notice.decreaseCommentCount() + } + + private fun saveCommentFiles( + comment: Comment, + files: List?, + ) { + val mappedFiles = fileMapper.toFileList(files, FileOwnerType.COMMENT, comment.id) + if (mappedFiles.isNotEmpty()) { + fileRepository.saveAll(mappedFiles) + } + } + + private fun replaceCommentFiles( + comment: Comment, + files: List?, + ) { + // 계약: + // files == null -> 첨부 유지(변경 안 함) + // files == [] -> 기존 첨부 전체 삭제 + // files == [...] -> 기존 첨부 삭제 후 전달 목록으로 교체 + if (files == null) { + return + } + + markCommentFilesDeleted(comment.id) + saveCommentFiles(comment, files) + } + + private fun deleteComment(comment: Comment) { + if (comment.isDeleted) { + throw CommentAlreadyDeletedException() + } + + // 자식 댓글이 없는 경우 -> 삭제 + if (comment.children.isEmpty()) { + deleteCommentFiles(comment) + val parent = comment.parent + commentRepository.delete(comment) + + // 부모 댓글이 삭제된 상태이고 자식 댓글이 1개인 경우 -> 부모 댓글도 삭제 + if (parent != null && parent.isDeleted && parent.children.size == 1) { + deleteCommentFiles(parent) + commentRepository.delete(parent) + } + return + } + + // 자식 댓글이 있는 경우 -> 댓글을 Soft Delete해 서비스에서 "삭제된 댓글"으로 표시 + deleteCommentFiles(comment) + comment.markAsDeleted() + } + + private fun deleteCommentFiles(comment: Comment) { + markCommentFilesDeleted(comment.id) + } + + private fun markCommentFilesDeleted(commentId: Long) { + fileReader + .findAll(FileOwnerType.COMMENT, commentId) + .forEach { it.markDeleted() } + } + + private fun ensureOwner( + comment: Comment, + userId: Long, + ) { + if (!comment.isOwnedBy(userId)) { + throw CommentNotOwnedException() + } + } + + private fun ensureNotDeleted(comment: Comment) { + if (comment.isDeleted) { + throw CommentAlreadyDeletedException() + } + } + + private fun findPostWithLock(postId: Long): Post = postRepository.findByIdWithLock(postId) ?: throw PostNotFoundException() + + private fun findNoticeWithLock(noticeId: Long): Notice = noticeRepository.findByIdWithLock(noticeId) ?: throw NoticeNotFoundException() +} diff --git a/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/NoticeCommentUsecase.kt b/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/NoticeCommentUsecase.kt new file mode 100644 index 00000000..c0c3f1bb --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/NoticeCommentUsecase.kt @@ -0,0 +1,25 @@ +package com.weeth.domain.comment.application.usecase.command + +import com.weeth.domain.comment.application.dto.request.CommentSaveRequest +import com.weeth.domain.comment.application.dto.request.CommentUpdateRequest + +interface NoticeCommentUsecase { + fun saveNoticeComment( + dto: CommentSaveRequest, + noticeId: Long, + userId: Long, + ) + + fun updateNoticeComment( + dto: CommentUpdateRequest, + noticeId: Long, + commentId: Long, + userId: Long, + ) + + fun deleteNoticeComment( + noticeId: Long, + commentId: Long, + userId: Long, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/PostCommentUsecase.kt b/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/PostCommentUsecase.kt new file mode 100644 index 00000000..fbbeda4a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/PostCommentUsecase.kt @@ -0,0 +1,25 @@ +package com.weeth.domain.comment.application.usecase.command + +import com.weeth.domain.comment.application.dto.request.CommentSaveRequest +import com.weeth.domain.comment.application.dto.request.CommentUpdateRequest + +interface PostCommentUsecase { + fun savePostComment( + dto: CommentSaveRequest, + postId: Long, + userId: Long, + ) + + fun updatePostComment( + dto: CommentUpdateRequest, + postId: Long, + commentId: Long, + userId: Long, + ) + + fun deletePostComment( + postId: Long, + commentId: Long, + userId: Long, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryService.kt b/src/main/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryService.kt new file mode 100644 index 00000000..443a04ac --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryService.kt @@ -0,0 +1,61 @@ +package com.weeth.domain.comment.application.usecase.query + +import com.weeth.domain.comment.application.dto.response.CommentResponse +import com.weeth.domain.comment.application.mapper.CommentMapper +import com.weeth.domain.comment.domain.entity.Comment +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.entity.File +import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.repository.FileReader +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class GetCommentQueryService( + private val fileReader: FileReader, + private val fileMapper: FileMapper, + private val commentMapper: CommentMapper, +) { + /** + * Comment 리스트를 받아 자식, 부모 관계 트리를 형성하는 메서드 + */ + fun toCommentTreeResponses(comments: List): List { + if (comments.isEmpty()) { + return emptyList() + } + + val commentIds: List = comments.map { it.id } + val filesByCommentId: Map> = + fileReader + .findAll(FileOwnerType.COMMENT, commentIds) + .groupBy { it.ownerId } + + val childrenByParentId: Map> = + comments + .filter { it.parent != null } + .groupBy { requireNotNull(it.parent).id } + + return comments + .filter { it.parent == null } + .map { mapToCommentResponse(it, childrenByParentId, filesByCommentId) } + } + + private fun mapToCommentResponse( + comment: Comment, + childrenByParentId: Map>, + filesByCommentId: Map>, + ): CommentResponse { + val children = + childrenByParentId[comment.id] + ?.map { mapToCommentResponse(it, childrenByParentId, filesByCommentId) } + ?: emptyList() + + val files = + filesByCommentId[comment.id] + ?.map(fileMapper::toFileResponse) + ?: emptyList() + + return commentMapper.toCommentDto(comment, children, files) + } +} diff --git a/src/main/kotlin/com/weeth/domain/comment/domain/entity/Comment.kt b/src/main/kotlin/com/weeth/domain/comment/domain/entity/Comment.kt new file mode 100644 index 00000000..491bbf0b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/comment/domain/entity/Comment.kt @@ -0,0 +1,96 @@ +package com.weeth.domain.comment.domain.entity + +import com.weeth.domain.board.domain.entity.Notice +import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.comment.domain.vo.CommentContent +import com.weeth.domain.user.domain.entity.User +import com.weeth.global.common.entity.BaseEntity +import jakarta.persistence.CascadeType +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.OneToMany +import jakarta.persistence.Table + +@Entity +@Table(name = "comment") +class Comment( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "comment_id") + val id: Long = 0, + @Column(length = 300, nullable = false) + var content: String, + @Column(nullable = false) + var isDeleted: Boolean = false, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id") + val post: Post? = null, // Todo: Board 도메인 리팩토링시 반영 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "notice_id") + val notice: Notice? = null, // Todo: Board 도메인 리팩토링시 반영 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + val user: User, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_id") + val parent: Comment? = null, + @OneToMany(mappedBy = "parent", cascade = [CascadeType.REMOVE], fetch = FetchType.LAZY) + val children: MutableList = mutableListOf(), +) : BaseEntity() { + fun markAsDeleted() { + isDeleted = true + content = DELETED_CONTENT + } + + fun getIsDeleted(): Boolean = isDeleted + + fun updateContent(newContent: String) { + content = CommentContent.from(newContent).value + } + + fun isOwnedBy(userId: Long): Boolean = user.id == userId + + companion object { + private const val DELETED_CONTENT = "삭제된 댓글입니다." + + fun createForPost( + content: String, + post: Post, + user: User, + parent: Comment?, + ): Comment { + require(parent == null || parent.post?.id == post.id) { + "부모 댓글은 동일한 게시글에 존재해야 합니다." + } + return Comment( + content = CommentContent.from(content).value, + post = post, + user = user, + parent = parent, + ) + } + + fun createForNotice( + content: String, + notice: Notice, + user: User, + parent: Comment?, + ): Comment { + require(parent == null || parent.notice?.id == notice.id) { + "부모 댓글은 동일한 공지글에 존재해야 합니다." + } + return Comment( + content = CommentContent.from(content).value, + notice = notice, + user = user, + parent = parent, + ) + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentRepository.kt b/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentRepository.kt new file mode 100644 index 00000000..e4ca6061 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentRepository.kt @@ -0,0 +1,16 @@ +package com.weeth.domain.comment.domain.repository + +import com.weeth.domain.comment.domain.entity.Comment +import org.springframework.data.jpa.repository.JpaRepository + +interface CommentRepository : JpaRepository { + fun findByIdAndPostId( + id: Long, + postId: Long, + ): Comment? + + fun findByIdAndNoticeId( + id: Long, + noticeId: Long, + ): Comment? +} diff --git a/src/main/kotlin/com/weeth/domain/comment/domain/vo/CommentContent.kt b/src/main/kotlin/com/weeth/domain/comment/domain/vo/CommentContent.kt new file mode 100644 index 00000000..001e926d --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/comment/domain/vo/CommentContent.kt @@ -0,0 +1,18 @@ +package com.weeth.domain.comment.domain.vo + +@JvmInline +value class CommentContent private constructor( + val value: String, +) { + companion object { + private const val MAX_LENGTH = 300 + + fun from(raw: String): CommentContent { + require(raw.isNotBlank()) { "댓글 내용은 빈 값이 될 수 없습니다." } + require(raw.length <= MAX_LENGTH) { + "댓글 내용은 ${MAX_LENGTH}자 이하로 입력해주세요." + } + return CommentContent(raw) + } + } +} diff --git a/src/main/java/com/weeth/domain/comment/presentation/CommentResponseCode.java b/src/main/kotlin/com/weeth/domain/comment/presentation/CommentResponseCode.kt similarity index 50% rename from src/main/java/com/weeth/domain/comment/presentation/CommentResponseCode.java rename to src/main/kotlin/com/weeth/domain/comment/presentation/CommentResponseCode.kt index 75009a3b..41d974e3 100644 --- a/src/main/java/com/weeth/domain/comment/presentation/CommentResponseCode.java +++ b/src/main/kotlin/com/weeth/domain/comment/presentation/CommentResponseCode.kt @@ -1,27 +1,17 @@ -package com.weeth.domain.comment.presentation; +package com.weeth.domain.comment.presentation -import com.weeth.global.common.response.ResponseCodeInterface; -import lombok.Getter; -import org.springframework.http.HttpStatus; +import com.weeth.global.common.response.ResponseCodeInterface +import org.springframework.http.HttpStatus -@Getter -public enum CommentResponseCode implements ResponseCodeInterface { - // NoticeCommentController 관련 +enum class CommentResponseCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ResponseCodeInterface { COMMENT_CREATED_SUCCESS(1400, HttpStatus.OK, "공지사항 댓글이 성공적으로 생성되었습니다."), COMMENT_UPDATED_SUCCESS(1401, HttpStatus.OK, "공지사항 댓글이 성공적으로 수정되었습니다."), COMMENT_DELETED_SUCCESS(1402, HttpStatus.OK, "공지사항 댓글이 성공적으로 삭제되었습니다."), - // PostCommentController 관련 POST_COMMENT_CREATED_SUCCESS(1403, HttpStatus.OK, "게시글 댓글이 성공적으로 생성되었습니다."), POST_COMMENT_UPDATED_SUCCESS(1404, HttpStatus.OK, "게시글 댓글이 성공적으로 수정되었습니다."), - POST_COMMENT_DELETED_SUCCESS(1405, HttpStatus.OK, "게시글 댓글이 성공적으로 삭제되었습니다."); - - private final int code; - private final HttpStatus status; - private final String message; - - CommentResponseCode(int code, HttpStatus status, String message) { - this.code = code; - this.status = status; - this.message = message; - } + POST_COMMENT_DELETED_SUCCESS(1405, HttpStatus.OK, "게시글 댓글이 성공적으로 삭제되었습니다."), } diff --git a/src/main/kotlin/com/weeth/domain/comment/presentation/NoticeCommentController.kt b/src/main/kotlin/com/weeth/domain/comment/presentation/NoticeCommentController.kt new file mode 100644 index 00000000..e47a35ab --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/comment/presentation/NoticeCommentController.kt @@ -0,0 +1,65 @@ +package com.weeth.domain.comment.presentation + +import com.weeth.domain.comment.application.dto.request.CommentSaveRequest +import com.weeth.domain.comment.application.dto.request.CommentUpdateRequest +import com.weeth.domain.comment.application.exception.CommentErrorCode +import com.weeth.domain.comment.application.usecase.command.NoticeCommentUsecase +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "COMMENT-NOTICE", description = "공지사항 댓글 API") +@RestController +@RequestMapping("/api/v1/notices/{noticeId}/comments") +@ApiErrorCodeExample(CommentErrorCode::class) +class NoticeCommentController( + private val noticeCommentUsecase: NoticeCommentUsecase, +) { + @PostMapping + @Operation(summary = "공지사항 댓글 작성") + fun saveNoticeComment( + @RequestBody @Valid dto: CommentSaveRequest, + @PathVariable noticeId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + noticeCommentUsecase.saveNoticeComment(dto, noticeId, userId) + return CommonResponse.success(CommentResponseCode.COMMENT_CREATED_SUCCESS) + } + + @PatchMapping("/{commentId}") + @Operation( + summary = "공지사항 댓글 수정", + description = "files 규약: null=기존 첨부 유지, []=기존 첨부 전체 삭제, 배열 전달=전달 목록으로 교체", + ) + fun updateNoticeComment( + @RequestBody @Valid dto: CommentUpdateRequest, + @PathVariable noticeId: Long, + @PathVariable commentId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + noticeCommentUsecase.updateNoticeComment(dto, noticeId, commentId, userId) + return CommonResponse.success(CommentResponseCode.COMMENT_UPDATED_SUCCESS) + } + + @DeleteMapping("/{commentId}") + @Operation(summary = "공지사항 댓글 삭제") + fun deleteNoticeComment( + @PathVariable noticeId: Long, + @PathVariable commentId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + noticeCommentUsecase.deleteNoticeComment(noticeId, commentId, userId) + return CommonResponse.success(CommentResponseCode.COMMENT_DELETED_SUCCESS) + } +} diff --git a/src/main/kotlin/com/weeth/domain/comment/presentation/PostCommentController.kt b/src/main/kotlin/com/weeth/domain/comment/presentation/PostCommentController.kt new file mode 100644 index 00000000..98310946 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/comment/presentation/PostCommentController.kt @@ -0,0 +1,65 @@ +package com.weeth.domain.comment.presentation + +import com.weeth.domain.comment.application.dto.request.CommentSaveRequest +import com.weeth.domain.comment.application.dto.request.CommentUpdateRequest +import com.weeth.domain.comment.application.exception.CommentErrorCode +import com.weeth.domain.comment.application.usecase.command.PostCommentUsecase +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "COMMENT-BOARD", description = "게시판 댓글 API") +@RestController +@RequestMapping("/api/v1/board/{boardId}/comments") +@ApiErrorCodeExample(CommentErrorCode::class) +class PostCommentController( + private val postCommentUsecase: PostCommentUsecase, +) { + @PostMapping + @Operation(summary = "게시글 댓글 작성") + fun savePostComment( + @RequestBody @Valid dto: CommentSaveRequest, + @PathVariable boardId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + postCommentUsecase.savePostComment(dto, boardId, userId) + return CommonResponse.success(CommentResponseCode.POST_COMMENT_CREATED_SUCCESS) + } + + @PatchMapping("/{commentId}") + @Operation( + summary = "게시글 댓글 수정", + description = "files 규약: null=기존 첨부 유지, []=기존 첨부 전체 삭제, 배열 전달=전달 목록으로 교체", + ) + fun updatePostComment( + @RequestBody @Valid dto: CommentUpdateRequest, + @PathVariable boardId: Long, + @PathVariable commentId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + postCommentUsecase.updatePostComment(dto, boardId, commentId, userId) + return CommonResponse.success(CommentResponseCode.POST_COMMENT_UPDATED_SUCCESS) + } + + @DeleteMapping("/{commentId}") + @Operation(summary = "게시글 댓글 삭제") + fun deletePostComment( + @PathVariable boardId: Long, + @PathVariable commentId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + postCommentUsecase.deletePostComment(boardId, commentId, userId) + return CommonResponse.success(CommentResponseCode.POST_COMMENT_DELETED_SUCCESS) + } +} diff --git a/src/main/kotlin/com/weeth/domain/file/domain/repository/FileReader.kt b/src/main/kotlin/com/weeth/domain/file/domain/repository/FileReader.kt index b979e3a1..c57e27e6 100644 --- a/src/main/kotlin/com/weeth/domain/file/domain/repository/FileReader.kt +++ b/src/main/kotlin/com/weeth/domain/file/domain/repository/FileReader.kt @@ -11,6 +11,17 @@ interface FileReader { status: FileStatus? = FileStatus.UPLOADED, ): List + fun findAll( + ownerType: FileOwnerType, + ownerIds: List, + status: FileStatus? = FileStatus.UPLOADED, + ): List { + if (ownerIds.isEmpty()) { + return emptyList() + } + return ownerIds.flatMap { findAll(ownerType, it, status) } + } + fun exists( ownerType: FileOwnerType, ownerId: Long, diff --git a/src/main/kotlin/com/weeth/domain/file/domain/repository/FileRepository.kt b/src/main/kotlin/com/weeth/domain/file/domain/repository/FileRepository.kt index 8c1c4f19..fab6d0dc 100644 --- a/src/main/kotlin/com/weeth/domain/file/domain/repository/FileRepository.kt +++ b/src/main/kotlin/com/weeth/domain/file/domain/repository/FileRepository.kt @@ -19,6 +19,17 @@ interface FileRepository : status: FileStatus, ): List + fun findAllByOwnerTypeAndOwnerIdIn( + ownerType: FileOwnerType, + ownerIds: List, + ): List + + fun findAllByOwnerTypeAndOwnerIdInAndStatus( + ownerType: FileOwnerType, + ownerIds: List, + status: FileStatus, + ): List + fun existsByOwnerTypeAndOwnerId( ownerType: FileOwnerType, ownerId: Long, @@ -38,6 +49,18 @@ interface FileRepository : status?.let { findAllByOwnerTypeAndOwnerIdAndStatus(ownerType, ownerId, it) } ?: findAllByOwnerTypeAndOwnerId(ownerType, ownerId) + override fun findAll( + ownerType: FileOwnerType, + ownerIds: List, + status: FileStatus?, + ): List { + if (ownerIds.isEmpty()) { + return emptyList() + } + return status?.let { findAllByOwnerTypeAndOwnerIdInAndStatus(ownerType, ownerIds, it) } + ?: findAllByOwnerTypeAndOwnerIdIn(ownerType, ownerIds) + } + override fun exists( ownerType: FileOwnerType, ownerId: Long, diff --git a/src/test/kotlin/com/weeth/config/QueryCountUtil.kt b/src/test/kotlin/com/weeth/config/QueryCountUtil.kt new file mode 100644 index 00000000..184f678f --- /dev/null +++ b/src/test/kotlin/com/weeth/config/QueryCountUtil.kt @@ -0,0 +1,56 @@ +package com.weeth.config + +import jakarta.persistence.EntityManager +import org.hibernate.SessionFactory + +/** + * Hibernate Statistics 기반 쿼리 카운터. + * 블록 실행 중 발생한 SQL prepared statement 수를 반환한다. + * + * 사용법: + * ``` + * val result = QueryCountUtil.count(entityManager) { + * repository.findById(id) + * } + * result.queryCount shouldBe 1 + * ``` + */ +object QueryCountUtil { + data class Result( + val queryCount: Long, + val entityLoadCount: Long, + val collectionLoadCount: Long, + val elapsedTimeMs: Double, + ) { + override fun toString(): String = + "queries=$queryCount, entityLoads=$entityLoadCount, collectionLoads=$collectionLoadCount, elapsedMs=%.3f".format( + elapsedTimeMs, + ) + } + + fun count( + entityManager: EntityManager, + block: () -> Unit, + ): Result { + val sessionFactory = entityManager.entityManagerFactory.unwrap(SessionFactory::class.java) + val stats = sessionFactory.statistics + + stats.isStatisticsEnabled = true + stats.clear() + + val startNanos = System.nanoTime() + block() + val elapsedNanos = System.nanoTime() - startNanos + + val result = + Result( + queryCount = stats.prepareStatementCount, + entityLoadCount = stats.entityLoadCount, + collectionLoadCount = stats.collectionFetchCount, + elapsedTimeMs = elapsedNanos / 1_000_000.0, + ) + + stats.isStatisticsEnabled = false + return result + } +} diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/NoticeUsecaseImplTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/NoticeUsecaseImplTest.kt index e12f7ac9..b3960018 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/NoticeUsecaseImplTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/NoticeUsecaseImplTest.kt @@ -8,7 +8,7 @@ import com.weeth.domain.board.domain.service.NoticeFindService import com.weeth.domain.board.domain.service.NoticeSaveService import com.weeth.domain.board.domain.service.NoticeUpdateService import com.weeth.domain.board.fixture.NoticeTestFixture -import com.weeth.domain.comment.application.mapper.CommentMapper +import com.weeth.domain.comment.application.usecase.query.GetCommentQueryService import com.weeth.domain.file.application.dto.request.FileSaveRequest import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.entity.File @@ -50,7 +50,7 @@ class NoticeUsecaseImplTest : val fileRepository = mockk(relaxed = true) val fileReader = mockk() val noticeMapper = mockk() - val commentMapper = mockk() + val getCommentQueryService = mockk() val fileMapper = mockk() val noticeUsecase = @@ -63,7 +63,7 @@ class NoticeUsecaseImplTest : fileRepository, fileReader, noticeMapper, - commentMapper, + getCommentQueryService, fileMapper, ) diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/PostUseCaseImplTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/PostUseCaseImplTest.kt index de6aa350..d7028b35 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/PostUseCaseImplTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/PostUseCaseImplTest.kt @@ -11,7 +11,7 @@ import com.weeth.domain.board.domain.service.PostFindService import com.weeth.domain.board.domain.service.PostSaveService import com.weeth.domain.board.domain.service.PostUpdateService import com.weeth.domain.board.fixture.PostTestFixture -import com.weeth.domain.comment.application.mapper.CommentMapper +import com.weeth.domain.comment.application.usecase.query.GetCommentQueryService import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.entity.FileOwnerType import com.weeth.domain.file.domain.repository.FileReader @@ -55,7 +55,7 @@ class PostUseCaseImplTest : val fileReader = mockk() val mapper = mockk() val fileMapper = mockk() - val commentMapper = mockk() + val getCommentQueryService = mockk() val postUseCase = PostUseCaseImpl( @@ -70,7 +70,7 @@ class PostUseCaseImplTest : fileReader, mapper, fileMapper, - commentMapper, + getCommentQueryService, ) describe("saveEducation") { diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/NoticeCommentUsecaseImplTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/NoticeCommentUsecaseImplTest.kt deleted file mode 100644 index 034b5a88..00000000 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/NoticeCommentUsecaseImplTest.kt +++ /dev/null @@ -1,129 +0,0 @@ -package com.weeth.domain.comment.application.usecase - -import com.weeth.domain.board.domain.service.NoticeFindService -import com.weeth.domain.board.fixture.NoticeTestFixture -import com.weeth.domain.comment.application.dto.CommentDTO -import com.weeth.domain.comment.application.mapper.CommentMapper -import com.weeth.domain.comment.domain.service.CommentDeleteService -import com.weeth.domain.comment.domain.service.CommentFindService -import com.weeth.domain.comment.domain.service.CommentSaveService -import com.weeth.domain.comment.fixture.CommentTestFixture -import com.weeth.domain.file.application.mapper.FileMapper -import com.weeth.domain.file.domain.entity.FileOwnerType -import com.weeth.domain.file.domain.repository.FileReader -import com.weeth.domain.file.domain.repository.FileRepository -import com.weeth.domain.user.application.exception.UserNotMatchException -import com.weeth.domain.user.domain.service.UserGetService -import com.weeth.domain.user.fixture.UserTestFixture -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.core.spec.style.DescribeSpec -import io.kotest.matchers.collections.shouldContain -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify - -class NoticeCommentUsecaseImplTest : - DescribeSpec({ - - val commentSaveService = mockk(relaxUnitFun = true) - val commentFindService = mockk() - val commentDeleteService = mockk(relaxUnitFun = true) - val fileRepository = mockk(relaxed = true) - val fileReader = mockk() - val fileMapper = mockk() - val noticeFindService = mockk() - val userGetService = mockk() - val commentMapper = mockk() - - val noticeCommentUsecase = - NoticeCommentUsecaseImpl( - commentSaveService, - commentFindService, - commentDeleteService, - fileRepository, - fileReader, - fileMapper, - noticeFindService, - userGetService, - commentMapper, - ) - - describe("saveNoticeComment") { - it("부모 댓글이 없는 공지사항 댓글 작성") { - val userId = 1L - val noticeId = 1L - val commentId = 1L - - val user = UserTestFixture.createActiveUser1(1L) - val notice = NoticeTestFixture.createNotice(id = noticeId, title = "제목1") - - val dto = CommentDTO.Save(null, "댓글1", listOf()) - - val comment = CommentTestFixture.createComment(commentId, dto.content(), user, notice) - - every { commentMapper.fromCommentDto(dto, notice, user, null) } returns comment - every { userGetService.find(user.id) } returns user - every { noticeFindService.find(notice.id) } returns notice - every { fileMapper.toFileList(dto.files(), FileOwnerType.COMMENT, commentId) } returns listOf() - - noticeCommentUsecase.saveNoticeComment(dto, noticeId, userId) - - verify { userGetService.find(userId) } - verify { noticeFindService.find(noticeId) } - verify { commentSaveService.save(comment) } - - notice.comments shouldContain comment - } - - it("부모 댓글이 있는 경우 공지사항 댓글 작성") { - val userId = 1L - val noticeId = 1L - val parentCommentId = 1L - val childCommentId = 2L - - val user = UserTestFixture.createActiveUser1(parentCommentId) - val notice = NoticeTestFixture.createNotice(id = noticeId, title = "제목1") - - val parentComment = CommentTestFixture.createComment(parentCommentId, "부모 댓글", user, notice) - - val childCommentDTO = CommentDTO.Save(parentCommentId, "자식 댓글", listOf()) - val childComment = CommentTestFixture.createComment(childCommentId, childCommentDTO.content(), user, notice) - - every { commentMapper.fromCommentDto(childCommentDTO, notice, user, parentComment) } returns childComment - every { userGetService.find(user.id) } returns user - every { commentFindService.find(parentComment.id) } returns parentComment - every { noticeFindService.find(notice.id) } returns notice - every { fileMapper.toFileList(childCommentDTO.files(), FileOwnerType.COMMENT, childCommentId) } returns listOf() - - noticeCommentUsecase.saveNoticeComment(childCommentDTO, noticeId, userId) - - verify { commentFindService.find(parentComment.id) } - verify { commentSaveService.save(childComment) } - - parentComment.children shouldContain childComment - } - } - - describe("updateNoticeComment") { - it("공지사항 댓글 수정 시 작성자와 수정 요청자가 다르면 예외가 발생한다") { - val different = 2L - val noticeId = 1L - val commentId = 1L - - val user = UserTestFixture.createActiveUser1(1L) - val user2 = UserTestFixture.createActiveUser1(2L) - val notice = NoticeTestFixture.createNotice(id = noticeId, title = "제목1") - - val dto = CommentDTO.Update("수정 완료", listOf()) - val comment = CommentTestFixture.createComment(commentId, dto.content(), user, notice) - - every { userGetService.find(user2.id) } returns user2 - every { noticeFindService.find(notice.id) } returns notice - every { commentFindService.find(comment.id) } returns comment - - shouldThrow { - noticeCommentUsecase.updateNoticeComment(dto, noticeId, comment.id, different) - } - } - } - }) diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt new file mode 100644 index 00000000..a928c7f2 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt @@ -0,0 +1,523 @@ +package com.weeth.domain.comment.application.usecase.command + +import com.weeth.domain.board.domain.entity.enums.Category +import com.weeth.domain.board.domain.repository.NoticeRepository +import com.weeth.domain.board.domain.repository.PostRepository +import com.weeth.domain.board.fixture.NoticeTestFixture +import com.weeth.domain.board.fixture.PostTestFixture +import com.weeth.domain.comment.application.dto.request.CommentSaveRequest +import com.weeth.domain.comment.application.dto.request.CommentUpdateRequest +import com.weeth.domain.comment.application.exception.CommentAlreadyDeletedException +import com.weeth.domain.comment.application.exception.CommentNotFoundException +import com.weeth.domain.comment.application.exception.CommentNotOwnedException +import com.weeth.domain.comment.domain.entity.Comment +import com.weeth.domain.comment.domain.repository.CommentRepository +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.entity.File +import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.entity.FileStatus +import com.weeth.domain.file.domain.repository.FileReader +import com.weeth.domain.file.domain.repository.FileRepository +import com.weeth.domain.user.domain.service.UserGetService +import com.weeth.domain.user.fixture.UserTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.springframework.test.util.ReflectionTestUtils + +class ManageCommentUseCaseTest : + DescribeSpec({ + val commentRepository = mockk(relaxUnitFun = true) + val postRepository = mockk() + val noticeRepository = mockk() + val userGetService = mockk() + val fileReader = mockk() + val fileRepository = mockk(relaxed = true) + val fileMapper = mockk() + + val useCase = + ManageCommentUseCase( + commentRepository, + postRepository, + noticeRepository, + userGetService, + fileReader, + fileRepository, + fileMapper, + ) + + beforeTest { + clearMocks( + commentRepository, + postRepository, + noticeRepository, + userGetService, + fileReader, + fileRepository, + fileMapper, + ) + every { fileMapper.toFileList(any(), FileOwnerType.COMMENT, any()) } returns emptyList() + every { commentRepository.save(any()) } answers { firstArg() } + every { fileReader.findAll(FileOwnerType.COMMENT, any(), any()) } returns emptyList() + } + + describe("savePostComment") { + it("최상위 댓글 저장 성공 시 댓글 수를 증가시킨다") { + val user = UserTestFixture.createActiveUser1(1L) + val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) + val dto = CommentSaveRequest(parentCommentId = null, content = "최상위 댓글", files = null) + + every { userGetService.find(1L) } returns user + every { postRepository.findByIdWithLock(10L) } returns post + + useCase.savePostComment(dto, postId = 10L, userId = 1L) + + post.commentCount shouldBe 1 + verify { commentRepository.save(any()) } + verify(exactly = 0) { commentRepository.findByIdAndPostId(any(), any()) } + } + + it("대댓글 저장 성공 시 같은 게시글 경계를 검증하고 댓글 수를 증가시킨다") { + val user = UserTestFixture.createActiveUser1(1L) + val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) + val parent = Comment(id = 100L, content = "parent", post = post, user = user) + val dto = + CommentSaveRequest( + parentCommentId = 100L, + content = "child", + files = + listOf( + FileSaveRequest( + "f.png", + "COMMENT/2026-02/123e4567-e89b-12d3-a456-426614174000_f.png", + 100L, + "image/png", + ), + ), + ) + val mappedFiles = + listOf( + File.createUploaded( + fileName = "f.png", + storageKey = "COMMENT/2026-02/123e4567-e89b-12d3-a456-426614174000_f.png", + fileSize = 100L, + contentType = "image/png", + ownerType = FileOwnerType.COMMENT, + ownerId = 999L, + ), + ) + + every { userGetService.find(1L) } returns user + every { postRepository.findByIdWithLock(10L) } returns post + every { commentRepository.findByIdAndPostId(100L, 10L) } returns parent + every { fileMapper.toFileList(dto.files, FileOwnerType.COMMENT, any()) } returns mappedFiles + + useCase.savePostComment(dto, postId = 10L, userId = 1L) + + post.commentCount shouldBe 1 + verify { commentRepository.findByIdAndPostId(100L, 10L) } + verify { commentRepository.save(any()) } + verify { fileRepository.saveAll(mappedFiles) } + } + + it("부모 댓글이 다른 리소스면 예외를 던진다") { + val user = UserTestFixture.createActiveUser1(1L) + val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) + val dto = CommentSaveRequest(parentCommentId = 999L, content = "child", files = null) + + every { userGetService.find(1L) } returns user + every { postRepository.findByIdWithLock(10L) } returns post + every { commentRepository.findByIdAndPostId(999L, 10L) } returns null + + shouldThrow { + useCase.savePostComment(dto, postId = 10L, userId = 1L) + } + + verify(exactly = 0) { commentRepository.save(any()) } + } + } + + describe("updatePostComment") { + it("작성자가 아니면 예외를 던진다") { + val owner = UserTestFixture.createActiveUser1(1L) + val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) + val comment = Comment(id = 200L, content = "old", post = post, user = owner) + val dto = CommentUpdateRequest(content = "new", files = null) + + every { commentRepository.findByIdAndPostId(200L, 10L) } returns comment + + shouldThrow { + useCase.updatePostComment(dto, postId = 10L, commentId = 200L, userId = 2L) + } + + verify(exactly = 0) { fileRepository.saveAll(any>()) } + } + + it("files가 null이면 기존 첨부를 유지한다") { + val owner = UserTestFixture.createActiveUser1(1L) + val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) + val comment = Comment(id = 201L, content = "old", post = post, user = owner) + val dto = CommentUpdateRequest(content = "new content", files = null) + + every { commentRepository.findByIdAndPostId(201L, 10L) } returns comment + + useCase.updatePostComment(dto, postId = 10L, commentId = 201L, userId = 1L) + + comment.content shouldBe "new content" + verify(exactly = 0) { fileReader.findAll(FileOwnerType.COMMENT, any(), any()) } + verify(exactly = 0) { fileRepository.saveAll(any>()) } + } + + it("files가 있으면 기존 파일을 삭제하고 새 파일을 저장한다") { + val owner = UserTestFixture.createActiveUser1(1L) + val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) + val comment = Comment(id = 202L, content = "old", post = post, user = owner) + val dto = + CommentUpdateRequest( + content = "new content", + files = + listOf( + FileSaveRequest( + "new.png", + "COMMENT/2026-02/123e4567-e89b-12d3-a456-426614174001_new.png", + 200L, + "image/png", + ), + ), + ) + val oldFile = + File.createUploaded( + fileName = "old.png", + storageKey = "COMMENT/2026-02/123e4567-e89b-12d3-a456-426614174002_old.png", + fileSize = 200L, + contentType = "image/png", + ownerType = FileOwnerType.COMMENT, + ownerId = comment.id, + ) + val newFile = + File.createUploaded( + fileName = "new.png", + storageKey = "COMMENT/2026-02/123e4567-e89b-12d3-a456-426614174003_new.png", + fileSize = 200L, + contentType = "image/png", + ownerType = FileOwnerType.COMMENT, + ownerId = comment.id, + ) + + every { commentRepository.findByIdAndPostId(202L, 10L) } returns comment + every { fileReader.findAll(FileOwnerType.COMMENT, 202L, any()) } returns listOf(oldFile) + every { fileMapper.toFileList(dto.files, FileOwnerType.COMMENT, 202L) } returns listOf(newFile) + + useCase.updatePostComment(dto, postId = 10L, commentId = 202L, userId = 1L) + + comment.content shouldBe "new content" + oldFile.status.name shouldBe "DELETED" + verify { fileRepository.saveAll(listOf(newFile)) } + } + + it("files가 빈 배열이면 기존 파일을 전체 삭제하고 새 파일은 저장하지 않는다") { + val owner = UserTestFixture.createActiveUser1(1L) + val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) + val comment = Comment(id = 204L, content = "old", post = post, user = owner) + val dto = CommentUpdateRequest(content = "new content", files = emptyList()) + val oldFile = + File.createUploaded( + fileName = "old.png", + storageKey = "COMMENT/2026-02/123e4567-e89b-12d3-a456-426614174004_old2.png", + fileSize = 300L, + contentType = "image/png", + ownerType = FileOwnerType.COMMENT, + ownerId = comment.id, + ) + + every { commentRepository.findByIdAndPostId(204L, 10L) } returns comment + every { fileReader.findAll(FileOwnerType.COMMENT, 204L, any()) } returns listOf(oldFile) + + useCase.updatePostComment(dto, postId = 10L, commentId = 204L, userId = 1L) + + oldFile.status.name shouldBe "DELETED" + verify(exactly = 0) { fileRepository.saveAll(any>()) } + } + + it("삭제된 댓글은 수정할 수 없다") { + val owner = UserTestFixture.createActiveUser1(1L) + val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) + val comment = Comment(id = 203L, content = "삭제된 댓글입니다.", post = post, user = owner, isDeleted = true) + val dto = CommentUpdateRequest(content = "new content", files = null) + + every { commentRepository.findByIdAndPostId(203L, 10L) } returns comment + + shouldThrow { + useCase.updatePostComment(dto, postId = 10L, commentId = 203L, userId = 1L) + } + + verify(exactly = 0) { fileReader.findAll(FileOwnerType.COMMENT, any(), any()) } + verify(exactly = 0) { fileRepository.saveAll(any>()) } + } + } + + describe("deletePostComment") { + it("자식이 있는 댓글 삭제 시 soft delete 하고 댓글 수를 감소시킨다") { + val owner = UserTestFixture.createActiveUser1(1L) + val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) + ReflectionTestUtils.setField(post, "commentCount", 2) + + val comment = Comment(id = 300L, content = "target", post = post, user = owner) + val child = + Comment(id = 301L, content = "child", post = post, user = owner, parent = comment) + comment.children.add(child) + + every { postRepository.findByIdWithLock(10L) } returns post + every { commentRepository.findByIdAndPostId(300L, 10L) } returns comment + + useCase.deletePostComment(postId = 10L, commentId = 300L, userId = 1L) + + comment.isDeleted shouldBe true + comment.content shouldBe "삭제된 댓글입니다." + post.commentCount shouldBe 1 + verify(exactly = 0) { commentRepository.delete(comment) } + } + + it("이미 삭제된 댓글을 다시 삭제하면 예외를 던진다") { + val owner = UserTestFixture.createActiveUser1(1L) + val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) + ReflectionTestUtils.setField(post, "commentCount", 2) + + val comment = + Comment(id = 300L, content = "target", post = post, user = owner, isDeleted = true) + val child = + Comment(id = 301L, content = "child", post = post, user = owner, parent = comment) + comment.children.add(child) + + every { postRepository.findByIdWithLock(10L) } returns post + every { commentRepository.findByIdAndPostId(300L, 10L) } returns comment + + shouldThrow { + useCase.deletePostComment(postId = 10L, commentId = 300L, userId = 1L) + } + + post.commentCount shouldBe 2 + } + + it("이미 삭제된 댓글은 자식이 없어도 예외를 던진다") { + val owner = UserTestFixture.createActiveUser1(1L) + val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) + val comment = Comment(id = 300L, content = "삭제된 댓글입니다.", post = post, user = owner, isDeleted = true) + + every { postRepository.findByIdWithLock(10L) } returns post + every { commentRepository.findByIdAndPostId(300L, 10L) } returns comment + + shouldThrow { + useCase.deletePostComment(postId = 10L, commentId = 300L, userId = 1L) + } + + verify(exactly = 0) { commentRepository.delete(any()) } + } + + it("자식 없는 리프 댓글 삭제 시 hard delete하고 댓글 수를 감소시킨다") { + val owner = UserTestFixture.createActiveUser1(1L) + val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) + ReflectionTestUtils.setField(post, "commentCount", 1) + val comment = Comment(id = 310L, content = "리프", post = post, user = owner) + + every { postRepository.findByIdWithLock(10L) } returns post + every { commentRepository.findByIdAndPostId(310L, 10L) } returns comment + + useCase.deletePostComment(postId = 10L, commentId = 310L, userId = 1L) + + post.commentCount shouldBe 0 + verify { commentRepository.delete(comment) } + } + + it("부모가 삭제됐어도 자식이 2개 이상이면 부모를 삭제하지 않는다") { + val owner = UserTestFixture.createActiveUser1(1L) + val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) + ReflectionTestUtils.setField(post, "commentCount", 2) + + val parent = + Comment( + id = 400L, + content = "삭제된 댓글입니다.", + post = post, + user = owner, + isDeleted = true, + ) + val child1 = Comment(id = 401L, content = "첫째", post = post, user = owner, parent = parent) + val child2 = Comment(id = 402L, content = "둘째", post = post, user = owner, parent = parent) + parent.children.add(child1) + parent.children.add(child2) + + every { postRepository.findByIdWithLock(10L) } returns post + every { commentRepository.findByIdAndPostId(401L, 10L) } returns child1 + + useCase.deletePostComment(postId = 10L, commentId = 401L, userId = 1L) + + verify { commentRepository.delete(child1) } + verify(exactly = 0) { commentRepository.delete(parent) } + } + + it("리프 댓글 삭제 시 부모가 삭제 상태이고 마지막 자식이면 부모까지 물리 삭제한다") { + val owner = UserTestFixture.createActiveUser1(1L) + val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) + ReflectionTestUtils.setField(post, "commentCount", 1) + + val parent = + Comment( + id = 400L, + content = "삭제된 댓글입니다.", + post = post, + user = owner, + isDeleted = true, + ) + val child = Comment(id = 401L, content = "leaf", post = post, user = owner, parent = parent) + parent.children.add(child) + + every { postRepository.findByIdWithLock(10L) } returns post + every { commentRepository.findByIdAndPostId(401L, 10L) } returns child + val childFile = + File.createUploaded( + fileName = "a", + storageKey = "COMMENT/2026-02/123e4567-e89b-12d3-a456-426614174005_a.png", + fileSize = 100L, + contentType = "image/png", + ownerType = FileOwnerType.COMMENT, + ownerId = 401L, + ) + every { fileReader.findAll(FileOwnerType.COMMENT, 401L, any()) } returns + listOf( + childFile, + ) + every { fileReader.findAll(FileOwnerType.COMMENT, 400L, any()) } returns emptyList() + + useCase.deletePostComment(postId = 10L, commentId = 401L, userId = 1L) + + post.commentCount shouldBe 0 + childFile.status.name shouldBe "DELETED" + verify { commentRepository.delete(child) } + verify { commentRepository.delete(parent) } + } + } + + describe("saveNoticeComment") { + it("공지 댓글 생성도 동일하게 lock 기반으로 처리한다") { + val user = UserTestFixture.createActiveUser1(1L) + val notice = NoticeTestFixture.createNotice(id = 11L, title = "notice", user = user) + val dto = CommentSaveRequest(parentCommentId = null, content = "notice comment", files = null) + + every { userGetService.find(1L) } returns user + every { noticeRepository.findByIdWithLock(11L) } returns notice + + useCase.saveNoticeComment(dto, noticeId = 11L, userId = 1L) + + notice.commentCount shouldBe 1 + verify { noticeRepository.findByIdWithLock(11L) } + verify { commentRepository.save(any()) } + } + } + + describe("updateNoticeComment") { + it("작성자가 아니면 예외를 던진다") { + val owner = UserTestFixture.createActiveUser1(1L) + val notice = NoticeTestFixture.createNotice(id = 11L, title = "notice", user = owner) + val comment = Comment(id = 500L, content = "old", notice = notice, user = owner) + val dto = CommentUpdateRequest(content = "new", files = null) + + every { commentRepository.findByIdAndNoticeId(500L, 11L) } returns comment + + shouldThrow { + useCase.updateNoticeComment(dto, noticeId = 11L, commentId = 500L, userId = 2L) + } + } + + it("작성자이면 내용을 변경한다") { + val owner = UserTestFixture.createActiveUser1(1L) + val notice = NoticeTestFixture.createNotice(id = 11L, title = "notice", user = owner) + val comment = Comment(id = 501L, content = "old", notice = notice, user = owner) + val dto = CommentUpdateRequest(content = "updated", files = null) + + every { commentRepository.findByIdAndNoticeId(501L, 11L) } returns comment + + useCase.updateNoticeComment(dto, noticeId = 11L, commentId = 501L, userId = 1L) + + comment.content shouldBe "updated" + } + + it("삭제된 댓글은 수정할 수 없다") { + val owner = UserTestFixture.createActiveUser1(1L) + val notice = NoticeTestFixture.createNotice(id = 11L, title = "notice", user = owner) + val comment = + Comment(id = 502L, content = "삭제된 댓글입니다.", notice = notice, user = owner, isDeleted = true) + val dto = CommentUpdateRequest(content = "updated", files = null) + + every { commentRepository.findByIdAndNoticeId(502L, 11L) } returns comment + + shouldThrow { + useCase.updateNoticeComment(dto, noticeId = 11L, commentId = 502L, userId = 1L) + } + + verify(exactly = 0) { fileReader.findAll(FileOwnerType.COMMENT, any(), any()) } + verify(exactly = 0) { fileRepository.saveAll(any>()) } + } + + it("files가 빈 배열이면 기존 파일을 전체 삭제하고 새 파일은 저장하지 않는다") { + val owner = UserTestFixture.createActiveUser1(1L) + val notice = NoticeTestFixture.createNotice(id = 11L, title = "notice", user = owner) + val comment = Comment(id = 503L, content = "old", notice = notice, user = owner) + val dto = CommentUpdateRequest(content = "updated", files = emptyList()) + val oldFile = + File.createUploaded( + fileName = "old.png", + storageKey = "COMMENT/2026-02/123e4567-e89b-12d3-a456-426614174006_old3.png", + fileSize = 400L, + contentType = "image/png", + ownerType = FileOwnerType.COMMENT, + ownerId = comment.id, + ) + + every { commentRepository.findByIdAndNoticeId(503L, 11L) } returns comment + every { fileReader.findAll(FileOwnerType.COMMENT, 503L, any()) } returns listOf(oldFile) + + useCase.updateNoticeComment(dto, noticeId = 11L, commentId = 503L, userId = 1L) + + oldFile.status.name shouldBe "DELETED" + verify(exactly = 0) { fileRepository.saveAll(any>()) } + } + } + + describe("deleteNoticeComment") { + it("자식 없는 리프 댓글 삭제 시 hard delete하고 댓글 수를 감소시킨다") { + val owner = UserTestFixture.createActiveUser1(1L) + val notice = NoticeTestFixture.createNotice(id = 11L, title = "notice", user = owner) + ReflectionTestUtils.setField(notice, "commentCount", 1) + val comment = Comment(id = 600L, content = "리프", notice = notice, user = owner) + + every { noticeRepository.findByIdWithLock(11L) } returns notice + every { commentRepository.findByIdAndNoticeId(600L, 11L) } returns comment + + useCase.deleteNoticeComment(noticeId = 11L, commentId = 600L, userId = 1L) + + notice.commentCount shouldBe 0 + verify { commentRepository.delete(comment) } + } + + it("작성자가 아니면 예외를 던진다") { + val owner = UserTestFixture.createActiveUser1(1L) + val notice = NoticeTestFixture.createNotice(id = 11L, title = "notice", user = owner) + val comment = Comment(id = 601L, content = "리프", notice = notice, user = owner) + + every { noticeRepository.findByIdWithLock(11L) } returns notice + every { commentRepository.findByIdAndNoticeId(601L, 11L) } returns comment + + shouldThrow { + useCase.deleteNoticeComment(noticeId = 11L, commentId = 601L, userId = 2L) + } + + verify(exactly = 0) { commentRepository.delete(any()) } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt new file mode 100644 index 00000000..d0639567 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt @@ -0,0 +1,213 @@ +package com.weeth.domain.comment.application.usecase.query + +import com.weeth.config.QueryCountUtil +import com.weeth.config.TestContainersConfig +import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.board.domain.entity.enums.Category +import com.weeth.domain.board.domain.entity.enums.Part +import com.weeth.domain.board.domain.repository.PostRepository +import com.weeth.domain.comment.application.dto.response.CommentResponse +import com.weeth.domain.comment.application.mapper.CommentMapper +import com.weeth.domain.comment.domain.entity.Comment +import com.weeth.domain.comment.domain.repository.CommentRepository +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.entity.File +import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.port.FileAccessUrlPort +import com.weeth.domain.file.domain.repository.FileRepository +import com.weeth.domain.user.domain.entity.User +import com.weeth.domain.user.domain.entity.enums.Position +import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.entity.enums.Status +import com.weeth.domain.user.domain.repository.UserRepository +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.longs.shouldBeLessThan +import io.kotest.matchers.shouldBe +import jakarta.persistence.EntityManager +import org.junit.jupiter.api.Tag +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import java.util.UUID + +@DataJpaTest +@Import(TestContainersConfig::class) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Tag("performance") +class CommentQueryPerformanceTest( + private val userRepository: UserRepository, + private val postRepository: PostRepository, + private val commentRepository: CommentRepository, + private val fileRepository: FileRepository, + private val entityManager: EntityManager, +) : DescribeSpec({ + val runPerformanceTests = System.getProperty("runPerformanceTests")?.toBoolean() ?: false + + fun setupData( + rootCount: Int, + childrenPerRoot: Int, + filesPerComment: Int, + ): List { + val user = + userRepository.save( + User + .builder() + .name("perf-user") + .email("perf-user@test.com") + .status(Status.ACTIVE) + .position(Position.BE) + .role(Role.USER) + .build(), + ) + val post = + postRepository.save( + Post + .builder() + .user(user) + .title("query-performance") + .content("measure comment query performance") + .category(Category.StudyLog) + .part(Part.BE) + .parts(listOf(Part.BE)) + .cardinalNumber(4) + .week(1) + .comments(ArrayList()) + .commentCount(0) + .build(), + ) + + val commentIds = mutableListOf() + repeat(rootCount) { rootIdx -> + val root = + commentRepository.save( + Comment.createForPost( + content = "root-$rootIdx", + post = post, + user = user, + parent = null, + ), + ) + commentIds += root.id + repeat(childrenPerRoot) { childIdx -> + val child = + commentRepository.save( + Comment.createForPost( + content = "child-$rootIdx-$childIdx", + post = post, + user = user, + parent = root, + ), + ) + commentIds += child.id + } + } + + commentIds.forEach { commentId -> + repeat(filesPerComment) { fileIdx -> + fileRepository.save( + File.createUploaded( + fileName = "file-$commentId-$fileIdx.png", + storageKey = "COMMENT/2026-02/${UUID.randomUUID()}_file-$commentId-$fileIdx.png", + fileSize = 1024L, + contentType = "image/png", + ownerType = FileOwnerType.COMMENT, + ownerId = commentId, + ), + ) + } + } + + return commentIds + } + + describe("comment file query performance") { + fun runComparison( + label: String, + rootCount: Int, + childrenPerRoot: Int, + filesPerComment: Int, + ) { + setupData(rootCount = rootCount, childrenPerRoot = childrenPerRoot, filesPerComment = filesPerComment) + + val fileMapper = + FileMapper( + object : FileAccessUrlPort { + override fun resolve(storageKey: String): String = "https://test.local/$storageKey" + }, + ) + val commentMapper = CommentMapper() + val legacyService = LegacyCommentQueryService(fileRepository, fileMapper, commentMapper) + val improvedService = GetCommentQueryService(fileRepository, fileMapper, commentMapper) + + entityManager.flush() + entityManager.clear() + + val legacy = + QueryCountUtil.count(entityManager) { + val comments = commentRepository.findAll().sortedBy { it.id } + val tree = legacyService.toCommentTreeResponses(comments) + tree.size shouldBe rootCount + } + + entityManager.clear() + + val improved = + QueryCountUtil.count(entityManager) { + val comments = commentRepository.findAll().sortedBy { it.id } + val tree = improvedService.toCommentTreeResponses(comments) + tree.size shouldBe rootCount + } + + improved.queryCount shouldBeLessThan legacy.queryCount + println("[$label] LEGACY: $legacy") + println("[$label] IMPROVED: $improved") + } + + it("소규모 데이터에서 배치 조회가 더 효율적이다").config(enabled = runPerformanceTests) { + runComparison(label = "small", rootCount = 10, childrenPerRoot = 1, filesPerComment = 1) + } + + it("대량 데이터에서도 배치 조회가 더 효율적이다").config(enabled = runPerformanceTests) { + runComparison(label = "large", rootCount = 200, childrenPerRoot = 1, filesPerComment = 1) + } + } + }) + +private class LegacyCommentQueryService( + private val fileRepository: FileRepository, + private val fileMapper: FileMapper, + private val commentMapper: CommentMapper, +) { + fun toCommentTreeResponses(comments: List): List { + if (comments.isEmpty()) { + return emptyList() + } + + val childrenByParentId = + comments + .filter { it.parent != null } + .groupBy { requireNotNull(it.parent).id } + + return comments + .filter { it.parent == null } + .map { mapToCommentResponse(it, childrenByParentId) } + } + + private fun mapToCommentResponse( + comment: Comment, + childrenByParentId: Map>, + ): CommentResponse { + val children = + childrenByParentId[comment.id] + ?.map { mapToCommentResponse(it, childrenByParentId) } + ?: emptyList() + + val files = + fileRepository + .findAll(FileOwnerType.COMMENT, comment.id) + .map(fileMapper::toFileResponse) + ?: emptyList() + + return commentMapper.toCommentDto(comment, children, files) + } +} diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt new file mode 100644 index 00000000..d1638bea --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt @@ -0,0 +1,109 @@ +package com.weeth.domain.comment.application.usecase.query + +import com.weeth.domain.board.domain.entity.enums.Category +import com.weeth.domain.board.fixture.PostTestFixture +import com.weeth.domain.comment.application.dto.response.CommentResponse +import com.weeth.domain.comment.application.mapper.CommentMapper +import com.weeth.domain.comment.fixture.CommentTestFixture +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.repository.FileReader +import com.weeth.domain.user.domain.entity.enums.Position +import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.fixture.UserTestFixture +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import java.time.LocalDateTime + +class GetCommentQueryServiceTest : + DescribeSpec({ + val fileReader = mockk() + val fileMapper = mockk() + val commentMapper = mockk() + val assembler = GetCommentQueryService(fileReader, fileMapper, commentMapper) + + val user = UserTestFixture.createActiveUser1(1L) + val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) + + beforeTest { + clearMocks(fileReader, fileMapper, commentMapper) + } + + fun stubResponse( + id: Long, + children: List = emptyList(), + ) = CommentResponse( + id = id, + name = "테스트유저", + position = Position.BE, + role = Role.USER, + content = "content", + time = LocalDateTime.now(), + fileUrls = emptyList(), + children = children, + ) + + describe("toCommentTreeResponses") { + it("빈 리스트면 빈 리스트를 반환하고 파일 조회를 하지 않는다") { + val result = assembler.toCommentTreeResponses(emptyList()) + + result shouldBe emptyList() + verify(exactly = 0) { fileReader.findAll(any(), any(), any()) } + verify(exactly = 0) { fileReader.findAll(any(), any>(), any()) } + } + + it("최상위 댓글만 있을 때 파일 조회를 1회 수행하고 트리를 반환한다") { + val comment = CommentTestFixture.createPostComment(id = 1L, post = post, user = user) + val response = stubResponse(1L) + + every { fileReader.findAll(FileOwnerType.COMMENT, listOf(1L), any()) } returns emptyList() + every { commentMapper.toCommentDto(comment, emptyList(), emptyList()) } returns response + + val result = assembler.toCommentTreeResponses(listOf(comment)) + + result.size shouldBe 1 + result[0].id shouldBe 1L + verify(exactly = 1) { fileReader.findAll(FileOwnerType.COMMENT, listOf(1L), any()) } + } + + it("부모-자식 구조가 있을 때 자식이 부모에 중첩된 트리로 조립된다") { + val parent = CommentTestFixture.createPostComment(id = 10L, post = post, user = user) + val child = CommentTestFixture.createPostComment(id = 11L, post = post, user = user, parent = parent) + val childResponse = stubResponse(11L) + val parentResponse = stubResponse(10L, children = listOf(childResponse)) + + every { fileReader.findAll(FileOwnerType.COMMENT, listOf(10L, 11L), any()) } returns emptyList() + every { commentMapper.toCommentDto(child, emptyList(), emptyList()) } returns childResponse + every { commentMapper.toCommentDto(parent, listOf(childResponse), emptyList()) } returns parentResponse + + val result = assembler.toCommentTreeResponses(listOf(parent, child)) + + result.size shouldBe 1 + result[0].id shouldBe 10L + result[0].children.size shouldBe 1 + result[0].children[0].id shouldBe 11L + verify(exactly = 1) { fileReader.findAll(FileOwnerType.COMMENT, listOf(10L, 11L), any()) } + } + + it("자식 댓글은 최상위 목록에 포함되지 않는다") { + val parent = CommentTestFixture.createPostComment(id = 10L, post = post, user = user) + val child = CommentTestFixture.createPostComment(id = 11L, post = post, user = user, parent = parent) + val childResponse = stubResponse(11L) + val parentResponse = stubResponse(10L, children = listOf(childResponse)) + + every { fileReader.findAll(FileOwnerType.COMMENT, listOf(10L, 11L), any()) } returns emptyList() + every { commentMapper.toCommentDto(child, emptyList(), emptyList()) } returns childResponse + every { commentMapper.toCommentDto(parent, listOf(childResponse), emptyList()) } returns parentResponse + + val result = assembler.toCommentTreeResponses(listOf(parent, child)) + + // 최상위에는 parent만 있어야 함 + result.size shouldBe 1 + result.none { it.id == 11L } shouldBe true + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/comment/domain/entity/CommentEntityTest.kt b/src/test/kotlin/com/weeth/domain/comment/domain/entity/CommentEntityTest.kt new file mode 100644 index 00000000..a2696047 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/comment/domain/entity/CommentEntityTest.kt @@ -0,0 +1,114 @@ +package com.weeth.domain.comment.domain.entity + +import com.weeth.domain.board.domain.entity.enums.Category +import com.weeth.domain.board.fixture.NoticeTestFixture +import com.weeth.domain.board.fixture.PostTestFixture +import com.weeth.domain.comment.fixture.CommentTestFixture +import com.weeth.domain.user.fixture.UserTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe + +class CommentEntityTest : + DescribeSpec({ + val user = UserTestFixture.createActiveUser1(1L) + val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) + val notice = NoticeTestFixture.createNotice(id = 11L, title = "notice", user = user) + + describe("createForPost") { + it("부모 없이 최상위 댓글을 생성한다") { + val comment = Comment.createForPost(content = "내용", post = post, user = user, parent = null) + + comment.content shouldBe "내용" + comment.post shouldBe post + comment.user shouldBe user + comment.parent shouldBe null + } + + it("부모 댓글이 같은 게시글이면 대댓글로 생성된다") { + val parent = CommentTestFixture.createPostComment(id = 100L, post = post, user = user) + val child = Comment.createForPost(content = "대댓글", post = post, user = user, parent = parent) + + child.parent shouldBe parent + } + + it("부모 댓글이 다른 게시글이면 예외를 던진다") { + val otherPost = PostTestFixture.createPost(id = 99L, title = "other", category = Category.StudyLog) + val parent = CommentTestFixture.createPostComment(id = 100L, post = otherPost, user = user) + + shouldThrow { + Comment.createForPost(content = "대댓글", post = post, user = user, parent = parent) + } + } + } + + describe("createForNotice") { + it("부모 없이 최상위 댓글을 생성한다") { + val comment = Comment.createForNotice(content = "내용", notice = notice, user = user, parent = null) + + comment.content shouldBe "내용" + comment.notice shouldBe notice + comment.parent shouldBe null + } + + it("부모 댓글이 다른 공지글이면 예외를 던진다") { + val otherNotice = NoticeTestFixture.createNotice(id = 99L, title = "other", user = user) + val parent = CommentTestFixture.createNoticeComment(id = 100L, notice = otherNotice, user = user) + + shouldThrow { + Comment.createForNotice(content = "대댓글", notice = notice, user = user, parent = parent) + } + } + } + + describe("markAsDeleted") { + it("isDeleted를 true로 바꾸고 내용을 대체 문구로 변경한다") { + val comment = CommentTestFixture.createPostComment(post = post, user = user) + + comment.markAsDeleted() + + comment.isDeleted shouldBe true + comment.content shouldBe "삭제된 댓글입니다." + } + } + + describe("updateContent") { + it("내용을 새 값으로 변경한다") { + val comment = CommentTestFixture.createPostComment(content = "원래 내용", post = post, user = user) + + comment.updateContent("수정된 내용") + + comment.content shouldBe "수정된 내용" + } + + it("빈 문자열이면 예외를 던진다") { + val comment = CommentTestFixture.createPostComment(post = post, user = user) + + shouldThrow { + comment.updateContent("") + } + } + + it("300자를 초과하면 예외를 던진다") { + val comment = CommentTestFixture.createPostComment(post = post, user = user) + + shouldThrow { + comment.updateContent("a".repeat(301)) + } + } + } + + describe("isOwnedBy") { + it("작성자 ID가 일치하면 true를 반환한다") { + val comment = CommentTestFixture.createPostComment(post = post, user = user) + + comment.isOwnedBy(1L) shouldBe true + } + + it("작성자 ID가 다르면 false를 반환한다") { + val comment = CommentTestFixture.createPostComment(post = post, user = user) + + comment.isOwnedBy(99L) shouldBe false + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/comment/domain/vo/CommentContentTest.kt b/src/test/kotlin/com/weeth/domain/comment/domain/vo/CommentContentTest.kt new file mode 100644 index 00000000..0328501c --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/comment/domain/vo/CommentContentTest.kt @@ -0,0 +1,38 @@ +package com.weeth.domain.comment.domain.vo + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe + +class CommentContentTest : + StringSpec({ + "정상 내용이면 생성된다" { + val content = CommentContent.from("정상 내용") + + content.value shouldBe "정상 내용" + } + + "빈 문자열이면 예외를 던진다" { + shouldThrow { + CommentContent.from("") + } + } + + "공백만 있으면 예외를 던진다" { + shouldThrow { + CommentContent.from(" ") + } + } + + "300자는 허용된다" { + val content = CommentContent.from("a".repeat(300)) + + content.value.length shouldBe 300 + } + + "301자이면 예외를 던진다" { + shouldThrow { + CommentContent.from("a".repeat(301)) + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/comment/fixture/CommentTestFixture.kt b/src/test/kotlin/com/weeth/domain/comment/fixture/CommentTestFixture.kt index 24da6e23..80f92c65 100644 --- a/src/test/kotlin/com/weeth/domain/comment/fixture/CommentTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/comment/fixture/CommentTestFixture.kt @@ -1,23 +1,40 @@ package com.weeth.domain.comment.fixture import com.weeth.domain.board.domain.entity.Notice +import com.weeth.domain.board.domain.entity.Post import com.weeth.domain.comment.domain.entity.Comment import com.weeth.domain.user.domain.entity.User object CommentTestFixture { - fun createComment( - id: Long, - content: String, + fun createPostComment( + id: Long = 1L, + content: String = "테스트 댓글", + post: Post, user: User, + parent: Comment? = null, + isDeleted: Boolean = false, + ) = Comment( + id = id, + content = content, + post = post, + user = user, + parent = parent, + isDeleted = isDeleted, + ) + + fun createNoticeComment( + id: Long = 1L, + content: String = "테스트 댓글", notice: Notice, - ): Comment = - Comment - .builder() - .id(id) - .content(content) - .notice(notice) - .user(user) - .children(ArrayList()) - .isDeleted(false) - .build() + user: User, + parent: Comment? = null, + isDeleted: Boolean = false, + ) = Comment( + id = id, + content = content, + notice = notice, + user = user, + parent = parent, + isDeleted = isDeleted, + ) } diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 4747e28c..6d3399f3 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -9,3 +9,4 @@ spring: hibernate: format_sql: true dialect: org.hibernate.dialect.MySQL8Dialect + generate_statistics: true From 1188e591b38bacb842ee54aadf1e8197b3f8e4fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=84=EC=88=98=ED=98=84?= <128474444+soo0711@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:30:19 +0900 Subject: [PATCH 07/73] =?UTF-8?q?[WTH-146]=20penalty=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=BD=94=ED=8B=80=EB=A6=B0=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20(#10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: Penalty enum java -> kotlin으로 폴더 변경 * refactor: Penalty enum kotlin으로 문법 변환 * refactor: Penalty entity, repository java -> kotlin으로 폴더 변경 * refactor: Penalty dto, mapper java -> kotlin으로 폴더 변경 * refactor: Penalty entity, repository kotlin으로 문법 변환 * refactor: Penalty dto, mapper kotlin으로 문법 변환 * build: Lombok 의존성 및 Kotlin 호환성 설정 추가 * refactor: Kotlin 마이그레이션에 따른 Java 호출 수정 * refactor: Penalty exception java -> kotlin으로 폴더 변경 * refactor: Penalty exception kotlin으로 문법 변환 * refactor: Penalty usecase java -> kotlin으로 폴더 변경 * refactor: Penalty service 불필요한 파일 삭제 * refactor: Penalty controller, responsecode java -> kotlin으로 폴더 변경 * refactor: 코틀린 문법에 맞게 Penalty errorcode 수정 * refactor: Penalty usecase kotlin으로 문법 변환 * refactor: Penalty controller, responseCode kotlin으로 문법 변환 * refactor: PenaltyType enum 패키지 이동 * refactor: Penalty DTO 개별 파일로 분리 * refactor: Penalty usecase command/query 분리 * refactor: Penalty mapper, controller 새 구조에 맞게 수정 * refactor: GetPenaltyQueryService 조회 N+1 쿼리 최적화 * refactor: Penalty 카운트 수정 시 User 비관적 락 적용 * fix: SavePenaltyUseCase 비관적 락 정확성 우선시하여 stale read 방지 * refactor: GetPenaltyQueryService UserCardinal N+1 쿼리 배치 조회로 최적화 * refactor: PenaltyRepository 동적 쿼리를 명시적 @Query로 전환 * fix: PenaltyResponse에 항상 null 반환되던 필드에 실제 값 매핑 * refactor: Penalty UseCase 메서드명 execute를 도메인 동작명으로 변경 * refactor: Penalty DTO 불필요한 nullable 제거 및 Mapper 파라미터 정리 * refactor: QueryService DTO 조합 로직 Mapper로 이동 및 메서드명 개선 * style: Penalty DTO에 Swagger @Schema 어노테이션 추가 * refactor: Penalty 쿼리 표준화 및 LAZY fetch 전환 * refactor: QueryService @Transactional 어노테이션 클래스 레벨로 이동 --- .../penalty/application/dto/PenaltyDTO.java | 51 ------ .../AutoPenaltyDeleteNotAllowedException.java | 9 -- .../exception/PenaltyErrorCode.java | 22 --- .../exception/PenaltyNotFoundException.java | 9 -- .../application/mapper/PenaltyMapper.java | 57 ------- .../application/usecase/PenaltyUsecase.java | 19 --- .../usecase/PenaltyUsecaseImpl.java | 150 ------------------ .../domain/penalty/domain/entity/Penalty.java | 41 ----- .../domain/entity/enums/PenaltyType.java | 7 - .../domain/repository/PenaltyRepository.java | 21 --- .../domain/service/PenaltyDeleteService.java | 17 -- .../domain/service/PenaltyFindService.java | 44 ----- .../domain/service/PenaltySaveService.java | 18 --- .../domain/service/PenaltyUpdateService.java | 15 -- .../presentation/PenaltyAdminController.java | 54 ------- .../presentation/PenaltyResponseCode.java | 26 --- .../presentation/PenaltyUserController.java | 35 ---- .../domain/repository/UserRepository.java | 9 ++ .../dto/request/SavePenaltyRequest.kt | 13 ++ .../dto/request/UpdatePenaltyRequest.kt | 10 ++ .../dto/response/PenaltyByCardinalResponse.kt | 10 ++ .../dto/response/PenaltyDetailResponse.kt | 18 +++ .../dto/response/PenaltyResponse.kt | 18 +++ .../AutoPenaltyDeleteNotAllowedException.kt | 5 + .../application/exception/PenaltyErrorCode.kt | 24 +++ .../exception/PenaltyNotFoundException.kt | 5 + .../application/mapper/PenaltyMapper.kt | 71 +++++++++ .../usecase/command/DeletePenaltyUseCase.kt | 63 ++++++++ .../usecase/command/SavePenaltyUseCase.kt | 55 +++++++ .../usecase/command/UpdatePenaltyUseCase.kt | 24 +++ .../usecase/query/GetPenaltyQueryService.kt | 59 +++++++ .../domain/penalty/domain/entity/Penalty.kt | 37 +++++ .../penalty/domain/enums/PenaltyType.kt | 7 + .../domain/repository/PenaltyRepository.kt | 35 ++++ .../presentation/PenaltyAdminController.kt | 68 ++++++++ .../presentation/PenaltyResponseCode.kt | 16 ++ .../presentation/PenaltyUserController.kt | 29 ++++ 37 files changed, 576 insertions(+), 595 deletions(-) delete mode 100644 src/main/java/com/weeth/domain/penalty/application/dto/PenaltyDTO.java delete mode 100644 src/main/java/com/weeth/domain/penalty/application/exception/AutoPenaltyDeleteNotAllowedException.java delete mode 100644 src/main/java/com/weeth/domain/penalty/application/exception/PenaltyErrorCode.java delete mode 100644 src/main/java/com/weeth/domain/penalty/application/exception/PenaltyNotFoundException.java delete mode 100644 src/main/java/com/weeth/domain/penalty/application/mapper/PenaltyMapper.java delete mode 100644 src/main/java/com/weeth/domain/penalty/application/usecase/PenaltyUsecase.java delete mode 100644 src/main/java/com/weeth/domain/penalty/application/usecase/PenaltyUsecaseImpl.java delete mode 100644 src/main/java/com/weeth/domain/penalty/domain/entity/Penalty.java delete mode 100644 src/main/java/com/weeth/domain/penalty/domain/entity/enums/PenaltyType.java delete mode 100644 src/main/java/com/weeth/domain/penalty/domain/repository/PenaltyRepository.java delete mode 100644 src/main/java/com/weeth/domain/penalty/domain/service/PenaltyDeleteService.java delete mode 100644 src/main/java/com/weeth/domain/penalty/domain/service/PenaltyFindService.java delete mode 100644 src/main/java/com/weeth/domain/penalty/domain/service/PenaltySaveService.java delete mode 100644 src/main/java/com/weeth/domain/penalty/domain/service/PenaltyUpdateService.java delete mode 100644 src/main/java/com/weeth/domain/penalty/presentation/PenaltyAdminController.java delete mode 100644 src/main/java/com/weeth/domain/penalty/presentation/PenaltyResponseCode.java delete mode 100644 src/main/java/com/weeth/domain/penalty/presentation/PenaltyUserController.java create mode 100644 src/main/kotlin/com/weeth/domain/penalty/application/dto/request/SavePenaltyRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/penalty/application/dto/request/UpdatePenaltyRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/penalty/application/dto/response/PenaltyByCardinalResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/penalty/application/dto/response/PenaltyDetailResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/penalty/application/dto/response/PenaltyResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/penalty/application/exception/AutoPenaltyDeleteNotAllowedException.kt create mode 100644 src/main/kotlin/com/weeth/domain/penalty/application/exception/PenaltyErrorCode.kt create mode 100644 src/main/kotlin/com/weeth/domain/penalty/application/exception/PenaltyNotFoundException.kt create mode 100644 src/main/kotlin/com/weeth/domain/penalty/application/mapper/PenaltyMapper.kt create mode 100644 src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/DeletePenaltyUseCase.kt create mode 100644 src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/SavePenaltyUseCase.kt create mode 100644 src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/UpdatePenaltyUseCase.kt create mode 100644 src/main/kotlin/com/weeth/domain/penalty/application/usecase/query/GetPenaltyQueryService.kt create mode 100644 src/main/kotlin/com/weeth/domain/penalty/domain/entity/Penalty.kt create mode 100644 src/main/kotlin/com/weeth/domain/penalty/domain/enums/PenaltyType.kt create mode 100644 src/main/kotlin/com/weeth/domain/penalty/domain/repository/PenaltyRepository.kt create mode 100644 src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyAdminController.kt create mode 100644 src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyResponseCode.kt create mode 100644 src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyUserController.kt diff --git a/src/main/java/com/weeth/domain/penalty/application/dto/PenaltyDTO.java b/src/main/java/com/weeth/domain/penalty/application/dto/PenaltyDTO.java deleted file mode 100644 index 0284ceb9..00000000 --- a/src/main/java/com/weeth/domain/penalty/application/dto/PenaltyDTO.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.weeth.domain.penalty.application.dto; - -import jakarta.validation.constraints.NotNull; -import com.weeth.domain.penalty.domain.entity.enums.PenaltyType; -import lombok.Builder; - -import java.time.LocalDateTime; -import java.util.List; - -public class PenaltyDTO { - - @Builder - public record Save( - @NotNull Long userId, - @NotNull PenaltyType penaltyType, - String penaltyDescription - ){} - - @Builder - public record Update( - @NotNull Long penaltyId, - String penaltyDescription - ){} - - @Builder - public record ResponseAll( - Integer cardinal, - List responses - ){} - - @Builder - public record Response( - Long userId, - Integer penaltyCount, - Integer warningCount, - String name, - List cardinals, - List Penalties - ){} - - @Builder - public record Penalties( - Long penaltyId, - PenaltyType penaltyType, - Integer cardinal, - String penaltyDescription, - LocalDateTime time - ){} - -} - diff --git a/src/main/java/com/weeth/domain/penalty/application/exception/AutoPenaltyDeleteNotAllowedException.java b/src/main/java/com/weeth/domain/penalty/application/exception/AutoPenaltyDeleteNotAllowedException.java deleted file mode 100644 index 6ff34a58..00000000 --- a/src/main/java/com/weeth/domain/penalty/application/exception/AutoPenaltyDeleteNotAllowedException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.penalty.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class AutoPenaltyDeleteNotAllowedException extends BaseException { - public AutoPenaltyDeleteNotAllowedException() { - super(PenaltyErrorCode.AUTO_PENALTY_DELETE_NOT_ALLOWED); - } -} diff --git a/src/main/java/com/weeth/domain/penalty/application/exception/PenaltyErrorCode.java b/src/main/java/com/weeth/domain/penalty/application/exception/PenaltyErrorCode.java deleted file mode 100644 index f5a341a1..00000000 --- a/src/main/java/com/weeth/domain/penalty/application/exception/PenaltyErrorCode.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.weeth.domain.penalty.application.exception; - -import com.weeth.global.common.exception.ErrorCodeInterface; -import com.weeth.global.common.exception.ExplainError; -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@AllArgsConstructor -public enum PenaltyErrorCode implements ErrorCodeInterface { - - @ExplainError("요청한 패널티 ID가 존재하지 않을 때 발생합니다.") - PENALTY_NOT_FOUND(2600, HttpStatus.NOT_FOUND, "존재하지 않는 패널티입니다."), - - @ExplainError("시스템에 의해 자동 부여된 패널티를 수동으로 삭제하려 할 때 발생합니다.") - AUTO_PENALTY_DELETE_NOT_ALLOWED(2601, HttpStatus.BAD_REQUEST, "자동 생성된 패널티는 삭제할 수 없습니다"); - - private final int code; - private final HttpStatus status; - private final String message; -} diff --git a/src/main/java/com/weeth/domain/penalty/application/exception/PenaltyNotFoundException.java b/src/main/java/com/weeth/domain/penalty/application/exception/PenaltyNotFoundException.java deleted file mode 100644 index cceb0ad6..00000000 --- a/src/main/java/com/weeth/domain/penalty/application/exception/PenaltyNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.penalty.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class PenaltyNotFoundException extends BaseException { - public PenaltyNotFoundException() { - super(PenaltyErrorCode.PENALTY_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/domain/penalty/application/mapper/PenaltyMapper.java b/src/main/java/com/weeth/domain/penalty/application/mapper/PenaltyMapper.java deleted file mode 100644 index 06544027..00000000 --- a/src/main/java/com/weeth/domain/penalty/application/mapper/PenaltyMapper.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.weeth.domain.penalty.application.mapper; - -import com.weeth.domain.penalty.application.dto.PenaltyDTO; -import com.weeth.domain.penalty.domain.entity.Penalty; -import com.weeth.domain.penalty.domain.entity.enums.PenaltyType; -import com.weeth.domain.user.domain.entity.Cardinal; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.entity.UserCardinal; -import org.mapstruct.Mapper; -import org.mapstruct.Mapping; -import org.mapstruct.MappingConstants; -import org.mapstruct.ReportingPolicy; - -import java.util.Collections; -import java.util.List; -import java.util.stream.Collectors; - -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, unmappedTargetPolicy = ReportingPolicy.IGNORE) -public interface PenaltyMapper { - - @Mapping(target = "user", source = "user") - @Mapping(target = "cardinal", source = "cardinal") - @Mapping(target = "id", ignore = true) - @Mapping(target = "createdAt", ignore = true) - @Mapping(target = "modifiedAt", ignore = true) - Penalty fromPenaltyDto(PenaltyDTO.Save dto, User user, Cardinal cardinal); - - @Mapping(target = "id", ignore = true) - @Mapping(target = "createdAt", ignore = true) - @Mapping(target = "modifiedAt", ignore = true) - Penalty toAutoPenalty(String penaltyDescription, User user, Cardinal cardinal, PenaltyType penaltyType); - - @Mapping(target = "Penalties", source = "penalties") - @Mapping(target = "userId", source = "user.id") - @Mapping(target = "cardinals", expression = "java( toCardinalNumbers(userCardinals) )") - PenaltyDTO.Response toPenaltyDto(User user, List penalties, List userCardinals); - - @Mapping(target = "time", source = "modifiedAt") - @Mapping(target = "penaltyId", source = "id") - @Mapping(target = "cardinal", - expression = "java(penalty.getCardinal() != null ? penalty.getCardinal().getCardinalNumber() : null)") - - PenaltyDTO.Penalties toPenalties(Penalty penalty); - - PenaltyDTO.ResponseAll toResponseAll(Integer cardinal, List responses); - - default List toCardinalNumbers(List userCardinals) { - if (userCardinals == null || userCardinals.isEmpty()) { - return Collections.emptyList(); - } - - return userCardinals.stream() - .map(uc -> uc.getCardinal().getCardinalNumber()) - .collect(Collectors.toList()); - } - -} diff --git a/src/main/java/com/weeth/domain/penalty/application/usecase/PenaltyUsecase.java b/src/main/java/com/weeth/domain/penalty/application/usecase/PenaltyUsecase.java deleted file mode 100644 index 3d30b4ca..00000000 --- a/src/main/java/com/weeth/domain/penalty/application/usecase/PenaltyUsecase.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.weeth.domain.penalty.application.usecase; - -import com.weeth.domain.penalty.application.dto.PenaltyDTO; - -import java.util.List; - -public interface PenaltyUsecase { - - void save(PenaltyDTO.Save dto); - - void update(PenaltyDTO.Update dto); - - List findAll(Integer cardinalNumber); - - PenaltyDTO.Response find(Long userId); - - void delete(Long penaltyId); - -} diff --git a/src/main/java/com/weeth/domain/penalty/application/usecase/PenaltyUsecaseImpl.java b/src/main/java/com/weeth/domain/penalty/application/usecase/PenaltyUsecaseImpl.java deleted file mode 100644 index ee906d6d..00000000 --- a/src/main/java/com/weeth/domain/penalty/application/usecase/PenaltyUsecaseImpl.java +++ /dev/null @@ -1,150 +0,0 @@ -package com.weeth.domain.penalty.application.usecase; - -import jakarta.transaction.Transactional; -import com.weeth.domain.penalty.application.dto.PenaltyDTO; -import com.weeth.domain.penalty.application.exception.AutoPenaltyDeleteNotAllowedException; -import com.weeth.domain.penalty.application.mapper.PenaltyMapper; -import com.weeth.domain.penalty.domain.entity.Penalty; -import com.weeth.domain.penalty.domain.entity.enums.PenaltyType; -import com.weeth.domain.penalty.domain.service.PenaltyDeleteService; -import com.weeth.domain.penalty.domain.service.PenaltyFindService; -import com.weeth.domain.penalty.domain.service.PenaltySaveService; -import com.weeth.domain.penalty.domain.service.PenaltyUpdateService; -import com.weeth.domain.user.domain.entity.Cardinal; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.entity.UserCardinal; -import com.weeth.domain.user.domain.service.CardinalGetService; -import com.weeth.domain.user.domain.service.UserCardinalGetService; -import com.weeth.domain.user.domain.service.UserGetService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -@Service -@RequiredArgsConstructor -public class PenaltyUsecaseImpl implements PenaltyUsecase{ - - private static final String AUTO_PENALTY_DESCRIPTION = "누적경고 %d회"; - - private final PenaltySaveService penaltySaveService; - private final PenaltyFindService penaltyFindService; - private final PenaltyUpdateService penaltyUpdateService; - private final PenaltyDeleteService penaltyDeleteService; - - private final UserGetService userGetService; - - private final UserCardinalGetService userCardinalGetService; - private final CardinalGetService cardinalGetService; - - private final PenaltyMapper mapper; - - @Override - @Transactional - public void save(PenaltyDTO.Save dto) { - User user = userGetService.find(dto.userId()); - Cardinal cardinal = userCardinalGetService.getCurrentCardinal(user); - - Penalty penalty = mapper.fromPenaltyDto(dto, user, cardinal); - - penaltySaveService.save(penalty); - - if(penalty.getPenaltyType().equals(PenaltyType.PENALTY)){ - user.incrementPenaltyCount(); - } else if (penalty.getPenaltyType().equals(PenaltyType.WARNING)){ - user.incrementWarningCount(); - - Integer warningCount = user.getWarningCount(); - if(warningCount % 2 == 0){ - String penaltyDescription = String.format(AUTO_PENALTY_DESCRIPTION, warningCount); - Penalty autoPenalty = mapper.toAutoPenalty(penaltyDescription, user, cardinal, PenaltyType.AUTO_PENALTY); - penaltySaveService.save(autoPenalty); - user.incrementPenaltyCount(); - } - } - } - - @Override - @Transactional - public void update(PenaltyDTO.Update dto) { - Penalty penalty = penaltyFindService.find(dto.penaltyId()); - penaltyUpdateService.update(penalty, dto); - - } - - // Todo: 쿼리 최적화 필요 - @Override - public List findAll(Integer cardinalNumber) { - List cardinals = (cardinalNumber == null) - ? cardinalGetService.findAllCardinalNumberDesc() - : List.of(cardinalGetService.findByAdminSide(cardinalNumber)); - - List result = new ArrayList<>(); - - for (Cardinal cardinal : cardinals) { - List penalties = penaltyFindService.findAllByCardinalId(cardinal.getId()); - - Map> penaltiesByUser = penalties.stream() - .collect(Collectors.groupingBy(p -> p.getUser().getId())); - - List responses = penaltiesByUser.entrySet().stream() - .map(entry -> toPenaltyDto(entry.getKey(), entry.getValue())) - .sorted(Comparator.comparing(PenaltyDTO.Response::userId)) - .toList(); - - result.add(mapper.toResponseAll(cardinal.getCardinalNumber(), responses)); - } - return result; - } - - @Override - public PenaltyDTO.Response find(Long userId) { - User user = userGetService.find(userId); - Cardinal currentCardinal = userCardinalGetService.getCurrentCardinal(user); - List penalties = penaltyFindService.findAllByUserIdAndCardinalId(userId, currentCardinal.getId()); - - return toPenaltyDto(userId, penalties); - } - - @Override - @Transactional - public void delete(Long penaltyId) { - Penalty penalty = penaltyFindService.find(penaltyId); - if(penalty.getPenaltyType().equals(PenaltyType.AUTO_PENALTY)){ - throw new AutoPenaltyDeleteNotAllowedException(); - } - - User user = penalty.getUser(); - - if(penalty.getPenaltyType().equals(PenaltyType.PENALTY)){ - penalty.getUser().decrementPenaltyCount(); - } else if (penalty.getPenaltyType().equals(PenaltyType.WARNING)) { - if(user.getWarningCount() % 2 == 0){ - Penalty relatedAutoPenalty = penaltyFindService.getRelatedAutoPenalty(penalty); - if(relatedAutoPenalty != null){ - penaltyDeleteService.delete(relatedAutoPenalty.getId()); - } - user.decrementPenaltyCount(); - } - penalty.getUser().decrementWarningCount(); - } - - penaltyDeleteService.delete(penaltyId); - } - - private PenaltyDTO.Response toPenaltyDto(Long userId, List penalties) { - User user = userGetService.find(userId); - List userCardinals = userCardinalGetService.getUserCardinals(user); - - List penaltyDTOs = penalties.stream() - .map(mapper::toPenalties) - .toList(); - - return mapper.toPenaltyDto(user, penaltyDTOs, userCardinals); - } - -} diff --git a/src/main/java/com/weeth/domain/penalty/domain/entity/Penalty.java b/src/main/java/com/weeth/domain/penalty/domain/entity/Penalty.java deleted file mode 100644 index 67c82810..00000000 --- a/src/main/java/com/weeth/domain/penalty/domain/entity/Penalty.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.weeth.domain.penalty.domain.entity; - -import jakarta.persistence.*; -import com.weeth.domain.penalty.domain.entity.enums.PenaltyType; -import com.weeth.domain.user.domain.entity.Cardinal; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.global.common.entity.BaseEntity; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.experimental.SuperBuilder; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@SuperBuilder -public class Penalty extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "penalty_id") - private Long id; - - @ManyToOne - @JoinColumn(name = "user_id") - private User user; - - @ManyToOne - @JoinColumn(name = "cardinal_id") - private Cardinal cardinal; - - @Enumerated(EnumType.STRING) - private PenaltyType penaltyType; - - private String penaltyDescription; - - public void update(String penaltyDescription) { - this.penaltyDescription = penaltyDescription; - } - -} diff --git a/src/main/java/com/weeth/domain/penalty/domain/entity/enums/PenaltyType.java b/src/main/java/com/weeth/domain/penalty/domain/entity/enums/PenaltyType.java deleted file mode 100644 index 44031768..00000000 --- a/src/main/java/com/weeth/domain/penalty/domain/entity/enums/PenaltyType.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.weeth.domain.penalty.domain.entity.enums; - -public enum PenaltyType { - PENALTY, - AUTO_PENALTY, - WARNING -} diff --git a/src/main/java/com/weeth/domain/penalty/domain/repository/PenaltyRepository.java b/src/main/java/com/weeth/domain/penalty/domain/repository/PenaltyRepository.java deleted file mode 100644 index 95d14c91..00000000 --- a/src/main/java/com/weeth/domain/penalty/domain/repository/PenaltyRepository.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.weeth.domain.penalty.domain.repository; - -import com.weeth.domain.penalty.domain.entity.Penalty; -import com.weeth.domain.penalty.domain.entity.enums.PenaltyType; -import com.weeth.domain.user.domain.entity.Cardinal; -import com.weeth.domain.user.domain.entity.User; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; - -public interface PenaltyRepository extends JpaRepository { - - List findByUserIdAndCardinalIdOrderByIdDesc(Long userId, Long cardinalId); - - Optional findFirstByUserAndCardinalAndPenaltyTypeAndCreatedAtAfterOrderByCreatedAtAsc( - User user, Cardinal cardinal, PenaltyType penaltyType, LocalDateTime createdAt); - - List findByCardinalIdOrderByIdDesc(Long cardinalId); -} diff --git a/src/main/java/com/weeth/domain/penalty/domain/service/PenaltyDeleteService.java b/src/main/java/com/weeth/domain/penalty/domain/service/PenaltyDeleteService.java deleted file mode 100644 index 457e239e..00000000 --- a/src/main/java/com/weeth/domain/penalty/domain/service/PenaltyDeleteService.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.weeth.domain.penalty.domain.service; - -import com.weeth.domain.penalty.domain.repository.PenaltyRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class PenaltyDeleteService { - - private final PenaltyRepository penaltyRepository; - - public void delete(Long penaltyId){ - penaltyRepository.deleteById(penaltyId); - } - -} diff --git a/src/main/java/com/weeth/domain/penalty/domain/service/PenaltyFindService.java b/src/main/java/com/weeth/domain/penalty/domain/service/PenaltyFindService.java deleted file mode 100644 index 7972de54..00000000 --- a/src/main/java/com/weeth/domain/penalty/domain/service/PenaltyFindService.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.weeth.domain.penalty.domain.service; - -import com.weeth.domain.penalty.domain.entity.Penalty; -import com.weeth.domain.penalty.domain.entity.enums.PenaltyType; -import com.weeth.domain.penalty.domain.repository.PenaltyRepository; -import com.weeth.domain.penalty.application.exception.PenaltyNotFoundException; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class PenaltyFindService { - - private final PenaltyRepository penaltyRepository; - - public Penalty find(Long penaltyId){ - return penaltyRepository.findById(penaltyId) - .orElseThrow(PenaltyNotFoundException::new); - } - - public List findAllByUserIdAndCardinalId(Long userId, Long cardinalId){ - return penaltyRepository.findByUserIdAndCardinalIdOrderByIdDesc(userId, cardinalId); - } - - public List findAll(){ - return penaltyRepository.findAll(); - } - - public Penalty getRelatedAutoPenalty(Penalty penalty) { - return penaltyRepository - .findFirstByUserAndCardinalAndPenaltyTypeAndCreatedAtAfterOrderByCreatedAtAsc( - penalty.getUser(), - penalty.getCardinal(), - PenaltyType.AUTO_PENALTY, - penalty.getCreatedAt() - ).orElse(null); - } - - public List findAllByCardinalId(Long cardinalId) { - return penaltyRepository.findByCardinalIdOrderByIdDesc(cardinalId); - } -} diff --git a/src/main/java/com/weeth/domain/penalty/domain/service/PenaltySaveService.java b/src/main/java/com/weeth/domain/penalty/domain/service/PenaltySaveService.java deleted file mode 100644 index 9b40dff7..00000000 --- a/src/main/java/com/weeth/domain/penalty/domain/service/PenaltySaveService.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.weeth.domain.penalty.domain.service; - -import com.weeth.domain.penalty.domain.entity.Penalty; -import com.weeth.domain.penalty.domain.repository.PenaltyRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class PenaltySaveService { - - private final PenaltyRepository penaltyRepository; - - public void save(Penalty penalty){ - penaltyRepository.save(penalty); - } - -} diff --git a/src/main/java/com/weeth/domain/penalty/domain/service/PenaltyUpdateService.java b/src/main/java/com/weeth/domain/penalty/domain/service/PenaltyUpdateService.java deleted file mode 100644 index a4148162..00000000 --- a/src/main/java/com/weeth/domain/penalty/domain/service/PenaltyUpdateService.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.weeth.domain.penalty.domain.service; - -import com.weeth.domain.penalty.application.dto.PenaltyDTO; -import com.weeth.domain.penalty.domain.entity.Penalty; -import org.springframework.stereotype.Service; - -@Service -public class PenaltyUpdateService { - - public void update(Penalty penalty, PenaltyDTO.Update dto) { - if (dto.penaltyDescription() != null && !dto.penaltyDescription().isBlank()) { - penalty.update(dto.penaltyDescription()); - } - } -} diff --git a/src/main/java/com/weeth/domain/penalty/presentation/PenaltyAdminController.java b/src/main/java/com/weeth/domain/penalty/presentation/PenaltyAdminController.java deleted file mode 100644 index ed1adb92..00000000 --- a/src/main/java/com/weeth/domain/penalty/presentation/PenaltyAdminController.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.weeth.domain.penalty.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import com.weeth.domain.penalty.application.dto.PenaltyDTO; -import com.weeth.domain.penalty.application.exception.PenaltyErrorCode; -import com.weeth.domain.penalty.application.usecase.PenaltyUsecase; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -import static com.weeth.domain.penalty.presentation.PenaltyResponseCode.*; - -@Tag(name = "PENALTY ADMIN", description = "[ADMIN] 패널티 어드민 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/admin/penalties") -@ApiErrorCodeExample(PenaltyErrorCode.class) -public class PenaltyAdminController { - - private final PenaltyUsecase penaltyUsecase; - - @PostMapping - @Operation(summary="패널티 부여") - public CommonResponse assignPenalty(@Valid @RequestBody PenaltyDTO.Save dto){ - penaltyUsecase.save(dto); - return CommonResponse.success(PENALTY_ASSIGN_SUCCESS); - } - - @PatchMapping - @Operation(summary = "패널티 수정") - public CommonResponse update(@Valid @RequestBody PenaltyDTO.Update dto){ - penaltyUsecase.update(dto); - return CommonResponse.success(PENALTY_UPDATE_SUCCESS); - } - - @GetMapping - @Operation(summary="전체 패널티 조회") - public CommonResponse> findAll(@RequestParam(required = false) Integer cardinal){ - return CommonResponse.success(PENALTY_FIND_ALL_SUCCESS, penaltyUsecase.findAll(cardinal)); - } - - @DeleteMapping - @Operation(summary="패널티 삭제") - public CommonResponse delete(@RequestParam Long penaltyId){ - penaltyUsecase.delete(penaltyId); - return CommonResponse.success(PENALTY_DELETE_SUCCESS); - } - -} diff --git a/src/main/java/com/weeth/domain/penalty/presentation/PenaltyResponseCode.java b/src/main/java/com/weeth/domain/penalty/presentation/PenaltyResponseCode.java deleted file mode 100644 index e73bdca4..00000000 --- a/src/main/java/com/weeth/domain/penalty/presentation/PenaltyResponseCode.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.weeth.domain.penalty.presentation; - -import com.weeth.global.common.response.ResponseCodeInterface; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -public enum PenaltyResponseCode implements ResponseCodeInterface { - // penaltyAdminController 관련 - PENALTY_ASSIGN_SUCCESS(1600, HttpStatus.OK, "페널티가 성공적으로 부여되었습니다."), - PENALTY_FIND_ALL_SUCCESS(1601, HttpStatus.OK, "모든 패널티가 성공적으로 조회되었습니다."), - PENALTY_DELETE_SUCCESS(1602, HttpStatus.OK, "패널티가 성공적으로 삭제되었습니다."), - PENALTY_UPDATE_SUCCESS(1603, HttpStatus.OK, "패널티를 성공적으로 수정했습니다."), - // penaltyUserController - PENALTY_USER_FIND_SUCCESS(1604, HttpStatus.OK, "패널티가 성공적으로 조회되었습니다."); - - private final int code; - private final HttpStatus status; - private final String message; - - PenaltyResponseCode(int code, HttpStatus status, String message) { - this.code = code; - this.status = status; - this.message = message; - } -} diff --git a/src/main/java/com/weeth/domain/penalty/presentation/PenaltyUserController.java b/src/main/java/com/weeth/domain/penalty/presentation/PenaltyUserController.java deleted file mode 100644 index aed2feea..00000000 --- a/src/main/java/com/weeth/domain/penalty/presentation/PenaltyUserController.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.weeth.domain.penalty.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import com.weeth.domain.penalty.application.dto.PenaltyDTO; -import com.weeth.domain.penalty.application.exception.PenaltyErrorCode; -import com.weeth.domain.penalty.application.usecase.PenaltyUsecase; -import com.weeth.global.auth.annotation.CurrentUser; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import static com.weeth.domain.penalty.presentation.PenaltyResponseCode.PENALTY_USER_FIND_SUCCESS; - -@Tag(name = "PENALTY", description = "패널티 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/penalties") -@ApiErrorCodeExample(PenaltyErrorCode.class) -public class PenaltyUserController { - - private final PenaltyUsecase penaltyUsecase; - - @GetMapping - @Operation(summary="본인 패널티 조회") - public CommonResponse findAllPenalties(@Parameter(hidden = true) @CurrentUser Long userId) { - PenaltyDTO.Response penalties = penaltyUsecase.find(userId); - return CommonResponse.success(PENALTY_USER_FIND_SUCCESS,penalties); - } - -} diff --git a/src/main/java/com/weeth/domain/user/domain/repository/UserRepository.java b/src/main/java/com/weeth/domain/user/domain/repository/UserRepository.java index ea074aae..586ce952 100644 --- a/src/main/java/com/weeth/domain/user/domain/repository/UserRepository.java +++ b/src/main/java/com/weeth/domain/user/domain/repository/UserRepository.java @@ -5,8 +5,12 @@ import com.weeth.domain.user.domain.entity.enums.Status; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; +import jakarta.persistence.LockModeType; +import jakarta.persistence.QueryHint; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.QueryHints; import org.springframework.data.repository.query.Param; import java.util.List; @@ -14,6 +18,11 @@ public interface UserRepository extends JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query("SELECT u FROM User u WHERE u.id = :id") + Optional findByIdWithLock(@Param("id") Long id); + Optional findByEmail(String email); Optional findByKakaoId(long kakaoId); diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/dto/request/SavePenaltyRequest.kt b/src/main/kotlin/com/weeth/domain/penalty/application/dto/request/SavePenaltyRequest.kt new file mode 100644 index 00000000..5acd0f5b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/application/dto/request/SavePenaltyRequest.kt @@ -0,0 +1,13 @@ +package com.weeth.domain.penalty.application.dto.request + +import com.weeth.domain.penalty.domain.enums.PenaltyType +import io.swagger.v3.oas.annotations.media.Schema + +data class SavePenaltyRequest( + @field:Schema(description = "패널티 대상 사용자 ID", example = "1") + val userId: Long, + @field:Schema(description = "패널티 유형", example = "WARNING") + val penaltyType: PenaltyType, + @field:Schema(description = "패널티 사유", example = "정기모임 무단 불참") + val penaltyDescription: String?, +) diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/dto/request/UpdatePenaltyRequest.kt b/src/main/kotlin/com/weeth/domain/penalty/application/dto/request/UpdatePenaltyRequest.kt new file mode 100644 index 00000000..443c8d1e --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/application/dto/request/UpdatePenaltyRequest.kt @@ -0,0 +1,10 @@ +package com.weeth.domain.penalty.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema + +data class UpdatePenaltyRequest( + @field:Schema(description = "수정할 패널티 ID", example = "1") + val penaltyId: Long, + @field:Schema(description = "수정할 패널티 사유", example = "정기모임 무단 불참 (수정)") + val penaltyDescription: String?, +) diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/dto/response/PenaltyByCardinalResponse.kt b/src/main/kotlin/com/weeth/domain/penalty/application/dto/response/PenaltyByCardinalResponse.kt new file mode 100644 index 00000000..ec2353b6 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/application/dto/response/PenaltyByCardinalResponse.kt @@ -0,0 +1,10 @@ +package com.weeth.domain.penalty.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class PenaltyByCardinalResponse( + @field:Schema(description = "기수 번호", example = "4") + val cardinal: Int?, + @field:Schema(description = "해당 기수의 유저별 패널티 목록") + val responses: List, +) diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/dto/response/PenaltyDetailResponse.kt b/src/main/kotlin/com/weeth/domain/penalty/application/dto/response/PenaltyDetailResponse.kt new file mode 100644 index 00000000..b6e1ab5e --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/application/dto/response/PenaltyDetailResponse.kt @@ -0,0 +1,18 @@ +package com.weeth.domain.penalty.application.dto.response + +import com.weeth.domain.penalty.domain.enums.PenaltyType +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class PenaltyDetailResponse( + @field:Schema(description = "패널티 ID", example = "1") + val penaltyId: Long, + @field:Schema(description = "패널티 유형", example = "WARNING") + val penaltyType: PenaltyType, + @field:Schema(description = "기수 번호", example = "4") + val cardinal: Int?, + @field:Schema(description = "패널티 사유", example = "정기모임 무단 불참") + val penaltyDescription: String, + @field:Schema(description = "최종 수정 시간", example = "2026-02-19T01:00:00") + val time: LocalDateTime, +) diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/dto/response/PenaltyResponse.kt b/src/main/kotlin/com/weeth/domain/penalty/application/dto/response/PenaltyResponse.kt new file mode 100644 index 00000000..fa4a4b4e --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/application/dto/response/PenaltyResponse.kt @@ -0,0 +1,18 @@ +package com.weeth.domain.penalty.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class PenaltyResponse( + @field:Schema(description = "사용자 ID", example = "1") + val userId: Long, + @field:Schema(description = "사용자 이름", example = "홍길동") + val name: String, + @field:Schema(description = "패널티 횟수", example = "2") + val penaltyCount: Int, + @field:Schema(description = "경고 횟수", example = "3") + val warningCount: Int, + @field:Schema(description = "소속 기수 목록", example = "[3, 4]") + val cardinals: List, + @field:Schema(description = "패널티 상세 목록") + val penalties: List, +) diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/exception/AutoPenaltyDeleteNotAllowedException.kt b/src/main/kotlin/com/weeth/domain/penalty/application/exception/AutoPenaltyDeleteNotAllowedException.kt new file mode 100644 index 00000000..23cef4fe --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/application/exception/AutoPenaltyDeleteNotAllowedException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.penalty.application.exception + +import com.weeth.global.common.exception.BaseException + +class AutoPenaltyDeleteNotAllowedException : BaseException(PenaltyErrorCode.AUTO_PENALTY_DELETE_NOT_ALLOWED) diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/exception/PenaltyErrorCode.kt b/src/main/kotlin/com/weeth/domain/penalty/application/exception/PenaltyErrorCode.kt new file mode 100644 index 00000000..d5218937 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/application/exception/PenaltyErrorCode.kt @@ -0,0 +1,24 @@ +package com.weeth.domain.penalty.application.exception + +import com.weeth.global.common.exception.ErrorCodeInterface +import com.weeth.global.common.exception.ExplainError +import org.springframework.http.HttpStatus + +enum class PenaltyErrorCode( + private val code: Int, + private val status: HttpStatus, + private val message: String, +) : ErrorCodeInterface { + @ExplainError("요청한 패널티 ID가 존재하지 않을 때 발생합니다.") + PENALTY_NOT_FOUND(2600, HttpStatus.NOT_FOUND, "존재하지 않는 패널티입니다."), + + @ExplainError("시스템에 의해 자동 부여된 패널티를 수동으로 삭제하려 할 때 발생합니다.") + AUTO_PENALTY_DELETE_NOT_ALLOWED(2601, HttpStatus.BAD_REQUEST, "자동 생성된 패널티는 삭제할 수 없습니다"), + ; + + override fun getCode(): Int = code + + override fun getStatus(): HttpStatus = status + + override fun getMessage(): String = message +} diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/exception/PenaltyNotFoundException.kt b/src/main/kotlin/com/weeth/domain/penalty/application/exception/PenaltyNotFoundException.kt new file mode 100644 index 00000000..820c80df --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/application/exception/PenaltyNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.penalty.application.exception + +import com.weeth.global.common.exception.BaseException + +class PenaltyNotFoundException : BaseException(PenaltyErrorCode.PENALTY_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/mapper/PenaltyMapper.kt b/src/main/kotlin/com/weeth/domain/penalty/application/mapper/PenaltyMapper.kt new file mode 100644 index 00000000..f4929e52 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/application/mapper/PenaltyMapper.kt @@ -0,0 +1,71 @@ +package com.weeth.domain.penalty.application.mapper + +import com.weeth.domain.penalty.application.dto.request.SavePenaltyRequest +import com.weeth.domain.penalty.application.dto.response.PenaltyByCardinalResponse +import com.weeth.domain.penalty.application.dto.response.PenaltyDetailResponse +import com.weeth.domain.penalty.application.dto.response.PenaltyResponse +import com.weeth.domain.penalty.domain.entity.Penalty +import com.weeth.domain.penalty.domain.enums.PenaltyType +import com.weeth.domain.user.domain.entity.Cardinal +import com.weeth.domain.user.domain.entity.User +import com.weeth.domain.user.domain.entity.UserCardinal +import org.springframework.stereotype.Component + +@Component +class PenaltyMapper { + fun toEntity( + request: SavePenaltyRequest, + user: User, + cardinal: Cardinal, + ): Penalty = + Penalty( + user = user, + cardinal = cardinal, + penaltyType = request.penaltyType, + penaltyDescription = request.penaltyDescription ?: "", + ) + + fun toAutoPenalty( + penaltyDescription: String, + user: User, + cardinal: Cardinal, + ): Penalty = + Penalty( + user = user, + cardinal = cardinal, + penaltyType = PenaltyType.AUTO_PENALTY, + penaltyDescription = penaltyDescription, + ) + + fun toResponse( + user: User, + penalties: List, + userCardinals: List, + ): PenaltyResponse = + PenaltyResponse( + userId = user.id, + name = user.name, + penaltyCount = user.penaltyCount, + warningCount = user.warningCount, + cardinals = userCardinals.map { it.cardinal.cardinalNumber }, + penalties = penalties.map(::toDetailResponse), + ) + + fun toDetailResponse(penalty: Penalty): PenaltyDetailResponse = + PenaltyDetailResponse( + penaltyId = penalty.id, + penaltyType = penalty.penaltyType, + cardinal = penalty.cardinal.cardinalNumber, + penaltyDescription = penalty.penaltyDescription, + time = penalty.modifiedAt, + ) + + fun toByCardinalResponse( + cardinal: Int?, + responses: List, + ): PenaltyByCardinalResponse = + PenaltyByCardinalResponse( + cardinal = cardinal, + responses = responses, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/DeletePenaltyUseCase.kt b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/DeletePenaltyUseCase.kt new file mode 100644 index 00000000..1cd077aa --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/DeletePenaltyUseCase.kt @@ -0,0 +1,63 @@ +package com.weeth.domain.penalty.application.usecase.command + +import com.weeth.domain.penalty.application.exception.AutoPenaltyDeleteNotAllowedException +import com.weeth.domain.penalty.application.exception.PenaltyNotFoundException +import com.weeth.domain.penalty.domain.enums.PenaltyType +import com.weeth.domain.penalty.domain.repository.PenaltyRepository +import com.weeth.domain.user.application.exception.UserNotFoundException +import com.weeth.domain.user.domain.repository.UserRepository +import org.springframework.data.domain.Pageable +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class DeletePenaltyUseCase( + private val penaltyRepository: PenaltyRepository, + private val userRepository: UserRepository, +) { + @Transactional + fun delete(penaltyId: Long) { + val penalty = + penaltyRepository.findByIdOrNull(penaltyId) + ?: throw PenaltyNotFoundException() + + if (penalty.penaltyType == PenaltyType.AUTO_PENALTY) { + throw AutoPenaltyDeleteNotAllowedException() + } + + val user = + userRepository + .findByIdWithLock(penalty.user.id) + .orElseThrow { UserNotFoundException() } + + when (penalty.penaltyType) { + PenaltyType.PENALTY -> { + user.decrementPenaltyCount() + } + + PenaltyType.WARNING -> { + if (user.warningCount % 2 == 0) { + val relatedAutoPenalty = + penaltyRepository + .findFirstAutoPenaltyAfter( + penalty.user.id, + penalty.cardinal.id, + PenaltyType.AUTO_PENALTY, + penalty.createdAt, + Pageable.ofSize(1), + ).firstOrNull() + if (relatedAutoPenalty != null) { + penaltyRepository.deleteById(relatedAutoPenalty.id) + } + user.decrementPenaltyCount() + } + user.decrementWarningCount() + } + + else -> {} + } + + penaltyRepository.deleteById(penaltyId) + } +} diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/SavePenaltyUseCase.kt b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/SavePenaltyUseCase.kt new file mode 100644 index 00000000..b9391d0a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/SavePenaltyUseCase.kt @@ -0,0 +1,55 @@ +package com.weeth.domain.penalty.application.usecase.command + +import com.weeth.domain.penalty.application.dto.request.SavePenaltyRequest +import com.weeth.domain.penalty.application.mapper.PenaltyMapper +import com.weeth.domain.penalty.domain.enums.PenaltyType +import com.weeth.domain.penalty.domain.repository.PenaltyRepository +import com.weeth.domain.user.application.exception.UserNotFoundException +import com.weeth.domain.user.domain.repository.UserRepository +import com.weeth.domain.user.domain.service.UserCardinalGetService +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class SavePenaltyUseCase( + private val penaltyRepository: PenaltyRepository, + private val userRepository: UserRepository, + private val userCardinalGetService: UserCardinalGetService, + private val mapper: PenaltyMapper, +) { + companion object { + private const val AUTO_PENALTY_DESCRIPTION = "누적경고 %d회" + } + + @Transactional + fun save(request: SavePenaltyRequest) { + val user = + userRepository + .findByIdWithLock(request.userId) + .orElseThrow { UserNotFoundException() } + val cardinal = userCardinalGetService.getCurrentCardinal(user) + + val penalty = mapper.toEntity(request, user, cardinal) + penaltyRepository.save(penalty) + + when (penalty.penaltyType) { + PenaltyType.PENALTY -> { + user.incrementPenaltyCount() + } + + PenaltyType.WARNING -> { + user.incrementWarningCount() + + val warningCount = user.warningCount + if (warningCount % 2 == 0) { + val description = AUTO_PENALTY_DESCRIPTION.format(warningCount) + val autoPenalty = mapper.toAutoPenalty(description, user, cardinal) + penaltyRepository.save(autoPenalty) + user.incrementPenaltyCount() + } + } + + else -> {} + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/UpdatePenaltyUseCase.kt b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/UpdatePenaltyUseCase.kt new file mode 100644 index 00000000..aa55fe2d --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/UpdatePenaltyUseCase.kt @@ -0,0 +1,24 @@ +package com.weeth.domain.penalty.application.usecase.command + +import com.weeth.domain.penalty.application.dto.request.UpdatePenaltyRequest +import com.weeth.domain.penalty.application.exception.PenaltyNotFoundException +import com.weeth.domain.penalty.domain.repository.PenaltyRepository +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class UpdatePenaltyUseCase( + private val penaltyRepository: PenaltyRepository, +) { + @Transactional + fun update(request: UpdatePenaltyRequest) { + val penalty = + penaltyRepository.findByIdOrNull(request.penaltyId) + ?: throw PenaltyNotFoundException() + + if (!request.penaltyDescription.isNullOrBlank()) { + penalty.update(request.penaltyDescription) + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/query/GetPenaltyQueryService.kt b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/query/GetPenaltyQueryService.kt new file mode 100644 index 00000000..cb842e2f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/query/GetPenaltyQueryService.kt @@ -0,0 +1,59 @@ +package com.weeth.domain.penalty.application.usecase.query + +import com.weeth.domain.penalty.application.dto.response.PenaltyByCardinalResponse +import com.weeth.domain.penalty.application.dto.response.PenaltyResponse +import com.weeth.domain.penalty.application.mapper.PenaltyMapper +import com.weeth.domain.penalty.domain.repository.PenaltyRepository +import com.weeth.domain.user.domain.service.CardinalGetService +import com.weeth.domain.user.domain.service.UserCardinalGetService +import com.weeth.domain.user.domain.service.UserGetService +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class GetPenaltyQueryService( + private val penaltyRepository: PenaltyRepository, + private val userGetService: UserGetService, + private val userCardinalGetService: UserCardinalGetService, + private val cardinalGetService: CardinalGetService, + private val mapper: PenaltyMapper, +) { + fun findAllByCardinal(cardinalNumber: Int?): List { + val cardinals = + if (cardinalNumber == null) { + cardinalGetService.findAllCardinalNumberDesc() + } else { + listOf(cardinalGetService.findByAdminSide(cardinalNumber)) + } + + return cardinals.map { cardinal -> + val penalties = penaltyRepository.findByCardinalIdOrderByIdDesc(cardinal.id) + val users = penalties.map { it.user }.distinct() + val userCardinalsMap = + userCardinalGetService + .findAll(users) + .groupBy { it.user.id } + + val responses = + penalties + .groupBy { it.user.id } + .entries + .map { (userId, userPenalties) -> + val userCardinals = userCardinalsMap[userId] ?: emptyList() + mapper.toResponse(userPenalties.first().user, userPenalties, userCardinals) + }.sortedBy { it.userId } + + mapper.toByCardinalResponse(cardinal.cardinalNumber, responses) + } + } + + fun findByUser(userId: Long): PenaltyResponse { + val user = userGetService.find(userId) + val currentCardinal = userCardinalGetService.getCurrentCardinal(user) + val penalties = penaltyRepository.findByUserIdAndCardinalIdOrderByIdDesc(userId, currentCardinal.id) + val userCardinals = userCardinalGetService.getUserCardinals(user) + + return mapper.toResponse(user, penalties, userCardinals) + } +} diff --git a/src/main/kotlin/com/weeth/domain/penalty/domain/entity/Penalty.kt b/src/main/kotlin/com/weeth/domain/penalty/domain/entity/Penalty.kt new file mode 100644 index 00000000..25f0db0c --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/domain/entity/Penalty.kt @@ -0,0 +1,37 @@ +package com.weeth.domain.penalty.domain.entity + +import com.weeth.domain.penalty.domain.enums.PenaltyType +import com.weeth.domain.user.domain.entity.Cardinal +import com.weeth.domain.user.domain.entity.User +import com.weeth.global.common.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne + +@Entity +class Penalty( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + val user: User, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "cardinal_id") + val cardinal: Cardinal, + @Enumerated(EnumType.STRING) + val penaltyType: PenaltyType, + var penaltyDescription: String, + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "penalty_id") + val id: Long = 0, +) : BaseEntity() { + fun update(penaltyDescription: String) { + this.penaltyDescription = penaltyDescription + } +} diff --git a/src/main/kotlin/com/weeth/domain/penalty/domain/enums/PenaltyType.kt b/src/main/kotlin/com/weeth/domain/penalty/domain/enums/PenaltyType.kt new file mode 100644 index 00000000..8822b075 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/domain/enums/PenaltyType.kt @@ -0,0 +1,7 @@ +package com.weeth.domain.penalty.domain.enums + +enum class PenaltyType { + PENALTY, + AUTO_PENALTY, + WARNING, +} diff --git a/src/main/kotlin/com/weeth/domain/penalty/domain/repository/PenaltyRepository.kt b/src/main/kotlin/com/weeth/domain/penalty/domain/repository/PenaltyRepository.kt new file mode 100644 index 00000000..0fb0a166 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/domain/repository/PenaltyRepository.kt @@ -0,0 +1,35 @@ +package com.weeth.domain.penalty.domain.repository + +import com.weeth.domain.penalty.domain.entity.Penalty +import com.weeth.domain.penalty.domain.enums.PenaltyType +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import java.time.LocalDateTime + +interface PenaltyRepository : JpaRepository { + @Query("SELECT p FROM Penalty p JOIN FETCH p.cardinal WHERE p.user.id = :userId AND p.cardinal.id = :cardinalId ORDER BY p.id DESC") + fun findByUserIdAndCardinalIdOrderByIdDesc( + userId: Long, + cardinalId: Long, + ): List + + @Query( + """ + SELECT p FROM Penalty p + WHERE p.user.id = :userId AND p.cardinal.id = :cardinalId + AND p.penaltyType = :penaltyType AND p.createdAt > :createdAt + ORDER BY p.createdAt ASC + """, + ) + fun findFirstAutoPenaltyAfter( + userId: Long, + cardinalId: Long, + penaltyType: PenaltyType, + createdAt: LocalDateTime, + pageable: Pageable, + ): List + + @Query("SELECT p FROM Penalty p JOIN FETCH p.user JOIN FETCH p.cardinal WHERE p.cardinal.id = :cardinalId ORDER BY p.id DESC") + fun findByCardinalIdOrderByIdDesc(cardinalId: Long): List +} diff --git a/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyAdminController.kt b/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyAdminController.kt new file mode 100644 index 00000000..061dd1d6 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyAdminController.kt @@ -0,0 +1,68 @@ +package com.weeth.domain.penalty.presentation + +import com.weeth.domain.penalty.application.dto.request.SavePenaltyRequest +import com.weeth.domain.penalty.application.dto.request.UpdatePenaltyRequest +import com.weeth.domain.penalty.application.dto.response.PenaltyByCardinalResponse +import com.weeth.domain.penalty.application.exception.PenaltyErrorCode +import com.weeth.domain.penalty.application.usecase.command.DeletePenaltyUseCase +import com.weeth.domain.penalty.application.usecase.command.SavePenaltyUseCase +import com.weeth.domain.penalty.application.usecase.command.UpdatePenaltyUseCase +import com.weeth.domain.penalty.application.usecase.query.GetPenaltyQueryService +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "PENALTY ADMIN", description = "[ADMIN] 패널티 어드민 API") +@RestController +@RequestMapping("/api/v1/admin/penalties") +@ApiErrorCodeExample(PenaltyErrorCode::class) +class PenaltyAdminController( + private val savePenaltyUseCase: SavePenaltyUseCase, + private val updatePenaltyUseCase: UpdatePenaltyUseCase, + private val deletePenaltyUseCase: DeletePenaltyUseCase, + private val getPenaltyQueryService: GetPenaltyQueryService, +) { + @PostMapping + @Operation(summary = "패널티 부여") + fun assignPenalty( + @Valid @RequestBody request: SavePenaltyRequest, + ): CommonResponse { + savePenaltyUseCase.save(request) + return CommonResponse.success(PenaltyResponseCode.PENALTY_ASSIGN_SUCCESS) + } + + @PatchMapping + @Operation(summary = "패널티 수정") + fun update( + @Valid @RequestBody request: UpdatePenaltyRequest, + ): CommonResponse { + updatePenaltyUseCase.update(request) + return CommonResponse.success(PenaltyResponseCode.PENALTY_UPDATE_SUCCESS) + } + + @GetMapping + @Operation(summary = "전체 패널티 조회") + fun findAll( + @RequestParam(required = false) cardinal: Int?, + ): CommonResponse> = + CommonResponse.success(PenaltyResponseCode.PENALTY_FIND_ALL_SUCCESS, getPenaltyQueryService.findAllByCardinal(cardinal)) + + @DeleteMapping + @Operation(summary = "패널티 삭제") + fun delete( + @RequestParam penaltyId: Long, + ): CommonResponse { + deletePenaltyUseCase.delete(penaltyId) + return CommonResponse.success(PenaltyResponseCode.PENALTY_DELETE_SUCCESS) + } +} diff --git a/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyResponseCode.kt b/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyResponseCode.kt new file mode 100644 index 00000000..f6b05674 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyResponseCode.kt @@ -0,0 +1,16 @@ +package com.weeth.domain.penalty.presentation + +import com.weeth.global.common.response.ResponseCodeInterface +import org.springframework.http.HttpStatus + +enum class PenaltyResponseCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ResponseCodeInterface { + PENALTY_ASSIGN_SUCCESS(1600, HttpStatus.OK, "페널티가 성공적으로 부여되었습니다."), + PENALTY_FIND_ALL_SUCCESS(1601, HttpStatus.OK, "모든 패널티가 성공적으로 조회되었습니다."), + PENALTY_DELETE_SUCCESS(1602, HttpStatus.OK, "패널티가 성공적으로 삭제되었습니다."), + PENALTY_UPDATE_SUCCESS(1603, HttpStatus.OK, "패널티를 성공적으로 수정했습니다."), + PENALTY_USER_FIND_SUCCESS(1604, HttpStatus.OK, "패널티가 성공적으로 조회되었습니다."), +} diff --git a/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyUserController.kt b/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyUserController.kt new file mode 100644 index 00000000..965fe5f9 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyUserController.kt @@ -0,0 +1,29 @@ +package com.weeth.domain.penalty.presentation + +import com.weeth.domain.penalty.application.dto.response.PenaltyResponse +import com.weeth.domain.penalty.application.exception.PenaltyErrorCode +import com.weeth.domain.penalty.application.usecase.query.GetPenaltyQueryService +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "PENALTY", description = "패널티 API") +@RestController +@RequestMapping("/api/v1/penalties") +@ApiErrorCodeExample(PenaltyErrorCode::class) +class PenaltyUserController( + private val getPenaltyQueryService: GetPenaltyQueryService, +) { + @GetMapping + @Operation(summary = "본인 패널티 조회") + fun findAllPenalties( + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success(PenaltyResponseCode.PENALTY_USER_FIND_SUCCESS, getPenaltyQueryService.findByUser(userId)) +} From ee9cf0aab74ccc3fe568d7c75e60e8776e00cc95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=84=EC=88=98=ED=98=84?= <128474444+soo0711@users.noreply.github.com> Date: Thu, 19 Feb 2026 21:06:53 +0900 Subject: [PATCH 08/73] =?UTF-8?q?[WTH-142]=20attendance=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=BD=94=ED=8B=80=EB=A6=B0=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: Attendacne domain java -> kotlin으로 폴더 변경 * build: Lombok 의존성 및 Kotlin 호환성 설정 추가 * refactor: Attendacne entity kotlin으로 문법 변환 * test: Attendance entity Test 추가 * refactor: Attendacne enum java -> kotlin으로 폴더 변경 * refactor: Attendacne eum kotlin으로 문법 변환 * refactor: Attendacne repository kotlin으로 문법 변환 * refactor: Attendacne dto java -> kotlin으로 폴더 변경 * refactor: Attendacne dto kotlin으로 문법 변환 * refactor: Attendacne dto 코틀린 코드에 맞춰 수정 * refactor: Attendacne mapper java -> kotlin으로 폴더 변경 및 dto 확장 준비 * refactor: Attendance DTO request/response로 분리 * refactor: Attendacne mapper 코틀린 코드에 맞춰 참조 코드 수정 * refactor: Attendacne service java -> kotlin으로 폴더 변경 * refactor: Attendacne service kotlin으로 문법 변환 * refactor: Attendacne usecase java -> kotlin으로 폴더 변경 * refactor: Attendacne usecase kotlin으로 문법 변환 * refactor: Attendacne exception java -> kotlin으로 폴더 변경 * refactor: Attendacne exception kotlin으로 문법 변환 * refactor: Attendacne controller, responseCode java -> kotlin으로 폴더 변경 * refactor: Attendacne controller, responseCode kotlin으로 문법 변환 * refactor: AttendanceScheduler를 service/scheduler 패키지로 이동 * refactor: Attendance UseCase command/query 분리 * test: Attendance command/query 분리에 따른 테스트 재작성 * refactor: Attendance controller UseCase 의존 분리 * test: AttendanceMapperTest 하드코딩 값을 변수 참조로 변경 * refactor: 테스트 FQCN 정리 및 하드코딩 값 변수 참조로 변경 * refactor: Attendance Schduler Transaction import jakarta에서 spring으로 변경 * refactor: Attendance N+1 방지 위해 @ManyToOne(fetch = FetchType.LAZY) 적용 * refactor: Attendance 단건 조회 N+1 방지 위해 JOIN FETCH 적용 * test: UpdateAttendanceStatusUseCase 테스트 findByIdWithUser 반영 * refactor: user.attendances 조회를 Repository 쿼리로 분리 * test: Repository 쿼리 전환에 따른 테스트 mock 수정 * refactor: AttendanceMapper 중복 메서드 통합 * refactor: AttendanceGetService 미사용 메서드 제거 * refactor: Attendance DTO에 @Schema 어노테이션 추가 * refactor: AttendanceMainResponse를 AttendanceSummaryResponse로 이름 변경 * refactor: UpdateAttendanceStatusRequest 불필요한 @NotNull 제거 * refactor: toMainResponse를 toSummaryResponse로 변경 * stye: kotlin 코드 포맷 적용 * test: toSummaryResponse mock에 isAdmin 파라미터 명시 * refactor: UseCase 메서드명 execute를 도메인 용어로 변경 * refactor: GetAttendanceQueryService 메서드명을 도메인 용어로 변경 * refactor: AttendanceScheduler를 infrastructure로 이동, Usecase로 분리 * refactor: 아키텍처 기준에 맞게 attendance enum 패키지 구조 수정 --- lombok.config | 2 + .../application/dto/AttendanceDTO.java | 57 ---- .../AttendanceCodeMismatchException.java | 9 - .../AttendanceEventTypeNotMatchException.java | 9 - .../AttendanceNotFoundException.java | 9 - .../application/mapper/AttendanceMapper.java | 58 ---- .../usecase/AttendanceUseCase.java | 25 -- .../usecase/AttendanceUseCaseImpl.java | 139 --------- .../attendance/domain/entity/Attendance.java | 62 ---- .../domain/entity/enums/Status.java | 7 - .../repository/AttendanceRepository.java | 18 -- .../service/AttendanceDeleteService.java | 20 -- .../domain/service/AttendanceGetService.java | 26 -- .../domain/service/AttendanceSaveService.java | 36 --- .../domain/service/AttendanceScheduler.java | 31 -- .../service/AttendanceUpdateService.java | 41 --- .../AttendanceAdminController.java | 58 ---- .../presentation/AttendanceController.java | 48 --- .../application/dto/request/CheckInRequest.kt | 8 + .../request/UpdateAttendanceStatusRequest.kt | 12 + .../dto/response/AttendanceDetailResponse.kt | 14 + .../dto/response/AttendanceInfoResponse.kt | 19 ++ .../dto/response/AttendanceResponse.kt | 20 ++ .../dto/response/AttendanceSummaryResponse.kt | 22 ++ .../AttendanceCodeMismatchException.kt | 5 + .../exception/AttendanceErrorCode.kt} | 30 +- .../AttendanceEventTypeNotMatchException.kt | 5 + .../exception/AttendanceNotFoundException.kt | 5 + .../application/mapper/AttendanceMapper.kt | 58 ++++ .../command/CheckInAttendanceUseCase.kt | 38 +++ .../usecase/command/CloseAttendanceUseCase.kt | 53 ++++ .../command/UpdateAttendanceStatusUseCase.kt | 34 +++ .../query/GetAttendanceQueryService.kt | 57 ++++ .../attendance/domain/entity/Attendance.kt | 53 ++++ .../domain/attendance/domain/enums/Status.kt | 7 + .../domain/repository/AttendanceRepository.kt | 70 +++++ .../domain/service/AttendanceDeleteService.kt | 14 + .../domain/service/AttendanceGetService.kt | 14 + .../domain/service/AttendanceSaveService.kt | 30 ++ .../domain/service/AttendanceUpdateService.kt | 33 ++ .../infrastructure/AttendanceScheduler.kt | 15 + .../presentation/AttendanceAdminController.kt | 72 +++++ .../presentation/AttendanceController.kt | 55 ++++ .../presentation/AttendanceResponseCode.kt} | 30 +- .../mapper/AttendanceMapperTest.kt | 115 ++++--- .../usecase/AttendanceUseCaseImplTest.kt | 287 ------------------ .../command/CheckInAttendanceUseCaseTest.kt | 88 ++++++ .../command/CloseAttendanceUseCaseTest.kt | 62 ++++ .../UpdateAttendanceStatusUseCaseTest.kt | 68 +++++ .../query/GetAttendanceQueryServiceTest.kt | 127 ++++++++ .../domain/entity/AttendanceTest.kt | 89 ++++++ .../service/AttendanceUpdateServiceTest.kt | 81 ----- 52 files changed, 1232 insertions(+), 1113 deletions(-) create mode 100644 lombok.config delete mode 100644 src/main/java/com/weeth/domain/attendance/application/dto/AttendanceDTO.java delete mode 100644 src/main/java/com/weeth/domain/attendance/application/exception/AttendanceCodeMismatchException.java delete mode 100644 src/main/java/com/weeth/domain/attendance/application/exception/AttendanceEventTypeNotMatchException.java delete mode 100644 src/main/java/com/weeth/domain/attendance/application/exception/AttendanceNotFoundException.java delete mode 100644 src/main/java/com/weeth/domain/attendance/application/mapper/AttendanceMapper.java delete mode 100644 src/main/java/com/weeth/domain/attendance/application/usecase/AttendanceUseCase.java delete mode 100644 src/main/java/com/weeth/domain/attendance/application/usecase/AttendanceUseCaseImpl.java delete mode 100644 src/main/java/com/weeth/domain/attendance/domain/entity/Attendance.java delete mode 100644 src/main/java/com/weeth/domain/attendance/domain/entity/enums/Status.java delete mode 100644 src/main/java/com/weeth/domain/attendance/domain/repository/AttendanceRepository.java delete mode 100644 src/main/java/com/weeth/domain/attendance/domain/service/AttendanceDeleteService.java delete mode 100644 src/main/java/com/weeth/domain/attendance/domain/service/AttendanceGetService.java delete mode 100644 src/main/java/com/weeth/domain/attendance/domain/service/AttendanceSaveService.java delete mode 100644 src/main/java/com/weeth/domain/attendance/domain/service/AttendanceScheduler.java delete mode 100644 src/main/java/com/weeth/domain/attendance/domain/service/AttendanceUpdateService.java delete mode 100644 src/main/java/com/weeth/domain/attendance/presentation/AttendanceAdminController.java delete mode 100644 src/main/java/com/weeth/domain/attendance/presentation/AttendanceController.java create mode 100644 src/main/kotlin/com/weeth/domain/attendance/application/dto/request/CheckInRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/attendance/application/dto/request/UpdateAttendanceStatusRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceDetailResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceInfoResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceSummaryResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceCodeMismatchException.kt rename src/main/{java/com/weeth/domain/attendance/application/exception/AttendanceErrorCode.java => kotlin/com/weeth/domain/attendance/application/exception/AttendanceErrorCode.kt} (51%) create mode 100644 src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceEventTypeNotMatchException.kt create mode 100644 src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceNotFoundException.kt create mode 100644 src/main/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapper.kt create mode 100644 src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/CheckInAttendanceUseCase.kt create mode 100644 src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/CloseAttendanceUseCase.kt create mode 100644 src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/UpdateAttendanceStatusUseCase.kt create mode 100644 src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt create mode 100644 src/main/kotlin/com/weeth/domain/attendance/domain/entity/Attendance.kt create mode 100644 src/main/kotlin/com/weeth/domain/attendance/domain/enums/Status.kt create mode 100644 src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt create mode 100644 src/main/kotlin/com/weeth/domain/attendance/domain/service/AttendanceDeleteService.kt create mode 100644 src/main/kotlin/com/weeth/domain/attendance/domain/service/AttendanceGetService.kt create mode 100644 src/main/kotlin/com/weeth/domain/attendance/domain/service/AttendanceSaveService.kt create mode 100644 src/main/kotlin/com/weeth/domain/attendance/domain/service/AttendanceUpdateService.kt create mode 100644 src/main/kotlin/com/weeth/domain/attendance/infrastructure/AttendanceScheduler.kt create mode 100644 src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceAdminController.kt create mode 100644 src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt rename src/main/{java/com/weeth/domain/attendance/presentation/AttendanceResponseCode.java => kotlin/com/weeth/domain/attendance/presentation/AttendanceResponseCode.kt} (58%) delete mode 100644 src/test/kotlin/com/weeth/domain/attendance/application/usecase/AttendanceUseCaseImplTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/CheckInAttendanceUseCaseTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/CloseAttendanceUseCaseTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/UpdateAttendanceStatusUseCaseTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/attendance/domain/entity/AttendanceTest.kt delete mode 100644 src/test/kotlin/com/weeth/domain/attendance/domain/service/AttendanceUpdateServiceTest.kt diff --git a/lombok.config b/lombok.config new file mode 100644 index 00000000..df71bb6a --- /dev/null +++ b/lombok.config @@ -0,0 +1,2 @@ +config.stopBubbling = true +lombok.addLombokGeneratedAnnotation = true diff --git a/src/main/java/com/weeth/domain/attendance/application/dto/AttendanceDTO.java b/src/main/java/com/weeth/domain/attendance/application/dto/AttendanceDTO.java deleted file mode 100644 index 072ea499..00000000 --- a/src/main/java/com/weeth/domain/attendance/application/dto/AttendanceDTO.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.weeth.domain.attendance.application.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Pattern; -import com.weeth.domain.attendance.domain.entity.enums.Status; - -import java.time.LocalDateTime; -import java.util.List; - -public class AttendanceDTO { - - public record Main( - Integer attendanceRate, - String title, - Status status, - @Schema(description = "어드민인 경우 출석 코드 노출") - Integer code, - LocalDateTime start, - LocalDateTime end, - String location - ) {} - - public record Detail( - Integer attendanceCount, - Integer total, - Integer absenceCount, - List attendances - ) {} - - public record Response( - Long id, - Status status, - String title, - LocalDateTime start, - LocalDateTime end, - String location - ) {} - - public record CheckIn( - Integer code - ) {} - - public record AttendanceInfo( - Long id, - Status status, - String name, - String position, - String department, - String studentId - ) {} - - public record UpdateStatus( - @NotNull Long attendanceId, - @NotNull @Pattern(regexp = "ATTEND|ABSENT")String status - ) {} -} diff --git a/src/main/java/com/weeth/domain/attendance/application/exception/AttendanceCodeMismatchException.java b/src/main/java/com/weeth/domain/attendance/application/exception/AttendanceCodeMismatchException.java deleted file mode 100644 index 2de9ba5f..00000000 --- a/src/main/java/com/weeth/domain/attendance/application/exception/AttendanceCodeMismatchException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.attendance.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class AttendanceCodeMismatchException extends BaseException { - public AttendanceCodeMismatchException() { - super(AttendanceErrorCode.ATTENDANCE_CODE_MISMATCH); - } -} diff --git a/src/main/java/com/weeth/domain/attendance/application/exception/AttendanceEventTypeNotMatchException.java b/src/main/java/com/weeth/domain/attendance/application/exception/AttendanceEventTypeNotMatchException.java deleted file mode 100644 index 5c978a47..00000000 --- a/src/main/java/com/weeth/domain/attendance/application/exception/AttendanceEventTypeNotMatchException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.attendance.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class AttendanceEventTypeNotMatchException extends BaseException { - public AttendanceEventTypeNotMatchException() { - super(AttendanceErrorCode.ATTENDANCE_EVENT_TYPE_NOT_MATCH); - } -} diff --git a/src/main/java/com/weeth/domain/attendance/application/exception/AttendanceNotFoundException.java b/src/main/java/com/weeth/domain/attendance/application/exception/AttendanceNotFoundException.java deleted file mode 100644 index c44b0b54..00000000 --- a/src/main/java/com/weeth/domain/attendance/application/exception/AttendanceNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.attendance.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class AttendanceNotFoundException extends BaseException { - public AttendanceNotFoundException() { - super(AttendanceErrorCode.ATTENDANCE_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/domain/attendance/application/mapper/AttendanceMapper.java b/src/main/java/com/weeth/domain/attendance/application/mapper/AttendanceMapper.java deleted file mode 100644 index 2b592858..00000000 --- a/src/main/java/com/weeth/domain/attendance/application/mapper/AttendanceMapper.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.weeth.domain.attendance.application.mapper; - -import com.weeth.domain.attendance.application.dto.AttendanceDTO; -import com.weeth.domain.attendance.domain.entity.Attendance; -import com.weeth.domain.user.domain.entity.User; -import org.mapstruct.*; - -import java.util.List; - -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, unmappedTargetPolicy = ReportingPolicy.IGNORE) -public interface AttendanceMapper { - - @Mappings({ - @Mapping(target = "attendanceRate", source = "user.attendanceRate"), - @Mapping(target = "title", source = "attendance.meeting.title"), - @Mapping(target = "status", source = "attendance.status"), - @Mapping(target = "code", ignore = true), - @Mapping(target = "start", source = "attendance.meeting.start"), - @Mapping(target = "end", source = "attendance.meeting.end"), - @Mapping(target = "location", source = "attendance.meeting.location"), - }) - AttendanceDTO.Main toMainDto(User user, Attendance attendance); - - @Mappings({ - @Mapping(target = "attendanceRate", source = "user.attendanceRate"), - @Mapping(target = "title", source = "attendance.meeting.title"), - @Mapping(target = "status", source = "attendance.status"), - @Mapping(target = "code", source = "attendance.meeting.code"), - @Mapping(target = "start", source = "attendance.meeting.start"), - @Mapping(target = "end", source = "attendance.meeting.end"), - @Mapping(target = "location", source = "attendance.meeting.location"), - }) - AttendanceDTO.Main toAdminResponse(User user, Attendance attendance); - - @Mappings({ - @Mapping(target = "attendances", source = "attendances"), - @Mapping(target = "total", expression = "java( user.getAttendanceCount() + user.getAbsenceCount() )") - }) - AttendanceDTO.Detail toDetailDto(User user, List attendances); - - @Mappings({ - @Mapping(target = "title", source = "attendance.meeting.title"), - @Mapping(target = "start", source = "attendance.meeting.start"), - @Mapping(target = "end", source = "attendance.meeting.end"), - @Mapping(target = "location", source = "attendance.meeting.location"), - }) AttendanceDTO.Response toResponseDto(Attendance attendance); - - @Mappings({ - @Mapping(target = "id", source = "attendance.id"), - @Mapping(target = "status", source = "attendance.status"), - @Mapping(target = "name", source = "attendance.user.name"), - @Mapping(target = "position", source = "attendance.user.position"), - @Mapping(target = "department", source = "attendance.user.department"), - @Mapping(target = "studentId", source = "attendance.user.studentId") - }) - AttendanceDTO.AttendanceInfo toAttendanceInfoDto(Attendance attendance); - -} diff --git a/src/main/java/com/weeth/domain/attendance/application/usecase/AttendanceUseCase.java b/src/main/java/com/weeth/domain/attendance/application/usecase/AttendanceUseCase.java deleted file mode 100644 index e6d87451..00000000 --- a/src/main/java/com/weeth/domain/attendance/application/usecase/AttendanceUseCase.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.weeth.domain.attendance.application.usecase; - -import java.util.List; -import com.weeth.domain.attendance.application.dto.AttendanceDTO; -import com.weeth.domain.attendance.application.dto.AttendanceDTO.AttendanceInfo; -import com.weeth.domain.attendance.application.exception.AttendanceCodeMismatchException; - -import java.time.LocalDate; - -import static com.weeth.domain.attendance.application.dto.AttendanceDTO.Detail; -import static com.weeth.domain.attendance.application.dto.AttendanceDTO.Main; - -public interface AttendanceUseCase { - void checkIn(Long userId, Integer code) throws AttendanceCodeMismatchException; - - Main find(Long userId); - - Detail findAllDetailsByCurrentCardinal(Long userId); - - List findAllAttendanceByMeeting(Long meetingId); - - void close(LocalDate now, Integer cardinal); - - void updateAttendanceStatus(List attendanceUpdates); -} diff --git a/src/main/java/com/weeth/domain/attendance/application/usecase/AttendanceUseCaseImpl.java b/src/main/java/com/weeth/domain/attendance/application/usecase/AttendanceUseCaseImpl.java deleted file mode 100644 index 50c71581..00000000 --- a/src/main/java/com/weeth/domain/attendance/application/usecase/AttendanceUseCaseImpl.java +++ /dev/null @@ -1,139 +0,0 @@ -package com.weeth.domain.attendance.application.usecase; - -import com.weeth.domain.attendance.application.dto.AttendanceDTO; -import com.weeth.domain.attendance.application.exception.AttendanceCodeMismatchException; -import com.weeth.domain.attendance.application.exception.AttendanceNotFoundException; -import com.weeth.domain.attendance.application.mapper.AttendanceMapper; -import com.weeth.domain.attendance.domain.entity.Attendance; -import com.weeth.domain.attendance.domain.entity.enums.Status; -import com.weeth.domain.attendance.domain.service.AttendanceGetService; -import com.weeth.domain.attendance.domain.service.AttendanceUpdateService; -import com.weeth.domain.schedule.application.exception.MeetingNotFoundException; -import com.weeth.domain.schedule.domain.entity.Meeting; -import com.weeth.domain.schedule.domain.service.MeetingGetService; -import com.weeth.domain.user.domain.entity.Cardinal; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.entity.enums.Role; -import com.weeth.domain.user.domain.service.UserCardinalGetService; -import com.weeth.domain.user.domain.service.UserGetService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.Comparator; -import java.util.List; - -@Service -@RequiredArgsConstructor -public class AttendanceUseCaseImpl implements AttendanceUseCase { - - private final UserGetService userGetService; - private final UserCardinalGetService userCardinalGetService; - - private final AttendanceGetService attendanceGetService; - private final AttendanceUpdateService attendanceUpdateService; - private final AttendanceMapper mapper; - - private final MeetingGetService meetingGetService; - - @Override - @Transactional - public void checkIn(Long userId, Integer code) throws AttendanceCodeMismatchException { - User user = userGetService.find(userId); - - LocalDateTime now = LocalDateTime.now(); - Attendance todayMeeting = user.getAttendances().stream() - .filter(attendance -> attendance.getMeeting().getStart().minusMinutes(10).isBefore(now) - && attendance.getMeeting().getEnd().isAfter(now)) - .findAny() - .orElseThrow(AttendanceNotFoundException::new); - - if (todayMeeting.isWrong(code)) - throw new AttendanceCodeMismatchException(); - - if (todayMeeting.getStatus() != Status.ATTEND) - attendanceUpdateService.attend(todayMeeting); - } - - @Override - public AttendanceDTO.Main find(Long userId) { - User user = userGetService.find(userId); - - Attendance todayMeeting = user.getAttendances().stream() - .filter(attendance -> attendance.getMeeting().getStart().toLocalDate().isEqual(LocalDate.now()) - && attendance.getMeeting().getEnd().toLocalDate().isEqual(LocalDate.now())) - .findAny() - .orElse(null); - - if (Role.ADMIN == user.getRole()) { - return mapper.toAdminResponse(user, todayMeeting); - } - - return mapper.toMainDto(user, todayMeeting); - } - - public AttendanceDTO.Detail findAllDetailsByCurrentCardinal(Long userId) { - User user = userGetService.find(userId); - Cardinal currentCardinal = userCardinalGetService.getCurrentCardinal(user); - - List responses = user.getAttendances().stream() - .filter(attendance -> attendance.getMeeting().getCardinal().equals(currentCardinal.getCardinalNumber())) - .sorted(Comparator.comparing(attendance -> attendance.getMeeting().getStart())) - .map(mapper::toResponseDto) - .toList(); - - return mapper.toDetailDto(user, responses); - } - - @Override - public List findAllAttendanceByMeeting(Long meetingId) { - Meeting meeting = meetingGetService.find(meetingId); - - List attendances = attendanceGetService.findAllByMeeting(meeting); - - return attendances.stream() - .map(mapper::toAttendanceInfoDto) - .toList(); - } - - @Override - public void close(LocalDate now, Integer cardinal) { - List meetings = meetingGetService.find(cardinal); - - /* - todo 차후 리팩토링 정기모임 id를 입력받아서 해당 정기모임의 출석을 마감하도록 수정 - */ - Meeting targetMeeting = meetings.stream() - .filter(meeting -> meeting.getStart().toLocalDate().isEqual(now) - && meeting.getEnd().toLocalDate().isEqual(now)) - .findAny() - .orElseThrow(MeetingNotFoundException::new); - - List attendanceList = attendanceGetService.findAllByMeeting(targetMeeting); - - attendanceUpdateService.close(attendanceList); - } - - @Override - @Transactional - public void updateAttendanceStatus(List attendanceUpdates) { - attendanceUpdates.forEach(update -> { - Attendance attendance = attendanceGetService.findByAttendanceId(update.attendanceId()); - User user = attendance.getUser(); - - Status newStatus = Status.valueOf(update.status()); - - if (newStatus == Status.ABSENT) { - attendance.close(); - user.removeAttend(); - user.absent(); - } else { - attendance.attend(); - user.removeAbsent(); - user.attend(); - } - }); - } -} diff --git a/src/main/java/com/weeth/domain/attendance/domain/entity/Attendance.java b/src/main/java/com/weeth/domain/attendance/domain/entity/Attendance.java deleted file mode 100644 index 35197ad3..00000000 --- a/src/main/java/com/weeth/domain/attendance/domain/entity/Attendance.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.weeth.domain.attendance.domain.entity; - -import jakarta.persistence.*; -import com.weeth.domain.attendance.domain.entity.enums.Status; -import com.weeth.domain.schedule.domain.entity.Meeting; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.global.common.entity.BaseEntity; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.experimental.SuperBuilder; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@SuperBuilder -public class Attendance extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "attendance_id") - private Long id; - - @Enumerated(EnumType.STRING) - private Status status; - - @ManyToOne - @JoinColumn(name = "meeting_id") - private Meeting meeting; - - @ManyToOne - @JoinColumn(name = "user_id") - private User user; - - @PrePersist - public void init() { - this.status = Status.PENDING; - } - - public Attendance(Meeting meeting, User user) { - this.meeting = meeting; - this.user = user; - } - - public void attend() { - this.status = Status.ATTEND; - } - - public void close() { - this.status = Status.ABSENT; - } - - public boolean isPending() { - return this.status == Status.PENDING; - } - - public boolean isWrong(Integer code) { - return !this.meeting.getCode().equals(code); - } -} diff --git a/src/main/java/com/weeth/domain/attendance/domain/entity/enums/Status.java b/src/main/java/com/weeth/domain/attendance/domain/entity/enums/Status.java deleted file mode 100644 index 4dbd7466..00000000 --- a/src/main/java/com/weeth/domain/attendance/domain/entity/enums/Status.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.weeth.domain.attendance.domain.entity.enums; - -public enum Status { - ATTEND, - PENDING, - ABSENT -} diff --git a/src/main/java/com/weeth/domain/attendance/domain/repository/AttendanceRepository.java b/src/main/java/com/weeth/domain/attendance/domain/repository/AttendanceRepository.java deleted file mode 100644 index 6fe213fa..00000000 --- a/src/main/java/com/weeth/domain/attendance/domain/repository/AttendanceRepository.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.weeth.domain.attendance.domain.repository; - -import com.weeth.domain.attendance.domain.entity.Attendance; -import com.weeth.domain.schedule.domain.entity.Meeting; -import com.weeth.domain.user.domain.entity.enums.Status; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; - -import java.util.List; - -public interface AttendanceRepository extends JpaRepository { - List findAllByMeetingAndUserStatus(Meeting meeting, Status status); - - @Modifying - @Query("DELETE FROM Attendance a WHERE a.meeting = :meeting") - void deleteAllByMeeting(Meeting meeting); -} diff --git a/src/main/java/com/weeth/domain/attendance/domain/service/AttendanceDeleteService.java b/src/main/java/com/weeth/domain/attendance/domain/service/AttendanceDeleteService.java deleted file mode 100644 index 853c21ab..00000000 --- a/src/main/java/com/weeth/domain/attendance/domain/service/AttendanceDeleteService.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.weeth.domain.attendance.domain.service; - -import com.weeth.domain.attendance.domain.entity.Attendance; -import com.weeth.domain.attendance.domain.repository.AttendanceRepository; -import com.weeth.domain.schedule.domain.entity.Meeting; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class AttendanceDeleteService { - - private final AttendanceRepository attendanceRepository; - - public void deleteAll(Meeting meeting) { - attendanceRepository.deleteAllByMeeting(meeting); - } -} diff --git a/src/main/java/com/weeth/domain/attendance/domain/service/AttendanceGetService.java b/src/main/java/com/weeth/domain/attendance/domain/service/AttendanceGetService.java deleted file mode 100644 index 82c6d8c1..00000000 --- a/src/main/java/com/weeth/domain/attendance/domain/service/AttendanceGetService.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.weeth.domain.attendance.domain.service; - -import com.weeth.domain.attendance.application.exception.AttendanceNotFoundException; -import com.weeth.domain.attendance.domain.entity.Attendance; -import com.weeth.domain.attendance.domain.repository.AttendanceRepository; -import com.weeth.domain.schedule.domain.entity.Meeting; -import com.weeth.domain.user.domain.entity.enums.Status; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class AttendanceGetService { - - private final AttendanceRepository attendanceRepository; - - public List findAllByMeeting(Meeting meeting) { - return attendanceRepository.findAllByMeetingAndUserStatus(meeting, Status.ACTIVE); - } - public Attendance findByAttendanceId(Long attendanceId) { - return attendanceRepository.findById(attendanceId) - .orElseThrow(AttendanceNotFoundException::new); - } -} diff --git a/src/main/java/com/weeth/domain/attendance/domain/service/AttendanceSaveService.java b/src/main/java/com/weeth/domain/attendance/domain/service/AttendanceSaveService.java deleted file mode 100644 index 8ad82e6b..00000000 --- a/src/main/java/com/weeth/domain/attendance/domain/service/AttendanceSaveService.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.weeth.domain.attendance.domain.service; - -import jakarta.transaction.Transactional; -import com.weeth.domain.attendance.domain.entity.Attendance; -import com.weeth.domain.attendance.domain.repository.AttendanceRepository; -import com.weeth.domain.schedule.domain.entity.Meeting; -import com.weeth.domain.user.domain.entity.User; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.ArrayList; -import java.util.List; - -@Service -@RequiredArgsConstructor -public class AttendanceSaveService { - - private final AttendanceRepository attendanceRepository; - - public void init(User user, List meetings) { - if (meetings != null) { - meetings.forEach(meeting -> { - Attendance attendance = attendanceRepository.save(new Attendance(meeting, user)); - user.add(attendance); - }); - } - } - - public void saveAll(List userList, Meeting meeting) { - List attendances = userList.stream() - .map(user -> new Attendance(meeting, user)) - .toList(); - - attendanceRepository.saveAll(attendances); - } -} diff --git a/src/main/java/com/weeth/domain/attendance/domain/service/AttendanceScheduler.java b/src/main/java/com/weeth/domain/attendance/domain/service/AttendanceScheduler.java deleted file mode 100644 index 57b541e3..00000000 --- a/src/main/java/com/weeth/domain/attendance/domain/service/AttendanceScheduler.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.weeth.domain.attendance.domain.service; - -import jakarta.transaction.Transactional; -import java.util.List; -import com.weeth.domain.attendance.domain.entity.Attendance; -import com.weeth.domain.schedule.domain.entity.Meeting; -import com.weeth.domain.schedule.domain.service.MeetingGetService; -import lombok.RequiredArgsConstructor; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class AttendanceScheduler { - - private final MeetingGetService meetingGetService; - private final AttendanceGetService attendanceGetService; - private final AttendanceUpdateService attendanceUpdateService; - - @Transactional - @Scheduled(cron = "0 0 22 * * THU", zone = "Asia/Seoul") - public void autoCloseAttendance() { - List meetings = meetingGetService.findAllOpenMeetingsBeforeNow(); - - meetings.forEach(meeting -> { - meeting.close(); - List attendanceList = attendanceGetService.findAllByMeeting(meeting); - attendanceUpdateService.close(attendanceList); - }); - } -} diff --git a/src/main/java/com/weeth/domain/attendance/domain/service/AttendanceUpdateService.java b/src/main/java/com/weeth/domain/attendance/domain/service/AttendanceUpdateService.java deleted file mode 100644 index 4bfbeb6b..00000000 --- a/src/main/java/com/weeth/domain/attendance/domain/service/AttendanceUpdateService.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.weeth.domain.attendance.domain.service; - -import jakarta.transaction.Transactional; -import com.weeth.domain.attendance.domain.entity.Attendance; -import com.weeth.domain.attendance.domain.entity.enums.Status; -import com.weeth.domain.user.domain.entity.User; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.List; - -@Service -@Transactional -@RequiredArgsConstructor -public class AttendanceUpdateService { - - public void attend(Attendance attendance) { - attendance.attend(); - attendance.getUser().attend(); - } - - public void close(List attendances) { - attendances.stream() - .filter(Attendance::isPending) - .forEach(attendance -> { - attendance.close(); - attendance.getUser().absent(); - }); - } - - public void updateUserAttendanceByStatus(List attendances) { - for (Attendance attendance : attendances) { - User user = attendance.getUser(); - if (attendance.getStatus().equals(Status.ATTEND)) { - user.removeAttend(); - } else { - user.removeAbsent(); - } - } - } -} diff --git a/src/main/java/com/weeth/domain/attendance/presentation/AttendanceAdminController.java b/src/main/java/com/weeth/domain/attendance/presentation/AttendanceAdminController.java deleted file mode 100644 index 1d76a3c0..00000000 --- a/src/main/java/com/weeth/domain/attendance/presentation/AttendanceAdminController.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.weeth.domain.attendance.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import com.weeth.domain.attendance.application.dto.AttendanceDTO; -import com.weeth.domain.attendance.application.exception.AttendanceErrorCode; -import com.weeth.domain.attendance.application.usecase.AttendanceUseCase; -import com.weeth.domain.schedule.application.dto.MeetingDTO; -import com.weeth.domain.schedule.application.usecase.MeetingUseCase; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import java.time.LocalDate; -import java.util.List; - -import static com.weeth.domain.attendance.presentation.AttendanceResponseCode.*; - -@Tag(name = "ATTENDANCE ADMIN", description = "[ADMIN] 출석 어드민 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/admin/attendances") -@ApiErrorCodeExample(AttendanceErrorCode.class) -public class AttendanceAdminController { - - private final AttendanceUseCase attendanceUseCase; - private final MeetingUseCase meetingUseCase; - - @PatchMapping - @Operation(summary="출석 마감") - public CommonResponse close(@RequestParam LocalDate now, @RequestParam Integer cardinal) { - attendanceUseCase.close(now, cardinal); - return CommonResponse.success(ATTENDANCE_CLOSE_SUCCESS); - } - - @GetMapping("/meetings") - @Operation(summary = "정기모임 조회") - public CommonResponse getMeetings(@RequestParam(required = false) Integer cardinal) { - MeetingDTO.Infos response = meetingUseCase.find(cardinal); - - return CommonResponse.success(MEETING_FIND_SUCCESS, response); - } - - @GetMapping("/{meetingId}") - @Operation(summary = "모든 인원 정기모임 출석 정보 조회") - public CommonResponse> getAllAttendance(@PathVariable Long meetingId) { - return CommonResponse.success(ATTENDANCE_FIND_DETAIL_SUCCESS, attendanceUseCase.findAllAttendanceByMeeting(meetingId)); - } - - @PatchMapping("/status") - @Operation(summary = "모든 인원 정기모임 개별 출석 상태 수정") - public CommonResponse updateAttendanceStatus(@RequestBody @Valid List attendanceUpdates) { - attendanceUseCase.updateAttendanceStatus(attendanceUpdates); - return CommonResponse.success(ATTENDANCE_UPDATED_SUCCESS); - } -} diff --git a/src/main/java/com/weeth/domain/attendance/presentation/AttendanceController.java b/src/main/java/com/weeth/domain/attendance/presentation/AttendanceController.java deleted file mode 100644 index 356a2666..00000000 --- a/src/main/java/com/weeth/domain/attendance/presentation/AttendanceController.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.weeth.domain.attendance.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import com.weeth.domain.attendance.application.dto.AttendanceDTO; -import com.weeth.domain.attendance.application.exception.AttendanceErrorCode; -import com.weeth.domain.attendance.application.usecase.AttendanceUseCase; -import com.weeth.global.auth.annotation.CurrentUser; -import com.weeth.domain.attendance.application.exception.AttendanceCodeMismatchException; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import static com.weeth.domain.attendance.application.dto.AttendanceDTO.*; -import static com.weeth.domain.attendance.presentation.AttendanceResponseCode.ATTENDANCE_CHECKIN_SUCCESS; -import static com.weeth.domain.attendance.presentation.AttendanceResponseCode.ATTENDANCE_FIND_ALL_SUCCESS; -import static com.weeth.domain.attendance.presentation.AttendanceResponseCode.ATTENDANCE_FIND_SUCCESS; - -@Tag(name = "ATTENDANCE", description = "출석 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/attendances") -@ApiErrorCodeExample(AttendanceErrorCode.class) -public class AttendanceController { - - private final AttendanceUseCase attendanceUseCase; - - @PatchMapping - @Operation(summary="출석체크") - public CommonResponse checkIn(@Parameter(hidden = true) @CurrentUser Long userId, @RequestBody AttendanceDTO.CheckIn checkIn) throws AttendanceCodeMismatchException { - attendanceUseCase.checkIn(userId, checkIn.code()); - return CommonResponse.success(ATTENDANCE_CHECKIN_SUCCESS); - } - - @GetMapping - @Operation(summary="출석 메인페이지") - public CommonResponse
find(@Parameter(hidden = true) @CurrentUser Long userId) { - return CommonResponse.success(ATTENDANCE_FIND_SUCCESS, attendanceUseCase.find(userId)); - } - - @GetMapping("/detail") - @Operation(summary="출석 내역 상세조회") - public CommonResponse findAll(@Parameter(hidden = true) @CurrentUser Long userId) { - return CommonResponse.success(ATTENDANCE_FIND_ALL_SUCCESS, attendanceUseCase.findAllDetailsByCurrentCardinal(userId)); - } -} diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/dto/request/CheckInRequest.kt b/src/main/kotlin/com/weeth/domain/attendance/application/dto/request/CheckInRequest.kt new file mode 100644 index 00000000..104b72a4 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/dto/request/CheckInRequest.kt @@ -0,0 +1,8 @@ +package com.weeth.domain.attendance.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema + +data class CheckInRequest( + @field:Schema(description = "출석 코드", example = "1234") + val code: Int, +) diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/dto/request/UpdateAttendanceStatusRequest.kt b/src/main/kotlin/com/weeth/domain/attendance/application/dto/request/UpdateAttendanceStatusRequest.kt new file mode 100644 index 00000000..326fac52 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/dto/request/UpdateAttendanceStatusRequest.kt @@ -0,0 +1,12 @@ +package com.weeth.domain.attendance.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.Pattern + +data class UpdateAttendanceStatusRequest( + @field:Schema(description = "출석 ID", example = "1") + val attendanceId: Long, + @field:Schema(description = "변경할 출석 상태", example = "ATTEND") + @field:Pattern(regexp = "ATTEND|ABSENT") + val status: String, +) diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceDetailResponse.kt b/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceDetailResponse.kt new file mode 100644 index 00000000..419ce800 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceDetailResponse.kt @@ -0,0 +1,14 @@ +package com.weeth.domain.attendance.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class AttendanceDetailResponse( + @field:Schema(description = "출석 횟수", example = "8") + val attendanceCount: Int, + @field:Schema(description = "전체 횟수", example = "10") + val total: Int, + @field:Schema(description = "결석 횟수", example = "2") + val absenceCount: Int, + @field:Schema(description = "출석 내역 목록") + val attendances: List, +) diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceInfoResponse.kt b/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceInfoResponse.kt new file mode 100644 index 00000000..434ad7a9 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceInfoResponse.kt @@ -0,0 +1,19 @@ +package com.weeth.domain.attendance.application.dto.response + +import com.weeth.domain.attendance.domain.enums.Status +import io.swagger.v3.oas.annotations.media.Schema + +data class AttendanceInfoResponse( + @field:Schema(description = "출석 ID", example = "1") + val id: Long, + @field:Schema(description = "출석 상태", example = "ATTEND") + val status: Status?, + @field:Schema(description = "사용자 이름", example = "이지훈") + val name: String?, + @field:Schema(description = "직책", example = "BE") + val position: String?, + @field:Schema(description = "소속 학과", example = "컴퓨터공학과") + val department: String?, + @field:Schema(description = "학번", example = "20201234") + val studentId: String?, +) diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceResponse.kt b/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceResponse.kt new file mode 100644 index 00000000..e99559af --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceResponse.kt @@ -0,0 +1,20 @@ +package com.weeth.domain.attendance.application.dto.response + +import com.weeth.domain.attendance.domain.enums.Status +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class AttendanceResponse( + @field:Schema(description = "출석 ID", example = "1") + val id: Long, + @field:Schema(description = "출석 상태", example = "ATTEND") + val status: Status?, + @field:Schema(description = "정기모임 제목", example = "1주차 정기모임") + val title: String?, + @field:Schema(description = "정기모임 시작 시간") + val start: LocalDateTime?, + @field:Schema(description = "정기모임 종료 시간") + val end: LocalDateTime?, + @field:Schema(description = "정기모임 장소", example = "공학관 401호") + val location: String?, +) diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceSummaryResponse.kt b/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceSummaryResponse.kt new file mode 100644 index 00000000..f53b633f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceSummaryResponse.kt @@ -0,0 +1,22 @@ +package com.weeth.domain.attendance.application.dto.response + +import com.weeth.domain.attendance.domain.enums.Status +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class AttendanceSummaryResponse( + @field:Schema(description = "출석률", example = "80") + val attendanceRate: Int?, + @field:Schema(description = "정기모임 제목", example = "1주차 정기모임") + val title: String?, + @field:Schema(description = "출석 상태", example = "ATTEND") + val status: Status?, + @field:Schema(description = "어드민인 경우 출석 코드 노출", example = "1234") + val code: Int?, + @field:Schema(description = "정기모임 시작 시간") + val start: LocalDateTime?, + @field:Schema(description = "정기모임 종료 시간") + val end: LocalDateTime?, + @field:Schema(description = "정기모임 장소", example = "공학관 401호") + val location: String?, +) diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceCodeMismatchException.kt b/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceCodeMismatchException.kt new file mode 100644 index 00000000..a62d043b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceCodeMismatchException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.attendance.application.exception + +import com.weeth.global.common.exception.BaseException + +class AttendanceCodeMismatchException : BaseException(AttendanceErrorCode.ATTENDANCE_CODE_MISMATCH) diff --git a/src/main/java/com/weeth/domain/attendance/application/exception/AttendanceErrorCode.java b/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceErrorCode.kt similarity index 51% rename from src/main/java/com/weeth/domain/attendance/application/exception/AttendanceErrorCode.java rename to src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceErrorCode.kt index 3cd3f3b7..bba918e5 100644 --- a/src/main/java/com/weeth/domain/attendance/application/exception/AttendanceErrorCode.java +++ b/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceErrorCode.kt @@ -1,15 +1,14 @@ -package com.weeth.domain.attendance.application.exception; +package com.weeth.domain.attendance.application.exception -import com.weeth.global.common.exception.ErrorCodeInterface; -import com.weeth.global.common.exception.ExplainError; -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@AllArgsConstructor -public enum AttendanceErrorCode implements ErrorCodeInterface { +import com.weeth.global.common.exception.ErrorCodeInterface +import com.weeth.global.common.exception.ExplainError +import org.springframework.http.HttpStatus +enum class AttendanceErrorCode( + private val code: Int, + private val status: HttpStatus, + private val message: String, +) : ErrorCodeInterface { @ExplainError("출석 정보를 찾을 수 없을 때 발생합니다.") ATTENDANCE_NOT_FOUND(2200, HttpStatus.NOT_FOUND, "출석 정보가 존재하지 않습니다."), @@ -17,9 +16,12 @@ public enum AttendanceErrorCode implements ErrorCodeInterface { ATTENDANCE_CODE_MISMATCH(2201, HttpStatus.BAD_REQUEST, "출석 코드가 일치하지 않습니다."), @ExplainError("사용자가 출석 일정을 직접 수정하려고 시도할 때 발생합니다. (출석 로직 위반)") - ATTENDANCE_EVENT_TYPE_NOT_MATCH(2202, HttpStatus.BAD_REQUEST, "출석일정은 직접 수정할 수 없습니다."); + ATTENDANCE_EVENT_TYPE_NOT_MATCH(2202, HttpStatus.BAD_REQUEST, "출석일정은 직접 수정할 수 없습니다."), + ; + + override fun getCode(): Int = code + + override fun getStatus(): HttpStatus = status - private final int code; - private final HttpStatus status; - private final String message; + override fun getMessage(): String = message } diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceEventTypeNotMatchException.kt b/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceEventTypeNotMatchException.kt new file mode 100644 index 00000000..e8e2716a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceEventTypeNotMatchException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.attendance.application.exception + +import com.weeth.global.common.exception.BaseException + +class AttendanceEventTypeNotMatchException : BaseException(AttendanceErrorCode.ATTENDANCE_EVENT_TYPE_NOT_MATCH) diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceNotFoundException.kt b/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceNotFoundException.kt new file mode 100644 index 00000000..95862a76 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.attendance.application.exception + +import com.weeth.global.common.exception.BaseException + +class AttendanceNotFoundException : BaseException(AttendanceErrorCode.ATTENDANCE_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapper.kt b/src/main/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapper.kt new file mode 100644 index 00000000..a60bc189 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapper.kt @@ -0,0 +1,58 @@ +package com.weeth.domain.attendance.application.mapper + +import com.weeth.domain.attendance.application.dto.response.AttendanceDetailResponse +import com.weeth.domain.attendance.application.dto.response.AttendanceInfoResponse +import com.weeth.domain.attendance.application.dto.response.AttendanceResponse +import com.weeth.domain.attendance.application.dto.response.AttendanceSummaryResponse +import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.user.domain.entity.User +import org.springframework.stereotype.Component + +@Component +class AttendanceMapper { + fun toSummaryResponse( + user: User, + attendance: Attendance?, + isAdmin: Boolean = false, + ): AttendanceSummaryResponse = + AttendanceSummaryResponse( + attendanceRate = user.attendanceRate, + title = attendance?.meeting?.title, + status = attendance?.status, + code = if (isAdmin) attendance?.meeting?.code else null, + start = attendance?.meeting?.start, + end = attendance?.meeting?.end, + location = attendance?.meeting?.location, + ) + + fun toDetailResponse( + user: User, + attendances: List, + ): AttendanceDetailResponse = + AttendanceDetailResponse( + attendanceCount = user.attendanceCount ?: 0, + total = (user.attendanceCount ?: 0) + (user.absenceCount ?: 0), + absenceCount = user.absenceCount ?: 0, + attendances = attendances, + ) + + fun toResponse(attendance: Attendance): AttendanceResponse = + AttendanceResponse( + id = attendance.id, + status = attendance.status, + title = attendance.meeting.title, + start = attendance.meeting.start, + end = attendance.meeting.end, + location = attendance.meeting.location, + ) + + fun toInfoResponse(attendance: Attendance): AttendanceInfoResponse = + AttendanceInfoResponse( + id = attendance.id, + status = attendance.status, + name = attendance.user.name, + position = attendance.user.position?.name, + department = attendance.user.department?.name, + studentId = attendance.user.studentId, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/CheckInAttendanceUseCase.kt b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/CheckInAttendanceUseCase.kt new file mode 100644 index 00000000..8f92ae0b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/CheckInAttendanceUseCase.kt @@ -0,0 +1,38 @@ +package com.weeth.domain.attendance.application.usecase.command + +import com.weeth.domain.attendance.application.exception.AttendanceCodeMismatchException +import com.weeth.domain.attendance.application.exception.AttendanceNotFoundException +import com.weeth.domain.attendance.domain.enums.Status +import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.user.domain.service.UserGetService +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +class CheckInAttendanceUseCase( + private val userGetService: UserGetService, + private val attendanceRepository: AttendanceRepository, +) { + @Transactional + fun checkIn( + userId: Long, + code: Int, + ) { + val user = userGetService.find(userId) + val now = LocalDateTime.now() + + val todayAttendance = + attendanceRepository.findCurrentByUserId(userId, now, now.plusMinutes(10)) + ?: throw AttendanceNotFoundException() + + if (todayAttendance.isWrong(code)) { + throw AttendanceCodeMismatchException() + } + + if (todayAttendance.status != Status.ATTEND) { + todayAttendance.attend() + user.attend() + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/CloseAttendanceUseCase.kt b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/CloseAttendanceUseCase.kt new file mode 100644 index 00000000..7d860987 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/CloseAttendanceUseCase.kt @@ -0,0 +1,53 @@ +package com.weeth.domain.attendance.application.usecase.command + +import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.schedule.application.exception.MeetingNotFoundException +import com.weeth.domain.schedule.domain.service.MeetingGetService +import com.weeth.domain.user.domain.entity.enums.Status +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate + +@Service +class CloseAttendanceUseCase( + private val meetingGetService: MeetingGetService, + private val attendanceRepository: AttendanceRepository, +) { + @Transactional + fun close( + now: LocalDate, + cardinal: Int, + ) { + val meetings = meetingGetService.find(cardinal) + + val targetMeeting = + meetings.firstOrNull { meeting -> + meeting.start.toLocalDate().isEqual(now) && + meeting.end.toLocalDate().isEqual(now) + } ?: throw MeetingNotFoundException() + + val attendanceList = attendanceRepository.findAllByMeetingAndUserStatus(targetMeeting, Status.ACTIVE) + closePendingAttendances(attendanceList) + } + + @Transactional + fun autoClose() { + val meetings = meetingGetService.findAllOpenMeetingsBeforeNow() + + meetings.forEach { meeting -> + meeting.close() + val attendanceList = attendanceRepository.findAllByMeetingAndUserStatus(meeting, Status.ACTIVE) + closePendingAttendances(attendanceList) + } + } + + private fun closePendingAttendances(attendances: List) { + attendances + .filter { it.isPending } + .forEach { attendance -> + attendance.close() + attendance.user.absent() + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/UpdateAttendanceStatusUseCase.kt b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/UpdateAttendanceStatusUseCase.kt new file mode 100644 index 00000000..14d984bf --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/UpdateAttendanceStatusUseCase.kt @@ -0,0 +1,34 @@ +package com.weeth.domain.attendance.application.usecase.command + +import com.weeth.domain.attendance.application.dto.request.UpdateAttendanceStatusRequest +import com.weeth.domain.attendance.application.exception.AttendanceNotFoundException +import com.weeth.domain.attendance.domain.enums.Status +import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class UpdateAttendanceStatusUseCase( + private val attendanceRepository: AttendanceRepository, +) { + @Transactional + fun updateStatus(attendanceUpdates: List) { + attendanceUpdates.forEach { update -> + val attendance = + attendanceRepository.findByIdWithUser(update.attendanceId) + ?: throw AttendanceNotFoundException() + val user = attendance.user + val newStatus = Status.valueOf(update.status) + + if (newStatus == Status.ABSENT) { + attendance.close() + user.removeAttend() + user.absent() + } else { + attendance.attend() + user.removeAbsent() + user.attend() + } + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt new file mode 100644 index 00000000..6a8c63cd --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt @@ -0,0 +1,57 @@ +package com.weeth.domain.attendance.application.usecase.query + +import com.weeth.domain.attendance.application.dto.response.AttendanceDetailResponse +import com.weeth.domain.attendance.application.dto.response.AttendanceInfoResponse +import com.weeth.domain.attendance.application.dto.response.AttendanceSummaryResponse +import com.weeth.domain.attendance.application.mapper.AttendanceMapper +import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.schedule.domain.service.MeetingGetService +import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.entity.enums.Status +import com.weeth.domain.user.domain.service.UserCardinalGetService +import com.weeth.domain.user.domain.service.UserGetService +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate + +@Service +@Transactional(readOnly = true) +class GetAttendanceQueryService( + private val userGetService: UserGetService, + private val userCardinalGetService: UserCardinalGetService, + private val meetingGetService: MeetingGetService, + private val attendanceRepository: AttendanceRepository, + private val mapper: AttendanceMapper, +) { + fun findAttendance(userId: Long): AttendanceSummaryResponse { + val user = userGetService.find(userId) + val today = LocalDate.now() + + val todayAttendance = + attendanceRepository.findTodayByUserId( + userId, + today.atStartOfDay(), + today.plusDays(1).atStartOfDay(), + ) + + return mapper.toSummaryResponse(user, todayAttendance, isAdmin = user.role == Role.ADMIN) + } + + fun findAllDetailsByCurrentCardinal(userId: Long): AttendanceDetailResponse { + val user = userGetService.find(userId) + val currentCardinal = userCardinalGetService.getCurrentCardinal(user) + + val responses = + attendanceRepository + .findAllByUserIdAndCardinal(userId, currentCardinal.cardinalNumber) + .map(mapper::toResponse) + + return mapper.toDetailResponse(user, responses) + } + + fun findAllAttendanceByMeeting(meetingId: Long): List { + val meeting = meetingGetService.find(meetingId) + val attendances = attendanceRepository.findAllByMeetingAndUserStatus(meeting, Status.ACTIVE) + return attendances.map(mapper::toInfoResponse) + } +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/entity/Attendance.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/entity/Attendance.kt new file mode 100644 index 00000000..f40184ba --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/entity/Attendance.kt @@ -0,0 +1,53 @@ +package com.weeth.domain.attendance.domain.entity + +import com.weeth.domain.attendance.domain.enums.Status +import com.weeth.domain.schedule.domain.entity.Meeting +import com.weeth.domain.user.domain.entity.User +import com.weeth.global.common.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.PrePersist + +@Entity +class Attendance + @JvmOverloads + constructor( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "meeting_id") + val meeting: Meeting, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + val user: User, + @Enumerated(EnumType.STRING) + var status: Status? = null, + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "attendance_id") + val id: Long = 0, + ) : BaseEntity() { + @PrePersist + fun init() { + status = Status.PENDING + } + + fun attend() { + status = Status.ATTEND + } + + fun close() { + status = Status.ABSENT + } + + val isPending: Boolean + get() = status == Status.PENDING + + fun isWrong(code: Int): Boolean = meeting.getCode() != code + } diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/enums/Status.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/enums/Status.kt new file mode 100644 index 00000000..6184bf45 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/enums/Status.kt @@ -0,0 +1,7 @@ +package com.weeth.domain.attendance.domain.enums + +enum class Status { + ATTEND, + PENDING, + ABSENT, +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt new file mode 100644 index 00000000..cd0d82a0 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt @@ -0,0 +1,70 @@ +package com.weeth.domain.attendance.domain.repository + +import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.schedule.domain.entity.Meeting +import com.weeth.domain.user.domain.entity.enums.Status +import org.springframework.data.jpa.repository.EntityGraph +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import java.time.LocalDateTime + +interface AttendanceRepository : JpaRepository { + @EntityGraph(attributePaths = ["user"]) + fun findAllByMeetingAndUserStatus( + meeting: Meeting, + status: Status, + ): List + + @Query("SELECT a FROM Attendance a JOIN FETCH a.user WHERE a.id = :id") + fun findByIdWithUser(id: Long): Attendance? + + @Query( + """ + SELECT a FROM Attendance a + JOIN FETCH a.meeting m + WHERE a.user.id = :userId + AND m.start <= :checkInEnd + AND m.end > :now + """, + ) + fun findCurrentByUserId( + @Param("userId") userId: Long, + @Param("now") now: LocalDateTime, + @Param("checkInEnd") checkInEnd: LocalDateTime, + ): Attendance? + + @Query( + """ + SELECT a FROM Attendance a + JOIN FETCH a.meeting m + WHERE a.user.id = :userId + AND m.start >= :dayStart + AND m.end < :dayEnd + """, + ) + fun findTodayByUserId( + @Param("userId") userId: Long, + @Param("dayStart") dayStart: LocalDateTime, + @Param("dayEnd") dayEnd: LocalDateTime, + ): Attendance? + + @Query( + """ + SELECT a FROM Attendance a + JOIN FETCH a.meeting m + WHERE a.user.id = :userId + AND m.cardinal = :cardinal + ORDER BY m.start + """, + ) + fun findAllByUserIdAndCardinal( + @Param("userId") userId: Long, + @Param("cardinal") cardinal: Int, + ): List + + @Modifying + @Query("DELETE FROM Attendance a WHERE a.meeting = :meeting") + fun deleteAllByMeeting(meeting: Meeting) +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/service/AttendanceDeleteService.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/service/AttendanceDeleteService.kt new file mode 100644 index 00000000..d4a05bb7 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/service/AttendanceDeleteService.kt @@ -0,0 +1,14 @@ +package com.weeth.domain.attendance.domain.service + +import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.schedule.domain.entity.Meeting +import org.springframework.stereotype.Service + +@Service +class AttendanceDeleteService( + private val attendanceRepository: AttendanceRepository, +) { + fun deleteAll(meeting: Meeting) { + attendanceRepository.deleteAllByMeeting(meeting) + } +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/service/AttendanceGetService.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/service/AttendanceGetService.kt new file mode 100644 index 00000000..647910b7 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/service/AttendanceGetService.kt @@ -0,0 +1,14 @@ +package com.weeth.domain.attendance.domain.service + +import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.schedule.domain.entity.Meeting +import com.weeth.domain.user.domain.entity.enums.Status +import org.springframework.stereotype.Service + +@Service +class AttendanceGetService( + private val attendanceRepository: AttendanceRepository, +) { + fun findAllByMeeting(meeting: Meeting): List = attendanceRepository.findAllByMeetingAndUserStatus(meeting, Status.ACTIVE) +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/service/AttendanceSaveService.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/service/AttendanceSaveService.kt new file mode 100644 index 00000000..f19106dc --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/service/AttendanceSaveService.kt @@ -0,0 +1,30 @@ +package com.weeth.domain.attendance.domain.service + +import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.schedule.domain.entity.Meeting +import com.weeth.domain.user.domain.entity.User +import org.springframework.stereotype.Service + +@Service +class AttendanceSaveService( + private val attendanceRepository: AttendanceRepository, +) { + fun init( + user: User, + meetings: List?, + ) { + meetings?.forEach { meeting -> + val attendance = attendanceRepository.save(Attendance(meeting, user)) + user.add(attendance) + } + } + + fun saveAll( + userList: List, + meeting: Meeting, + ) { + val attendances = userList.map { user -> Attendance(meeting, user) } + attendanceRepository.saveAll(attendances) + } +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/service/AttendanceUpdateService.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/service/AttendanceUpdateService.kt new file mode 100644 index 00000000..cc8f98ac --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/service/AttendanceUpdateService.kt @@ -0,0 +1,33 @@ +package com.weeth.domain.attendance.domain.service + +import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.attendance.domain.enums.Status +import org.springframework.stereotype.Service + +@Service +class AttendanceUpdateService { + fun attend(attendance: Attendance) { + attendance.attend() + attendance.user.attend() + } + + fun close(attendances: List) { + attendances + .filter { it.isPending } + .forEach { attendance -> + attendance.close() + attendance.user.absent() + } + } + + fun updateUserAttendanceByStatus(attendances: List) { + attendances.forEach { attendance -> + val user = attendance.user + if (attendance.status == Status.ATTEND) { + user.removeAttend() + } else { + user.removeAbsent() + } + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/infrastructure/AttendanceScheduler.kt b/src/main/kotlin/com/weeth/domain/attendance/infrastructure/AttendanceScheduler.kt new file mode 100644 index 00000000..a41113e6 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/infrastructure/AttendanceScheduler.kt @@ -0,0 +1,15 @@ +package com.weeth.domain.attendance.infrastructure + +import com.weeth.domain.attendance.application.usecase.command.CloseAttendanceUseCase +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component + +@Component +class AttendanceScheduler( + private val closeAttendanceUseCase: CloseAttendanceUseCase, +) { + @Scheduled(cron = "0 0 22 * * THU", zone = "Asia/Seoul") + fun autoCloseAttendance() { + closeAttendanceUseCase.autoClose() + } +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceAdminController.kt b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceAdminController.kt new file mode 100644 index 00000000..4e8ca2a2 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceAdminController.kt @@ -0,0 +1,72 @@ +package com.weeth.domain.attendance.presentation + +import com.weeth.domain.attendance.application.dto.request.UpdateAttendanceStatusRequest +import com.weeth.domain.attendance.application.dto.response.AttendanceInfoResponse +import com.weeth.domain.attendance.application.exception.AttendanceErrorCode +import com.weeth.domain.attendance.application.usecase.command.CloseAttendanceUseCase +import com.weeth.domain.attendance.application.usecase.command.UpdateAttendanceStatusUseCase +import com.weeth.domain.attendance.application.usecase.query.GetAttendanceQueryService +import com.weeth.domain.schedule.application.dto.MeetingDTO +import com.weeth.domain.schedule.application.usecase.MeetingUseCase +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import java.time.LocalDate + +@Tag(name = "ATTENDANCE ADMIN", description = "[ADMIN] 출석 어드민 API") +@RestController +@RequestMapping("/api/v1/admin/attendances") +@ApiErrorCodeExample(AttendanceErrorCode::class) +class AttendanceAdminController( + private val closeAttendanceUseCase: CloseAttendanceUseCase, + private val updateAttendanceStatusUseCase: UpdateAttendanceStatusUseCase, + private val getAttendanceQueryService: GetAttendanceQueryService, + private val meetingUseCase: MeetingUseCase, +) { + @PatchMapping + @Operation(summary = "출석 마감") + fun close( + @RequestParam now: LocalDate, + @RequestParam cardinal: Int, + ): CommonResponse { + closeAttendanceUseCase.close(now, cardinal) + return CommonResponse.success(AttendanceResponseCode.ATTENDANCE_CLOSE_SUCCESS) + } + + @GetMapping("/meetings") + @Operation(summary = "정기모임 조회") + fun getMeetings( + @RequestParam(required = false) cardinal: Int?, + ): CommonResponse { + val response = meetingUseCase.find(cardinal) + return CommonResponse.success(AttendanceResponseCode.MEETING_FIND_SUCCESS, response) + } + + @GetMapping("/{meetingId}") + @Operation(summary = "모든 인원 정기모임 출석 정보 조회") + fun getAllAttendance( + @PathVariable meetingId: Long, + ): CommonResponse> = + CommonResponse.success( + AttendanceResponseCode.ATTENDANCE_FIND_DETAIL_SUCCESS, + getAttendanceQueryService.findAllAttendanceByMeeting(meetingId), + ) + + @PatchMapping("/status") + @Operation(summary = "모든 인원 정기모임 개별 출석 상태 수정") + fun updateAttendanceStatus( + @RequestBody @Valid attendanceUpdates: List, + ): CommonResponse { + updateAttendanceStatusUseCase.updateStatus(attendanceUpdates) + return CommonResponse.success(AttendanceResponseCode.ATTENDANCE_UPDATED_SUCCESS) + } +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt new file mode 100644 index 00000000..8a1256b7 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt @@ -0,0 +1,55 @@ +package com.weeth.domain.attendance.presentation + +import com.weeth.domain.attendance.application.dto.request.CheckInRequest +import com.weeth.domain.attendance.application.dto.response.AttendanceDetailResponse +import com.weeth.domain.attendance.application.dto.response.AttendanceSummaryResponse +import com.weeth.domain.attendance.application.exception.AttendanceErrorCode +import com.weeth.domain.attendance.application.usecase.command.CheckInAttendanceUseCase +import com.weeth.domain.attendance.application.usecase.query.GetAttendanceQueryService +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "ATTENDANCE", description = "출석 API") +@RestController +@RequestMapping("/api/v1/attendances") +@ApiErrorCodeExample(AttendanceErrorCode::class) +class AttendanceController( + private val checkInAttendanceUseCase: CheckInAttendanceUseCase, + private val getAttendanceQueryService: GetAttendanceQueryService, +) { + @PatchMapping + @Operation(summary = "출석체크") + fun checkIn( + @Parameter(hidden = true) @CurrentUser userId: Long, + @RequestBody checkIn: CheckInRequest, + ): CommonResponse { + checkInAttendanceUseCase.checkIn(userId, checkIn.code) + return CommonResponse.success(AttendanceResponseCode.ATTENDANCE_CHECKIN_SUCCESS) + } + + @GetMapping + @Operation(summary = "출석 메인페이지") + fun find( + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success(AttendanceResponseCode.ATTENDANCE_FIND_SUCCESS, getAttendanceQueryService.findAttendance(userId)) + + @GetMapping("/detail") + @Operation(summary = "출석 내역 상세조회") + fun findAll( + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success( + AttendanceResponseCode.ATTENDANCE_FIND_ALL_SUCCESS, + getAttendanceQueryService.findAllDetailsByCurrentCardinal(userId), + ) +} diff --git a/src/main/java/com/weeth/domain/attendance/presentation/AttendanceResponseCode.java b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceResponseCode.kt similarity index 58% rename from src/main/java/com/weeth/domain/attendance/presentation/AttendanceResponseCode.java rename to src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceResponseCode.kt index d3dd0068..54ad6348 100644 --- a/src/main/java/com/weeth/domain/attendance/presentation/AttendanceResponseCode.java +++ b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceResponseCode.kt @@ -1,29 +1,21 @@ -package com.weeth.domain.attendance.presentation; +package com.weeth.domain.attendance.presentation -import com.weeth.global.common.response.ResponseCodeInterface; -import lombok.Getter; -import org.springframework.http.HttpStatus; +import com.weeth.global.common.response.ResponseCodeInterface +import org.springframework.http.HttpStatus -@Getter -public enum AttendanceResponseCode implements ResponseCodeInterface { - //AttendanceAdminController 관련 +enum class AttendanceResponseCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ResponseCodeInterface { + // AttendanceAdminController 관련 ATTENDANCE_CLOSE_SUCCESS(1200, HttpStatus.OK, "출석이 성공적으로 마감되었습니다."), ATTENDANCE_UPDATED_SUCCESS(1201, HttpStatus.OK, "개별 출석 상태가 성공적으로 수정되었습니다."), ATTENDANCE_FIND_DETAIL_SUCCESS(1202, HttpStatus.OK, "모든 인원의 정기모임 출석 정보가 성공적으로 조회되었습니다."), MEETING_FIND_SUCCESS(1203, HttpStatus.OK, "기수별 정기모임 리스트를 성공적으로 조회했습니다."), - //AttendanceController 관련 + // AttendanceController 관련 ATTENDANCE_CHECKIN_SUCCESS(1204, HttpStatus.OK, "출석이 성공적으로 처리되었습니다."), ATTENDANCE_FIND_SUCCESS(1205, HttpStatus.OK, "사용자의 출석 정보가 성공적으로 조회되었습니다."), - ATTENDANCE_FIND_ALL_SUCCESS(1206, HttpStatus.OK, "사용자의 상세 출석 정보가 성공적으로 조회되었습니다."); - - private final int code; - private final HttpStatus status; - private final String message; - - AttendanceResponseCode(int code, HttpStatus status, String message) { - this.code = code; - this.status = status; - this.message = message; - } + ATTENDANCE_FIND_ALL_SUCCESS(1206, HttpStatus.OK, "사용자의 상세 출석 정보가 성공적으로 조회되었습니다."), } diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapperTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapperTest.kt index 2c38f017..f737afe3 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapperTest.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapperTest.kt @@ -13,41 +13,40 @@ import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe -import org.mapstruct.factory.Mappers import java.time.LocalDate class AttendanceMapperTest : DescribeSpec({ - val attendanceMapper = Mappers.getMapper(AttendanceMapper::class.java) + val mapper = AttendanceMapper() - describe("toMainDto") { - it("사용자 + 당일 출석 객체를 Main DTO로 매핑한다") { + describe("toSummaryResponse") { + it("사용자 + 당일 출석 객체를 MainResponse로 매핑한다") { val today = LocalDate.now() val meeting = createOneDayMeeting(today, 1, 1111, "Today") val user = createActiveUserWithAttendances("이지훈", listOf(meeting)) val attendance = user.attendances[0] - val main = attendanceMapper.toMainDto(user, attendance) + val main = mapper.toSummaryResponse(user, attendance) main.shouldNotBeNull() - main.title() shouldBe "Today" - main.status() shouldBe attendance.status - main.start() shouldBe meeting.start - main.end() shouldBe meeting.end - main.location() shouldBe meeting.location + main.title shouldBe meeting.title + main.status shouldBe attendance.status + main.start shouldBe meeting.start + main.end shouldBe meeting.end + main.location shouldBe meeting.location } - it("todayAttendance가 null이면 필드는 null로 매핑") { + it("attendance가 null이면 필드는 null로 매핑") { val user = createActiveUser("이지훈") - val main = attendanceMapper.toMainDto(user, null) + val main = mapper.toSummaryResponse(user, null) main.shouldNotBeNull() - main.title().shouldBeNull() - main.start().shouldBeNull() - main.end().shouldBeNull() - main.location().shouldBeNull() + main.title.shouldBeNull() + main.start.shouldBeNull() + main.end.shouldBeNull() + main.location.shouldBeNull() } it("일반 유저는 출석 코드가 null로 매핑된다") { @@ -56,33 +55,50 @@ class AttendanceMapperTest : val user = createActiveUserWithAttendances("일반유저", listOf(meeting)) val attendance = user.attendances[0] - val main = attendanceMapper.toMainDto(user, attendance) + val main = mapper.toSummaryResponse(user, attendance) main.shouldNotBeNull() - main.code().shouldBeNull() - main.title() shouldBe "Today" - main.status() shouldBe attendance.status + main.code.shouldBeNull() + main.title shouldBe meeting.title + main.status shouldBe attendance.status + } + + it("ADMIN 유저는 출석 코드가 포함된다") { + val today = LocalDate.now() + val expectedCode = 1234 + val meeting = createOneDayMeeting(today, 1, expectedCode, "Today") + val adminUser = createAdminUserWithAttendances("관리자", listOf(meeting)) + val attendance = adminUser.attendances[0] + + val main = mapper.toSummaryResponse(adminUser, attendance, isAdmin = true) + + main.shouldNotBeNull() + main.code shouldBe expectedCode + main.title shouldBe meeting.title + main.start shouldBe meeting.start + main.end shouldBe meeting.end + main.location shouldBe meeting.location } } - describe("toResponseDto") { - it("단일 출석을 Response DTO로 매핑한다") { + describe("toResponse") { + it("단일 출석을 AttendanceResponse로 매핑한다") { val meeting = createOneDayMeeting(LocalDate.now().minusDays(1), 1, 2222, "D-1") val user = createActiveUser("사용자A") val attendance = createAttendance(meeting, user) - val response = attendanceMapper.toResponseDto(attendance) + val response = mapper.toResponse(attendance) response.shouldNotBeNull() - response.title() shouldBe "D-1" - response.start() shouldBe meeting.start - response.end() shouldBe meeting.end - response.location() shouldBe meeting.location + response.title shouldBe meeting.title + response.start shouldBe meeting.start + response.end shouldBe meeting.end + response.location shouldBe meeting.location } } - describe("toDetailDto") { - it("사용자 + Response 리스트를 Detail DTO로 매핑(total = attend + absence)") { + describe("toDetailResponse") { + it("사용자 + Response 리스트를 DetailResponse로 매핑(total = attend + absence)") { val base = LocalDate.now() val m1 = createOneDayMeeting(base.minusDays(2), 1, 1000, "D-2") val m2 = createOneDayMeeting(base.minusDays(1), 1, 1001, "D-1") @@ -92,19 +108,19 @@ class AttendanceMapperTest : val a1 = createAttendance(m1, user) val a2 = createAttendance(m2, user) - val r1 = attendanceMapper.toResponseDto(a1) - val r2 = attendanceMapper.toResponseDto(a2) + val r1 = mapper.toResponse(a1) + val r2 = mapper.toResponse(a2) - val detail = attendanceMapper.toDetailDto(user, listOf(r1, r2)) + val detail = mapper.toDetailResponse(user, listOf(r1, r2)) detail.shouldNotBeNull() - detail.attendances() shouldBe listOf(r1, r2) - detail.total() shouldBe 5 + detail.attendances shouldBe listOf(r1, r2) + detail.total shouldBe user.attendanceCount + user.absenceCount } } - describe("toAttendanceInfoDto") { - it("Attendance를 Info DTO로 매핑") { + describe("toInfoResponse") { + it("Attendance를 InfoResponse로 매핑") { val meeting = createOneDayMeeting(LocalDate.now(), 1, 3333, "Info") val user = createActiveUser("유저B") enrichUserProfile(user, Position.BE, "컴퓨터공학과", "20201234") @@ -112,31 +128,12 @@ class AttendanceMapperTest : val attendance = createAttendance(meeting, user) setAttendanceId(attendance, 10L) - val info = attendanceMapper.toAttendanceInfoDto(attendance) + val info = mapper.toInfoResponse(attendance) info.shouldNotBeNull() - info.id() shouldBe 10L - info.status() shouldBe attendance.status - info.name() shouldBe "유저B" - } - } - - describe("toAdminResponse") { - it("ADMIN 유저는 출석 코드가 포함된다") { - val today = LocalDate.now() - val expectedCode = 1234 - val meeting = createOneDayMeeting(today, 1, expectedCode, "Today") - val adminUser = createAdminUserWithAttendances("관리자", listOf(meeting)) - val attendance = adminUser.attendances[0] - - val main = attendanceMapper.toAdminResponse(adminUser, attendance) - - main.shouldNotBeNull() - main.code() shouldBe expectedCode - main.title() shouldBe "Today" - main.start() shouldBe meeting.start - main.end() shouldBe meeting.end - main.location() shouldBe meeting.location + info.id shouldBe attendance.id + info.status shouldBe attendance.status + info.name shouldBe user.name } } }) diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/AttendanceUseCaseImplTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/AttendanceUseCaseImplTest.kt deleted file mode 100644 index 6f41a783..00000000 --- a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/AttendanceUseCaseImplTest.kt +++ /dev/null @@ -1,287 +0,0 @@ -package com.weeth.domain.attendance.application.usecase - -import com.weeth.domain.attendance.application.dto.AttendanceDTO -import com.weeth.domain.attendance.application.exception.AttendanceCodeMismatchException -import com.weeth.domain.attendance.application.exception.AttendanceNotFoundException -import com.weeth.domain.attendance.application.mapper.AttendanceMapper -import com.weeth.domain.attendance.domain.entity.Attendance -import com.weeth.domain.attendance.domain.entity.enums.Status -import com.weeth.domain.attendance.domain.service.AttendanceGetService -import com.weeth.domain.attendance.domain.service.AttendanceUpdateService -import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createActiveUserWithAttendances -import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createInProgressMeeting -import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createOneDayMeeting -import com.weeth.domain.schedule.application.exception.MeetingNotFoundException -import com.weeth.domain.schedule.domain.entity.Meeting -import com.weeth.domain.schedule.domain.service.MeetingGetService -import com.weeth.domain.user.domain.entity.Cardinal -import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.service.UserCardinalGetService -import com.weeth.domain.user.domain.service.UserGetService -import io.kotest.assertions.throwables.shouldNotThrowAny -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.core.spec.style.DescribeSpec -import io.kotest.matchers.shouldBe -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import java.time.LocalDate -import java.time.LocalDateTime - -class AttendanceUseCaseImplTest : - DescribeSpec({ - - val userId = 10L - val userGetService = mockk() - val userCardinalGetService = mockk() - val attendanceGetService = mockk() - val attendanceUpdateService = mockk(relaxUnitFun = true) - val attendanceMapper = mockk() - val meetingGetService = mockk() - - val attendanceUseCase = - AttendanceUseCaseImpl( - userGetService, - userCardinalGetService, - attendanceGetService, - attendanceUpdateService, - attendanceMapper, - meetingGetService, - ) - - describe("find") { - it("여러 날짜의 출석 목록 중 시작/종료 날짜가 모두 오늘인 출석정보를 선택") { - val today = LocalDate.now() - - val meetingYesterday = createOneDayMeeting(today.minusDays(1), 1, 1111, "Yesterday") - val meetingToday = createOneDayMeeting(today, 1, 2222, "Today") - val meetingTomorrow = createOneDayMeeting(today.plusDays(1), 1, 3333, "Tomorrow") - - val user = - createActiveUserWithAttendances( - "이지훈", - listOf(meetingYesterday, meetingToday, meetingTomorrow), - ) - - val expectedTodayAttendance = - user.attendances.first { - it.meeting.title == "Today" - } - - val mapped = mockk() - - every { userGetService.find(userId) } returns user - every { attendanceMapper.toMainDto(eq(user), eq(expectedTodayAttendance)) } returns mapped - - val actual = attendanceUseCase.find(userId) - - actual shouldBe mapped - verify { attendanceMapper.toMainDto(eq(user), eq(expectedTodayAttendance)) } - } - - it("시작/종료 날짜가 모두 오늘인 출석이 없다면 mapper.toMainDto(user, null)을 호출") { - val today = LocalDate.now() - - val yesterdayMeeting = createOneDayMeeting(today.minusDays(1), 1, 1111, "Yesterday") - val tomorrowMeeting = createOneDayMeeting(today.plusDays(1), 1, 3333, "Tomorrow") - - val user = - createActiveUserWithAttendances( - "이지훈", - listOf(yesterdayMeeting, tomorrowMeeting), - ) - - val mapped = mockk() - every { userGetService.find(userId) } returns user - every { attendanceMapper.toMainDto(user, null) } returns mapped - - val actual = attendanceUseCase.find(userId) - - actual shouldBe mapped - verify { attendanceMapper.toMainDto(user, null) } - } - } - - describe("checkIn") { - context("10분 전부터 출석이 가능한지 확인") { - it("5분 뒤 시작 회의에 출석 성공") { - val now = LocalDateTime.now() - val meeting = - Meeting - .builder() - .start(now.plusMinutes(5)) - .end(now.plusHours(2)) - .code(1234) - .title("Today") - .cardinal(1) - .build() - - val user = createActiveUserWithAttendances("이지훈", listOf(meeting)) - - every { userGetService.find(userId) } returns user - - shouldNotThrowAny { - attendanceUseCase.checkIn(userId, 1234) - } - verify(exactly = 1) { attendanceUpdateService.attend(any()) } - } - - it("11분 전에 출석시 오류 발생") { - val now = LocalDateTime.now() - val meeting = - Meeting - .builder() - .start(now.plusMinutes(11)) - .end(now.plusHours(2)) - .code(1234) - .title("Today") - .cardinal(1) - .build() - - val user = createActiveUserWithAttendances("이지훈", listOf(meeting)) - - every { userGetService.find(userId) } returns user - - shouldThrow { - attendanceUseCase.checkIn(userId, 1234) - } - } - } - - context("진행 중 정기모임이고 코드 일치하며 상태가 ATTEND가 아닐 때") { - it("출석 처리된다") { - val user = mockk() - val inProgressMeeting = createInProgressMeeting(1, 1234, "InProgress") - val attendance = mockk() - every { attendance.meeting } returns inProgressMeeting - every { attendance.isWrong(1234) } returns false - every { attendance.status } returns Status.PENDING - - every { userGetService.find(userId) } returns user - every { user.attendances } returns listOf(attendance) - - attendanceUseCase.checkIn(userId, 1234) - - verify { attendanceUpdateService.attend(attendance) } - } - } - - context("진행 중 정기모임이 없을 때") { - it("AttendanceNotFoundException") { - val user = mockk() - every { userGetService.find(userId) } returns user - every { user.attendances } returns listOf() - - shouldThrow { - attendanceUseCase.checkIn(userId, 1234) - } - } - } - - context("코드 불일치 시") { - it("AttendanceCodeMismatchException") { - val user = mockk() - val inProgressMeeting = createInProgressMeeting(1, 1234, "InProgress") - - val attendance = mockk() - every { attendance.meeting } returns inProgressMeeting - every { attendance.isWrong(9999) } returns true - - every { userGetService.find(userId) } returns user - every { user.attendances } returns listOf(attendance) - - shouldThrow { - attendanceUseCase.checkIn(userId, 9999) - } - } - } - - context("이미 ATTEND일 때") { - it("추가 처리 없이 종료") { - val user = mockk() - val inProgressMeeting = createInProgressMeeting(1, 1234, "InProgress") - - val attendance = mockk() - every { attendance.meeting } returns inProgressMeeting - every { attendance.isWrong(1234) } returns false - every { attendance.status } returns Status.ATTEND - - every { userGetService.find(userId) } returns user - every { user.attendances } returns listOf(attendance) - - attendanceUseCase.checkIn(userId, 1234) - - verify(exactly = 0) { attendanceUpdateService.attend(attendance) } - } - } - } - - describe("findAllDetailsByCurrentCardinal") { - it("현재 기수만 필터링·정렬하여 Detail 매핑") { - val today = LocalDate.now() - val meetingDayMinus1 = createOneDayMeeting(today.minusDays(1), 1, 1111, "D-1") - val meetingToday = createOneDayMeeting(today, 1, 2222, "D-Day") - val user = createActiveUserWithAttendances("이지훈", listOf(meetingDayMinus1, meetingToday)) - - val userAttendances = user.attendances - val attendanceFirst = userAttendances[0] - val attendanceSecond = userAttendances[1] - - every { userGetService.find(userId) } returns user - val currentCardinal = mockk() - every { currentCardinal.cardinalNumber } returns 1 - every { userCardinalGetService.getCurrentCardinal(user) } returns currentCardinal - - val responseFirst = mockk() - val responseSecond = mockk() - every { attendanceMapper.toResponseDto(attendanceFirst) } returns responseFirst - every { attendanceMapper.toResponseDto(attendanceSecond) } returns responseSecond - - val expectedDetail = mockk() - every { attendanceMapper.toDetailDto(eq(user), any()) } returns expectedDetail - - val actualDetail = attendanceUseCase.findAllDetailsByCurrentCardinal(userId) - - actualDetail shouldBe expectedDetail - verify { - attendanceMapper.toDetailDto( - eq(user), - match { it.size == 2 }, - ) - } - } - } - - describe("close") { - it("당일 정기모임을 찾아 close") { - val now = LocalDate.now() - val targetMeeting = createOneDayMeeting(now, 1, 1111, "Today") - val otherMeeting = createOneDayMeeting(now.minusDays(1), 1, 9999, "Yesterday") - - val attendance1 = mockk() - val attendance2 = mockk() - - every { meetingGetService.find(1) } returns listOf(targetMeeting, otherMeeting) - every { attendanceGetService.findAllByMeeting(targetMeeting) } returns listOf(attendance1, attendance2) - - attendanceUseCase.close(now, 1) - - verify { - attendanceUpdateService.close( - match { it.size == 2 && it.containsAll(listOf(attendance1, attendance2)) }, - ) - } - } - - it("당일 정기모임이 없으면 MeetingNotFoundException") { - val now = LocalDate.now() - val otherDayMeeting = createOneDayMeeting(now.minusDays(1), 1, 9999, "Yesterday") - - every { meetingGetService.find(1) } returns listOf(otherDayMeeting) - - shouldThrow { - attendanceUseCase.close(now, 1) - } - } - } - }) diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/CheckInAttendanceUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/CheckInAttendanceUseCaseTest.kt new file mode 100644 index 00000000..3e96b276 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/CheckInAttendanceUseCaseTest.kt @@ -0,0 +1,88 @@ +package com.weeth.domain.attendance.application.usecase.command + +import com.weeth.domain.attendance.application.exception.AttendanceCodeMismatchException +import com.weeth.domain.attendance.application.exception.AttendanceNotFoundException +import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.attendance.domain.enums.Status +import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.user.domain.entity.User +import com.weeth.domain.user.domain.service.UserGetService +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify + +class CheckInAttendanceUseCaseTest : + DescribeSpec({ + + val userId = 10L + val userGetService = mockk() + val attendanceRepository = mockk() + + val useCase = CheckInAttendanceUseCase(userGetService, attendanceRepository) + + describe("checkIn") { + context("진행 중 정기모임이고 코드 일치하며 상태가 ATTEND가 아닐 때") { + it("출석 처리된다") { + val user = mockk() + val attendance = mockk(relaxUnitFun = true) + every { attendance.isWrong(1234) } returns false + every { attendance.status } returns Status.PENDING + + every { userGetService.find(userId) } returns user + every { attendanceRepository.findCurrentByUserId(eq(userId), any(), any()) } returns attendance + every { user.attend() } returns Unit + + useCase.checkIn(userId, 1234) + + verify { attendance.attend() } + verify { user.attend() } + } + } + + context("진행 중 정기모임이 없을 때") { + it("AttendanceNotFoundException") { + val user = mockk() + every { userGetService.find(userId) } returns user + every { attendanceRepository.findCurrentByUserId(eq(userId), any(), any()) } returns null + + shouldThrow { + useCase.checkIn(userId, 1234) + } + } + } + + context("코드 불일치 시") { + it("AttendanceCodeMismatchException") { + val user = mockk() + val attendance = mockk() + every { attendance.isWrong(9999) } returns true + + every { userGetService.find(userId) } returns user + every { attendanceRepository.findCurrentByUserId(eq(userId), any(), any()) } returns attendance + + shouldThrow { + useCase.checkIn(userId, 9999) + } + } + } + + context("이미 ATTEND일 때") { + it("추가 처리 없이 종료") { + val user = mockk() + val attendance = mockk() + every { attendance.isWrong(1234) } returns false + every { attendance.status } returns Status.ATTEND + + every { userGetService.find(userId) } returns user + every { attendanceRepository.findCurrentByUserId(eq(userId), any(), any()) } returns attendance + + useCase.checkIn(userId, 1234) + + verify(exactly = 0) { attendance.attend() } + verify(exactly = 0) { user.attend() } + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/CloseAttendanceUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/CloseAttendanceUseCaseTest.kt new file mode 100644 index 00000000..63c3dd68 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/CloseAttendanceUseCaseTest.kt @@ -0,0 +1,62 @@ +package com.weeth.domain.attendance.application.usecase.command + +import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createOneDayMeeting +import com.weeth.domain.schedule.application.exception.MeetingNotFoundException +import com.weeth.domain.schedule.domain.service.MeetingGetService +import com.weeth.domain.user.domain.entity.User +import com.weeth.domain.user.domain.entity.enums.Status +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import java.time.LocalDate + +class CloseAttendanceUseCaseTest : + DescribeSpec({ + + val meetingGetService = mockk() + val attendanceRepository = mockk() + + val useCase = CloseAttendanceUseCase(meetingGetService, attendanceRepository) + + describe("close") { + it("당일 정기모임을 찾아 pending 출석을 close") { + val now = LocalDate.now() + val targetMeeting = createOneDayMeeting(now, 1, 1111, "Today") + val otherMeeting = createOneDayMeeting(now.minusDays(1), 1, 9999, "Yesterday") + + val pendingAttendance = mockk(relaxUnitFun = true) + val attendedAttendance = mockk(relaxUnitFun = true) + val pendingUser = mockk(relaxUnitFun = true) + + every { pendingAttendance.isPending } returns true + every { pendingAttendance.user } returns pendingUser + every { attendedAttendance.isPending } returns false + + every { meetingGetService.find(1) } returns listOf(targetMeeting, otherMeeting) + every { + attendanceRepository.findAllByMeetingAndUserStatus(targetMeeting, Status.ACTIVE) + } returns listOf(pendingAttendance, attendedAttendance) + + useCase.close(now, 1) + + verify { pendingAttendance.close() } + verify { pendingUser.absent() } + verify(exactly = 0) { attendedAttendance.close() } + } + + it("당일 정기모임이 없으면 MeetingNotFoundException") { + val now = LocalDate.now() + val otherDayMeeting = createOneDayMeeting(now.minusDays(1), 1, 9999, "Yesterday") + + every { meetingGetService.find(1) } returns listOf(otherDayMeeting) + + shouldThrow { + useCase.close(now, 1) + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/UpdateAttendanceStatusUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/UpdateAttendanceStatusUseCaseTest.kt new file mode 100644 index 00000000..ebbecb4e --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/UpdateAttendanceStatusUseCaseTest.kt @@ -0,0 +1,68 @@ +package com.weeth.domain.attendance.application.usecase.command + +import com.weeth.domain.attendance.application.dto.request.UpdateAttendanceStatusRequest +import com.weeth.domain.attendance.application.exception.AttendanceNotFoundException +import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.user.domain.entity.User +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify + +class UpdateAttendanceStatusUseCaseTest : + DescribeSpec({ + + val attendanceRepository = mockk() + + val useCase = UpdateAttendanceStatusUseCase(attendanceRepository) + + describe("updateStatus") { + context("ABSENT로 변경 시") { + it("close + removeAttend + absent 호출") { + val user = mockk(relaxUnitFun = true) + val attendance = mockk(relaxUnitFun = true) + every { attendance.user } returns user + + every { attendanceRepository.findByIdWithUser(1L) } returns attendance + + val request = UpdateAttendanceStatusRequest(attendanceId = 1L, status = "ABSENT") + useCase.updateStatus(listOf(request)) + + verify { attendance.close() } + verify { user.removeAttend() } + verify { user.absent() } + } + } + + context("ATTEND로 변경 시") { + it("attend + removeAbsent + attend 호출") { + val user = mockk(relaxUnitFun = true) + val attendance = mockk(relaxUnitFun = true) + every { attendance.user } returns user + + every { attendanceRepository.findByIdWithUser(1L) } returns attendance + + val request = UpdateAttendanceStatusRequest(attendanceId = 1L, status = "ATTEND") + useCase.updateStatus(listOf(request)) + + verify { attendance.attend() } + verify { user.removeAbsent() } + verify { user.attend() } + } + } + + context("출석 정보가 없을 때") { + it("AttendanceNotFoundException") { + every { attendanceRepository.findByIdWithUser(999L) } returns null + + val request = UpdateAttendanceStatusRequest(attendanceId = 999L, status = "ABSENT") + + shouldThrow { + useCase.updateStatus(listOf(request)) + } + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt new file mode 100644 index 00000000..6c600674 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt @@ -0,0 +1,127 @@ +package com.weeth.domain.attendance.application.usecase.query + +import com.weeth.domain.attendance.application.dto.response.AttendanceDetailResponse +import com.weeth.domain.attendance.application.dto.response.AttendanceInfoResponse +import com.weeth.domain.attendance.application.dto.response.AttendanceResponse +import com.weeth.domain.attendance.application.dto.response.AttendanceSummaryResponse +import com.weeth.domain.attendance.application.mapper.AttendanceMapper +import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createActiveUser +import com.weeth.domain.schedule.domain.entity.Meeting +import com.weeth.domain.schedule.domain.service.MeetingGetService +import com.weeth.domain.user.domain.entity.Cardinal +import com.weeth.domain.user.domain.entity.enums.Status +import com.weeth.domain.user.domain.service.UserCardinalGetService +import com.weeth.domain.user.domain.service.UserGetService +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify + +class GetAttendanceQueryServiceTest : + DescribeSpec({ + + val userGetService = mockk() + val userCardinalGetService = mockk() + val meetingGetService = mockk() + val attendanceRepository = mockk() + val attendanceMapper = mockk() + + val queryService = + GetAttendanceQueryService( + userGetService, + userCardinalGetService, + meetingGetService, + attendanceRepository, + attendanceMapper, + ) + + val userId = 10L + + describe("find") { + it("오늘 출석 정보가 있으면 mapper.toSummaryResponse(user, attendance, isAdmin=false) 호출") { + val user = createActiveUser("이지훈") + val todayAttendance = mockk() + val mapped = mockk() + + every { userGetService.find(userId) } returns user + every { attendanceRepository.findTodayByUserId(eq(userId), any(), any()) } returns todayAttendance + every { attendanceMapper.toSummaryResponse(eq(user), eq(todayAttendance), eq(false)) } returns mapped + + val actual = queryService.findAttendance(userId) + + actual shouldBe mapped + verify { attendanceMapper.toSummaryResponse(eq(user), eq(todayAttendance), eq(false)) } + } + + it("오늘 출석이 없다면 mapper.toSummaryResponse(user, null, isAdmin=false) 호출") { + val user = createActiveUser("이지훈") + val mapped = mockk() + + every { userGetService.find(userId) } returns user + every { attendanceRepository.findTodayByUserId(eq(userId), any(), any()) } returns null + every { attendanceMapper.toSummaryResponse(user, null, false) } returns mapped + + val actual = queryService.findAttendance(userId) + + actual shouldBe mapped + verify { attendanceMapper.toSummaryResponse(user, null, false) } + } + } + + describe("findAllDetailsByCurrentCardinal") { + it("현재 기수의 출석 목록을 매핑하여 Detail 반환") { + val user = createActiveUser("이지훈") + val attendance1 = mockk() + val attendance2 = mockk() + + every { userGetService.find(userId) } returns user + val currentCardinal = mockk() + every { currentCardinal.cardinalNumber } returns 1 + every { userCardinalGetService.getCurrentCardinal(user) } returns currentCardinal + every { attendanceRepository.findAllByUserIdAndCardinal(userId, 1) } returns listOf(attendance1, attendance2) + + val response1 = mockk() + val response2 = mockk() + every { attendanceMapper.toResponse(attendance1) } returns response1 + every { attendanceMapper.toResponse(attendance2) } returns response2 + + val expectedDetail = mockk() + every { attendanceMapper.toDetailResponse(eq(user), any()) } returns expectedDetail + + val actualDetail = queryService.findAllDetailsByCurrentCardinal(userId) + + actualDetail shouldBe expectedDetail + verify { + attendanceMapper.toDetailResponse( + eq(user), + match { it.size == 2 }, + ) + } + } + } + + describe("findAllAttendanceByMeeting") { + it("해당 정기모임의 출석 정보를 조회") { + val meetingId = 1L + val meeting = mockk() + val attendance1 = mockk() + val attendance2 = mockk() + val response1 = mockk() + val response2 = mockk() + + every { meetingGetService.find(meetingId) } returns meeting + every { + attendanceRepository.findAllByMeetingAndUserStatus(meeting, Status.ACTIVE) + } returns listOf(attendance1, attendance2) + every { attendanceMapper.toInfoResponse(attendance1) } returns response1 + every { attendanceMapper.toInfoResponse(attendance2) } returns response2 + + val result = queryService.findAllAttendanceByMeeting(meetingId) + + result shouldBe listOf(response1, response2) + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/attendance/domain/entity/AttendanceTest.kt b/src/test/kotlin/com/weeth/domain/attendance/domain/entity/AttendanceTest.kt new file mode 100644 index 00000000..bc61965e --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/attendance/domain/entity/AttendanceTest.kt @@ -0,0 +1,89 @@ +package com.weeth.domain.attendance.domain.entity + +import com.weeth.domain.attendance.domain.enums.Status +import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createActiveUser +import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createAttendance +import com.weeth.domain.schedule.fixture.ScheduleTestFixture.createMeeting +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe + +class AttendanceTest : + DescribeSpec({ + + describe("attend") { + it("상태를 ATTEND로 변경한다") { + val meeting = createMeeting() + val user = createActiveUser("테스트유저") + val attendance = createAttendance(meeting, user) + attendance.init() + + attendance.attend() + + attendance.status shouldBe Status.ATTEND + } + } + + describe("close") { + it("상태를 ABSENT로 변경한다") { + val meeting = createMeeting() + val user = createActiveUser("테스트유저") + val attendance = createAttendance(meeting, user) + attendance.init() + + attendance.close() + + attendance.status shouldBe Status.ABSENT + } + } + + describe("isPending") { + it("상태가 PENDING이면 true를 반환한다") { + val meeting = createMeeting() + val user = createActiveUser("테스트유저") + val attendance = createAttendance(meeting, user) + attendance.init() + + attendance.isPending shouldBe true + } + + it("상태가 PENDING이 아니면 false를 반환한다") { + val meeting = createMeeting() + val user = createActiveUser("테스트유저") + val attendance = createAttendance(meeting, user) + attendance.init() + attendance.attend() + + attendance.isPending shouldBe false + } + } + + describe("isWrong") { + it("코드가 일치하지 않으면 true를 반환한다") { + val meeting = createMeeting() + val user = createActiveUser("테스트유저") + val attendance = createAttendance(meeting, user) + + attendance.isWrong(9999) shouldBe true + } + + it("코드가 일치하면 false를 반환한다") { + val meeting = createMeeting() + val user = createActiveUser("테스트유저") + val attendance = createAttendance(meeting, user) + + attendance.isWrong(1234) shouldBe false + } + } + + describe("init") { + it("상태를 PENDING으로 초기화한다") { + val meeting = createMeeting() + val user = createActiveUser("테스트유저") + val attendance = createAttendance(meeting, user) + + attendance.init() + + attendance.status shouldBe Status.PENDING + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/attendance/domain/service/AttendanceUpdateServiceTest.kt b/src/test/kotlin/com/weeth/domain/attendance/domain/service/AttendanceUpdateServiceTest.kt deleted file mode 100644 index 13ac8305..00000000 --- a/src/test/kotlin/com/weeth/domain/attendance/domain/service/AttendanceUpdateServiceTest.kt +++ /dev/null @@ -1,81 +0,0 @@ -package com.weeth.domain.attendance.domain.service - -import com.weeth.domain.attendance.domain.entity.Attendance -import com.weeth.domain.attendance.domain.entity.enums.Status -import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createActiveUser -import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createAttendance -import com.weeth.domain.schedule.fixture.ScheduleTestFixture.createMeeting -import io.kotest.core.spec.style.DescribeSpec -import io.mockk.every -import io.mockk.spyk -import io.mockk.verify - -class AttendanceUpdateServiceTest : - DescribeSpec({ - - val attendanceUpdateService = AttendanceUpdateService() - - describe("attend") { - it("attendance.attend() + user.attend()을 호출한다") { - val meeting = createMeeting() - val userSpy = spyk(createActiveUser("이지훈")) - every { userSpy.attend() } returns Unit - - val attendanceSpy = spyk(createAttendance(meeting, userSpy)) - - attendanceUpdateService.attend(attendanceSpy) - - verify { attendanceSpy.attend() } - verify { userSpy.attend() } - } - } - - describe("close") { - it("pending만 close() + user.absent()을 호출한다") { - val meeting = createMeeting() - - val pendingUserSpy = spyk(createActiveUser("pending-user")) - val nonPendingUserSpy = spyk(createActiveUser("non-pending-user")) - every { pendingUserSpy.absent() } returns Unit - every { nonPendingUserSpy.absent() } returns Unit - - val pendingAttendanceSpy = spyk(createAttendance(meeting, pendingUserSpy)) - val nonPendingAttendanceSpy = spyk(createAttendance(meeting, nonPendingUserSpy)) - every { pendingAttendanceSpy.isPending } returns true - every { nonPendingAttendanceSpy.isPending } returns false - - attendanceUpdateService.close(listOf(pendingAttendanceSpy, nonPendingAttendanceSpy)) - - verify { pendingAttendanceSpy.close() } - verify { pendingUserSpy.absent() } - - verify(exactly = 0) { nonPendingAttendanceSpy.close() } - verify(exactly = 0) { nonPendingUserSpy.absent() } - } - } - - describe("updateUserAttendanceByStatus") { - it("ATTEND면 user.removeAttend(), 그 외에는 user.removeAbsent()") { - val meeting = createMeeting() - - val attendUserSpy = spyk(createActiveUser("attend-user")) - val absentUserSpy = spyk(createActiveUser("absent-user")) - every { attendUserSpy.removeAttend() } returns Unit - every { absentUserSpy.removeAbsent() } returns Unit - - val attendAttendanceSpy = spyk(createAttendance(meeting, attendUserSpy)) - val absentAttendanceSpy = spyk(createAttendance(meeting, absentUserSpy)) - every { attendAttendanceSpy.status } returns Status.ATTEND - every { absentAttendanceSpy.status } returns Status.ABSENT - every { attendAttendanceSpy.user } returns attendUserSpy - every { absentAttendanceSpy.user } returns absentUserSpy - - attendanceUpdateService.updateUserAttendanceByStatus( - listOf(attendAttendanceSpy, absentAttendanceSpy), - ) - - verify { attendUserSpy.removeAttend() } - verify { absentUserSpy.removeAbsent() } - } - } - }) From 868dee9073c6ed0e4d0979218f2e7544d9673efb Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Thu, 19 Feb 2026 21:26:07 +0900 Subject: [PATCH 09/73] =?UTF-8?q?[WTH-145]=20board=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 10 + .../board/application/dto/NoticeDTO.java | 70 ---- .../board/application/dto/PartPostDTO.java | 14 - .../domain/board/application/dto/PostDTO.java | 131 ------ .../application/exception/BoardErrorCode.java | 25 -- .../CategoryAccessDeniedException.java | 9 - .../exception/NoSearchResultException.java | 9 - .../exception/NoticeErrorCode.java | 22 - .../exception/NoticeNotFoundException.java | 9 - .../NoticeTypeNotMatchException.java | 9 - .../exception/PageNotFoundException.java | 9 - .../application/exception/PostErrorCode.java | 19 - .../exception/PostNotFoundException.java | 9 - .../application/mapper/NoticeMapper.java | 42 -- .../board/application/mapper/PostMapper.java | 74 ---- .../application/usecase/NoticeUsecase.java | 20 - .../usecase/NoticeUsecaseImpl.java | 157 ------- .../application/usecase/PostUseCaseImpl.java | 265 ------------ .../application/usecase/PostUsecase.java | 35 -- .../domain/converter/PartListConverter.java | 30 -- .../domain/board/domain/entity/Board.java | 83 ---- .../domain/board/domain/entity/Notice.java | 38 -- .../domain/board/domain/entity/Post.java | 76 ---- .../board/domain/entity/enums/Category.java | 7 - .../board/domain/entity/enums/Part.java | 9 - .../domain/repository/NoticeRepository.java | 31 -- .../domain/repository/PostRepository.java | 131 ------ .../domain/service/NoticeDeleteService.java | 19 - .../domain/service/NoticeFindService.java | 38 -- .../domain/service/NoticeSaveService.java | 18 - .../domain/service/NoticeUpdateService.java | 19 - .../domain/service/PostDeleteService.java | 18 - .../board/domain/service/PostFindService.java | 88 ---- .../board/domain/service/PostSaveService.java | 17 - .../domain/service/PostUpdateService.java | 19 - .../board/presentation/BoardResponseCode.java | 39 -- .../EducationAdminController.java | 46 --- .../presentation/NoticeAdminController.java | 55 --- .../board/presentation/NoticeController.java | 45 -- .../board/presentation/PostController.java | 105 ----- .../dto/response/UserResponseDto.java | 2 +- .../auth/annotation/CurrentUserRole.java | 11 + .../JwtAuthenticationProcessingFilter.java | 31 +- .../global/auth/model/AuthenticatedUser.java | 10 + .../resolver/CurrentUserArgumentResolver.java | 20 +- .../CurrentUserRoleArgumentResolver.java | 48 +++ .../controller/ExceptionDocController.java | 5 +- .../weeth/global/config/SecurityConfig.java | 6 +- .../com/weeth/global/config/WebMvcConfig.java | 9 +- .../global/config/swagger/SwaggerConfig.java | 10 +- .../dto/request/CreateBoardRequest.kt | 24 ++ .../dto/request/CreatePostRequest.kt | 23 ++ .../dto/request/UpdateBoardRequest.kt | 17 + .../dto/request/UpdatePostRequest.kt | 23 ++ .../dto/response/BoardDetailResponse.kt | 24 ++ .../dto/response/BoardListResponse.kt | 13 + .../dto/response/PostDetailResponse.kt | 28 ++ .../dto/response/PostListResponse.kt | 26 ++ .../dto/response/PostSaveResponse.kt | 8 + .../application/exception/BoardErrorCode.kt | 36 ++ .../exception/BoardNotFoundException.kt | 5 + .../CategoryAccessDeniedException.kt | 5 + .../exception/NoSearchResultException.kt | 5 + .../exception/PageNotFoundException.kt | 5 + .../exception/PostNotFoundException.kt | 5 + .../exception/PostNotOwnedException.kt | 5 + .../board/application/mapper/BoardMapper.kt | 38 ++ .../board/application/mapper/PostMapper.kt | 47 +++ .../usecase/command/ManageBoardUseCase.kt | 67 +++ .../usecase/command/ManagePostUseCase.kt | 138 +++++++ .../usecase/query/GetBoardQueryService.kt | 40 ++ .../usecase/query/GetPostQueryService.kt | 123 ++++++ .../domain/converter/BoardConfigConverter.kt | 9 + .../weeth/domain/board/domain/entity/Board.kt | 64 +++ .../weeth/domain/board/domain/entity/Post.kt | 106 +++++ .../board/domain/entity/enums/BoardType.kt | 8 + .../domain/board/domain/entity/enums/Part.kt | 9 + .../domain/repository/BoardRepository.kt | 12 + .../board/domain/repository/PostRepository.kt | 64 +++ .../domain/board/domain/vo/BoardConfig.kt | 9 + .../presentation/BoardAdminController.kt | 61 +++ .../board/presentation/BoardController.kt | 43 ++ .../board/presentation/BoardResponseCode.kt | 22 + .../board/presentation/PostController.kt | 106 +++++ .../dto/response/CommentResponse.kt | 9 + .../usecase/command/ManageCommentUseCase.kt | 81 +--- .../usecase/command/NoticeCommentUsecase.kt | 25 -- .../domain/comment/domain/entity/Comment.kt | 25 +- .../domain/repository/CommentReader.kt | 7 + .../domain/repository/CommentRepository.kt | 9 +- .../presentation/CommentResponseCode.kt | 3 - .../presentation/NoticeCommentController.kt | 65 --- .../presentation/PostCommentController.kt | 16 +- .../file/domain/entity/FileOwnerType.kt | 1 - .../file/domain/repository/FileReader.kt | 7 +- .../global/common/converter/JsonConverter.kt | 21 + .../com/weeth/config/TestContainersConfig.kt | 8 + .../application/mapper/PostMapperTest.kt | 102 +++-- .../usecase/NoticeUsecaseImplTest.kt | 252 ------------ .../usecase/PostUseCaseImplTest.kt | 288 ------------- .../usecase/command/ManageBoardUseCaseTest.kt | 82 ++++ .../usecase/command/ManagePostUseCaseTest.kt | 272 ++++++++++++ .../usecase/query/GetBoardQueryServiceTest.kt | 79 ++++ .../usecase/query/GetPostQueryServiceTest.kt | 233 +++++++++++ .../converter/BoardConfigConverterTest.kt | 30 ++ .../board/domain/entity/BoardEntityTest.kt | 121 ++++++ .../board/domain/entity/PostEntityTest.kt | 77 ++++ .../domain/repository/NoticeRepositoryTest.kt | 65 --- .../domain/board/fixture/BoardTestFixture.kt | 32 ++ .../domain/board/fixture/NoticeTestFixture.kt | 21 - .../domain/board/fixture/PostTestFixture.kt | 89 +--- .../usecase/command/CommentConcurrencyTest.kt | 327 +++++++++++++++ .../command/ManageCommentUseCaseTest.kt | 389 ++---------------- .../query/CommentQueryPerformanceTest.kt | 71 ++-- .../query/GetCommentQueryServiceTest.kt | 33 +- .../domain/entity/CommentEntityTest.kt | 73 +--- .../comment/fixture/CommentTestFixture.kt | 17 - .../domain/file/domain/entity/FileTest.kt | 4 +- .../domain/repository/FileRepositoryTest.kt | 6 +- .../domain/file/fixture/FileTestFixture.kt | 4 +- src/test/resources/application-test.yml | 45 +- 121 files changed, 2867 insertions(+), 3361 deletions(-) delete mode 100644 src/main/java/com/weeth/domain/board/application/dto/NoticeDTO.java delete mode 100644 src/main/java/com/weeth/domain/board/application/dto/PartPostDTO.java delete mode 100644 src/main/java/com/weeth/domain/board/application/dto/PostDTO.java delete mode 100644 src/main/java/com/weeth/domain/board/application/exception/BoardErrorCode.java delete mode 100644 src/main/java/com/weeth/domain/board/application/exception/CategoryAccessDeniedException.java delete mode 100644 src/main/java/com/weeth/domain/board/application/exception/NoSearchResultException.java delete mode 100644 src/main/java/com/weeth/domain/board/application/exception/NoticeErrorCode.java delete mode 100644 src/main/java/com/weeth/domain/board/application/exception/NoticeNotFoundException.java delete mode 100644 src/main/java/com/weeth/domain/board/application/exception/NoticeTypeNotMatchException.java delete mode 100644 src/main/java/com/weeth/domain/board/application/exception/PageNotFoundException.java delete mode 100644 src/main/java/com/weeth/domain/board/application/exception/PostErrorCode.java delete mode 100644 src/main/java/com/weeth/domain/board/application/exception/PostNotFoundException.java delete mode 100644 src/main/java/com/weeth/domain/board/application/mapper/NoticeMapper.java delete mode 100644 src/main/java/com/weeth/domain/board/application/mapper/PostMapper.java delete mode 100644 src/main/java/com/weeth/domain/board/application/usecase/NoticeUsecase.java delete mode 100644 src/main/java/com/weeth/domain/board/application/usecase/NoticeUsecaseImpl.java delete mode 100644 src/main/java/com/weeth/domain/board/application/usecase/PostUseCaseImpl.java delete mode 100644 src/main/java/com/weeth/domain/board/application/usecase/PostUsecase.java delete mode 100644 src/main/java/com/weeth/domain/board/domain/converter/PartListConverter.java delete mode 100644 src/main/java/com/weeth/domain/board/domain/entity/Board.java delete mode 100644 src/main/java/com/weeth/domain/board/domain/entity/Notice.java delete mode 100644 src/main/java/com/weeth/domain/board/domain/entity/Post.java delete mode 100644 src/main/java/com/weeth/domain/board/domain/entity/enums/Category.java delete mode 100644 src/main/java/com/weeth/domain/board/domain/entity/enums/Part.java delete mode 100644 src/main/java/com/weeth/domain/board/domain/repository/NoticeRepository.java delete mode 100644 src/main/java/com/weeth/domain/board/domain/repository/PostRepository.java delete mode 100644 src/main/java/com/weeth/domain/board/domain/service/NoticeDeleteService.java delete mode 100644 src/main/java/com/weeth/domain/board/domain/service/NoticeFindService.java delete mode 100644 src/main/java/com/weeth/domain/board/domain/service/NoticeSaveService.java delete mode 100644 src/main/java/com/weeth/domain/board/domain/service/NoticeUpdateService.java delete mode 100644 src/main/java/com/weeth/domain/board/domain/service/PostDeleteService.java delete mode 100644 src/main/java/com/weeth/domain/board/domain/service/PostFindService.java delete mode 100644 src/main/java/com/weeth/domain/board/domain/service/PostSaveService.java delete mode 100644 src/main/java/com/weeth/domain/board/domain/service/PostUpdateService.java delete mode 100644 src/main/java/com/weeth/domain/board/presentation/BoardResponseCode.java delete mode 100644 src/main/java/com/weeth/domain/board/presentation/EducationAdminController.java delete mode 100644 src/main/java/com/weeth/domain/board/presentation/NoticeAdminController.java delete mode 100644 src/main/java/com/weeth/domain/board/presentation/NoticeController.java delete mode 100644 src/main/java/com/weeth/domain/board/presentation/PostController.java create mode 100644 src/main/java/com/weeth/global/auth/annotation/CurrentUserRole.java create mode 100644 src/main/java/com/weeth/global/auth/model/AuthenticatedUser.java create mode 100644 src/main/java/com/weeth/global/auth/resolver/CurrentUserRoleArgumentResolver.java create mode 100644 src/main/kotlin/com/weeth/domain/board/application/dto/request/CreateBoardRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/dto/request/CreatePostRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdateBoardRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdatePostRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardDetailResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardListResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/dto/response/PostSaveResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/exception/BoardNotFoundException.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/exception/CategoryAccessDeniedException.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/exception/NoSearchResultException.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/exception/PageNotFoundException.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/exception/PostNotFoundException.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/exception/PostNotOwnedException.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/mapper/BoardMapper.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/domain/converter/BoardConfigConverter.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/domain/entity/Post.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/domain/entity/enums/BoardType.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/domain/entity/enums/Part.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/domain/repository/BoardRepository.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/domain/vo/BoardConfig.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt delete mode 100644 src/main/kotlin/com/weeth/domain/comment/application/usecase/command/NoticeCommentUsecase.kt create mode 100644 src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentReader.kt delete mode 100644 src/main/kotlin/com/weeth/domain/comment/presentation/NoticeCommentController.kt create mode 100644 src/main/kotlin/com/weeth/global/common/converter/JsonConverter.kt delete mode 100644 src/test/kotlin/com/weeth/domain/board/application/usecase/NoticeUsecaseImplTest.kt delete mode 100644 src/test/kotlin/com/weeth/domain/board/application/usecase/PostUseCaseImplTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/board/domain/converter/BoardConfigConverterTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/board/domain/entity/PostEntityTest.kt delete mode 100644 src/test/kotlin/com/weeth/domain/board/domain/repository/NoticeRepositoryTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt delete mode 100644 src/test/kotlin/com/weeth/domain/board/fixture/NoticeTestFixture.kt create mode 100644 src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb0cfdd4..e1d4298c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,16 @@ on: jobs: ci: runs-on: ubuntu-latest + services: + redis: + image: redis:7.2 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - name: Checkout diff --git a/src/main/java/com/weeth/domain/board/application/dto/NoticeDTO.java b/src/main/java/com/weeth/domain/board/application/dto/NoticeDTO.java deleted file mode 100644 index 4c6ed47e..00000000 --- a/src/main/java/com/weeth/domain/board/application/dto/NoticeDTO.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.weeth.domain.board.application.dto; - -import com.weeth.domain.comment.application.dto.response.CommentResponse; -import com.weeth.domain.file.application.dto.request.FileSaveRequest; -import com.weeth.domain.file.application.dto.response.FileResponse; -import com.weeth.domain.user.domain.entity.enums.Position; -import com.weeth.domain.user.domain.entity.enums.Role; -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotNull; -import lombok.Builder; - -import java.time.LocalDateTime; -import java.util.List; - -public class NoticeDTO { - - @Builder - public record Save( - @NotNull String title, - @NotNull String content, - @Valid List<@NotNull FileSaveRequest> files - ) { - } - - @Builder - public record Update( - @NotNull String title, - @NotNull String content, - @Valid List<@NotNull FileSaveRequest> files - ) { - } - - @Builder - public record Response( - Long id, - String name, - Position position, - Role role, - String title, - String content, - LocalDateTime time, //createdAt - Integer commentCount, - List comments, - List fileUrls - ) { - } - - @Builder - public record ResponseAll( - Long id, - String name, - Position position, - Role role, - String title, - String content, - LocalDateTime time,//modifiedAt - Integer commentCount, - boolean hasFile - ) { - } - - @Builder - public record SaveResponse( - @Schema(description = "공지사항 생성 응답", example = "1") - long id - ) { - } - -} diff --git a/src/main/java/com/weeth/domain/board/application/dto/PartPostDTO.java b/src/main/java/com/weeth/domain/board/application/dto/PartPostDTO.java deleted file mode 100644 index 72c2680c..00000000 --- a/src/main/java/com/weeth/domain/board/application/dto/PartPostDTO.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.weeth.domain.board.application.dto; - -import jakarta.validation.constraints.NotNull; -import com.weeth.domain.board.domain.entity.enums.Category; -import com.weeth.domain.board.domain.entity.enums.Part; - -public record PartPostDTO( - @NotNull Part part, - @NotNull Category category, - Integer cardinalNumber, - Integer week, - String studyName -) { -} diff --git a/src/main/java/com/weeth/domain/board/application/dto/PostDTO.java b/src/main/java/com/weeth/domain/board/application/dto/PostDTO.java deleted file mode 100644 index c0f26dd2..00000000 --- a/src/main/java/com/weeth/domain/board/application/dto/PostDTO.java +++ /dev/null @@ -1,131 +0,0 @@ -package com.weeth.domain.board.application.dto; - -import com.weeth.domain.board.domain.entity.enums.Category; -import com.weeth.domain.board.domain.entity.enums.Part; -import com.weeth.domain.comment.application.dto.response.CommentResponse; -import com.weeth.domain.file.application.dto.request.FileSaveRequest; -import com.weeth.domain.file.application.dto.response.FileResponse; -import com.weeth.domain.user.domain.entity.enums.Position; -import com.weeth.domain.user.domain.entity.enums.Role; -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import lombok.Builder; - -import java.time.LocalDateTime; -import java.util.List; - -public class PostDTO { - - @Builder - public record Save( - @NotBlank(message = "제목 입력은 필수입니다.") String title, - @NotBlank(message = "내용 입력은 필수입니다.") String content, - @NotNull Category category, - String studyName, - int week, - @NotNull Part part, - @NotNull Integer cardinalNumber, - @Valid List<@NotNull FileSaveRequest> files - ) { - } - - @Builder - public record SaveEducation( - @NotNull String title, - @NotNull String content, - @NotNull List parts, - @NotNull Integer cardinalNumber, - @Valid List<@NotNull FileSaveRequest> files - ) { - } - - @Builder - public record SaveResponse( - @Schema(description = "게시글 생성시 응답", example = "1") - long id - ) { - } - - @Builder - public record Update( - String title, - String content, - String studyName, - Integer week, - Part part, - Integer cardinalNumber, - @Valid List files - ) { - } - - @Builder - public record UpdateEducation( - String title, - String content, - List parts, - Integer cardinalNumber, - @Valid List files - ) { - } - - @Builder - public record Response( - Long id, - String name, - Position position, - Role role, - String title, - String content, - String studyName, - Integer week, - Integer cardinalNumber, - Part part, - List parts, - LocalDateTime time, - Integer commentCount, - List comments, - List fileUrls - ) { - } - - @Builder - public record ResponseAll( - Long id, - String name, - Part part, - Position position, - Role role, - String title, - String content, - String studyName, - int week, - LocalDateTime time, - Integer commentCount, - boolean hasFile, - boolean isNew - ) { - } - - @Builder - public record ResponseEducationAll( - Long id, - String name, - List parts, - Position position, - Role role, - String title, - String content, - LocalDateTime time, - Integer commentCount, - boolean hasFile, - boolean isNew - ) { - } - - public record ResponseStudyNames( - List studyNames - ) { - } -} diff --git a/src/main/java/com/weeth/domain/board/application/exception/BoardErrorCode.java b/src/main/java/com/weeth/domain/board/application/exception/BoardErrorCode.java deleted file mode 100644 index d6a1851d..00000000 --- a/src/main/java/com/weeth/domain/board/application/exception/BoardErrorCode.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.weeth.domain.board.application.exception; - -import com.weeth.global.common.exception.ErrorCodeInterface; -import com.weeth.global.common.exception.ExplainError; -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@AllArgsConstructor -public enum BoardErrorCode implements ErrorCodeInterface { - - @ExplainError("검색 조건에 맞는 게시글이 하나도 없을 때 발생합니다.") - NO_SEARCH_RESULT(2300, HttpStatus.NOT_FOUND, "일치하는 검색 결과를 찾을 수 없습니다."), - - @ExplainError("요청한 페이지 번호가 유효 범위를 벗어났을 때 발생합니다.") - PAGE_NOT_FOUND(2301, HttpStatus.NOT_FOUND, "존재하지 않는 페이지입니다."), - - @ExplainError("일반 유저가 어드민 전용 카테고리에 접근하려 할 때 발생합니다.") - CATEGORY_ACCESS_DENIED(2302, HttpStatus.FORBIDDEN, "어드민 유저만 접근 가능한 카테고리입니다"); - - private final int code; - private final HttpStatus status; - private final String message; -} diff --git a/src/main/java/com/weeth/domain/board/application/exception/CategoryAccessDeniedException.java b/src/main/java/com/weeth/domain/board/application/exception/CategoryAccessDeniedException.java deleted file mode 100644 index 3e67ae51..00000000 --- a/src/main/java/com/weeth/domain/board/application/exception/CategoryAccessDeniedException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.board.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class CategoryAccessDeniedException extends BaseException { - public CategoryAccessDeniedException() { - super(BoardErrorCode.CATEGORY_ACCESS_DENIED); - } -} diff --git a/src/main/java/com/weeth/domain/board/application/exception/NoSearchResultException.java b/src/main/java/com/weeth/domain/board/application/exception/NoSearchResultException.java deleted file mode 100644 index 475d7216..00000000 --- a/src/main/java/com/weeth/domain/board/application/exception/NoSearchResultException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.board.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class NoSearchResultException extends BaseException { - public NoSearchResultException() { - super(BoardErrorCode.NO_SEARCH_RESULT); - } -} diff --git a/src/main/java/com/weeth/domain/board/application/exception/NoticeErrorCode.java b/src/main/java/com/weeth/domain/board/application/exception/NoticeErrorCode.java deleted file mode 100644 index 06b62f98..00000000 --- a/src/main/java/com/weeth/domain/board/application/exception/NoticeErrorCode.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.weeth.domain.board.application.exception; - -import com.weeth.global.common.exception.ErrorCodeInterface; -import com.weeth.global.common.exception.ExplainError; -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@AllArgsConstructor -public enum NoticeErrorCode implements ErrorCodeInterface { - - @ExplainError("요청한 공지사항 ID에 해당하는 공지사항이 없을 때 발생합니다.") - NOTICE_NOT_FOUND(2303, HttpStatus.NOT_FOUND, "존재하지 않는 공지사항입니다."), - - @ExplainError("일반 게시판에서 공지사항을 수정하려 하거나, 그 반대의 경우 발생합니다.") - NOTICE_TYPE_NOT_MATCH(2304, HttpStatus.BAD_REQUEST, "공지사항은 공지사항 게시판에서 수정하세요."); - - private final int code; - private final HttpStatus status; - private final String message; -} diff --git a/src/main/java/com/weeth/domain/board/application/exception/NoticeNotFoundException.java b/src/main/java/com/weeth/domain/board/application/exception/NoticeNotFoundException.java deleted file mode 100644 index b42fb2d7..00000000 --- a/src/main/java/com/weeth/domain/board/application/exception/NoticeNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.board.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class NoticeNotFoundException extends BaseException { - public NoticeNotFoundException() { - super(NoticeErrorCode.NOTICE_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/domain/board/application/exception/NoticeTypeNotMatchException.java b/src/main/java/com/weeth/domain/board/application/exception/NoticeTypeNotMatchException.java deleted file mode 100644 index 51a51bb8..00000000 --- a/src/main/java/com/weeth/domain/board/application/exception/NoticeTypeNotMatchException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.board.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class NoticeTypeNotMatchException extends BaseException { - public NoticeTypeNotMatchException() { - super(NoticeErrorCode.NOTICE_TYPE_NOT_MATCH); - } -} diff --git a/src/main/java/com/weeth/domain/board/application/exception/PageNotFoundException.java b/src/main/java/com/weeth/domain/board/application/exception/PageNotFoundException.java deleted file mode 100644 index 7bcf73e7..00000000 --- a/src/main/java/com/weeth/domain/board/application/exception/PageNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.board.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class PageNotFoundException extends BaseException { - public PageNotFoundException() { - super(BoardErrorCode.PAGE_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/domain/board/application/exception/PostErrorCode.java b/src/main/java/com/weeth/domain/board/application/exception/PostErrorCode.java deleted file mode 100644 index 681dbb89..00000000 --- a/src/main/java/com/weeth/domain/board/application/exception/PostErrorCode.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.weeth.domain.board.application.exception; - -import com.weeth.global.common.exception.ErrorCodeInterface; -import com.weeth.global.common.exception.ExplainError; -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@AllArgsConstructor -public enum PostErrorCode implements ErrorCodeInterface { - - @ExplainError("요청한 게시글 ID에 해당하는 게시글이 없을 때 발생합니다.") - POST_NOT_FOUND(2305, HttpStatus.NOT_FOUND, "존재하지 않는 게시물입니다."); - - private final int code; - private final HttpStatus status; - private final String message; -} diff --git a/src/main/java/com/weeth/domain/board/application/exception/PostNotFoundException.java b/src/main/java/com/weeth/domain/board/application/exception/PostNotFoundException.java deleted file mode 100644 index 27e140b4..00000000 --- a/src/main/java/com/weeth/domain/board/application/exception/PostNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.board.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class PostNotFoundException extends BaseException { - public PostNotFoundException() { - super(PostErrorCode.POST_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/domain/board/application/mapper/NoticeMapper.java b/src/main/java/com/weeth/domain/board/application/mapper/NoticeMapper.java deleted file mode 100644 index 4acc286a..00000000 --- a/src/main/java/com/weeth/domain/board/application/mapper/NoticeMapper.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.weeth.domain.board.application.mapper; - -import com.weeth.domain.board.application.dto.NoticeDTO; -import com.weeth.domain.board.domain.entity.Notice; -import com.weeth.domain.comment.application.dto.response.CommentResponse; -import com.weeth.domain.comment.application.mapper.CommentMapper; -import com.weeth.domain.file.application.dto.response.FileResponse; -import com.weeth.domain.user.domain.entity.User; -import org.mapstruct.*; - -import java.util.List; - -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, uses = CommentMapper.class, unmappedTargetPolicy = ReportingPolicy.IGNORE) -public interface NoticeMapper { - - @Mappings({ - @Mapping(target = "id", ignore = true), - @Mapping(target = "user", source = "user") - }) - Notice fromNoticeDto(NoticeDTO.Save dto, User user); - - @Mappings({ - @Mapping(target = "name", source = "notice.user.name"), - @Mapping(target = "position", source = "notice.user.position"), - @Mapping(target = "role", source = "notice.user.role"), - @Mapping(target = "time", source = "notice.createdAt"), - @Mapping(target = "hasFile", expression = "java(fileExists)") - }) - NoticeDTO.ResponseAll toAll(Notice notice, boolean fileExists); - - @Mappings({ - @Mapping(target = "name", source = "notice.user.name"), - @Mapping(target = "position", source = "notice.user.position"), - @Mapping(target = "role", source = "notice.user.role"), - @Mapping(target = "time", source = "notice.createdAt"), - @Mapping(target = "comments", source = "comments") - }) - NoticeDTO.Response toNoticeDto(Notice notice, List fileUrls, List comments); - - NoticeDTO.SaveResponse toSaveResponse(Notice notice); - -} diff --git a/src/main/java/com/weeth/domain/board/application/mapper/PostMapper.java b/src/main/java/com/weeth/domain/board/application/mapper/PostMapper.java deleted file mode 100644 index db3924b5..00000000 --- a/src/main/java/com/weeth/domain/board/application/mapper/PostMapper.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.weeth.domain.board.application.mapper; - -import com.weeth.domain.board.application.dto.PostDTO; -import com.weeth.domain.board.domain.entity.Post; -import com.weeth.domain.comment.application.dto.response.CommentResponse; -import com.weeth.domain.comment.application.mapper.CommentMapper; -import com.weeth.domain.file.application.dto.response.FileResponse; -import com.weeth.domain.user.domain.entity.User; -import org.mapstruct.*; - -import java.util.List; - -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, uses = CommentMapper.class, unmappedTargetPolicy = ReportingPolicy.IGNORE, imports = { java.time.LocalDateTime.class }) -public interface PostMapper { - - @Mappings({ - @Mapping(target = "id", ignore = true), - @Mapping(target = "createdAt", ignore = true), - @Mapping(target = "modifiedAt", ignore = true), - @Mapping(target = "user", source = "user"), - @Mapping(target = "part", source = "dto.part"), - @Mapping(target = "parts", expression = "java(List.of(dto.part()))"), - @Mapping(target = "cardinalNumber", source = "dto.cardinalNumber") - }) - Post fromPostDto(PostDTO.Save dto, User user); - - @Mapping(target = "id", ignore = true) - @Mapping(target = "createdAt", ignore = true) - @Mapping(target = "modifiedAt", ignore = true) - @Mapping(target = "user", source = "user") - @Mapping(target = "part", ignore = true) - @Mapping(target = "parts", source = "dto.parts") - @Mapping(target = "cardinalNumber", source = "dto.cardinalNumber") - @Mapping(target = "category", constant = "Education") - Post fromEducationDto(PostDTO.SaveEducation dto, User user); - - PostDTO.SaveResponse toSaveResponse(Post post); - - @Mappings({ - @Mapping(target = "name", source = "post.user.name"), - @Mapping(target = "position", source = "post.user.position"), - @Mapping(target = "role", source = "post.user.role"), - @Mapping(target = "time", source = "post.createdAt"), - @Mapping(target = "hasFile", expression = "java(fileExists)"), - @Mapping(target = "isNew", expression = "java(post.getCreatedAt().isAfter(LocalDateTime.now().minusHours(24)))") - }) - PostDTO.ResponseAll toAll(Post post, boolean fileExists); - - @Mappings({ - @Mapping(target = "id", source = "post.id"), - @Mapping(target = "name", source = "post.user.name"), - @Mapping(target = "parts", source = "post.parts"), - @Mapping(target = "position", source = "post.user.position"), - @Mapping(target = "role", source = "post.user.role"), - @Mapping(target = "commentCount", source = "post.commentCount"), - @Mapping(target = "time", source = "post.createdAt"), - @Mapping(target = "hasFile", expression = "java(fileExists)"), - @Mapping(target = "isNew", expression = "java(post.getCreatedAt().isAfter(LocalDateTime.now().minusHours(24)))") - }) - PostDTO.ResponseEducationAll toEducationAll(Post post, boolean fileExists); - - @Mappings({ - @Mapping(target = "name", source = "post.user.name"), - @Mapping(target = "position", source = "post.user.position"), - @Mapping(target = "role", source = "post.user.role"), - @Mapping(target = "time", source = "post.createdAt"), - @Mapping(target = "comments", source = "comments") - }) - PostDTO.Response toPostDto(Post post, List fileUrls, List comments); - - default PostDTO.ResponseStudyNames toStudyNames(List studyNames) { - return new PostDTO.ResponseStudyNames(studyNames); - } -} diff --git a/src/main/java/com/weeth/domain/board/application/usecase/NoticeUsecase.java b/src/main/java/com/weeth/domain/board/application/usecase/NoticeUsecase.java deleted file mode 100644 index b2dc0d40..00000000 --- a/src/main/java/com/weeth/domain/board/application/usecase/NoticeUsecase.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.weeth.domain.board.application.usecase; - -import com.weeth.domain.board.application.dto.NoticeDTO; -import com.weeth.domain.user.application.exception.UserNotMatchException; -import org.springframework.data.domain.Slice; - - -public interface NoticeUsecase { - NoticeDTO.SaveResponse save(NoticeDTO.Save dto, Long userId); - - NoticeDTO.Response findNotice(Long noticeId); - - Slice findNotices(int pageNumber, int pageSize); - - NoticeDTO.SaveResponse update(Long noticeId, NoticeDTO.Update dto, Long userId) throws UserNotMatchException; - - void delete(Long noticeId, Long userId) throws UserNotMatchException; - - Slice searchNotice(String keyword, int pageNumber, int pageSize); -} diff --git a/src/main/java/com/weeth/domain/board/application/usecase/NoticeUsecaseImpl.java b/src/main/java/com/weeth/domain/board/application/usecase/NoticeUsecaseImpl.java deleted file mode 100644 index ea1e6ceb..00000000 --- a/src/main/java/com/weeth/domain/board/application/usecase/NoticeUsecaseImpl.java +++ /dev/null @@ -1,157 +0,0 @@ -package com.weeth.domain.board.application.usecase; - -import com.weeth.domain.board.application.dto.NoticeDTO; -import com.weeth.domain.board.application.exception.NoSearchResultException; -import com.weeth.domain.board.application.exception.PageNotFoundException; -import com.weeth.domain.board.application.mapper.NoticeMapper; -import com.weeth.domain.board.domain.entity.Notice; -import com.weeth.domain.board.domain.service.NoticeDeleteService; -import com.weeth.domain.board.domain.service.NoticeFindService; -import com.weeth.domain.board.domain.service.NoticeSaveService; -import com.weeth.domain.board.domain.service.NoticeUpdateService; -import com.weeth.domain.comment.application.dto.response.CommentResponse; -import com.weeth.domain.comment.application.usecase.query.GetCommentQueryService; -import com.weeth.domain.comment.domain.entity.Comment; -import com.weeth.domain.file.application.dto.response.FileResponse; -import com.weeth.domain.file.application.mapper.FileMapper; -import com.weeth.domain.file.domain.entity.File; -import com.weeth.domain.file.domain.entity.FileOwnerType; -import com.weeth.domain.file.domain.repository.FileReader; -import com.weeth.domain.file.domain.repository.FileRepository; -import com.weeth.domain.user.application.exception.UserNotMatchException; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.service.UserGetService; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -import org.springframework.data.domain.Sort; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class NoticeUsecaseImpl implements NoticeUsecase { - - private final NoticeSaveService noticeSaveService; - private final NoticeFindService noticeFindService; - private final NoticeUpdateService noticeUpdateService; - private final NoticeDeleteService noticeDeleteService; - - private final UserGetService userGetService; - - private final FileRepository fileRepository; - private final FileReader fileReader; - - private final NoticeMapper mapper; - private final GetCommentQueryService getCommentQueryService; - private final FileMapper fileMapper; - - @Override - @Transactional - public NoticeDTO.SaveResponse save(NoticeDTO.Save request, Long userId) { - User user = userGetService.find(userId); - - Notice notice = mapper.fromNoticeDto(request, user); - Notice savedNotice = noticeSaveService.save(notice); - - List files = fileMapper.toFileList(request.files(), FileOwnerType.NOTICE, savedNotice.getId()); - fileRepository.saveAll(files); - - return mapper.toSaveResponse(savedNotice); - } - - @Override - public NoticeDTO.Response findNotice(Long noticeId) { - Notice notice = noticeFindService.find(noticeId); - - List response = getFiles(noticeId).stream() - .map(fileMapper::toFileResponse) - .toList(); - - return mapper.toNoticeDto(notice, response, filterParentComments(notice.getComments())); - } - - @Override - public Slice findNotices(int pageNumber, int pageSize) { - if (pageNumber < 0) { - throw new PageNotFoundException(); - } - Pageable pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")); // id를 기준으로 내림차순 - Slice notices = noticeFindService.findRecentNotices(pageable); - return notices.map(notice->mapper.toAll(notice, checkFileExistsByNotice(notice.id))); - } - - @Override - public Slice searchNotice(String keyword, int pageNumber, int pageSize) { - validatePageNumber(pageNumber); - - keyword = keyword.strip(); - - Pageable pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")); - Slice notices = noticeFindService.search(keyword, pageable); - - if (notices.isEmpty()){ - throw new NoSearchResultException(); - } - - return notices.map(notice -> mapper.toAll(notice, checkFileExistsByNotice(notice.id))); - } - - @Override - @Transactional - public NoticeDTO.SaveResponse update(Long noticeId, NoticeDTO.Update dto, Long userId) { - Notice notice = validateOwner(noticeId, userId); - - if (dto.files() != null) { - List fileList = getFiles(noticeId); - fileRepository.deleteAll(fileList); - - List files = fileMapper.toFileList(dto.files(), FileOwnerType.NOTICE, notice.getId()); - fileRepository.saveAll(files); - } - - noticeUpdateService.update(notice, dto); - - return mapper.toSaveResponse(notice); - } - - @Override - @Transactional - public void delete(Long noticeId, Long userId) { - validateOwner(noticeId, userId); - - List fileList = getFiles(noticeId); - fileRepository.deleteAll(fileList); - - noticeDeleteService.delete(noticeId); - } - - private List getFiles(Long noticeId) { - return fileReader.findAll(FileOwnerType.NOTICE, noticeId, null); - } - - private Notice validateOwner(Long noticeId, Long userId) { - Notice notice = noticeFindService.find(noticeId); - if (!notice.getUser().getId().equals(userId)) { - throw new UserNotMatchException(); - } - return notice; - } - - private boolean checkFileExistsByNotice(Long noticeId){ - return fileReader.exists(FileOwnerType.NOTICE, noticeId, null); - } - - private List filterParentComments(List comments) { - return getCommentQueryService.toCommentTreeResponses(comments); - } - - private void validatePageNumber(int pageNumber){ - if (pageNumber < 0) { - throw new PageNotFoundException(); - } - } -} diff --git a/src/main/java/com/weeth/domain/board/application/usecase/PostUseCaseImpl.java b/src/main/java/com/weeth/domain/board/application/usecase/PostUseCaseImpl.java deleted file mode 100644 index e0546d38..00000000 --- a/src/main/java/com/weeth/domain/board/application/usecase/PostUseCaseImpl.java +++ /dev/null @@ -1,265 +0,0 @@ -package com.weeth.domain.board.application.usecase; - -import com.weeth.domain.board.application.dto.PartPostDTO; -import com.weeth.domain.board.application.dto.PostDTO; -import com.weeth.domain.board.application.exception.CategoryAccessDeniedException; -import com.weeth.domain.board.application.exception.NoSearchResultException; -import com.weeth.domain.board.application.exception.PageNotFoundException; -import com.weeth.domain.board.application.mapper.PostMapper; -import com.weeth.domain.board.domain.entity.Post; -import com.weeth.domain.board.domain.entity.enums.Category; -import com.weeth.domain.board.domain.entity.enums.Part; -import com.weeth.domain.board.domain.service.PostDeleteService; -import com.weeth.domain.board.domain.service.PostFindService; -import com.weeth.domain.board.domain.service.PostSaveService; -import com.weeth.domain.board.domain.service.PostUpdateService; -import com.weeth.domain.comment.application.dto.response.CommentResponse; -import com.weeth.domain.comment.application.usecase.query.GetCommentQueryService; -import com.weeth.domain.comment.domain.entity.Comment; -import com.weeth.domain.file.application.dto.response.FileResponse; -import com.weeth.domain.file.application.mapper.FileMapper; -import com.weeth.domain.file.domain.entity.File; -import com.weeth.domain.file.domain.entity.FileOwnerType; -import com.weeth.domain.file.domain.repository.FileReader; -import com.weeth.domain.file.domain.repository.FileRepository; -import com.weeth.domain.user.application.exception.UserNotMatchException; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.entity.enums.Role; -import com.weeth.domain.user.domain.service.CardinalGetService; -import com.weeth.domain.user.domain.service.UserCardinalGetService; -import com.weeth.domain.user.domain.service.UserGetService; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.*; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Collections; -import java.util.List; - -@Service -@RequiredArgsConstructor -public class PostUseCaseImpl implements PostUsecase { - - private final PostSaveService postSaveService; - private final PostFindService postFindService; - private final PostUpdateService postUpdateService; - private final PostDeleteService postDeleteService; - - private final UserGetService userGetService; - private final UserCardinalGetService userCardinalGetService; - private final CardinalGetService cardinalGetService; - - private final FileRepository fileRepository; - private final FileReader fileReader; - - private final PostMapper mapper; - private final FileMapper fileMapper; - private final GetCommentQueryService getCommentQueryService; - - @Override - @Transactional - public PostDTO.SaveResponse save(PostDTO.Save request, Long userId) { - User user = userGetService.find(userId); - - if (request.category() == Category.Education - && !user.hasRole(Role.ADMIN)) { - throw new CategoryAccessDeniedException(); - } - - cardinalGetService.findByUserSide(request.cardinalNumber()); - Post post = mapper.fromPostDto(request, user); - Post savedPost = postSaveService.save(post); - - List files = fileMapper.toFileList(request.files(), FileOwnerType.POST, savedPost.getId()); - fileRepository.saveAll(files); - - return mapper.toSaveResponse(savedPost); - } - - @Override - @Transactional - public PostDTO.SaveResponse saveEducation(PostDTO.SaveEducation request, Long userId) { - User user = userGetService.find(userId); - - Post post = mapper.fromEducationDto(request, user); - Post saverPost = postSaveService.save(post); - - List files = fileMapper.toFileList(request.files(), FileOwnerType.POST, saverPost.getId()); - fileRepository.saveAll(files); - - return mapper.toSaveResponse(saverPost); - } - - @Override - public PostDTO.Response findPost(Long postId) { - Post post = postFindService.find(postId); - - List response = getFiles(postId).stream() - .map(fileMapper::toFileResponse) - .toList(); - - return mapper.toPostDto(post, response, filterParentComments(post.getComments())); - } - - @Override - public Slice findPosts(int pageNumber, int pageSize) { - validatePageNumber(pageNumber); - - Pageable pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")); - Slice posts = postFindService.findRecentPosts(pageable); - - return posts.map(post->mapper.toAll(post, checkFileExistsByPost(post.id))); - } - - @Override - public Slice findPartPosts(PartPostDTO dto, int pageNumber, int pageSize) { - validatePageNumber(pageNumber); - - Pageable pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")); - Slice posts = postFindService.findByPartAndOptionalFilters(dto.part(), dto.category(), dto.cardinalNumber(), dto.studyName(), dto.week(), pageable); - - return posts.map(post->mapper.toAll(post, checkFileExistsByPost(post.id))); - } - - @Override - public Slice findEducationPosts(Long userId, Part part, Integer cardinalNumber, int pageNumber, int pageSize) { - User user = userGetService.find(userId); - Pageable pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")); - - if (user.hasRole(Role.ADMIN)) { - - return postFindService.findByCategory(part, Category.Education, cardinalNumber, pageNumber, pageSize) - .map(post -> mapper.toEducationAll(post, checkFileExistsByPost(post.getId()))); - } - - if (cardinalNumber != null) { - if (userCardinalGetService.notContains(user, cardinalGetService.findByUserSide(cardinalNumber))) { - return new SliceImpl<>(Collections.emptyList(), pageable, false); - } - Slice posts = postFindService.findEducationByCardinal(part, cardinalNumber, pageable); - return posts.map(post -> mapper.toEducationAll(post, checkFileExistsByPost(post.getId()))); - } - - List userCardinals = userCardinalGetService.getCardinalNumbers(user); - if (userCardinals.isEmpty()) { - return new SliceImpl<>(Collections.emptyList(), pageable, false); - } - Slice posts = postFindService.findEducationByCardinals(part, userCardinals, pageable); - - return posts.map(post -> mapper.toEducationAll(post, checkFileExistsByPost(post.getId()))); - } - - @Override - public PostDTO.ResponseStudyNames findStudyNames(Part part) { - List names = postFindService.findByPart(part); - - return mapper.toStudyNames(names); - } - - @Override - public Slice searchPost(String keyword, int pageNumber, int pageSize){ - validatePageNumber(pageNumber); - - keyword = keyword.strip(); // 문자열 앞뒤 공백 제거 - - Pageable pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")); - Slice posts = postFindService.search(keyword, pageable); - - if(posts.isEmpty()){ - throw new NoSearchResultException(); - } - - return posts.map(post->mapper.toAll(post, checkFileExistsByPost(post.id))); - } - - @Override - public Slice searchEducation(String keyword, int pageNumber, int pageSize) { - validatePageNumber(pageNumber); - - keyword = keyword.strip(); - - Pageable pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")); - Slice posts = postFindService.searchEducation(keyword, pageable); - - if(posts.isEmpty()){ - throw new NoSearchResultException(); - } - - return posts.map(post->mapper.toEducationAll(post, checkFileExistsByPost(post.id))); - } - - @Override - @Transactional - public PostDTO.SaveResponse update(Long postId, PostDTO.Update dto, Long userId) { - Post post = validateOwner(postId, userId); - - if (dto.files() != null) { - List fileList = getFiles(postId); - fileRepository.deleteAll(fileList); - - List files = fileMapper.toFileList(dto.files(), FileOwnerType.POST, post.getId()); - fileRepository.saveAll(files); - } - - postUpdateService.update(post, dto); - - return mapper.toSaveResponse(post); - } - - @Override - @Transactional - public PostDTO.SaveResponse updateEducation(Long postId, PostDTO.UpdateEducation dto, Long userId) { - Post post = validateOwner(postId, userId); - - if (dto.files() != null) { - List fileList = getFiles(postId); - fileRepository.deleteAll(fileList); - - List files = fileMapper.toFileList(dto.files(), FileOwnerType.POST, post.getId()); - fileRepository.saveAll(files); - } - - postUpdateService.updateEducation(post, dto); - - return mapper.toSaveResponse(post); - } - - @Override - @Transactional - public void delete(Long postId, Long userId) { - validateOwner(postId, userId); - - List fileList = getFiles(postId); - fileRepository.deleteAll(fileList); - - postDeleteService.delete(postId); - } - - private List getFiles(Long postId) { - return fileReader.findAll(FileOwnerType.POST, postId, null); - } - - private Post validateOwner(Long postId, Long userId) { - Post post = postFindService.find(postId); - - if (!post.getUser().getId().equals(userId)) { - throw new UserNotMatchException(); - } - return post; - } - - public boolean checkFileExistsByPost(Long postId){ - return fileReader.exists(FileOwnerType.POST, postId, null); - } - - private List filterParentComments(List comments) { - return getCommentQueryService.toCommentTreeResponses(comments); - } - - private void validatePageNumber(int pageNumber){ - if (pageNumber < 0) { - throw new PageNotFoundException(); - } - } - -} diff --git a/src/main/java/com/weeth/domain/board/application/usecase/PostUsecase.java b/src/main/java/com/weeth/domain/board/application/usecase/PostUsecase.java deleted file mode 100644 index ea365a32..00000000 --- a/src/main/java/com/weeth/domain/board/application/usecase/PostUsecase.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.weeth.domain.board.application.usecase; - -import com.weeth.domain.board.application.dto.PartPostDTO; -import com.weeth.domain.board.application.dto.PostDTO; -import com.weeth.domain.board.domain.entity.enums.Part; -import com.weeth.domain.user.application.exception.UserNotMatchException; -import org.springframework.data.domain.Slice; - - -public interface PostUsecase { - - PostDTO.SaveResponse save(PostDTO.Save request, Long userId); - - PostDTO.SaveResponse saveEducation(PostDTO.SaveEducation request, Long userId); - - PostDTO.Response findPost(Long postId); - - Slice findPosts(int pageNumber, int pageSize); - - Slice findPartPosts(PartPostDTO dto, int pageNumber, int pageSize); - - Slice findEducationPosts(Long userId, Part part, Integer cardinalNumber, int pageNumber, int pageSize); - - PostDTO.ResponseStudyNames findStudyNames(Part part); - - PostDTO.SaveResponse update(Long postId, PostDTO.Update dto, Long userId) throws UserNotMatchException; - - PostDTO.SaveResponse updateEducation(Long postId, PostDTO.UpdateEducation dto, Long userId) throws UserNotMatchException; - - void delete(Long postId, Long userId) throws UserNotMatchException; - - Slice searchPost(String keyword, int pageNumber, int pageSize); - - Slice searchEducation(String keyword, int pageNumber, int pageSize); -} diff --git a/src/main/java/com/weeth/domain/board/domain/converter/PartListConverter.java b/src/main/java/com/weeth/domain/board/domain/converter/PartListConverter.java deleted file mode 100644 index 58872ffb..00000000 --- a/src/main/java/com/weeth/domain/board/domain/converter/PartListConverter.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.weeth.domain.board.domain.converter; - -import jakarta.persistence.AttributeConverter; -import jakarta.persistence.Converter; -import java.util.Arrays; -import java.util.List; -import java.util.stream.Collectors; -import com.weeth.domain.board.domain.entity.enums.Part; - -@Converter -public class PartListConverter implements AttributeConverter, String> { - - private static final String DELIMITER = ","; - - @Override - public String convertToDatabaseColumn(List parts) { - - return parts.stream() - .map(Part::name) - .collect(Collectors.joining(DELIMITER)); - } - - @Override - public List convertToEntityAttribute(String dbData) { - - return Arrays.stream(dbData.split(DELIMITER)) - .map(Part::valueOf) - .collect(Collectors.toList()); - } -} diff --git a/src/main/java/com/weeth/domain/board/domain/entity/Board.java b/src/main/java/com/weeth/domain/board/domain/entity/Board.java deleted file mode 100644 index 37ea7de0..00000000 --- a/src/main/java/com/weeth/domain/board/domain/entity/Board.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.weeth.domain.board.domain.entity; - -import jakarta.persistence.Column; -import jakarta.persistence.EntityListeners; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.MappedSuperclass; -import jakarta.persistence.PrePersist; -import java.util.List; -import com.weeth.domain.board.application.dto.NoticeDTO; -import com.weeth.domain.board.application.dto.PostDTO; -import com.weeth.domain.comment.domain.entity.Comment; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.global.common.entity.BaseEntity; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.experimental.SuperBuilder; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.jpa.domain.support.AuditingEntityListener; - -@Getter -@MappedSuperclass -@EntityListeners(AuditingEntityListener.class) -@SuperBuilder -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@Slf4j -public class Board extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - public Long id; - - private String title; - - @Column(columnDefinition = "TEXT") - private String content; - - @ManyToOne - @JoinColumn(name = "user_id") - private User user; - - private Integer commentCount; - - @PrePersist - public void prePersist() { - commentCount = 0; - } - - public void decreaseCommentCount() { - if (commentCount > 0) { - commentCount--; - } - } - - public void increaseCommentCount() { - commentCount++; - } - - public void updateCommentCount(List comments) { - this.commentCount = (int) comments.stream() - .filter(comment -> !comment.getIsDeleted()) - .count(); - } - - public void updateUpperClass(NoticeDTO.Update dto) { - this.title = dto.title(); - this.content = dto.content(); - } - - public void updateUpperClass(PostDTO.Update dto) { - if (dto.title() != null) this.title = dto.title(); - if (dto.content() != null) this.content = dto.content(); - } - - public void updateUpperClass(PostDTO.UpdateEducation dto) { - if (dto.title() != null) this.title = dto.title(); - if (dto.content() != null) this.content = dto.content(); - } -} diff --git a/src/main/java/com/weeth/domain/board/domain/entity/Notice.java b/src/main/java/com/weeth/domain/board/domain/entity/Notice.java deleted file mode 100644 index 0477a32a..00000000 --- a/src/main/java/com/weeth/domain/board/domain/entity/Notice.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.weeth.domain.board.domain.entity; - -import com.fasterxml.jackson.annotation.JsonManagedReference; -import jakarta.persistence.Entity; -import jakarta.persistence.OneToMany; -import com.weeth.domain.board.application.dto.NoticeDTO; -import com.weeth.domain.comment.domain.entity.Comment; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.experimental.SuperBuilder; - -import java.util.List; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@SuperBuilder -public class Notice extends Board { - - // Todo: OneToMany 매핑 제거 - @OneToMany(mappedBy = "notice", orphanRemoval = true) - @JsonManagedReference - private List comments; - - public void updateCommentCount() { - this.updateCommentCount(this.comments); - } - - public void addComment(Comment comment) { - comments.add(comment); - } - - public void update(NoticeDTO.Update dto){ - this.updateUpperClass(dto); - } - -} diff --git a/src/main/java/com/weeth/domain/board/domain/entity/Post.java b/src/main/java/com/weeth/domain/board/domain/entity/Post.java deleted file mode 100644 index 4bcb9ffc..00000000 --- a/src/main/java/com/weeth/domain/board/domain/entity/Post.java +++ /dev/null @@ -1,76 +0,0 @@ -package com.weeth.domain.board.domain.entity; - -import com.fasterxml.jackson.annotation.JsonManagedReference; -import jakarta.persistence.Column; -import jakarta.persistence.Convert; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.OneToMany; -import java.util.ArrayList; -import java.util.List; -import com.weeth.domain.board.application.dto.PostDTO; -import com.weeth.domain.board.domain.converter.PartListConverter; -import com.weeth.domain.board.domain.entity.enums.Category; -import com.weeth.domain.board.domain.entity.enums.Part; -import com.weeth.domain.comment.domain.entity.Comment; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.experimental.SuperBuilder; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@SuperBuilder -public class Post extends Board { - - @Column - private String studyName; - - @Column(nullable = false) - private int cardinalNumber; - - @Column(nullable=false) - private int week; - - @Enumerated(EnumType.STRING) - private Part part; - - @Column(nullable = false, columnDefinition = "varchar(255)") - @Convert(converter = PartListConverter.class) - private List parts = new ArrayList<>(); - - @Enumerated(EnumType.STRING) - private Category category; - - @OneToMany(mappedBy = "post", orphanRemoval = true) - @JsonManagedReference - private List comments; - - public void updateCommentCount() { - this.updateCommentCount(this.comments); - } - - public void addComment(Comment comment) { - comments.add(comment); - } - - public void update(PostDTO.Update dto) { - this.updateUpperClass(dto); - if (dto.studyName() != null) this.studyName = dto.studyName(); - if (dto.week() != null) this.week = dto.week(); - if (dto.part() != null) { - this.part = dto.part(); - this.parts = List.of(dto.part()); - } - if (dto.cardinalNumber() != null) this.cardinalNumber = dto.cardinalNumber(); - } - - public void updateEducation(PostDTO.UpdateEducation dto) { - this.updateUpperClass(dto); - this.part = null; - if (dto.parts() != null) this.parts = dto.parts(); - if (dto.cardinalNumber() != null) this.cardinalNumber = dto.cardinalNumber(); - } -} diff --git a/src/main/java/com/weeth/domain/board/domain/entity/enums/Category.java b/src/main/java/com/weeth/domain/board/domain/entity/enums/Category.java deleted file mode 100644 index 64c59a44..00000000 --- a/src/main/java/com/weeth/domain/board/domain/entity/enums/Category.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.weeth.domain.board.domain.entity.enums; - -public enum Category { - StudyLog, - Article, - Education -} diff --git a/src/main/java/com/weeth/domain/board/domain/entity/enums/Part.java b/src/main/java/com/weeth/domain/board/domain/entity/enums/Part.java deleted file mode 100644 index 1af83a71..00000000 --- a/src/main/java/com/weeth/domain/board/domain/entity/enums/Part.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.board.domain.entity.enums; - -public enum Part { - D, - BE, - FE, - PM, - ALL -} diff --git a/src/main/java/com/weeth/domain/board/domain/repository/NoticeRepository.java b/src/main/java/com/weeth/domain/board/domain/repository/NoticeRepository.java deleted file mode 100644 index 42c615a9..00000000 --- a/src/main/java/com/weeth/domain/board/domain/repository/NoticeRepository.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.weeth.domain.board.domain.repository; - -import com.weeth.domain.board.domain.entity.Notice; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -import org.springframework.data.jpa.repository.Lock; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.jpa.repository.QueryHints; -import org.springframework.data.repository.query.Param; - -import jakarta.persistence.LockModeType; -import jakarta.persistence.QueryHint; - -public interface NoticeRepository extends JpaRepository { - - @Lock(LockModeType.PESSIMISTIC_WRITE) - @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) - @Query("select n from Notice n where n.id = :id") - Notice findByIdWithLock(@Param("id") Long id); - - Slice findPageBy(Pageable page); - - @Query(""" - SELECT n FROM Notice n - WHERE (LOWER(n.title) LIKE LOWER(CONCAT('%', :kw, '%')) - OR LOWER(n.content) LIKE LOWER(CONCAT('%', :kw, '%'))) - ORDER BY n.id DESC - """) - Slice search(@Param("kw") String kw, Pageable pageable); -} diff --git a/src/main/java/com/weeth/domain/board/domain/repository/PostRepository.java b/src/main/java/com/weeth/domain/board/domain/repository/PostRepository.java deleted file mode 100644 index 20e2f949..00000000 --- a/src/main/java/com/weeth/domain/board/domain/repository/PostRepository.java +++ /dev/null @@ -1,131 +0,0 @@ -package com.weeth.domain.board.domain.repository; - -import com.weeth.domain.board.domain.entity.Post; -import com.weeth.domain.board.domain.entity.enums.Category; -import com.weeth.domain.board.domain.entity.enums.Part; -import jakarta.persistence.LockModeType; -import jakarta.persistence.QueryHint; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Lock; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.jpa.repository.QueryHints; -import org.springframework.data.repository.query.Param; - -import java.util.Collection; -import java.util.List; - -public interface PostRepository extends JpaRepository { - - @Lock(LockModeType.PESSIMISTIC_WRITE) - @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) - @Query("select p from Post p where p.id = :id") - Post findByIdWithLock(@Param("id") Long id); - - @Query(""" - SELECT p FROM Post p - WHERE p.category IN ( - com.weeth.domain.board.domain.entity.enums.Category.StudyLog, - com.weeth.domain.board.domain.entity.enums.Category.Article - ) - ORDER BY p.id DESC - """) - Slice findRecentPart(Pageable pageable); - - @Query(""" - SELECT p FROM Post p - WHERE p.category = com.weeth.domain.board.domain.entity.enums.Category.Education - ORDER BY p.id DESC - """) - Slice findRecentEducation(Pageable pageable); - - @Query(""" - SELECT p FROM Post p - WHERE p.category IN ( - com.weeth.domain.board.domain.entity.enums.Category.StudyLog, - com.weeth.domain.board.domain.entity.enums.Category.Article - ) - AND ( - LOWER(p.title) LIKE LOWER(CONCAT('%', :kw, '%')) - OR LOWER(p.content) LIKE LOWER(CONCAT('%', :kw, '%')) - ) - ORDER BY p.id DESC - """) - Slice searchPart(@Param("kw") String kw, Pageable pageable); - - @Query(""" - SELECT p FROM Post p - WHERE p.category = com.weeth.domain.board.domain.entity.enums.Category.Education - AND ( - LOWER(p.title) LIKE LOWER(CONCAT('%', :kw, '%')) - OR LOWER(p.content) LIKE LOWER(CONCAT('%', :kw, '%')) - ) - ORDER BY p.id DESC - """) - Slice searchEducation(@Param("kw") String kw, Pageable pageable); - - @Query(""" - SELECT DISTINCT p.studyName - FROM Post p - WHERE (:part = com.weeth.domain.board.domain.entity.enums.Part.ALL OR p.part = :part) - AND p.studyName IS NOT NULL - ORDER BY p.studyName ASC - """) - List findDistinctStudyNamesByPart(@Param("part") Part part); - - @Query(""" - SELECT p - FROM Post p - WHERE (p.part = :part OR p.part = com.weeth.domain.board.domain.entity.enums.Part.ALL OR :part = com.weeth.domain.board.domain.entity.enums.Part.ALL - ) - AND (:category IS NULL OR p.category = :category) - AND (:cardinal IS NULL OR p.cardinalNumber = :cardinal) - AND (:studyName IS NULL OR p.studyName = :studyName) - AND (:week IS NULL OR p.week = :week) - ORDER BY p.id DESC - """) - Slice findByPartAndOptionalFilters(@Param("part") Part part, @Param("category") Category category, @Param("cardinal") Integer cardinal, @Param("studyName") String studyName, @Param("week") Integer week, Pageable pageable); - - @Query(""" - SELECT p - FROM Post p - WHERE p.category = :category - AND (:cardinal IS NULL OR p.cardinalNumber = :cardinal) - AND ( - :partName = 'ALL' - OR FUNCTION('FIND_IN_SET', :partName, p.parts) > 0 - OR FUNCTION('FIND_IN_SET', 'ALL', p.parts) > 0 - ) - ORDER BY p.id DESC - """) - Slice findByCategoryAndOptionalCardinalWithPart(@Param("partName") String partName, @Param("category") Category category, @Param("cardinal") Integer cardinal, Pageable pageable); - - @Query(""" - SELECT p - FROM Post p - WHERE p.category = :category - AND p.cardinalNumber = :cardinal - AND ( - :partName = 'ALL' - OR FUNCTION('FIND_IN_SET', :partName, p.parts) > 0 - OR FUNCTION('FIND_IN_SET', 'ALL', p.parts) > 0 - ) - ORDER BY p.id DESC - """) - Slice findByCategoryAndCardinalNumberWithPart(@Param("partName") String partName, @Param("category") Category category, @Param("cardinal") Integer cardinal, Pageable pageable); - - @Query(""" - SELECT p - FROM Post p - WHERE p.category = :category - AND p.cardinalNumber IN :cardinals - AND ( - :partName = 'ALL' - OR FUNCTION('FIND_IN_SET', :partName, p.parts) > 0 - OR FUNCTION('FIND_IN_SET', 'ALL', p.parts) > 0 - ) - ORDER BY p.id DESC - """) - Slice findByCategoryAndCardinalInWithPart(@Param("partName") String partName, @Param("category") Category category, @Param("cardinals") Collection cardinals, Pageable pageable); -} diff --git a/src/main/java/com/weeth/domain/board/domain/service/NoticeDeleteService.java b/src/main/java/com/weeth/domain/board/domain/service/NoticeDeleteService.java deleted file mode 100644 index af8f72ec..00000000 --- a/src/main/java/com/weeth/domain/board/domain/service/NoticeDeleteService.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.weeth.domain.board.domain.service; - -import jakarta.transaction.Transactional; -import com.weeth.domain.board.domain.repository.NoticeRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class NoticeDeleteService { - - private final NoticeRepository noticeRepository; - - @Transactional - public void delete(Long noticeId) { - noticeRepository.deleteById(noticeId); - } - -} diff --git a/src/main/java/com/weeth/domain/board/domain/service/NoticeFindService.java b/src/main/java/com/weeth/domain/board/domain/service/NoticeFindService.java deleted file mode 100644 index 0bf77b58..00000000 --- a/src/main/java/com/weeth/domain/board/domain/service/NoticeFindService.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.weeth.domain.board.domain.service; - -import java.util.List; -import com.weeth.domain.board.application.exception.NoticeNotFoundException; -import com.weeth.domain.board.domain.entity.Notice; -import com.weeth.domain.board.domain.repository.NoticeRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class NoticeFindService { - - private final NoticeRepository noticeRepository; - - public Notice find(Long noticeId) { - return noticeRepository.findById(noticeId) - .orElseThrow(NoticeNotFoundException::new); - } - - public List find() { - return noticeRepository.findAll(); - } - - - public Slice findRecentNotices(Pageable pageable) { - return noticeRepository.findPageBy(pageable); - } - - public Slice search(String keyword, Pageable pageable) { - if(keyword == null || keyword.isEmpty()){ - return findRecentNotices(pageable); - } - return noticeRepository.search(keyword.strip(), pageable); - } -} diff --git a/src/main/java/com/weeth/domain/board/domain/service/NoticeSaveService.java b/src/main/java/com/weeth/domain/board/domain/service/NoticeSaveService.java deleted file mode 100644 index a0730dc1..00000000 --- a/src/main/java/com/weeth/domain/board/domain/service/NoticeSaveService.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.weeth.domain.board.domain.service; - -import com.weeth.domain.board.domain.entity.Notice; -import com.weeth.domain.board.domain.repository.NoticeRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class NoticeSaveService { - - private final NoticeRepository noticeRepository; - - public Notice save(Notice notice){ - return noticeRepository.save(notice); - } - -} diff --git a/src/main/java/com/weeth/domain/board/domain/service/NoticeUpdateService.java b/src/main/java/com/weeth/domain/board/domain/service/NoticeUpdateService.java deleted file mode 100644 index 0d28974e..00000000 --- a/src/main/java/com/weeth/domain/board/domain/service/NoticeUpdateService.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.weeth.domain.board.domain.service; - -import jakarta.transaction.Transactional; -import com.weeth.domain.board.application.dto.NoticeDTO; -import com.weeth.domain.board.domain.entity.Notice; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class NoticeUpdateService { - - public void update(Notice notice, NoticeDTO.Update dto){ - notice.update(dto); - } - -} diff --git a/src/main/java/com/weeth/domain/board/domain/service/PostDeleteService.java b/src/main/java/com/weeth/domain/board/domain/service/PostDeleteService.java deleted file mode 100644 index 25266a05..00000000 --- a/src/main/java/com/weeth/domain/board/domain/service/PostDeleteService.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.weeth.domain.board.domain.service; - -import jakarta.transaction.Transactional; -import com.weeth.domain.board.domain.repository.PostRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class PostDeleteService { - - private final PostRepository postRepository; - - public void delete(Long postId) { - postRepository.deleteById(postId); - } - -} diff --git a/src/main/java/com/weeth/domain/board/domain/service/PostFindService.java b/src/main/java/com/weeth/domain/board/domain/service/PostFindService.java deleted file mode 100644 index f813c135..00000000 --- a/src/main/java/com/weeth/domain/board/domain/service/PostFindService.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.weeth.domain.board.domain.service; - -import java.util.Collection; -import java.util.Collections; -import java.util.List; -import com.weeth.domain.board.application.exception.PostNotFoundException; -import com.weeth.domain.board.domain.entity.Post; -import com.weeth.domain.board.domain.entity.enums.Category; -import com.weeth.domain.board.domain.entity.enums.Part; -import com.weeth.domain.board.domain.repository.PostRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -import org.springframework.data.domain.SliceImpl; -import org.springframework.data.domain.Sort; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class PostFindService { - - private final PostRepository postRepository; - - public Post find(Long postId){ - return postRepository.findById(postId) - .orElseThrow(PostNotFoundException::new); - } - - public List find(){ - return postRepository.findAll(); - } - - public List findByPart(Part part) { - return postRepository.findDistinctStudyNamesByPart(part); - } - - public Slice findRecentPosts(Pageable pageable) { - return postRepository.findRecentPart(pageable); - } - - public Slice findRecentEducationPosts(Pageable pageable) { - return postRepository.findRecentEducation(pageable); - } - - public Slice search(String keyword, Pageable pageable) { - if(keyword == null || keyword.isEmpty()){ - return findRecentPosts(pageable); - } - return postRepository.searchPart(keyword.strip(), pageable); - } - - public Slice searchEducation(String keyword, Pageable pageable) { - if(keyword == null || keyword.isEmpty()){ - return findRecentEducationPosts(pageable); - } - return postRepository.searchEducation(keyword.strip(), pageable); - } - - public Slice findByPartAndOptionalFilters(Part part, Category category, Integer cardinalNumber, String studyName, Integer week, Pageable pageable) { - - return postRepository.findByPartAndOptionalFilters( - part, category, cardinalNumber, studyName, week, pageable - ); - } - - public Slice findEducationByCardinals(Part part, Collection cardinals, Pageable pageable) { - if (cardinals == null || cardinals.isEmpty()) { - return new SliceImpl<>(Collections.emptyList(), pageable, false); - } - String partName = (part != null ? part.name() : Part.ALL.name()); - - return postRepository.findByCategoryAndCardinalInWithPart(partName, Category.Education, cardinals, pageable); - } - - public Slice findEducationByCardinal(Part part, int cardinalNumber, Pageable pageable) { - String partName = (part != null ? part.name() : Part.ALL.name()); - - return postRepository.findByCategoryAndCardinalNumberWithPart(partName, Category.Education, cardinalNumber, pageable); - } - - public Slice findByCategory(Part part, Category category, Integer cardinal, int pageNumber, int pageSize) { - Pageable pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")); - String partName = (part != null ? part.name() : Part.ALL.name()); - - return postRepository.findByCategoryAndOptionalCardinalWithPart(partName, category, cardinal, pageable); - } -} diff --git a/src/main/java/com/weeth/domain/board/domain/service/PostSaveService.java b/src/main/java/com/weeth/domain/board/domain/service/PostSaveService.java deleted file mode 100644 index c1abc88e..00000000 --- a/src/main/java/com/weeth/domain/board/domain/service/PostSaveService.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.weeth.domain.board.domain.service; - -import com.weeth.domain.board.domain.entity.Post; -import com.weeth.domain.board.domain.repository.PostRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class PostSaveService { - - private final PostRepository postRepository; - - public Post save(Post post) { - return postRepository.save(post); - } -} diff --git a/src/main/java/com/weeth/domain/board/domain/service/PostUpdateService.java b/src/main/java/com/weeth/domain/board/domain/service/PostUpdateService.java deleted file mode 100644 index e5c95e93..00000000 --- a/src/main/java/com/weeth/domain/board/domain/service/PostUpdateService.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.weeth.domain.board.domain.service; - -import com.weeth.domain.board.application.dto.PostDTO; -import com.weeth.domain.board.domain.entity.Post; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class PostUpdateService { - - public void update(Post post, PostDTO.Update dto){ - post.update(dto); - } - - public void updateEducation(Post post, PostDTO.UpdateEducation dto){ - post.updateEducation(dto); - } -} diff --git a/src/main/java/com/weeth/domain/board/presentation/BoardResponseCode.java b/src/main/java/com/weeth/domain/board/presentation/BoardResponseCode.java deleted file mode 100644 index 2ac54cad..00000000 --- a/src/main/java/com/weeth/domain/board/presentation/BoardResponseCode.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.weeth.domain.board.presentation; -import com.weeth.global.common.response.ResponseCodeInterface; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -public enum BoardResponseCode implements ResponseCodeInterface { - //NoticeAdminController 관련 - NOTICE_CREATED_SUCCESS(1300, HttpStatus.OK, "공지사항이 성공적으로 생성되었습니다."), - NOTICE_UPDATED_SUCCESS(1301, HttpStatus.OK, "공지사항이 성공적으로 수정되었습니다."), - NOTICE_DELETED_SUCCESS(1302, HttpStatus.OK, "공지사항이 성공적으로 삭제되었습니다."), - //NoticeController 관련 - NOTICE_FIND_ALL_SUCCESS(1303, HttpStatus.OK, "공지사항 목록이 성공적으로 조회되었습니다."), - NOTICE_FIND_BY_ID_SUCCESS(1304, HttpStatus.OK, "공지사항이 성공적으로 조회되었습니다."), - NOTICE_SEARCH_SUCCESS(1305, HttpStatus.OK, "공지사항 검색 결과가 성공적으로 조회되었습니다."), - //PostController 관련 - POST_CREATED_SUCCESS(1306, HttpStatus.OK, "게시글이 성공적으로 생성되었습니다."), - POST_UPDATED_SUCCESS(1307, HttpStatus.OK, "파트 게시글이 성공적으로 수정되었습니다."), - POST_DELETED_SUCCESS(1308, HttpStatus.OK, "게시글이 성공적으로 삭제되었습니다."), - POST_FIND_ALL_SUCCESS(1309, HttpStatus.OK, "게시글 목록이 성공적으로 조회되었습니다."), - POST_PART_FIND_ALL_SUCCESS(1310, HttpStatus.OK, "파트별 게시글 목록이 성공적으로 조회되었습니다."), - POST_EDU_FIND_SUCCESS(1311, HttpStatus.OK, "교육 게시글 목록이 성공적으로 조회되었습니다."), - POST_FIND_BY_ID_SUCCESS(1312, HttpStatus.OK, "파트 게시글이 성공적으로 조회되었습니다."), - POST_SEARCH_SUCCESS(1313, HttpStatus.OK, "파트 게시글 검색 결과가 성공적으로 조회되었습니다."), - EDUCATION_SEARCH_SUCCESS(1314, HttpStatus.OK, "교육 자료 검색 결과가 성공적으로 조회되었습니다."), - POST_STUDY_NAMES_FIND_SUCCESS(1315, HttpStatus.OK, "스터디 이름 목록이 성공적으로 조회되었습니다."), - - EDUCATION_UPDATED_SUCCESS(1316, HttpStatus.OK, "교육자료가 성공적으로 수정되었습니다."); - - private final int code; - private final HttpStatus status; - private final String message; - - BoardResponseCode(int code, HttpStatus status, String message) { - this.code = code; - this.status = status; - this.message = message; - } -} diff --git a/src/main/java/com/weeth/domain/board/presentation/EducationAdminController.java b/src/main/java/com/weeth/domain/board/presentation/EducationAdminController.java deleted file mode 100644 index 4af3cd1f..00000000 --- a/src/main/java/com/weeth/domain/board/presentation/EducationAdminController.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.weeth.domain.board.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import com.weeth.domain.board.application.dto.PostDTO; -import com.weeth.domain.board.application.exception.BoardErrorCode; -import com.weeth.domain.board.application.exception.PostErrorCode; -import com.weeth.domain.board.application.usecase.PostUsecase; -import com.weeth.domain.user.application.exception.UserNotMatchException; -import com.weeth.global.auth.annotation.CurrentUser; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import static com.weeth.domain.board.presentation.BoardResponseCode.EDUCATION_UPDATED_SUCCESS; -import static com.weeth.domain.board.presentation.BoardResponseCode.POST_CREATED_SUCCESS; - -@Tag(name = "EDUCATION ADMIN", description = "[ADMIN] 공지사항 교육자료 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/admin/educations") -@ApiErrorCodeExample({BoardErrorCode.class, PostErrorCode.class}) -public class EducationAdminController { - private final PostUsecase postUsecase; - - @PostMapping("/education") - @Operation(summary = "교육자료 생성") - public CommonResponse saveEducation(@RequestBody @Valid PostDTO.SaveEducation dto, @Parameter(hidden = true) @CurrentUser Long userId) { - PostDTO.SaveResponse response = postUsecase.saveEducation(dto, userId); - - return CommonResponse.success(POST_CREATED_SUCCESS, response); - } - - @PatchMapping(value = "/{boardId}") - @Operation(summary="교육자료 게시글 수정") - public CommonResponse update(@PathVariable Long boardId, - @RequestBody @Valid PostDTO.UpdateEducation dto, - @Parameter(hidden = true) @CurrentUser Long userId) throws UserNotMatchException { - PostDTO.SaveResponse response = postUsecase.updateEducation(boardId, dto, userId); - - return CommonResponse.success(EDUCATION_UPDATED_SUCCESS, response); - } -} diff --git a/src/main/java/com/weeth/domain/board/presentation/NoticeAdminController.java b/src/main/java/com/weeth/domain/board/presentation/NoticeAdminController.java deleted file mode 100644 index 94b61a01..00000000 --- a/src/main/java/com/weeth/domain/board/presentation/NoticeAdminController.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.weeth.domain.board.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import com.weeth.domain.board.application.dto.NoticeDTO; -import com.weeth.domain.board.application.exception.BoardErrorCode; -import com.weeth.domain.board.application.exception.NoticeErrorCode; -import com.weeth.domain.board.application.usecase.NoticeUsecase; -import com.weeth.domain.user.application.exception.UserNotMatchException; -import com.weeth.global.auth.annotation.CurrentUser; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import static com.weeth.domain.board.presentation.BoardResponseCode.*; - -@Tag(name = "NOTICE ADMIN", description = "[ADMIN] 공지사항 어드민 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/admin/notices") -@ApiErrorCodeExample({BoardErrorCode.class, NoticeErrorCode.class}) -public class NoticeAdminController { - - private final NoticeUsecase noticeUsecase; - - @PostMapping - @Operation(summary="공지사항 생성") - public CommonResponse save(@RequestBody @Valid NoticeDTO.Save dto, - @Parameter(hidden = true) @CurrentUser Long userId) { - NoticeDTO.SaveResponse response = noticeUsecase.save(dto, userId); - - return CommonResponse.success(NOTICE_CREATED_SUCCESS, response); - } - - @PatchMapping(value = "/{noticeId}") - @Operation(summary="특정 공지사항 수정") - public CommonResponse update(@PathVariable Long noticeId, - @RequestBody @Valid NoticeDTO.Update dto, - @Parameter(hidden = true) @CurrentUser Long userId) throws UserNotMatchException { - NoticeDTO.SaveResponse response = noticeUsecase.update(noticeId, dto, userId); - - return CommonResponse.success(NOTICE_UPDATED_SUCCESS, response); - } - - @DeleteMapping("/{noticeId}") - @Operation(summary="특정 공지사항 삭제") - public CommonResponse delete(@PathVariable Long noticeId, @Parameter(hidden = true) @CurrentUser Long userId) throws UserNotMatchException { - noticeUsecase.delete(noticeId, userId); - return CommonResponse.success(NOTICE_DELETED_SUCCESS); - } - -} diff --git a/src/main/java/com/weeth/domain/board/presentation/NoticeController.java b/src/main/java/com/weeth/domain/board/presentation/NoticeController.java deleted file mode 100644 index 5ddeb287..00000000 --- a/src/main/java/com/weeth/domain/board/presentation/NoticeController.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.weeth.domain.board.presentation; - - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import com.weeth.domain.board.application.dto.NoticeDTO; -import com.weeth.domain.board.application.exception.BoardErrorCode; -import com.weeth.domain.board.application.exception.NoticeErrorCode; -import com.weeth.domain.board.application.usecase.NoticeUsecase; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Slice; -import org.springframework.web.bind.annotation.*; - -import static com.weeth.domain.board.presentation.BoardResponseCode.*; - - -@Tag(name = "NOTICE", description = "공지사항 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/notices") -@ApiErrorCodeExample({BoardErrorCode.class, NoticeErrorCode.class}) -public class NoticeController { - - private final NoticeUsecase noticeUsecase; - - @GetMapping - @Operation(summary="공지사항 목록 조회 [무한스크롤]") - public CommonResponse> findNotices(@RequestParam("pageNumber") int pageNumber, @RequestParam("pageSize") int pageSize) { - return CommonResponse.success(NOTICE_FIND_ALL_SUCCESS, noticeUsecase.findNotices(pageNumber, pageSize)); - } - - @GetMapping("/{noticeId}") - @Operation(summary="특정 공지사항 조회") - public CommonResponse findNoticeById(@PathVariable Long noticeId) { - return CommonResponse.success(NOTICE_FIND_BY_ID_SUCCESS, noticeUsecase.findNotice(noticeId)); - } - - @GetMapping("/search") - @Operation(summary="공지사항 검색 [무한스크롤]") - public CommonResponse> findNotice(@RequestParam String keyword, @RequestParam("pageNumber") int pageNumber, @RequestParam("pageSize") int pageSize) { - return CommonResponse.success(NOTICE_SEARCH_SUCCESS, noticeUsecase.searchNotice(keyword, pageNumber, pageSize)); - } -} diff --git a/src/main/java/com/weeth/domain/board/presentation/PostController.java b/src/main/java/com/weeth/domain/board/presentation/PostController.java deleted file mode 100644 index 633ce6a2..00000000 --- a/src/main/java/com/weeth/domain/board/presentation/PostController.java +++ /dev/null @@ -1,105 +0,0 @@ -package com.weeth.domain.board.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import com.weeth.domain.board.application.dto.PartPostDTO; -import com.weeth.domain.board.application.dto.PostDTO; -import com.weeth.domain.board.application.exception.BoardErrorCode; -import com.weeth.domain.board.application.exception.PostErrorCode; -import com.weeth.domain.board.application.usecase.PostUsecase; -import com.weeth.domain.board.domain.entity.enums.Part; -import com.weeth.domain.user.application.exception.UserNotMatchException; -import com.weeth.global.auth.annotation.CurrentUser; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Slice; -import org.springframework.web.bind.annotation.*; - -import static com.weeth.domain.board.presentation.BoardResponseCode.*; - -@Tag(name = "BOARD", description = "게시판 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/board") -@ApiErrorCodeExample({BoardErrorCode.class, PostErrorCode.class}) -public class PostController { - - private final PostUsecase postUsecase; - - @PostMapping - @Operation(summary="파트 게시글 생성 (스터디 로그, 아티클)") - public CommonResponse save(@RequestBody @Valid PostDTO.Save dto, @Parameter(hidden = true) @CurrentUser Long userId) { - PostDTO.SaveResponse response = postUsecase.save(dto, userId); - - return CommonResponse.success(POST_CREATED_SUCCESS, response); - } - - @GetMapping - @Operation(summary="게시글 목록 조회 [무한스크롤]") - public CommonResponse> findPosts(@RequestParam("pageNumber") int pageNumber, - @RequestParam("pageSize") int pageSize) { - return CommonResponse.success(POST_FIND_ALL_SUCCESS, postUsecase.findPosts(pageNumber, pageSize)); - } - - @GetMapping("/part") - @Operation(summary="파트별 스터디 게시글 목록 조회 [무한스크롤]") - public CommonResponse> findPartPosts(@ModelAttribute @Valid PartPostDTO dto, @RequestParam("pageNumber") int pageNumber, @RequestParam("pageSize") int pageSize) { - Slice response = postUsecase.findPartPosts(dto, pageNumber, pageSize); - - return CommonResponse.success(POST_PART_FIND_ALL_SUCCESS, response); - } - - @GetMapping("/education") - @Operation(summary="교육자료 조회 [무한스크롤]") - public CommonResponse> findEducationMaterials(@RequestParam Part part, @RequestParam(required = false) Integer cardinalNumber, @RequestParam("pageNumber") int pageNumber, @RequestParam("pageSize") int pageSize, @Parameter(hidden = true) @CurrentUser Long userId) { - - return CommonResponse.success(POST_EDU_FIND_SUCCESS, postUsecase.findEducationPosts(userId, part, cardinalNumber, pageNumber, pageSize)); - } - - @GetMapping("/{boardId}") - @Operation(summary="특정 게시글 조회") - public CommonResponse findPost(@PathVariable Long boardId) { - return CommonResponse.success(POST_FIND_BY_ID_SUCCESS, postUsecase.findPost(boardId)); - } - - @GetMapping("/part/studies") - @Operation(summary="파트별 스터디 이름 목록 조회") - public CommonResponse findStudyNames(@RequestParam Part part) { - - return CommonResponse.success(BoardResponseCode.POST_STUDY_NAMES_FIND_SUCCESS, postUsecase.findStudyNames(part)); - } - - @GetMapping("/search/part") - @Operation(summary="파트 게시글 검색 [무한스크롤]") - public CommonResponse> findPost(@RequestParam String keyword, @RequestParam("pageNumber") int pageNumber, - @RequestParam("pageSize") int pageSize) { - return CommonResponse.success(POST_SEARCH_SUCCESS, postUsecase.searchPost(keyword, pageNumber, pageSize)); - } - - @GetMapping("/search/education") - @Operation(summary="교육자료 검색 [무한스크롤]") - public CommonResponse> findEducation(@RequestParam String keyword, @RequestParam("pageNumber") int pageNumber, - @RequestParam("pageSize") int pageSize) { - return CommonResponse.success(EDUCATION_SEARCH_SUCCESS, postUsecase.searchEducation(keyword, pageNumber, pageSize)); - } - - @PatchMapping(value = "/{boardId}/part") - @Operation(summary="파트 게시글 수정") - public CommonResponse update(@PathVariable Long boardId, - @RequestBody @Valid PostDTO.Update dto, - @Parameter(hidden = true) @CurrentUser Long userId) throws UserNotMatchException { - PostDTO.SaveResponse response = postUsecase.update(boardId, dto, userId); - - return CommonResponse.success(POST_UPDATED_SUCCESS, response); - } - - @DeleteMapping("/{boardId}") - @Operation(summary="특정 게시글 삭제") - public CommonResponse delete(@PathVariable Long boardId, @Parameter(hidden = true) @CurrentUser Long userId) throws UserNotMatchException { - postUsecase.delete(boardId, userId); - return CommonResponse.success(POST_DELETED_SUCCESS); - } -} diff --git a/src/main/java/com/weeth/domain/user/application/dto/response/UserResponseDto.java b/src/main/java/com/weeth/domain/user/application/dto/response/UserResponseDto.java index 50112da1..9ec2b286 100644 --- a/src/main/java/com/weeth/domain/user/application/dto/response/UserResponseDto.java +++ b/src/main/java/com/weeth/domain/user/application/dto/response/UserResponseDto.java @@ -87,4 +87,4 @@ public record UserInfo( Role role ) { } -} +} //todo: User 전역 dto 구현 (id, 이름, role) diff --git a/src/main/java/com/weeth/global/auth/annotation/CurrentUserRole.java b/src/main/java/com/weeth/global/auth/annotation/CurrentUserRole.java new file mode 100644 index 00000000..56643824 --- /dev/null +++ b/src/main/java/com/weeth/global/auth/annotation/CurrentUserRole.java @@ -0,0 +1,11 @@ +package com.weeth.global.auth.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface CurrentUserRole { +} diff --git a/src/main/java/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.java b/src/main/java/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.java index c0ecba1f..7490ca02 100644 --- a/src/main/java/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.java +++ b/src/main/java/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.java @@ -4,35 +4,29 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import com.weeth.domain.user.domain.entity.User; import com.weeth.domain.user.domain.entity.enums.Role; -import com.weeth.domain.user.domain.service.UserGetService; import com.weeth.global.auth.jwt.exception.TokenNotFoundException; +import com.weeth.global.auth.model.AuthenticatedUser; import com.weeth.global.auth.jwt.service.JwtProvider; import com.weeth.global.auth.jwt.service.JwtService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; -import org.springframework.security.core.authority.mapping.NullAuthoritiesMapper; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.userdetails.UserDetails; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; +import java.util.List; @RequiredArgsConstructor @Slf4j public class JwtAuthenticationProcessingFilter extends OncePerRequestFilter { private static final String NO_CHECK_URL = "/api/v1/login"; - private final String DUMMY = "DUMMY_PASSWORD"; private final JwtProvider jwtProvider; private final JwtService jwtService; - private final UserGetService userGetService; - - private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper(); @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { @@ -59,18 +53,17 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse public void saveAuthentication(String accessToken) { - String email = jwtService.extractEmail(accessToken).get(); - Role role = Role.valueOf(jwtService.extractRole(accessToken).get()); - - UserDetails userDetailsUser = org.springframework.security.core.userdetails.User.builder() - .username(email) - .password(DUMMY) - .roles(role.name()) - .build(); + Long userId = jwtService.extractId(accessToken).orElseThrow(TokenNotFoundException::new); + String email = jwtService.extractEmail(accessToken).orElseThrow(TokenNotFoundException::new); + Role role = Role.valueOf(jwtService.extractRole(accessToken).orElseThrow(TokenNotFoundException::new)); + AuthenticatedUser principal = new AuthenticatedUser(userId, email, role); UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken(userDetailsUser, null, - authoritiesMapper.mapAuthorities(userDetailsUser.getAuthorities())); + new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority("ROLE_" + role.name())) + ); SecurityContextHolder.getContext().setAuthentication(authentication); } diff --git a/src/main/java/com/weeth/global/auth/model/AuthenticatedUser.java b/src/main/java/com/weeth/global/auth/model/AuthenticatedUser.java new file mode 100644 index 00000000..b79c8800 --- /dev/null +++ b/src/main/java/com/weeth/global/auth/model/AuthenticatedUser.java @@ -0,0 +1,10 @@ +package com.weeth.global.auth.model; + +import com.weeth.domain.user.domain.entity.enums.Role; + +public record AuthenticatedUser( + Long id, + String email, + Role role +) { +} diff --git a/src/main/java/com/weeth/global/auth/resolver/CurrentUserArgumentResolver.java b/src/main/java/com/weeth/global/auth/resolver/CurrentUserArgumentResolver.java index b4fb497d..49c801eb 100644 --- a/src/main/java/com/weeth/global/auth/resolver/CurrentUserArgumentResolver.java +++ b/src/main/java/com/weeth/global/auth/resolver/CurrentUserArgumentResolver.java @@ -2,8 +2,7 @@ import com.weeth.global.auth.annotation.CurrentUser; import com.weeth.global.auth.jwt.exception.AnonymousAuthenticationException; -import com.weeth.global.auth.jwt.service.JwtService; -import lombok.RequiredArgsConstructor; +import com.weeth.global.auth.model.AuthenticatedUser; import org.springframework.core.MethodParameter; import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.core.Authentication; @@ -13,13 +12,8 @@ import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.method.support.ModelAndViewContainer; -import java.util.Optional; - -@RequiredArgsConstructor public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver { - private final JwtService jwtService; - @Override public boolean supportsParameter(MethodParameter parameter) { // parameter가 해당 resolver를 지원하는 여부 확인 boolean hasAnnotation = parameter.hasParameterAnnotation(CurrentUser.class); // @CurrentUser이 존재하는가? @@ -31,13 +25,15 @@ public boolean supportsParameter(MethodParameter parameter) { // parameter가 public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); // 인증 객체 가져오기 - if (authentication instanceof AnonymousAuthenticationToken) { // 익명 인증 토큰의 인스턴스라면 0 반환 + if (authentication == null || authentication instanceof AnonymousAuthenticationToken) { throw new AnonymousAuthenticationException(); } - String token = Optional.ofNullable(webRequest.getHeader("Authorization")) - .map(accessToken -> accessToken.replace("Bearer ", "")).get(); + Object principal = authentication.getPrincipal(); + if (principal instanceof AuthenticatedUser authenticatedUser) { + return authenticatedUser.id(); + } - return jwtService.extractId(token).get(); // 토큰에서 userId 조회 + throw new AnonymousAuthenticationException(); } -} \ No newline at end of file +} diff --git a/src/main/java/com/weeth/global/auth/resolver/CurrentUserRoleArgumentResolver.java b/src/main/java/com/weeth/global/auth/resolver/CurrentUserRoleArgumentResolver.java new file mode 100644 index 00000000..063be6a1 --- /dev/null +++ b/src/main/java/com/weeth/global/auth/resolver/CurrentUserRoleArgumentResolver.java @@ -0,0 +1,48 @@ +package com.weeth.global.auth.resolver; + +import com.weeth.domain.user.domain.entity.enums.Role; +import com.weeth.global.auth.annotation.CurrentUserRole; +import com.weeth.global.auth.jwt.exception.AnonymousAuthenticationException; +import com.weeth.global.auth.model.AuthenticatedUser; +import org.springframework.core.MethodParameter; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +public class CurrentUserRoleArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + boolean hasAnnotation = parameter.hasParameterAnnotation(CurrentUserRole.class); + boolean parameterType = Role.class.isAssignableFrom(parameter.getParameterType()); + return hasAnnotation && parameterType; + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || authentication instanceof AnonymousAuthenticationToken) { + throw new AnonymousAuthenticationException(); + } + + Object principal = authentication.getPrincipal(); + if (principal instanceof AuthenticatedUser authenticatedUser) { + return authenticatedUser.role(); + } + + for (GrantedAuthority authority : authentication.getAuthorities()) { + String role = authority.getAuthority(); + if (role != null && role.startsWith("ROLE_")) { + return Role.valueOf(role.substring("ROLE_".length())); + } + } + + throw new AnonymousAuthenticationException(); + } +} diff --git a/src/main/java/com/weeth/global/common/controller/ExceptionDocController.java b/src/main/java/com/weeth/global/common/controller/ExceptionDocController.java index 771f1457..95b7c199 100644 --- a/src/main/java/com/weeth/global/common/controller/ExceptionDocController.java +++ b/src/main/java/com/weeth/global/common/controller/ExceptionDocController.java @@ -5,8 +5,6 @@ import com.weeth.domain.account.application.exception.AccountErrorCode; import com.weeth.domain.attendance.application.exception.AttendanceErrorCode; import com.weeth.domain.board.application.exception.BoardErrorCode; -import com.weeth.domain.board.application.exception.NoticeErrorCode; -import com.weeth.domain.board.application.exception.PostErrorCode; import com.weeth.domain.comment.application.exception.CommentErrorCode; import com.weeth.domain.penalty.application.exception.PenaltyErrorCode; import com.weeth.domain.schedule.application.exception.EventErrorCode; @@ -37,7 +35,7 @@ public void attendanceErrorCodes() { @GetMapping("/board") @Operation(summary = "Board 도메인 에러 코드 목록") - @ApiErrorCodeExample({BoardErrorCode.class, NoticeErrorCode.class, PostErrorCode.class, CommentErrorCode.class}) + @ApiErrorCodeExample({BoardErrorCode.class, CommentErrorCode.class}) public void boardErrorCodes() { } @@ -59,7 +57,6 @@ public void scheduleErrorCodes() { public void userErrorCodes() { } - //todo: SAS 관련 예외도 추가 @GetMapping("/auth") @Operation(summary = "인증/인가 에러 코드 목록") @ApiErrorCodeExample({JwtErrorCode.class}) diff --git a/src/main/java/com/weeth/global/config/SecurityConfig.java b/src/main/java/com/weeth/global/config/SecurityConfig.java index e8175593..223c0e71 100644 --- a/src/main/java/com/weeth/global/config/SecurityConfig.java +++ b/src/main/java/com/weeth/global/config/SecurityConfig.java @@ -1,7 +1,6 @@ package com.weeth.global.config; import com.fasterxml.jackson.databind.ObjectMapper; -import com.weeth.domain.user.domain.service.UserGetService; import com.weeth.global.auth.authentication.CustomAccessDeniedHandler; import com.weeth.global.auth.authentication.CustomAuthenticationEntryPoint; import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase; @@ -39,7 +38,6 @@ public class SecurityConfig { private final JwtProvider jwtProvider; private final JwtService jwtService; private final JwtManageUseCase jwtManageUseCase; - private final UserGetService userGetService; private final ObjectMapper objectMapper; private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; @@ -78,7 +76,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return new AuthorizationDecision(allowed); }) .requestMatchers("/actuator/health").permitAll() - .requestMatchers("/api/v1/admin/**").hasRole("ADMIN") + .requestMatchers("/api/v1/admin/**", "/api/v4/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) .exceptionHandling(exceptionHandling -> @@ -112,6 +110,6 @@ public PasswordEncoder passwordEncoder() { @Bean public JwtAuthenticationProcessingFilter jwtAuthenticationProcessingFilter() { - return new JwtAuthenticationProcessingFilter(jwtProvider, jwtService, userGetService); + return new JwtAuthenticationProcessingFilter(jwtProvider, jwtService); } } diff --git a/src/main/java/com/weeth/global/config/WebMvcConfig.java b/src/main/java/com/weeth/global/config/WebMvcConfig.java index 4d1fd0de..d0127ba9 100644 --- a/src/main/java/com/weeth/global/config/WebMvcConfig.java +++ b/src/main/java/com/weeth/global/config/WebMvcConfig.java @@ -1,8 +1,7 @@ package com.weeth.global.config; -import com.weeth.global.auth.jwt.service.JwtService; import com.weeth.global.auth.resolver.CurrentUserArgumentResolver; -import lombok.RequiredArgsConstructor; +import com.weeth.global.auth.resolver.CurrentUserRoleArgumentResolver; import org.springframework.context.annotation.Configuration; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @@ -10,13 +9,11 @@ import java.util.List; @Configuration -@RequiredArgsConstructor public class WebMvcConfig implements WebMvcConfigurer { - private final JwtService jwtService; - @Override public void addArgumentResolvers(List resolvers) { - resolvers.add(new CurrentUserArgumentResolver(jwtService)); + resolvers.add(new CurrentUserArgumentResolver()); + resolvers.add(new CurrentUserRoleArgumentResolver()); } } diff --git a/src/main/java/com/weeth/global/config/swagger/SwaggerConfig.java b/src/main/java/com/weeth/global/config/swagger/SwaggerConfig.java index afd607e6..ad5958f1 100644 --- a/src/main/java/com/weeth/global/config/swagger/SwaggerConfig.java +++ b/src/main/java/com/weeth/global/config/swagger/SwaggerConfig.java @@ -18,8 +18,8 @@ import io.swagger.v3.oas.models.security.SecurityScheme; import io.swagger.v3.oas.models.servers.Server; import lombok.RequiredArgsConstructor; -import org.springdoc.core.models.GroupedOpenApi; import org.springdoc.core.customizers.OperationCustomizer; +import org.springdoc.core.models.GroupedOpenApi; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -41,7 +41,7 @@ - Domain Error: **2xxx** - Server Error: **3xxx** - Client Error: **4xxx** - + ## 도메인별 코드 범위 | Domain | Success | Error | |--------|---------|------| @@ -54,7 +54,7 @@ | Schedule | 17xx | 27xx | | User | 18xx | 28xx | | Auth/JWT (Global) | - | 29xx | - + > 각 API의 상세 응답 예시는 Swagger의 **Responses** 섹션에서 확인하세요. """ ) @@ -83,7 +83,7 @@ public OpenAPI openAPI() { public GroupedOpenApi adminApi() { return GroupedOpenApi.builder() .group("admin") - .pathsToMatch("/api/v1/admin/**") + .pathsToMatch("/api/v1/admin/**", "/api/v4/admin/**") .addOperationCustomizer(operationCustomizer()) .build(); } @@ -92,7 +92,7 @@ public GroupedOpenApi adminApi() { public GroupedOpenApi publicApi() { return GroupedOpenApi.builder() .group("public") - .pathsToExclude("/api/v1/admin/**") + .pathsToExclude("/api/v1/admin/**", "/api/v4/admin/**") .addOperationCustomizer(operationCustomizer()) .build(); } diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/request/CreateBoardRequest.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/request/CreateBoardRequest.kt new file mode 100644 index 00000000..905ac2d4 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/request/CreateBoardRequest.kt @@ -0,0 +1,24 @@ +package com.weeth.domain.board.application.dto.request + +import com.weeth.domain.board.domain.entity.enums.BoardType +import com.weeth.domain.user.domain.entity.enums.Role +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size + +data class CreateBoardRequest( + @field:Schema(description = "게시판 이름", example = "공지사항") + @field:NotBlank + @field:Size(max = 100) + val name: String, + @field:Schema(description = "게시판 타입", example = "NOTICE") + @field:NotNull + var type: BoardType, + @field:Schema(description = "댓글 허용 여부", example = "true") + val commentEnabled: Boolean = true, + @field:Schema(description = "게시글 작성 권한", example = "USER") + val writePermission: Role = Role.USER, + @field:Schema(description = "비공개 게시판 여부", example = "false") + val isPrivate: Boolean = false, +) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/request/CreatePostRequest.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/request/CreatePostRequest.kt new file mode 100644 index 00000000..e1f5805a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/request/CreatePostRequest.kt @@ -0,0 +1,23 @@ +package com.weeth.domain.board.application.dto.request + +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size + +data class CreatePostRequest( + @field:Schema(description = "게시글 제목", example = "스터디 로그") + @field:NotBlank + @field:Size(max = 200) + val title: String, + @field:Schema(description = "게시글 내용", example = "내용입니다.") + @field:NotBlank + val content: String, + @field:Schema(description = "기수", nullable = true) + val cardinalNumber: Int? = null, + @field:Schema(description = "첨부 파일 목록", nullable = true) + @field:Valid + val files: List<@NotNull FileSaveRequest>? = null, +) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdateBoardRequest.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdateBoardRequest.kt new file mode 100644 index 00000000..0712551b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdateBoardRequest.kt @@ -0,0 +1,17 @@ +package com.weeth.domain.board.application.dto.request + +import com.weeth.domain.user.domain.entity.enums.Role +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.Size + +data class UpdateBoardRequest( + @field:Schema(description = "게시판 이름", example = "새 공지사항", nullable = true) + @field:Size(max = 100) + val name: String? = null, + @field:Schema(description = "댓글 허용 여부", example = "true", nullable = true) + val commentEnabled: Boolean? = null, + @field:Schema(description = "게시글 작성 권한", example = "USER", nullable = true) + val writePermission: Role? = null, + @field:Schema(description = "비공개 게시판 여부", example = "false", nullable = true) + val isPrivate: Boolean? = null, +) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdatePostRequest.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdatePostRequest.kt new file mode 100644 index 00000000..bb685e29 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdatePostRequest.kt @@ -0,0 +1,23 @@ +package com.weeth.domain.board.application.dto.request + +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size + +data class UpdatePostRequest( + @field:Schema(description = "게시글 제목") + @field:NotBlank + @field:Size(max = 200) + val title: String, + @field:Schema(description = "게시글 내용") + @field:NotBlank + val content: String, + @field:Schema(description = "기수", nullable = true) + val cardinalNumber: Int? = null, + @field:Schema(description = "첨부 파일 변경 규약: null=변경 안 함, []=전체 삭제, 배열 전달=해당 목록으로 교체", nullable = true) + @field:Valid + val files: List<@NotNull FileSaveRequest>? = null, +) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardDetailResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardDetailResponse.kt new file mode 100644 index 00000000..d42d623b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardDetailResponse.kt @@ -0,0 +1,24 @@ +package com.weeth.domain.board.application.dto.response + +import com.fasterxml.jackson.annotation.JsonInclude +import com.weeth.domain.board.domain.entity.enums.BoardType +import com.weeth.domain.user.domain.entity.enums.Role +import io.swagger.v3.oas.annotations.media.Schema + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class BoardDetailResponse( + @field:Schema(description = "게시판 ID") + val id: Long, + @field:Schema(description = "게시판 이름") + val name: String, + @field:Schema(description = "게시판 타입") + val type: BoardType, + @field:Schema(description = "댓글 허용 여부") + val commentEnabled: Boolean, + @field:Schema(description = "게시글 작성 권한") + val writePermission: Role, + @field:Schema(description = "비공개 게시판 여부") + val isPrivate: Boolean, + @field:Schema(description = "삭제 여부 (관리자 페이지에서만 값 존재)") + val isDeleted: Boolean?, +) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardListResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardListResponse.kt new file mode 100644 index 00000000..0024a619 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardListResponse.kt @@ -0,0 +1,13 @@ +package com.weeth.domain.board.application.dto.response + +import com.weeth.domain.board.domain.entity.enums.BoardType +import io.swagger.v3.oas.annotations.media.Schema + +data class BoardListResponse( + @field:Schema(description = "게시판 ID") + val id: Long, + @field:Schema(description = "게시판 이름") + val name: String, + @field:Schema(description = "게시판 타입") + val type: BoardType, +) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt new file mode 100644 index 00000000..62963ee6 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt @@ -0,0 +1,28 @@ +package com.weeth.domain.board.application.dto.response + +import com.weeth.domain.comment.application.dto.response.CommentResponse +import com.weeth.domain.file.application.dto.response.FileResponse +import com.weeth.domain.user.domain.entity.enums.Role +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class PostDetailResponse( + @field:Schema(description = "게시글 ID") + val id: Long, + @field:Schema(description = "작성자명") + val name: String, + @field:Schema(description = "작성자 역할") + val role: Role, + @field:Schema(description = "제목") + val title: String, + @field:Schema(description = "내용") + val content: String, + @field:Schema(description = "수정 시각") + val time: LocalDateTime, + @field:Schema(description = "댓글 수") + val commentCount: Int, + @field:Schema(description = "댓글 목록") + val comments: List, + @field:Schema(description = "첨부 파일 목록") + val fileUrls: List, +) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt new file mode 100644 index 00000000..10729f2f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt @@ -0,0 +1,26 @@ +package com.weeth.domain.board.application.dto.response + +import com.weeth.domain.user.domain.entity.enums.Role +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class PostListResponse( + @field:Schema(description = "게시글 ID") + val id: Long, + @field:Schema(description = "작성자명") + val name: String, + @field:Schema(description = "작성자 역할") + val role: Role, + @field:Schema(description = "제목") + val title: String, + @field:Schema(description = "내용") + val content: String, + @field:Schema(description = "수정 시각") + val time: LocalDateTime, + @field:Schema(description = "댓글 수") + val commentCount: Int, + @field:Schema(description = "파일 첨부 여부") + val hasFile: Boolean, + @field:Schema(description = "신규 게시글 여부 (24시간 이내)") + val isNew: Boolean, +) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostSaveResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostSaveResponse.kt new file mode 100644 index 00000000..e13b78f0 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostSaveResponse.kt @@ -0,0 +1,8 @@ +package com.weeth.domain.board.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class PostSaveResponse( + @field:Schema(description = "게시글 ID", example = "1") + val id: Long, +) diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt new file mode 100644 index 00000000..2328b874 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt @@ -0,0 +1,36 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.ErrorCodeInterface +import com.weeth.global.common.exception.ExplainError +import org.springframework.http.HttpStatus + +enum class BoardErrorCode( + private val code: Int, + private val status: HttpStatus, + private val message: String, +) : ErrorCodeInterface { + @ExplainError("검색 결과가 없을 때 발생합니다.") + NO_SEARCH_RESULT(2300, HttpStatus.NOT_FOUND, "검색 결과가 없습니다."), + + @ExplainError("유효하지 않은 페이지 번호를 요청할 때 발생합니다.") + PAGE_NOT_FOUND(2301, HttpStatus.BAD_REQUEST, "유효하지 않은 페이지입니다."), + + @ExplainError("ADMIN 전용 게시판에 일반 사용자가 글을 작성할 때 발생합니다.") + CATEGORY_ACCESS_DENIED(2302, HttpStatus.FORBIDDEN, "해당 카테고리에 대한 권한이 없습니다."), + + @ExplainError("게시판 ID로 조회했으나 해당 게시판이 존재하지 않을 때 발생합니다.") + BOARD_NOT_FOUND(2303, HttpStatus.NOT_FOUND, "존재하지 않는 게시판입니다."), + + @ExplainError("게시글 ID로 조회했으나 해당 게시글이 존재하지 않을 때 발생합니다.") + POST_NOT_FOUND(2304, HttpStatus.NOT_FOUND, "존재하지 않는 게시글입니다."), + + @ExplainError("게시글 작성자가 아닌 사용자가 수정/삭제를 시도할 때 발생합니다.") + POST_NOT_OWNED(2305, HttpStatus.FORBIDDEN, "게시글 작성자만 수정/삭제할 수 있습니다."), + ; + + override fun getCode(): Int = code + + override fun getStatus(): HttpStatus = status + + override fun getMessage(): String = message +} diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/BoardNotFoundException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardNotFoundException.kt new file mode 100644 index 00000000..5bfd3f72 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class BoardNotFoundException : BaseException(BoardErrorCode.BOARD_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/CategoryAccessDeniedException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/CategoryAccessDeniedException.kt new file mode 100644 index 00000000..4ef91e1e --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/CategoryAccessDeniedException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class CategoryAccessDeniedException : BaseException(BoardErrorCode.CATEGORY_ACCESS_DENIED) diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/NoSearchResultException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/NoSearchResultException.kt new file mode 100644 index 00000000..0dd443b4 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/NoSearchResultException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class NoSearchResultException : BaseException(BoardErrorCode.NO_SEARCH_RESULT) diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/PageNotFoundException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/PageNotFoundException.kt new file mode 100644 index 00000000..d14fd215 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/PageNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class PageNotFoundException : BaseException(BoardErrorCode.PAGE_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/PostNotFoundException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/PostNotFoundException.kt new file mode 100644 index 00000000..1870190a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/PostNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class PostNotFoundException : BaseException(BoardErrorCode.POST_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/PostNotOwnedException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/PostNotOwnedException.kt new file mode 100644 index 00000000..cbd1bb1c --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/PostNotOwnedException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class PostNotOwnedException : BaseException(BoardErrorCode.POST_NOT_OWNED) diff --git a/src/main/kotlin/com/weeth/domain/board/application/mapper/BoardMapper.kt b/src/main/kotlin/com/weeth/domain/board/application/mapper/BoardMapper.kt new file mode 100644 index 00000000..08c7934d --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/mapper/BoardMapper.kt @@ -0,0 +1,38 @@ +package com.weeth.domain.board.application.mapper + +import com.weeth.domain.board.application.dto.response.BoardDetailResponse +import com.weeth.domain.board.application.dto.response.BoardListResponse +import com.weeth.domain.board.domain.entity.Board +import org.springframework.stereotype.Component + +@Component +class BoardMapper { + fun toListResponse(board: Board) = + BoardListResponse( + id = board.id, + name = board.name, + type = board.type, + ) + + fun toDetailResponse(board: Board) = + BoardDetailResponse( + id = board.id, + name = board.name, + type = board.type, + commentEnabled = board.config.commentEnabled, + writePermission = board.config.writePermission, + isPrivate = board.config.isPrivate, + isDeleted = null, // public api에서 삭제 여부는 보여주지 않음 + ) + + fun toDetailResponseForAdmin(board: Board) = + BoardDetailResponse( + id = board.id, + name = board.name, + type = board.type, + commentEnabled = board.config.commentEnabled, + writePermission = board.config.writePermission, + isPrivate = board.config.isPrivate, + isDeleted = board.isDeleted, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt b/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt new file mode 100644 index 00000000..c984d77f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt @@ -0,0 +1,47 @@ +package com.weeth.domain.board.application.mapper + +import com.weeth.domain.board.application.dto.response.PostDetailResponse +import com.weeth.domain.board.application.dto.response.PostListResponse +import com.weeth.domain.board.application.dto.response.PostSaveResponse +import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.comment.application.dto.response.CommentResponse +import com.weeth.domain.file.application.dto.response.FileResponse +import org.springframework.stereotype.Component +import java.time.LocalDateTime + +@Component +class PostMapper { + fun toSaveResponse(post: Post) = PostSaveResponse(id = post.id) + + fun toDetailResponse( + post: Post, + comments: List, + files: List, + ) = PostDetailResponse( + id = post.id, + name = post.user.name, + role = post.user.role, + title = post.title, + content = post.content, + time = post.modifiedAt, + commentCount = post.commentCount, + comments = comments, + fileUrls = files, + ) + + fun toListResponse( + post: Post, + hasFile: Boolean, + now: LocalDateTime, + ) = PostListResponse( + id = post.id, + name = post.user.name, + role = post.user.role, + title = post.title, + content = post.content, + time = post.modifiedAt, + commentCount = post.commentCount, + hasFile = hasFile, + isNew = post.createdAt.isAfter(now.minusHours(24)), + ) +} diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt new file mode 100644 index 00000000..e6a559c5 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt @@ -0,0 +1,67 @@ +package com.weeth.domain.board.application.usecase.command + +import com.weeth.domain.board.application.dto.request.CreateBoardRequest +import com.weeth.domain.board.application.dto.request.UpdateBoardRequest +import com.weeth.domain.board.application.dto.response.BoardDetailResponse +import com.weeth.domain.board.application.exception.BoardNotFoundException +import com.weeth.domain.board.application.mapper.BoardMapper +import com.weeth.domain.board.domain.entity.Board +import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.board.domain.vo.BoardConfig +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class ManageBoardUseCase( + private val boardRepository: BoardRepository, + private val boardMapper: BoardMapper, +) { + @Transactional + fun create(request: CreateBoardRequest): BoardDetailResponse { + val board = + Board( + name = request.name, + type = request.type, + config = + BoardConfig( + commentEnabled = request.commentEnabled, + writePermission = request.writePermission, + isPrivate = request.isPrivate, + ), + ) + val savedBoard = boardRepository.save(board) + return boardMapper.toDetailResponse(savedBoard) + } + + @Transactional + fun update( + boardId: Long, + request: UpdateBoardRequest, + ): BoardDetailResponse { + val board = findBoard(boardId) + + // TODO: PATCH 규칙 - 요청 값이 현재 값과 다를 때만 반영하도록 수정 필요 + request.name?.let { board.rename(it) } + + if (request.commentEnabled != null || request.writePermission != null || request.isPrivate != null) { + // TODO: PATCH 규칙 - 각 필드별로 변경 여부를 비교해 바뀐 값만 업데이트하도록 수정 필요 + board.updateConfig( + board.config.copy( + commentEnabled = request.commentEnabled ?: board.config.commentEnabled, + writePermission = request.writePermission ?: board.config.writePermission, + isPrivate = request.isPrivate ?: board.config.isPrivate, + ), + ) + } + + return boardMapper.toDetailResponse(board) + } + + @Transactional + fun delete(boardId: Long) { + val board = findBoard(boardId) + board.markDeleted() + } + + private fun findBoard(boardId: Long): Board = boardRepository.findByIdAndIsDeletedFalse(boardId) ?: throw BoardNotFoundException() +} diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt new file mode 100644 index 00000000..afe3bf21 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt @@ -0,0 +1,138 @@ +package com.weeth.domain.board.application.usecase.command + +import com.weeth.domain.board.application.dto.request.CreatePostRequest +import com.weeth.domain.board.application.dto.request.UpdatePostRequest +import com.weeth.domain.board.application.dto.response.PostSaveResponse +import com.weeth.domain.board.application.exception.BoardNotFoundException +import com.weeth.domain.board.application.exception.CategoryAccessDeniedException +import com.weeth.domain.board.application.exception.PostNotFoundException +import com.weeth.domain.board.application.exception.PostNotOwnedException +import com.weeth.domain.board.application.mapper.PostMapper +import com.weeth.domain.board.domain.entity.Board +import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.board.domain.repository.PostRepository +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.repository.FileReader +import com.weeth.domain.file.domain.repository.FileRepository +import com.weeth.domain.user.domain.entity.User +import com.weeth.domain.user.domain.service.UserGetService +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class ManagePostUseCase( + private val postRepository: PostRepository, + private val boardRepository: BoardRepository, // 동일 도메인 + private val userGetService: UserGetService, + private val fileRepository: FileRepository, + private val fileReader: FileReader, + private val fileMapper: FileMapper, + private val postMapper: PostMapper, +) { + @Transactional + fun save( + boardId: Long, + request: CreatePostRequest, + userId: Long, + ): PostSaveResponse { + val user = userGetService.find(userId) // todo: Reader 인터페이스로 수정 + val board = findBoard(boardId) + checkWritePermission(board, user) + + val post = + Post.create( + title = request.title, + content = request.content, + user = user, + board = board, + cardinalNumber = request.cardinalNumber, + ) + + val savedPost = postRepository.save(post) + savePostFiles(savedPost, request.files) + return postMapper.toSaveResponse(savedPost) + } + + @Transactional + fun update( + postId: Long, + request: UpdatePostRequest, + userId: Long, + ): PostSaveResponse { + val post = findPost(postId) + validateOwner(post, userId) + + // TODO: PATCH 규칙 - title/content/cardinalNumber는 실제 변경된 경우에만 반영하도록 수정 필요 + post.update( + newTitle = request.title, + newContent = request.content, + newCardinalNumber = request.cardinalNumber, + ) + + replacePostFiles(post, request.files) + return postMapper.toSaveResponse(post) + } + + @Transactional + fun delete( + postId: Long, + userId: Long, + ) { + val post = findPost(postId) + validateOwner(post, userId) + + markPostFilesDeleted(post.id) + post.markDeleted() + } + + private fun findBoard(boardId: Long): Board = boardRepository.findByIdAndIsDeletedFalse(boardId) ?: throw BoardNotFoundException() + + private fun findPost(postId: Long): Post = postRepository.findByIdAndIsDeletedFalse(postId) ?: throw PostNotFoundException() + + private fun validateOwner( + post: Post, + userId: Long, + ) { + if (!post.isOwnedBy(userId)) { + throw PostNotOwnedException() + } + } + + private fun checkWritePermission( + board: Board, + user: User, + ) { + val userRole = user.role ?: throw CategoryAccessDeniedException() + if (!board.canWriteBy(userRole)) { + throw CategoryAccessDeniedException() + } + } + + private fun replacePostFiles( + post: Post, + files: List?, + ) { + if (files == null) { + return + } + markPostFilesDeleted(post.id) + savePostFiles(post, files) + } + + private fun savePostFiles( + post: Post, + files: List?, + ) { + val mappedFiles = fileMapper.toFileList(files, FileOwnerType.POST, post.id) + if (mappedFiles.isNotEmpty()) { + fileRepository.saveAll(mappedFiles) + } + } + + private fun markPostFilesDeleted(postId: Long) { + fileReader.findAll(FileOwnerType.POST, postId).forEach { it.markDeleted() } + } +} diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt new file mode 100644 index 00000000..e63bf21d --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt @@ -0,0 +1,40 @@ +package com.weeth.domain.board.application.usecase.query + +import com.weeth.domain.board.application.dto.response.BoardDetailResponse +import com.weeth.domain.board.application.dto.response.BoardListResponse +import com.weeth.domain.board.application.exception.BoardNotFoundException +import com.weeth.domain.board.application.mapper.BoardMapper +import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.user.domain.entity.enums.Role +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class GetBoardQueryService( + private val boardRepository: BoardRepository, + private val boardMapper: BoardMapper, +) { + fun findBoards(role: Role): List = + boardRepository + .findAllByIsDeletedFalseOrderByIdAsc() + .filter { it.isAccessibleBy(role) } + .map(boardMapper::toListResponse) + + fun findBoard( + boardId: Long, + role: Role, + ): BoardDetailResponse { + val board = + boardRepository + .findByIdAndIsDeletedFalse(boardId) + ?.takeIf { it.isAccessibleBy(role) } + ?: throw BoardNotFoundException() + return boardMapper.toDetailResponse(board) + } + + fun findAllBoardsForAdmin(): List = + boardRepository + .findAllByIsDeletedFalseOrderByIdAsc() + .map(boardMapper::toDetailResponseForAdmin) +} diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt new file mode 100644 index 00000000..25d8d747 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt @@ -0,0 +1,123 @@ +package com.weeth.domain.board.application.usecase.query + +import com.weeth.domain.board.application.dto.response.PostDetailResponse +import com.weeth.domain.board.application.dto.response.PostListResponse +import com.weeth.domain.board.application.exception.BoardNotFoundException +import com.weeth.domain.board.application.exception.NoSearchResultException +import com.weeth.domain.board.application.exception.PageNotFoundException +import com.weeth.domain.board.application.exception.PostNotFoundException +import com.weeth.domain.board.application.mapper.PostMapper +import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.board.domain.repository.PostRepository +import com.weeth.domain.comment.application.usecase.query.GetCommentQueryService +import com.weeth.domain.comment.domain.repository.CommentReader +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.repository.FileReader +import com.weeth.domain.user.domain.entity.enums.Role +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Slice +import org.springframework.data.domain.Sort +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +@Transactional(readOnly = true) +class GetPostQueryService( + private val postRepository: PostRepository, + private val boardRepository: BoardRepository, + private val commentReader: CommentReader, + private val getCommentQueryService: GetCommentQueryService, + private val fileReader: FileReader, + private val fileMapper: FileMapper, + private val postMapper: PostMapper, +) { + companion object { + private const val MAX_PAGE_SIZE = 50 + } + + fun findPost( + postId: Long, + role: Role, + ): PostDetailResponse { + val post = postRepository.findByIdAndIsDeletedFalse(postId) ?: throw PostNotFoundException() + if (post.board.isDeleted || !post.board.isAccessibleBy(role)) { + throw PostNotFoundException() + } + + val files = fileReader.findAll(FileOwnerType.POST, post.id).map(fileMapper::toFileResponse) + val comments = commentReader.findAllByPostId(post.id) + val commentTree = getCommentQueryService.toCommentTreeResponses(comments) + + return postMapper.toDetailResponse(post, commentTree, files) + } + + fun findPosts( + boardId: Long, + pageNumber: Int, + pageSize: Int, + role: Role, + ): Slice { + validatePage(pageNumber, pageSize) + validateBoardVisibility(boardId, role) + val pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")) + val posts = postRepository.findAllActiveByBoardId(boardId, pageable) + + val postIds = posts.content.map { it.id } + val fileExistsByPostId = buildFileExistsMap(postIds) + val now = LocalDateTime.now() + + return posts.map { postMapper.toListResponse(it, fileExistsByPostId[it.id] == true, now) } + } + + fun searchPosts( + boardId: Long, + keyword: String, + pageNumber: Int, + pageSize: Int, + role: Role, + ): Slice { + validatePage(pageNumber, pageSize) + validateBoardVisibility(boardId, role) + val pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")) + val posts = postRepository.searchByBoardId(boardId, keyword.trim(), pageable) + + if (posts.isEmpty) { + throw NoSearchResultException() + } + + val postIds = posts.content.map { it.id } + val fileExistsByPostId = buildFileExistsMap(postIds) + val now = LocalDateTime.now() + + return posts.map { postMapper.toListResponse(it, fileExistsByPostId[it.id] == true, now) } + } + + private fun validatePage( + pageNumber: Int, + pageSize: Int, + ) { + if (pageNumber < 0 || pageSize !in 1..MAX_PAGE_SIZE) { + throw PageNotFoundException() + } + } + + private fun buildFileExistsMap(postIds: List): Map { + if (postIds.isEmpty()) { + return emptyMap() + } + val filesGrouped = fileReader.findAll(FileOwnerType.POST, postIds).groupBy { it.ownerId } + return postIds.associateWith { filesGrouped.containsKey(it) } + } + + private fun validateBoardVisibility( + boardId: Long, + role: Role, + ) { + val board = boardRepository.findByIdAndIsDeletedFalse(boardId) ?: throw BoardNotFoundException() + if (!board.isAccessibleBy(role)) { + throw BoardNotFoundException() + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/board/domain/converter/BoardConfigConverter.kt b/src/main/kotlin/com/weeth/domain/board/domain/converter/BoardConfigConverter.kt new file mode 100644 index 00000000..057fa9da --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/converter/BoardConfigConverter.kt @@ -0,0 +1,9 @@ +package com.weeth.domain.board.domain.converter + +import com.fasterxml.jackson.core.type.TypeReference +import com.weeth.domain.board.domain.vo.BoardConfig +import com.weeth.global.common.converter.JsonConverter +import jakarta.persistence.Converter + +@Converter +class BoardConfigConverter : JsonConverter(object : TypeReference() {}) diff --git a/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt b/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt new file mode 100644 index 00000000..34894e3f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt @@ -0,0 +1,64 @@ +package com.weeth.domain.board.domain.entity + +import com.weeth.domain.board.domain.converter.BoardConfigConverter +import com.weeth.domain.board.domain.entity.enums.BoardType +import com.weeth.domain.board.domain.vo.BoardConfig +import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.global.common.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Convert +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table + +@Entity +@Table(name = "board") +class Board( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0, + @Column(nullable = false) + var name: String, + @Enumerated(EnumType.STRING) + @Column(nullable = false) + val type: BoardType, + @Column(columnDefinition = "JSON") // Json 속성 사용으로 인한 커스텀 컨버터 적용 + @Convert(converter = BoardConfigConverter::class) + var config: BoardConfig = BoardConfig(), + @Column(nullable = false) + var isDeleted: Boolean = false, +) : BaseEntity() { + val isCommentEnabled: Boolean + get() = config.commentEnabled + + val isAdminOnly: Boolean + get() = config.writePermission == Role.ADMIN + + val isRestricted: Boolean + get() = isAdminOnly || config.isPrivate + + fun isAccessibleBy(role: Role): Boolean = role == Role.ADMIN || !config.isPrivate + + fun canWriteBy(role: Role): Boolean = isAccessibleBy(role) && (!isAdminOnly || role == Role.ADMIN) + + fun updateConfig(newConfig: BoardConfig) { + config = newConfig + } + + fun rename(newName: String) { + require(newName.isNotBlank()) { "게시판 이름은 공백이 될 수 없습니다." } + name = newName + } + + fun markDeleted() { + isDeleted = true + } + + fun restore() { + isDeleted = false + } +} diff --git a/src/main/kotlin/com/weeth/domain/board/domain/entity/Post.kt b/src/main/kotlin/com/weeth/domain/board/domain/entity/Post.kt new file mode 100644 index 00000000..9545169c --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/entity/Post.kt @@ -0,0 +1,106 @@ +package com.weeth.domain.board.domain.entity + +import com.weeth.domain.user.domain.entity.User +import com.weeth.global.common.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table + +@Entity +@Table(name = "post") +class Post( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0, + @Column(nullable = false) + var title: String, + @Column(columnDefinition = "TEXT", nullable = false) + var content: String, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + val user: User, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "board_id") + val board: Board, + @Column(nullable = false) + var commentCount: Int = 0, + @Column(nullable = false) + var likeCount: Int = 0, + @Column + var cardinalNumber: Int? = null, + @Column(nullable = false) + var isDeleted: Boolean = false, +) : BaseEntity() { + fun increaseCommentCount() { + commentCount++ + } + + fun decreaseCommentCount() { + check(commentCount > 0) { "comment count cannot be negative" } + commentCount-- + } + + fun increaseLikeCount() { + likeCount++ + } + + fun decreaseLikeCount() { + check(likeCount > 0) { "like count cannot be negative" } + likeCount-- + } + + fun updateContent( + newTitle: String, + newContent: String, + ) { + require(newTitle.isNotBlank()) { "title must not be blank" } + require(newContent.isNotBlank()) { "content must not be blank" } + title = newTitle + content = newContent + } + + fun isOwnedBy(userId: Long): Boolean = user.id == userId + + fun update( + newTitle: String, + newContent: String, + newCardinalNumber: Int?, + ) { + updateContent(newTitle, newContent) + cardinalNumber = newCardinalNumber + } + + fun markDeleted() { + isDeleted = true + } + + fun restore() { + isDeleted = false + } + + companion object { + fun create( + title: String, + content: String, + user: User, + board: Board, + cardinalNumber: Int? = null, + ): Post { + require(title.isNotBlank()) { "title must not be blank" } + require(content.isNotBlank()) { "content must not be blank" } + return Post( + title = title, + content = content, + user = user, + board = board, + cardinalNumber = cardinalNumber, + ) + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/board/domain/entity/enums/BoardType.kt b/src/main/kotlin/com/weeth/domain/board/domain/entity/enums/BoardType.kt new file mode 100644 index 00000000..f992c924 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/entity/enums/BoardType.kt @@ -0,0 +1,8 @@ +package com.weeth.domain.board.domain.entity.enums + +enum class BoardType { + NOTICE, + GALLERY, + GENERAL, + INFORMATION, +} diff --git a/src/main/kotlin/com/weeth/domain/board/domain/entity/enums/Part.kt b/src/main/kotlin/com/weeth/domain/board/domain/entity/enums/Part.kt new file mode 100644 index 00000000..e6287a3e --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/entity/enums/Part.kt @@ -0,0 +1,9 @@ +package com.weeth.domain.board.domain.entity.enums + +enum class Part { + D, + BE, + FE, + PM, + ALL, +} diff --git a/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardRepository.kt b/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardRepository.kt new file mode 100644 index 00000000..268cd084 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardRepository.kt @@ -0,0 +1,12 @@ +package com.weeth.domain.board.domain.repository + +import com.weeth.domain.board.domain.entity.Board +import org.springframework.data.jpa.repository.JpaRepository + +interface BoardRepository : JpaRepository { + fun findAllByIsDeletedFalseOrderByIdAsc(): List + + fun findByIdAndIsDeletedFalse(id: Long): Board? + + fun findAllByOrderByIdAsc(): List +} diff --git a/src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt new file mode 100644 index 00000000..1f5a64c3 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt @@ -0,0 +1,64 @@ +package com.weeth.domain.board.domain.repository + +import com.weeth.domain.board.domain.entity.Post +import jakarta.persistence.LockModeType +import jakarta.persistence.QueryHint +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Slice +import org.springframework.data.jpa.repository.EntityGraph +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Lock +import org.springframework.data.jpa.repository.Query +import org.springframework.data.jpa.repository.QueryHints +import org.springframework.data.repository.query.Param + +interface PostRepository : JpaRepository { + @EntityGraph(attributePaths = ["user"]) + @Query( + """ + SELECT p + FROM Post p + WHERE p.board.id = :boardId + AND p.isDeleted = false + AND p.board.isDeleted = false + """, + ) + fun findAllActiveByBoardId( + @Param("boardId") boardId: Long, + pageable: Pageable, + ): Slice + + fun findByIdAndIsDeletedFalse(id: Long): Post? + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query( + """ + SELECT p + FROM Post p + WHERE p.id = :id + AND p.isDeleted = false + AND p.board.isDeleted = false + """, + ) + fun findByIdWithLock( + @Param("id") id: Long, + ): Post? + + @EntityGraph(attributePaths = ["user"]) + @Query( + """ + SELECT p + FROM Post p + WHERE p.board.id = :boardId + AND p.isDeleted = false + AND p.board.isDeleted = false + AND (LOWER(p.title) LIKE LOWER(CONCAT('%', :keyword, '%')) OR LOWER(p.content) LIKE LOWER(CONCAT('%', :keyword, '%'))) + """, + ) + fun searchByBoardId( + @Param("boardId") boardId: Long, + @Param("keyword") keyword: String, + pageable: Pageable, + ): Slice +} diff --git a/src/main/kotlin/com/weeth/domain/board/domain/vo/BoardConfig.kt b/src/main/kotlin/com/weeth/domain/board/domain/vo/BoardConfig.kt new file mode 100644 index 00000000..6926c827 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/vo/BoardConfig.kt @@ -0,0 +1,9 @@ +package com.weeth.domain.board.domain.vo + +import com.weeth.domain.user.domain.entity.enums.Role + +data class BoardConfig( + val commentEnabled: Boolean = true, + val writePermission: Role = Role.USER, + val isPrivate: Boolean = false, +) diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt b/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt new file mode 100644 index 00000000..9ed701f9 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt @@ -0,0 +1,61 @@ +package com.weeth.domain.board.presentation + +import com.weeth.domain.board.application.dto.request.CreateBoardRequest +import com.weeth.domain.board.application.dto.request.UpdateBoardRequest +import com.weeth.domain.board.application.dto.response.BoardDetailResponse +import com.weeth.domain.board.application.exception.BoardErrorCode +import com.weeth.domain.board.application.usecase.command.ManageBoardUseCase +import com.weeth.domain.board.application.usecase.query.GetBoardQueryService +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "Board-Admin", description = "Board Admin API") +@RestController +@RequestMapping("/api/v4/admin/board") +@PreAuthorize("hasRole('ADMIN')") +@ApiErrorCodeExample(BoardErrorCode::class) +class BoardAdminController( + private val manageBoardUseCase: ManageBoardUseCase, + private val getBoardQueryService: GetBoardQueryService, +) { + @GetMapping + @Operation(summary = "게시판 전체 목록 조회 (삭제/비공개 포함)") + fun findAllBoards(): CommonResponse> = + CommonResponse.success(BoardResponseCode.BOARD_FIND_ALL_SUCCESS, getBoardQueryService.findAllBoardsForAdmin()) + + @PostMapping + @Operation(summary = "게시판 생성") + fun createBoard( + @RequestBody @Valid request: CreateBoardRequest, + ): CommonResponse = + CommonResponse.success(BoardResponseCode.BOARD_CREATED_SUCCESS, manageBoardUseCase.create(request)) + + @PatchMapping("/{boardId}") + @Operation(summary = "게시판 설정/이름 수정") + fun updateBoard( + @PathVariable boardId: Long, + @RequestBody @Valid request: UpdateBoardRequest, + ): CommonResponse = + CommonResponse.success(BoardResponseCode.BOARD_UPDATED_SUCCESS, manageBoardUseCase.update(boardId, request)) + + @DeleteMapping("/{boardId}") + @Operation(summary = "게시판 삭제") + fun deleteBoard( + @PathVariable boardId: Long, + ): CommonResponse { + manageBoardUseCase.delete(boardId) + return CommonResponse.success(BoardResponseCode.BOARD_DELETED_SUCCESS) + } +} diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt b/src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt new file mode 100644 index 00000000..7fa127e0 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt @@ -0,0 +1,43 @@ +package com.weeth.domain.board.presentation + +import com.weeth.domain.board.application.dto.response.BoardDetailResponse +import com.weeth.domain.board.application.dto.response.BoardListResponse +import com.weeth.domain.board.application.exception.BoardErrorCode +import com.weeth.domain.board.application.usecase.query.GetBoardQueryService +import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.global.auth.annotation.CurrentUserRole +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "BOARD", description = "게시판 API") +@RestController +@RequestMapping("/api/v4/boards") +@ApiErrorCodeExample(BoardErrorCode::class) +class BoardController( + private val getBoardQueryService: GetBoardQueryService, +) { + @GetMapping + @Operation(summary = "게시판 목록 조회") + fun findBoards( + @Parameter(hidden = true) @CurrentUserRole role: Role, + ): CommonResponse> = + CommonResponse.success(BoardResponseCode.BOARD_FIND_ALL_SUCCESS, getBoardQueryService.findBoards(role)) + + @GetMapping("/{boardId}") + @Operation(summary = "게시판 상세 조회") + fun findBoard( + @PathVariable boardId: Long, + @Parameter(hidden = true) @CurrentUserRole role: Role, + ): CommonResponse = + CommonResponse.success( + BoardResponseCode.BOARD_FIND_BY_ID_SUCCESS, + getBoardQueryService.findBoard(boardId, role), + ) +} diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt b/src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt new file mode 100644 index 00000000..2f45b492 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt @@ -0,0 +1,22 @@ +package com.weeth.domain.board.presentation + +import com.weeth.global.common.response.ResponseCodeInterface +import org.springframework.http.HttpStatus + +enum class BoardResponseCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ResponseCodeInterface { + BOARD_CREATED_SUCCESS(1300, HttpStatus.OK, "게시판이 성공적으로 생성되었습니다."), + POST_CREATED_SUCCESS(1301, HttpStatus.OK, "게시글이 성공적으로 생성되었습니다."), + POST_UPDATED_SUCCESS(1302, HttpStatus.OK, "게시글이 성공적으로 수정되었습니다."), + POST_DELETED_SUCCESS(1303, HttpStatus.OK, "게시글이 성공적으로 삭제되었습니다."), + POST_FIND_ALL_SUCCESS(1304, HttpStatus.OK, "게시글 목록이 성공적으로 조회되었습니다."), + POST_FIND_BY_ID_SUCCESS(1305, HttpStatus.OK, "게시글이 성공적으로 조회되었습니다."), + POST_SEARCH_SUCCESS(1306, HttpStatus.OK, "게시글 검색 결과가 성공적으로 조회되었습니다."), + BOARD_UPDATED_SUCCESS(1307, HttpStatus.OK, "게시판이 성공적으로 수정되었습니다."), + BOARD_DELETED_SUCCESS(1308, HttpStatus.OK, "게시판이 성공적으로 삭제되었습니다."), + BOARD_FIND_ALL_SUCCESS(1309, HttpStatus.OK, "게시판 목록이 성공적으로 조회되었습니다."), + BOARD_FIND_BY_ID_SUCCESS(1310, HttpStatus.OK, "게시판이 성공적으로 조회되었습니다."), +} diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt b/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt new file mode 100644 index 00000000..6209d1a0 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt @@ -0,0 +1,106 @@ +package com.weeth.domain.board.presentation + +import com.weeth.domain.board.application.dto.request.CreatePostRequest +import com.weeth.domain.board.application.dto.request.UpdatePostRequest +import com.weeth.domain.board.application.dto.response.PostDetailResponse +import com.weeth.domain.board.application.dto.response.PostListResponse +import com.weeth.domain.board.application.dto.response.PostSaveResponse +import com.weeth.domain.board.application.exception.BoardErrorCode +import com.weeth.domain.board.application.usecase.command.ManagePostUseCase +import com.weeth.domain.board.application.usecase.query.GetPostQueryService +import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.auth.annotation.CurrentUserRole +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.data.domain.Slice +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "BOARD", description = "게시글 API") +@RestController +@RequestMapping("/api/v4/boards") +@ApiErrorCodeExample(BoardErrorCode::class) +class PostController( + private val managePostUseCase: ManagePostUseCase, + private val getPostQueryService: GetPostQueryService, +) { + @PostMapping("/{boardId}/posts") + @Operation(summary = "게시글 작성") + fun save( + @PathVariable boardId: Long, + @RequestBody @Valid request: CreatePostRequest, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success(BoardResponseCode.POST_CREATED_SUCCESS, managePostUseCase.save(boardId, request, userId)) + + @GetMapping("/{boardId}/posts") + @Operation(summary = "게시글 목록 조회") + fun findPosts( + @PathVariable boardId: Long, + @RequestParam(defaultValue = "0") pageNumber: Int, + @RequestParam(defaultValue = "10") pageSize: Int, + @Parameter(hidden = true) @CurrentUserRole role: Role, + ): CommonResponse> = + CommonResponse.success( + BoardResponseCode.POST_FIND_ALL_SUCCESS, + getPostQueryService.findPosts(boardId, pageNumber, pageSize, role), + ) + + @GetMapping("/posts/{postId}") + @Operation(summary = "게시글 상세 조회") + fun findPost( + @PathVariable postId: Long, + @Parameter(hidden = true) @CurrentUserRole role: Role, + ): CommonResponse = + CommonResponse.success(BoardResponseCode.POST_FIND_BY_ID_SUCCESS, getPostQueryService.findPost(postId, role)) + + @PatchMapping("/posts/{postId}") + @Operation(summary = "게시글 수정") + fun update( + @PathVariable postId: Long, + @RequestBody @Valid request: UpdatePostRequest, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success( + BoardResponseCode.POST_UPDATED_SUCCESS, + managePostUseCase.update(postId, request, userId), + ) + + @DeleteMapping("/posts/{postId}") + @Operation(summary = "게시글 삭제") + fun delete( + @PathVariable postId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + managePostUseCase.delete(postId, userId) + return CommonResponse.success(BoardResponseCode.POST_DELETED_SUCCESS) + } + + @GetMapping("/{boardId}/posts/search") + @Operation(summary = "게시글 검색") + fun searchPosts( + @PathVariable boardId: Long, + @RequestParam keyword: String, + @RequestParam(defaultValue = "0") pageNumber: Int, + @RequestParam(defaultValue = "10") pageSize: Int, + @Parameter(hidden = true) @CurrentUserRole role: Role, + ): CommonResponse> = + CommonResponse.success( + BoardResponseCode.POST_SEARCH_SUCCESS, + getPostQueryService.searchPosts(boardId, keyword, pageNumber, pageSize, role), + ) + + // todo: 좋아요 관련 API 추가 +} diff --git a/src/main/kotlin/com/weeth/domain/comment/application/dto/response/CommentResponse.kt b/src/main/kotlin/com/weeth/domain/comment/application/dto/response/CommentResponse.kt index 0a30e1d6..810996c0 100644 --- a/src/main/kotlin/com/weeth/domain/comment/application/dto/response/CommentResponse.kt +++ b/src/main/kotlin/com/weeth/domain/comment/application/dto/response/CommentResponse.kt @@ -3,15 +3,24 @@ package com.weeth.domain.comment.application.dto.response import com.weeth.domain.file.application.dto.response.FileResponse import com.weeth.domain.user.domain.entity.enums.Position import com.weeth.domain.user.domain.entity.enums.Role +import io.swagger.v3.oas.annotations.media.Schema import java.time.LocalDateTime data class CommentResponse( + @field:Schema(description = "댓글 ID", example = "1") val id: Long, + @field:Schema(description = "작성자 이름", example = "홍길동") val name: String, + @field:Schema(description = "작성자 포지션", example = "BE") val position: Position, + @field:Schema(description = "작성자 역할", example = "USER") val role: Role, + @field:Schema(description = "댓글 내용", example = "댓글입니다.") val content: String, + @field:Schema(description = "작성 시간", example = "2026-02-18T12:00:00") val time: LocalDateTime, + @field:Schema(description = "첨부 파일 목록") val fileUrls: List, + @field:Schema(description = "대댓글 목록") val children: List, ) diff --git a/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt b/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt index 03fde804..af074fdb 100644 --- a/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt @@ -1,10 +1,7 @@ package com.weeth.domain.comment.application.usecase.command -import com.weeth.domain.board.application.exception.NoticeNotFoundException import com.weeth.domain.board.application.exception.PostNotFoundException -import com.weeth.domain.board.domain.entity.Notice import com.weeth.domain.board.domain.entity.Post -import com.weeth.domain.board.domain.repository.NoticeRepository import com.weeth.domain.board.domain.repository.PostRepository import com.weeth.domain.comment.application.dto.request.CommentSaveRequest import com.weeth.domain.comment.application.dto.request.CommentUpdateRequest @@ -26,14 +23,11 @@ import org.springframework.transaction.annotation.Transactional class ManageCommentUseCase( private val commentRepository: CommentRepository, private val postRepository: PostRepository, - private val noticeRepository: NoticeRepository, private val userGetService: UserGetService, private val fileReader: FileReader, private val fileRepository: FileRepository, private val fileMapper: FileMapper, -) : PostCommentUsecase, - NoticeCommentUsecase { - // Todo: Board 도메인 리팩토링 후 단일 Post 대응으로 수정. +) : PostCommentUsecase { @Transactional override fun savePostComment( dto: CommentSaveRequest, @@ -88,61 +82,6 @@ class ManageCommentUseCase( post.decreaseCommentCount() } - @Transactional - override fun saveNoticeComment( - dto: CommentSaveRequest, - noticeId: Long, - userId: Long, - ) { - val user = userGetService.find(userId) - val notice = findNoticeWithLock(noticeId) - val parent = - dto.parentCommentId?.let { parentId -> - commentRepository.findByIdAndNoticeId(parentId, noticeId) ?: throw CommentNotFoundException() - } - - val comment = - Comment.createForNotice( - content = dto.content, - notice = notice, - user = user, - parent = parent, - ) - val savedComment = commentRepository.save(comment) - saveCommentFiles(savedComment, dto.files) - notice.increaseCommentCount() - } - - @Transactional - override fun updateNoticeComment( - dto: CommentUpdateRequest, - noticeId: Long, - commentId: Long, - userId: Long, - ) { - val comment = commentRepository.findByIdAndNoticeId(commentId, noticeId) ?: throw CommentNotFoundException() - ensureOwner(comment, userId) - ensureNotDeleted(comment) - - comment.updateContent(dto.content) - replaceCommentFiles(comment, dto.files) - } - - @Transactional - override fun deleteNoticeComment( - noticeId: Long, - commentId: Long, - userId: Long, - ) { - val notice = findNoticeWithLock(noticeId) - val comment = - commentRepository.findByIdAndNoticeId(commentId, noticeId) ?: throw CommentNotFoundException() - ensureOwner(comment, userId) - - deleteComment(comment) - notice.decreaseCommentCount() - } - private fun saveCommentFiles( comment: Comment, files: List?, @@ -157,10 +96,6 @@ class ManageCommentUseCase( comment: Comment, files: List?, ) { - // 계약: - // files == null -> 첨부 유지(변경 안 함) - // files == [] -> 기존 첨부 전체 삭제 - // files == [...] -> 기존 첨부 삭제 후 전달 목록으로 교체 if (files == null) { return } @@ -174,21 +109,21 @@ class ManageCommentUseCase( throw CommentAlreadyDeletedException() } - // 자식 댓글이 없는 경우 -> 삭제 if (comment.children.isEmpty()) { deleteCommentFiles(comment) val parent = comment.parent + val shouldDeleteParent = parent?.let { it.isDeleted && it.children.size == 1 } == true commentRepository.delete(comment) - // 부모 댓글이 삭제된 상태이고 자식 댓글이 1개인 경우 -> 부모 댓글도 삭제 - if (parent != null && parent.isDeleted && parent.children.size == 1) { - deleteCommentFiles(parent) - commentRepository.delete(parent) + if (shouldDeleteParent) { + parent.let { + deleteCommentFiles(it) + commentRepository.delete(it) + } } return } - // 자식 댓글이 있는 경우 -> 댓글을 Soft Delete해 서비스에서 "삭제된 댓글"으로 표시 deleteCommentFiles(comment) comment.markAsDeleted() } @@ -219,6 +154,4 @@ class ManageCommentUseCase( } private fun findPostWithLock(postId: Long): Post = postRepository.findByIdWithLock(postId) ?: throw PostNotFoundException() - - private fun findNoticeWithLock(noticeId: Long): Notice = noticeRepository.findByIdWithLock(noticeId) ?: throw NoticeNotFoundException() } diff --git a/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/NoticeCommentUsecase.kt b/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/NoticeCommentUsecase.kt deleted file mode 100644 index c0c3f1bb..00000000 --- a/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/NoticeCommentUsecase.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.weeth.domain.comment.application.usecase.command - -import com.weeth.domain.comment.application.dto.request.CommentSaveRequest -import com.weeth.domain.comment.application.dto.request.CommentUpdateRequest - -interface NoticeCommentUsecase { - fun saveNoticeComment( - dto: CommentSaveRequest, - noticeId: Long, - userId: Long, - ) - - fun updateNoticeComment( - dto: CommentUpdateRequest, - noticeId: Long, - commentId: Long, - userId: Long, - ) - - fun deleteNoticeComment( - noticeId: Long, - commentId: Long, - userId: Long, - ) -} diff --git a/src/main/kotlin/com/weeth/domain/comment/domain/entity/Comment.kt b/src/main/kotlin/com/weeth/domain/comment/domain/entity/Comment.kt index 491bbf0b..f070b906 100644 --- a/src/main/kotlin/com/weeth/domain/comment/domain/entity/Comment.kt +++ b/src/main/kotlin/com/weeth/domain/comment/domain/entity/Comment.kt @@ -1,6 +1,5 @@ package com.weeth.domain.comment.domain.entity -import com.weeth.domain.board.domain.entity.Notice import com.weeth.domain.board.domain.entity.Post import com.weeth.domain.comment.domain.vo.CommentContent import com.weeth.domain.user.domain.entity.User @@ -30,10 +29,7 @@ class Comment( var isDeleted: Boolean = false, @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "post_id") - val post: Post? = null, // Todo: Board 도메인 리팩토링시 반영 - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "notice_id") - val notice: Notice? = null, // Todo: Board 도메인 리팩토링시 반영 + val post: Post, @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") val user: User, @@ -65,7 +61,7 @@ class Comment( user: User, parent: Comment?, ): Comment { - require(parent == null || parent.post?.id == post.id) { + require(parent == null || parent.post.id == post.id) { "부모 댓글은 동일한 게시글에 존재해야 합니다." } return Comment( @@ -75,22 +71,5 @@ class Comment( parent = parent, ) } - - fun createForNotice( - content: String, - notice: Notice, - user: User, - parent: Comment?, - ): Comment { - require(parent == null || parent.notice?.id == notice.id) { - "부모 댓글은 동일한 공지글에 존재해야 합니다." - } - return Comment( - content = CommentContent.from(content).value, - notice = notice, - user = user, - parent = parent, - ) - } } } diff --git a/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentReader.kt b/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentReader.kt new file mode 100644 index 00000000..81dc6a10 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentReader.kt @@ -0,0 +1,7 @@ +package com.weeth.domain.comment.domain.repository + +import com.weeth.domain.comment.domain.entity.Comment + +interface CommentReader { + fun findAllByPostId(postId: Long): List +} diff --git a/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentRepository.kt b/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentRepository.kt index e4ca6061..3728d37d 100644 --- a/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentRepository.kt +++ b/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentRepository.kt @@ -3,14 +3,11 @@ package com.weeth.domain.comment.domain.repository import com.weeth.domain.comment.domain.entity.Comment import org.springframework.data.jpa.repository.JpaRepository -interface CommentRepository : JpaRepository { +interface CommentRepository : + JpaRepository, + CommentReader { fun findByIdAndPostId( id: Long, postId: Long, ): Comment? - - fun findByIdAndNoticeId( - id: Long, - noticeId: Long, - ): Comment? } diff --git a/src/main/kotlin/com/weeth/domain/comment/presentation/CommentResponseCode.kt b/src/main/kotlin/com/weeth/domain/comment/presentation/CommentResponseCode.kt index 41d974e3..0a485e33 100644 --- a/src/main/kotlin/com/weeth/domain/comment/presentation/CommentResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/comment/presentation/CommentResponseCode.kt @@ -8,9 +8,6 @@ enum class CommentResponseCode( override val status: HttpStatus, override val message: String, ) : ResponseCodeInterface { - COMMENT_CREATED_SUCCESS(1400, HttpStatus.OK, "공지사항 댓글이 성공적으로 생성되었습니다."), - COMMENT_UPDATED_SUCCESS(1401, HttpStatus.OK, "공지사항 댓글이 성공적으로 수정되었습니다."), - COMMENT_DELETED_SUCCESS(1402, HttpStatus.OK, "공지사항 댓글이 성공적으로 삭제되었습니다."), POST_COMMENT_CREATED_SUCCESS(1403, HttpStatus.OK, "게시글 댓글이 성공적으로 생성되었습니다."), POST_COMMENT_UPDATED_SUCCESS(1404, HttpStatus.OK, "게시글 댓글이 성공적으로 수정되었습니다."), POST_COMMENT_DELETED_SUCCESS(1405, HttpStatus.OK, "게시글 댓글이 성공적으로 삭제되었습니다."), diff --git a/src/main/kotlin/com/weeth/domain/comment/presentation/NoticeCommentController.kt b/src/main/kotlin/com/weeth/domain/comment/presentation/NoticeCommentController.kt deleted file mode 100644 index e47a35ab..00000000 --- a/src/main/kotlin/com/weeth/domain/comment/presentation/NoticeCommentController.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.weeth.domain.comment.presentation - -import com.weeth.domain.comment.application.dto.request.CommentSaveRequest -import com.weeth.domain.comment.application.dto.request.CommentUpdateRequest -import com.weeth.domain.comment.application.exception.CommentErrorCode -import com.weeth.domain.comment.application.usecase.command.NoticeCommentUsecase -import com.weeth.global.auth.annotation.CurrentUser -import com.weeth.global.common.exception.ApiErrorCodeExample -import com.weeth.global.common.response.CommonResponse -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.Parameter -import io.swagger.v3.oas.annotations.tags.Tag -import jakarta.validation.Valid -import org.springframework.web.bind.annotation.DeleteMapping -import org.springframework.web.bind.annotation.PatchMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController - -@Tag(name = "COMMENT-NOTICE", description = "공지사항 댓글 API") -@RestController -@RequestMapping("/api/v1/notices/{noticeId}/comments") -@ApiErrorCodeExample(CommentErrorCode::class) -class NoticeCommentController( - private val noticeCommentUsecase: NoticeCommentUsecase, -) { - @PostMapping - @Operation(summary = "공지사항 댓글 작성") - fun saveNoticeComment( - @RequestBody @Valid dto: CommentSaveRequest, - @PathVariable noticeId: Long, - @Parameter(hidden = true) @CurrentUser userId: Long, - ): CommonResponse { - noticeCommentUsecase.saveNoticeComment(dto, noticeId, userId) - return CommonResponse.success(CommentResponseCode.COMMENT_CREATED_SUCCESS) - } - - @PatchMapping("/{commentId}") - @Operation( - summary = "공지사항 댓글 수정", - description = "files 규약: null=기존 첨부 유지, []=기존 첨부 전체 삭제, 배열 전달=전달 목록으로 교체", - ) - fun updateNoticeComment( - @RequestBody @Valid dto: CommentUpdateRequest, - @PathVariable noticeId: Long, - @PathVariable commentId: Long, - @Parameter(hidden = true) @CurrentUser userId: Long, - ): CommonResponse { - noticeCommentUsecase.updateNoticeComment(dto, noticeId, commentId, userId) - return CommonResponse.success(CommentResponseCode.COMMENT_UPDATED_SUCCESS) - } - - @DeleteMapping("/{commentId}") - @Operation(summary = "공지사항 댓글 삭제") - fun deleteNoticeComment( - @PathVariable noticeId: Long, - @PathVariable commentId: Long, - @Parameter(hidden = true) @CurrentUser userId: Long, - ): CommonResponse { - noticeCommentUsecase.deleteNoticeComment(noticeId, commentId, userId) - return CommonResponse.success(CommentResponseCode.COMMENT_DELETED_SUCCESS) - } -} diff --git a/src/main/kotlin/com/weeth/domain/comment/presentation/PostCommentController.kt b/src/main/kotlin/com/weeth/domain/comment/presentation/PostCommentController.kt index 98310946..0c138560 100644 --- a/src/main/kotlin/com/weeth/domain/comment/presentation/PostCommentController.kt +++ b/src/main/kotlin/com/weeth/domain/comment/presentation/PostCommentController.kt @@ -19,9 +19,9 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController -@Tag(name = "COMMENT-BOARD", description = "게시판 댓글 API") +@Tag(name = "COMMENT-POST", description = "게시글 댓글 API") @RestController -@RequestMapping("/api/v1/board/{boardId}/comments") +@RequestMapping("/api/v1/posts/{postId}/comments") @ApiErrorCodeExample(CommentErrorCode::class) class PostCommentController( private val postCommentUsecase: PostCommentUsecase, @@ -30,10 +30,10 @@ class PostCommentController( @Operation(summary = "게시글 댓글 작성") fun savePostComment( @RequestBody @Valid dto: CommentSaveRequest, - @PathVariable boardId: Long, + @PathVariable postId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse { - postCommentUsecase.savePostComment(dto, boardId, userId) + postCommentUsecase.savePostComment(dto, postId, userId) return CommonResponse.success(CommentResponseCode.POST_COMMENT_CREATED_SUCCESS) } @@ -44,22 +44,22 @@ class PostCommentController( ) fun updatePostComment( @RequestBody @Valid dto: CommentUpdateRequest, - @PathVariable boardId: Long, + @PathVariable postId: Long, @PathVariable commentId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse { - postCommentUsecase.updatePostComment(dto, boardId, commentId, userId) + postCommentUsecase.updatePostComment(dto, postId, commentId, userId) return CommonResponse.success(CommentResponseCode.POST_COMMENT_UPDATED_SUCCESS) } @DeleteMapping("/{commentId}") @Operation(summary = "게시글 댓글 삭제") fun deletePostComment( - @PathVariable boardId: Long, + @PathVariable postId: Long, @PathVariable commentId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse { - postCommentUsecase.deletePostComment(boardId, commentId, userId) + postCommentUsecase.deletePostComment(postId, commentId, userId) return CommonResponse.success(CommentResponseCode.POST_COMMENT_DELETED_SUCCESS) } } diff --git a/src/main/kotlin/com/weeth/domain/file/domain/entity/FileOwnerType.kt b/src/main/kotlin/com/weeth/domain/file/domain/entity/FileOwnerType.kt index a8028a3a..da114464 100644 --- a/src/main/kotlin/com/weeth/domain/file/domain/entity/FileOwnerType.kt +++ b/src/main/kotlin/com/weeth/domain/file/domain/entity/FileOwnerType.kt @@ -2,7 +2,6 @@ package com.weeth.domain.file.domain.entity enum class FileOwnerType { POST, - NOTICE, COMMENT, RECEIPT, } diff --git a/src/main/kotlin/com/weeth/domain/file/domain/repository/FileReader.kt b/src/main/kotlin/com/weeth/domain/file/domain/repository/FileReader.kt index c57e27e6..3c0969d6 100644 --- a/src/main/kotlin/com/weeth/domain/file/domain/repository/FileReader.kt +++ b/src/main/kotlin/com/weeth/domain/file/domain/repository/FileReader.kt @@ -15,12 +15,7 @@ interface FileReader { ownerType: FileOwnerType, ownerIds: List, status: FileStatus? = FileStatus.UPLOADED, - ): List { - if (ownerIds.isEmpty()) { - return emptyList() - } - return ownerIds.flatMap { findAll(ownerType, it, status) } - } + ): List fun exists( ownerType: FileOwnerType, diff --git a/src/main/kotlin/com/weeth/global/common/converter/JsonConverter.kt b/src/main/kotlin/com/weeth/global/common/converter/JsonConverter.kt new file mode 100644 index 00000000..4cec9574 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/converter/JsonConverter.kt @@ -0,0 +1,21 @@ +package com.weeth.global.common.converter + +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.KotlinModule +import jakarta.persistence.AttributeConverter + +abstract class JsonConverter( + private val typeRef: TypeReference, +) : AttributeConverter { + companion object { + private val objectMapper = + ObjectMapper().apply { + registerModule(KotlinModule.Builder().build()) + } + } + + override fun convertToDatabaseColumn(attribute: T?): String? = attribute?.let { objectMapper.writeValueAsString(it) } + + override fun convertToEntityAttribute(dbData: String?): T? = dbData?.let { objectMapper.readValue(it, typeRef) } +} diff --git a/src/test/kotlin/com/weeth/config/TestContainersConfig.kt b/src/test/kotlin/com/weeth/config/TestContainersConfig.kt index 23c5dd1f..70c0403d 100644 --- a/src/test/kotlin/com/weeth/config/TestContainersConfig.kt +++ b/src/test/kotlin/com/weeth/config/TestContainersConfig.kt @@ -3,6 +3,7 @@ package com.weeth.config import org.springframework.boot.test.context.TestConfiguration import org.springframework.boot.testcontainers.service.connection.ServiceConnection import org.springframework.context.annotation.Bean +import org.testcontainers.containers.GenericContainer import org.testcontainers.containers.MySQLContainer import org.testcontainers.utility.DockerImageName @@ -12,7 +13,14 @@ class TestContainersConfig { @ServiceConnection fun mysqlContainer(): MySQLContainer<*> = MySQLContainer(DockerImageName.parse(MYSQL_IMAGE)) + @Bean + @ServiceConnection(name = "redis") + fun redisContainer(): GenericContainer<*> = + GenericContainer(DockerImageName.parse(REDIS_IMAGE)) + .withExposedPorts(6379) + companion object { private const val MYSQL_IMAGE = "mysql:8.0.41" + private const val REDIS_IMAGE = "redis:7.2.7" } } diff --git a/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt b/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt index f8cbe2d4..dda26cba 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt @@ -1,38 +1,82 @@ package com.weeth.domain.board.application.mapper import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.board.domain.entity.enums.BoardType +import com.weeth.domain.comment.application.dto.response.CommentResponse +import com.weeth.domain.file.application.dto.response.FileResponse +import com.weeth.domain.file.domain.entity.FileStatus import com.weeth.domain.user.domain.entity.User -import io.kotest.core.spec.style.StringSpec -import io.kotest.matchers.nulls.shouldNotBeNull +import com.weeth.domain.user.domain.entity.enums.Position +import com.weeth.domain.user.domain.entity.enums.Role +import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe -import org.mapstruct.factory.Mappers +import io.mockk.every +import io.mockk.mockk +import java.time.LocalDateTime class PostMapperTest : - StringSpec({ - - val mapper = Mappers.getMapper(PostMapper::class.java) - - "Post를 PostDTO.SaveResponse로 변환" { - val testUser = - User - .builder() - .id(1L) - .name("테스트유저") - .email("test@weeth.com") - .build() - - val testPost = - Post - .builder() - .id(1L) - .title("테스트 게시글") - .user(testUser) - .content("테스트 내용입니다.") - .build() - - val response = mapper.toSaveResponse(testPost) - - response.shouldNotBeNull() - response.id() shouldBe testPost.id + DescribeSpec({ + val mapper = PostMapper() + val now = LocalDateTime.now() + val user = mockk() + val post = mockk() + + every { user.name } returns "테스터" + every { user.position } returns Position.BE + every { user.role } returns Role.USER + + every { post.id } returns 1L + every { post.title } returns "제목" + every { post.content } returns "내용" + every { post.user } returns user + every { post.commentCount } returns 2 + every { post.createdAt } returns now.minusHours(1) + every { post.modifiedAt } returns now + + describe("toListResponse") { + it("24시간 이내 생성된 게시글은 isNew=true") { + val response = mapper.toListResponse(post, hasFile = true, now = now) + + response.id shouldBe 1L + response.hasFile shouldBe true + response.isNew shouldBe true + } + } + + describe("toDetailResponse") { + it("댓글/파일 목록을 포함해 상세 응답으로 변환한다") { + val comments = + listOf( + CommentResponse( + id = 10L, + name = "댓글작성자", + position = Position.BE, + role = Role.USER, + content = "댓글", + time = LocalDateTime.now(), + fileUrls = emptyList(), + children = emptyList(), + ), + ) + val files = + listOf( + FileResponse( + fileId = 5L, + fileName = "a.png", + fileUrl = "https://cdn/a.png", + storageKey = "POST/2026-02/550e8400-e29b-41d4-a716-446655440000_a.png", + fileSize = 100, + contentType = "image/png", + status = FileStatus.UPLOADED, + ), + ) + + val response = mapper.toDetailResponse(post, comments, files) + + response.id shouldBe 1L + response.commentCount shouldBe 2 + response.comments.size shouldBe 1 + response.fileUrls.size shouldBe 1 + } } }) diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/NoticeUsecaseImplTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/NoticeUsecaseImplTest.kt deleted file mode 100644 index b3960018..00000000 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/NoticeUsecaseImplTest.kt +++ /dev/null @@ -1,252 +0,0 @@ -package com.weeth.domain.board.application.usecase - -import com.weeth.domain.board.application.dto.NoticeDTO -import com.weeth.domain.board.application.mapper.NoticeMapper -import com.weeth.domain.board.domain.entity.Notice -import com.weeth.domain.board.domain.service.NoticeDeleteService -import com.weeth.domain.board.domain.service.NoticeFindService -import com.weeth.domain.board.domain.service.NoticeSaveService -import com.weeth.domain.board.domain.service.NoticeUpdateService -import com.weeth.domain.board.fixture.NoticeTestFixture -import com.weeth.domain.comment.application.usecase.query.GetCommentQueryService -import com.weeth.domain.file.application.dto.request.FileSaveRequest -import com.weeth.domain.file.application.mapper.FileMapper -import com.weeth.domain.file.domain.entity.File -import com.weeth.domain.file.domain.entity.FileOwnerType -import com.weeth.domain.file.domain.repository.FileReader -import com.weeth.domain.file.domain.repository.FileRepository -import com.weeth.domain.file.domain.vo.FileContentType -import com.weeth.domain.file.domain.vo.StorageKey -import com.weeth.domain.file.fixture.FileTestFixture -import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.entity.enums.Department -import com.weeth.domain.user.domain.entity.enums.Position -import com.weeth.domain.user.domain.entity.enums.Role -import com.weeth.domain.user.domain.service.UserGetService -import com.weeth.domain.user.fixture.UserTestFixture -import io.kotest.core.spec.style.DescribeSpec -import io.kotest.matchers.booleans.shouldBeFalse -import io.kotest.matchers.booleans.shouldBeTrue -import io.kotest.matchers.collections.shouldContainExactly -import io.kotest.matchers.collections.shouldHaveSize -import io.kotest.matchers.nulls.shouldNotBeNull -import io.kotest.matchers.shouldBe -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import org.springframework.data.domain.PageRequest -import org.springframework.data.domain.SliceImpl -import org.springframework.data.domain.Sort -import org.springframework.test.util.ReflectionTestUtils - -class NoticeUsecaseImplTest : - DescribeSpec({ - - val noticeSaveService = mockk(relaxUnitFun = true) - val noticeFindService = mockk() - val noticeUpdateService = mockk(relaxUnitFun = true) - val noticeDeleteService = mockk(relaxUnitFun = true) - val userGetService = mockk() - val fileRepository = mockk(relaxed = true) - val fileReader = mockk() - val noticeMapper = mockk() - val getCommentQueryService = mockk() - val fileMapper = mockk() - - val noticeUsecase = - NoticeUsecaseImpl( - noticeSaveService, - noticeFindService, - noticeUpdateService, - noticeDeleteService, - userGetService, - fileRepository, - fileReader, - noticeMapper, - getCommentQueryService, - fileMapper, - ) - - describe("findNotices") { - it("공지사항이 최신순으로 정렬된다") { - val user = - User - .builder() - .email("abc@test.com") - .name("홍길동") - .position(Position.BE) - .department(Department.SW) - .role(Role.USER) - .build() - - val notices = - (0 until 5).map { i -> - NoticeTestFixture.createNotice(title = "공지$i", user = user).also { - ReflectionTestUtils.setField(it, "id", (i + 1).toLong()) - } - } - - val pageable = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "id")) - val slice = SliceImpl(listOf(notices[4], notices[3], notices[2]), pageable, true) - - every { noticeFindService.findRecentNotices(any()) } returns slice - every { fileReader.exists(FileOwnerType.NOTICE, any(), null) } returns false - every { noticeMapper.toAll(any(), any()) } answers { - val notice = firstArg() - NoticeDTO.ResponseAll( - notice.id, - notice.user?.name ?: "", - notice.user?.position ?: Position.BE, - notice.user?.role ?: Role.USER, - notice.title, - notice.content, - notice.createdAt, - notice.commentCount, - false, - ) - } - - val noticeResponses = noticeUsecase.findNotices(0, 3) - - noticeResponses.shouldNotBeNull() - noticeResponses.content shouldHaveSize 3 - noticeResponses.content.map { it.title() } shouldContainExactly - listOf(notices[4].title, notices[3].title, notices[2].title) - noticeResponses.hasNext().shouldBeTrue() - - verify(exactly = 1) { noticeFindService.findRecentNotices(pageable) } - } - } - - describe("searchNotice") { - it("공지사항 검색시 결과와 파일 존재여부가 정상적으로 반환") { - val user = - User - .builder() - .email("abc@test.com") - .name("홍길동") - .position(Position.BE) - .department(Department.SW) - .role(Role.USER) - .build() - - val notices = mutableListOf() - for (i in 0 until 3) { - val notice = NoticeTestFixture.createNotice(title = "공지$i", user = user) - ReflectionTestUtils.setField(notice, "id", (i + 1).toLong()) - notices.add(notice) - } - for (i in 3 until 6) { - val notice = NoticeTestFixture.createNotice(title = "검색$i", user = user) - ReflectionTestUtils.setField(notice, "id", (i + 1).toLong()) - notices.add(notice) - } - - val pageable = PageRequest.of(0, 5, Sort.by(Sort.Direction.DESC, "id")) - val slice = SliceImpl(listOf(notices[5], notices[4], notices[3]), pageable, false) - - every { noticeFindService.search(any(), any()) } returns slice - every { fileReader.exists(FileOwnerType.NOTICE, any(), null) } answers { - val noticeId = secondArg() - noticeId % 2 == 0L - } - every { noticeMapper.toAll(any(), any()) } answers { - val notice = firstArg() - val fileExists = secondArg() - NoticeDTO.ResponseAll( - notice.id, - notice.user?.name ?: "", - notice.user?.position ?: Position.BE, - notice.user?.role ?: Role.USER, - notice.title, - notice.content, - notice.createdAt, - notice.commentCount, - fileExists, - ) - } - - val noticeResponses = noticeUsecase.searchNotice("검색", 0, 5) - - noticeResponses.shouldNotBeNull() - noticeResponses.content shouldHaveSize 3 - noticeResponses.content.map { it.title() } shouldContainExactly - listOf(notices[5].title, notices[4].title, notices[3].title) - noticeResponses.hasNext().shouldBeFalse() - - noticeResponses.content[0].hasFile().shouldBeTrue() - noticeResponses.content[1].hasFile().shouldBeFalse() - - verify(exactly = 1) { noticeFindService.search("검색", pageable) } - } - } - - describe("update") { - it("공지사항 수정 시 기존 파일 삭제 후 새 파일로 업데이트된다") { - val noticeId = 1L - val userId = 1L - - val user = UserTestFixture.createActiveUser1(userId) - val notice = NoticeTestFixture.createNotice(id = noticeId, title = "기존 제목", user = user) - - val oldFile = - FileTestFixture.createFile( - 1L, - "old.pdf", - storageKey = StorageKey("NOTICE/2026-02/00000000-0000-0000-0000-000000000000_old.pdf"), - ownerType = FileOwnerType.NOTICE, - ownerId = noticeId, - contentType = FileContentType("application/pdf"), - ) - val oldFiles = listOf(oldFile) - - val dto = - NoticeDTO.Update( - "수정된 제목", - "수정된 내용", - listOf(FileSaveRequest("new.pdf", "NOTICE/2026-02/new.pdf", 100L, "application/pdf")), - ) - - val newFile = - FileTestFixture.createFile( - 2L, - "new.pdf", - storageKey = StorageKey("NOTICE/2026-02/00000000-0000-0000-0000-000000000000_old.pdf"), - ownerType = FileOwnerType.NOTICE, - ownerId = noticeId, - contentType = FileContentType("application/pdf"), - ) - val newFiles = listOf(newFile) - - val expectedResponse = NoticeDTO.SaveResponse(noticeId) - - every { noticeFindService.find(noticeId) } returns notice - every { fileReader.findAll(FileOwnerType.NOTICE, noticeId, null) } returns oldFiles - every { fileMapper.toFileList(dto.files(), FileOwnerType.NOTICE, noticeId) } returns newFiles - every { noticeMapper.toSaveResponse(notice) } returns expectedResponse - - val response = noticeUsecase.update(noticeId, dto, userId) - - response shouldBe expectedResponse - - verify { noticeFindService.find(noticeId) } - verify { fileReader.findAll(FileOwnerType.NOTICE, noticeId, null) } - verify { fileRepository.deleteAll(oldFiles) } - verify { fileMapper.toFileList(dto.files(), FileOwnerType.NOTICE, noticeId) } - verify { fileRepository.saveAll(newFiles) } - verify { noticeUpdateService.update(notice, dto) } - } - - it("공지사항 엔티티 update() 호출 시 제목과 내용이 변경된다") { - val userId = 1L - val user = UserTestFixture.createActiveUser1(userId) - val notice = NoticeTestFixture.createNotice(id = 1L, title = "기존 제목", user = user) - val dto = NoticeDTO.Update("수정된 제목", "수정된 내용", listOf()) - - notice.update(dto) - - notice.title shouldBe dto.title() - notice.content shouldBe dto.content() - } - } - }) diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/PostUseCaseImplTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/PostUseCaseImplTest.kt deleted file mode 100644 index d7028b35..00000000 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/PostUseCaseImplTest.kt +++ /dev/null @@ -1,288 +0,0 @@ -package com.weeth.domain.board.application.usecase - -import com.weeth.domain.board.application.dto.PartPostDTO -import com.weeth.domain.board.application.dto.PostDTO -import com.weeth.domain.board.application.exception.CategoryAccessDeniedException -import com.weeth.domain.board.application.mapper.PostMapper -import com.weeth.domain.board.domain.entity.enums.Category -import com.weeth.domain.board.domain.entity.enums.Part -import com.weeth.domain.board.domain.service.PostDeleteService -import com.weeth.domain.board.domain.service.PostFindService -import com.weeth.domain.board.domain.service.PostSaveService -import com.weeth.domain.board.domain.service.PostUpdateService -import com.weeth.domain.board.fixture.PostTestFixture -import com.weeth.domain.comment.application.usecase.query.GetCommentQueryService -import com.weeth.domain.file.application.mapper.FileMapper -import com.weeth.domain.file.domain.entity.FileOwnerType -import com.weeth.domain.file.domain.repository.FileReader -import com.weeth.domain.file.domain.repository.FileRepository -import com.weeth.domain.file.domain.vo.StorageKey -import com.weeth.domain.file.fixture.FileTestFixture -import com.weeth.domain.user.domain.service.CardinalGetService -import com.weeth.domain.user.domain.service.UserCardinalGetService -import com.weeth.domain.user.domain.service.UserGetService -import com.weeth.domain.user.fixture.CardinalTestFixture -import com.weeth.domain.user.fixture.UserTestFixture -import io.kotest.assertions.throwables.shouldNotThrowAny -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.core.spec.style.DescribeSpec -import io.kotest.matchers.booleans.shouldBeFalse -import io.kotest.matchers.booleans.shouldBeTrue -import io.kotest.matchers.collections.shouldBeEmpty -import io.kotest.matchers.collections.shouldContainExactly -import io.kotest.matchers.collections.shouldHaveSize -import io.kotest.matchers.nulls.shouldNotBeNull -import io.kotest.matchers.shouldBe -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import org.springframework.data.domain.PageRequest -import org.springframework.data.domain.Pageable -import org.springframework.data.domain.SliceImpl -import org.springframework.data.domain.Sort - -class PostUseCaseImplTest : - DescribeSpec({ - - val postSaveService = mockk() - val postFindService = mockk() - val postUpdateService = mockk() - val postDeleteService = mockk() - val userGetService = mockk() - val userCardinalGetService = mockk() - val cardinalGetService = mockk() - val fileRepository = mockk(relaxed = true) - val fileReader = mockk() - val mapper = mockk() - val fileMapper = mockk() - val getCommentQueryService = mockk() - - val postUseCase = - PostUseCaseImpl( - postSaveService, - postFindService, - postUpdateService, - postDeleteService, - userGetService, - userCardinalGetService, - cardinalGetService, - fileRepository, - fileReader, - mapper, - fileMapper, - getCommentQueryService, - ) - - describe("saveEducation") { - it("교육 게시글 저장 성공") { - val userId = 1L - val postId = 1L - - val request = PostDTO.SaveEducation("제목1", "내용", listOf(Part.BE), 1, listOf()) - val user = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.createPost(postId, "제목1", Category.Education) - - every { userGetService.find(userId) } returns user - every { mapper.fromEducationDto(request, user) } returns post - every { postSaveService.save(post) } returns post - every { fileMapper.toFileList(request.files(), FileOwnerType.POST, postId) } returns listOf() - every { mapper.toSaveResponse(post) } returns PostDTO.SaveResponse(postId) - - val response = postUseCase.saveEducation(request, userId) - - response.id() shouldBe postId - verify { userGetService.find(userId) } - verify { postSaveService.save(post) } - verify { mapper.toSaveResponse(post) } - } - } - - describe("save") { - context("관리자 권한이 없는 사용자가 교육 게시글 생성 시") { - it("예외를 던진다") { - val userId = 1L - val request = PostDTO.Save("제목", "내용", Category.Education, null, 1, Part.BE, 1, listOf()) - val user = UserTestFixture.createActiveUser1(1L) - - every { userGetService.find(userId) } returns user - - shouldThrow { - postUseCase.save(request, userId) - } - } - } - } - - describe("findPartPosts") { - it("특정 파트와 주차 조건으로 게시글 목록 조회 성공") { - val dto = PartPostDTO(Part.BE, Category.Education, 1, 2, "스터디1") - val pageNumber = 0 - val pageSize = 5 - val user = UserTestFixture.createActiveUser1() - - val post2 = - PostTestFixture.createEducationPost( - 2L, - user, - "게시글2", - Category.Education, - listOf(Part.BE), - 1, - 2, - ) - val postSlice = SliceImpl(listOf(post2)) - val response2 = PostTestFixture.createResponseAll(post2) - - every { - postFindService.findByPartAndOptionalFilters( - dto.part(), - dto.category(), - dto.cardinalNumber(), - dto.studyName(), - dto.week(), - PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")), - ) - } returns postSlice - - every { mapper.toAll(post2, false) } returns response2 - every { fileReader.exists(FileOwnerType.POST, post2.id, null) } returns false - - val result = postUseCase.findPartPosts(dto, pageNumber, pageSize) - - result.shouldNotBeNull() - result.content shouldHaveSize 1 - result.content[0].title() shouldBe "게시글2" - result.content[0].hasFile().shouldBeFalse() - - verify { - postFindService.findByPartAndOptionalFilters( - dto.part(), - dto.category(), - dto.cardinalNumber(), - dto.studyName(), - dto.week(), - PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")), - ) - } - } - } - - describe("findEducationPosts") { - it("관리자 권한 사용자가 교육 게시글 목록 조회 시 성공적으로 반환한다") { - val userId = 1L - val part = Part.BE - val cardinalNumber = 1 - val pageNumber = 0 - val pageSize = 5 - - val adminUser = UserTestFixture.createAdmin(userId) - - val post1 = - PostTestFixture.createEducationPost( - 1L, - adminUser, - "교육글1", - Category.Education, - listOf(Part.BE), - 1, - 1, - ) - val post2 = - PostTestFixture.createEducationPost( - 2L, - adminUser, - "교육글2", - Category.Education, - listOf(Part.BE), - 1, - 2, - ) - val postSlice = SliceImpl(listOf(post1, post2)) - - val response1 = PostTestFixture.createResponseEducationAll(post1, false) - val response2 = PostTestFixture.createResponseEducationAll(post2, false) - - every { userGetService.find(userId) } returns adminUser - every { postFindService.findByCategory(part, Category.Education, cardinalNumber, pageNumber, pageSize) } returns postSlice - every { mapper.toEducationAll(post1, false) } returns response1 - every { mapper.toEducationAll(post2, false) } returns response2 - every { fileReader.exists(FileOwnerType.POST, post1.id, null) } returns false - every { fileReader.exists(FileOwnerType.POST, post2.id, null) } returns false - - val result = postUseCase.findEducationPosts(userId, part, cardinalNumber, pageNumber, pageSize) - - result.shouldNotBeNull() - result.content shouldHaveSize 2 - result.content.map { it.title() } shouldContainExactly listOf("교육글1", "교육글2") - - verify { postFindService.findByCategory(part, Category.Education, cardinalNumber, pageNumber, pageSize) } - verify { mapper.toEducationAll(post1, false) } - verify { mapper.toEducationAll(post2, false) } - } - - it("본인이 속하지 않은 교육 자료를 검색하면 빈 리스트를 반환한다") { - val userId = 1L - val part = Part.BE - val cardinalNumber = 3 - val pageNumber = 0 - val pageSize = 5 - - val user = UserTestFixture.createActiveUser1(userId) - val cardinal = CardinalTestFixture.createCardinal(cardinalNumber = 1, year = 2025, semester = 1) - - every { userGetService.find(userId) } returns user - every { cardinalGetService.findByUserSide(cardinalNumber) } returns cardinal - every { userCardinalGetService.notContains(user, cardinal) } returns true - - val result = postUseCase.findEducationPosts(userId, part, cardinalNumber, pageNumber, pageSize) - - result.shouldNotBeNull() - result.content.shouldBeEmpty() - result.hasNext().shouldBeFalse() - - verify { userGetService.find(userId) } - verify { cardinalGetService.findByUserSide(cardinalNumber) } - verify { userCardinalGetService.notContains(user, cardinal) } - verify(exactly = 0) { postFindService.findEducationByCardinal(any(), any(), any()) } - } - } - - describe("findStudyNames") { - it("스터디가 없을 시 예외가 발생하지 않는다") { - val part = Part.BE - val emptyNames = listOf() - val expectedResponse = PostDTO.ResponseStudyNames(emptyNames) - - every { postFindService.findByPart(part) } returns emptyNames - every { mapper.toStudyNames(emptyNames) } returns expectedResponse - - shouldNotThrowAny { - postUseCase.findStudyNames(part) - } - - verify { postFindService.findByPart(part) } - verify { mapper.toStudyNames(emptyNames) } - } - } - - describe("checkFileExistsByPost") { - it("파일이 존재하는 경우 true를 반환한다") { - val postId = 1L - val file = - FileTestFixture.createFile( - postId, - "파일1", - storageKey = StorageKey("POST/2026-02/00000000-0000-0000-0000-000000000000_url1"), - ownerType = FileOwnerType.POST, - ownerId = postId, - ) - - every { fileReader.exists(FileOwnerType.POST, postId, null) } returns true - - val fileExists = postUseCase.checkFileExistsByPost(postId) - - fileExists.shouldBeTrue() - verify { fileReader.exists(FileOwnerType.POST, postId, null) } - } - } - }) diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt new file mode 100644 index 00000000..4f34b708 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt @@ -0,0 +1,82 @@ +package com.weeth.domain.board.application.usecase.command + +import com.weeth.domain.board.application.dto.request.CreateBoardRequest +import com.weeth.domain.board.application.dto.request.UpdateBoardRequest +import com.weeth.domain.board.application.exception.BoardNotFoundException +import com.weeth.domain.board.application.mapper.BoardMapper +import com.weeth.domain.board.domain.entity.Board +import com.weeth.domain.board.domain.entity.enums.BoardType +import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.user.domain.entity.enums.Role +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify + +class ManageBoardUseCaseTest : + DescribeSpec({ + val boardRepository = mockk() + val boardMapper = BoardMapper() + val useCase = ManageBoardUseCase(boardRepository, boardMapper) + + beforeTest { + every { boardRepository.save(any()) } answers { firstArg() } + } + + describe("create") { + it("요청값으로 게시판과 설정을 생성한다") { + val request = + CreateBoardRequest( + name = "운영공지", + type = BoardType.NOTICE, + commentEnabled = false, + writePermission = Role.ADMIN, + isPrivate = true, + ) + + val result = useCase.create(request) + + result.name shouldBe "운영공지" + result.type shouldBe BoardType.NOTICE + result.commentEnabled shouldBe false + result.writePermission shouldBe Role.ADMIN + result.isPrivate shouldBe true + } + } + + describe("update") { + it("일부 필드만 전달되면 해당 필드만 갱신한다") { + val board = Board(id = 1L, name = "기존", type = BoardType.GENERAL) + every { boardRepository.findByIdAndIsDeletedFalse(1L) } returns board + + val result = useCase.update(1L, UpdateBoardRequest(name = "변경", isPrivate = true)) + + result.name shouldBe "변경" + result.commentEnabled shouldBe true + result.writePermission shouldBe Role.USER + result.isPrivate shouldBe true + } + + it("존재하지 않는 게시판이면 예외를 던진다") { + every { boardRepository.findByIdAndIsDeletedFalse(999L) } returns null + + shouldThrow { + useCase.update(999L, UpdateBoardRequest(name = "변경")) + } + } + } + + describe("delete") { + it("게시판을 soft delete 처리한다") { + val board = Board(id = 1L, name = "일반", type = BoardType.GENERAL) + every { boardRepository.findByIdAndIsDeletedFalse(1L) } returns board + + useCase.delete(1L) + + board.isDeleted shouldBe true + verify(exactly = 0) { boardRepository.delete(any()) } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt new file mode 100644 index 00000000..9374e211 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt @@ -0,0 +1,272 @@ +package com.weeth.domain.board.application.usecase.command + +import com.weeth.domain.board.application.dto.request.CreatePostRequest +import com.weeth.domain.board.application.dto.request.UpdatePostRequest +import com.weeth.domain.board.application.dto.response.PostSaveResponse +import com.weeth.domain.board.application.exception.BoardNotFoundException +import com.weeth.domain.board.application.exception.CategoryAccessDeniedException +import com.weeth.domain.board.application.mapper.PostMapper +import com.weeth.domain.board.domain.entity.Board +import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.board.domain.entity.enums.BoardType +import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.board.domain.repository.PostRepository +import com.weeth.domain.board.domain.vo.BoardConfig +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.entity.File +import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.repository.FileReader +import com.weeth.domain.file.domain.repository.FileRepository +import com.weeth.domain.user.domain.entity.User +import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.entity.enums.Status +import com.weeth.domain.user.domain.service.UserGetService +import com.weeth.domain.user.fixture.UserTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify + +class ManagePostUseCaseTest : + DescribeSpec({ + val postRepository = mockk() + val boardRepository = mockk() + val userGetService = mockk() + val fileRepository = mockk() + val fileReader = mockk() + val fileMapper = mockk() + val postMapper = mockk() + + val useCase = + ManagePostUseCase( + postRepository, + boardRepository, + userGetService, + fileRepository, + fileReader, + fileMapper, + postMapper, + ) + + fun createUploadedPostFile( + fileName: String, + ownerId: Long = 1L, + ): File = + File.createUploaded( + fileName = fileName, + storageKey = "POST/2026-02/550e8400-e29b-41d4-a716-446655440000_$fileName", + fileSize = 10, + contentType = "image/png", + ownerType = FileOwnerType.POST, + ownerId = ownerId, + ) + + fun createUser( + id: Long = 1L, + role: Role = Role.USER, + ): User = + User + .builder() + .id(id) + .name("적순") + .email("test1@test.com") + .status(Status.ACTIVE) + .role(role) + .build() + + beforeTest { + clearMocks(postRepository, boardRepository, userGetService, fileRepository, fileReader, fileMapper, postMapper) + every { postRepository.save(any()) } answers { firstArg() } + every { fileMapper.toFileList(any(), any(), any()) } returns emptyList() + every { fileRepository.saveAll(any>()) } returns emptyList() + every { fileReader.findAll(any(), any(), any()) } returns emptyList() + every { postMapper.toSaveResponse(any()) } returns PostSaveResponse(1L) + every { fileRepository.delete(any()) } just runs + } + + describe("save") { + it("일반 게시판에서 게시글을 저장한다") { + val user = createUser(1L, Role.USER) + val board = Board(id = 10L, name = "일반", type = BoardType.GENERAL) + val request = CreatePostRequest(title = "제목", content = "내용") + + every { userGetService.find(1L) } returns user + every { boardRepository.findByIdAndIsDeletedFalse(10L) } returns board + + val result = useCase.save(10L, request, 1L) + + result.id shouldBe 1L + verify(exactly = 1) { postRepository.save(any()) } + } + + it("ADMIN 전용 게시판에 일반 사용자가 작성하면 예외를 던진다") { + val user = createUser(1L, Role.USER) + val board = + Board( + id = 20L, + name = "공지", + type = BoardType.NOTICE, + config = BoardConfig(writePermission = Role.ADMIN), + ) + val request = CreatePostRequest(title = "제목", content = "내용") + + every { userGetService.find(1L) } returns user + every { boardRepository.findByIdAndIsDeletedFalse(20L) } returns board + + shouldThrow { + useCase.save(20L, request, 1L) + } + + verify(exactly = 0) { postRepository.save(any()) } + } + + it("비공개 게시판에 일반 사용자가 작성하면 예외를 던진다") { + val user = createUser(1L, Role.USER) + val board = + Board( + id = 21L, + name = "비공개", + type = BoardType.GENERAL, + config = BoardConfig(isPrivate = true), + ) + val request = CreatePostRequest(title = "제목", content = "내용") + + every { userGetService.find(1L) } returns user + every { boardRepository.findByIdAndIsDeletedFalse(21L) } returns board + + shouldThrow { + useCase.save(21L, request, 1L) + } + + verify(exactly = 0) { postRepository.save(any()) } + } + + it("cardinalNumber가 전달되면 게시글에 반영된다") { + val user = createUser(1L, Role.USER) + val board = Board(id = 11L, name = "일반", type = BoardType.GENERAL) + val request = + CreatePostRequest( + title = "게시글", + content = "내용", + cardinalNumber = 6, + ) + + every { userGetService.find(1L) } returns user + every { boardRepository.findByIdAndIsDeletedFalse(11L) } returns board + + useCase.save(11L, request, 1L) + + verify { + postRepository.save( + match { + it.cardinalNumber == 6 + }, + ) + } + } + + it("존재하지 않는 boardId면 예외를 던진다") { + val user = createUser(1L, Role.USER) + val request = CreatePostRequest(title = "제목", content = "내용") + + every { userGetService.find(1L) } returns user + every { boardRepository.findByIdAndIsDeletedFalse(999L) } returns null + + shouldThrow { + useCase.save(999L, request, 1L) + } + } + } + + describe("update") { + it("files가 null이면 기존 파일을 유지한다") { + val user = UserTestFixture.createActiveUser1(1L) + val board = Board(id = 1L, name = "일반", type = BoardType.GENERAL) + val post = Post.create("제목", "내용", user, board) + val request = UpdatePostRequest(title = "수정", content = "수정") + + every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post + + useCase.update(1L, request, 1L) + + verify(exactly = 0) { fileReader.findAll(any(), any(), any()) } + verify(exactly = 0) { fileRepository.saveAll(any>()) } + } + + it("files가 있으면 기존 파일을 soft delete 후 교체한다") { + val user = UserTestFixture.createActiveUser1(1L) + val board = Board(id = 1L, name = "일반", type = BoardType.GENERAL) + val post = Post(id = 1L, title = "제목", content = "내용", user = user, board = board) + val oldFile = createUploadedPostFile("old.png") + val newFiles = + listOf( + createUploadedPostFile("new.png"), + ) + val request = + UpdatePostRequest( + title = "수정", + content = "수정", + files = + listOf( + FileSaveRequest( + "new.png", + "POST/2026-02/550e8400-e29b-41d4-a716-446655440001_new.png", + 10, + "image/png", + ), + ), + ) + + every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post + every { fileReader.findAll(FileOwnerType.POST, 1L, any()) } returns listOf(oldFile) + every { fileMapper.toFileList(request.files, FileOwnerType.POST, 1L) } returns newFiles + every { fileRepository.saveAll(newFiles) } returns newFiles + + useCase.update(1L, request, 1L) + + oldFile.status.name shouldBe "DELETED" + post.title shouldBe "수정" + post.content shouldBe "수정" + verify(exactly = 1) { fileRepository.saveAll(newFiles) } + } + } + + describe("delete") { + it("삭제 시 첨부 파일과 게시글을 soft delete한다") { + val user = UserTestFixture.createActiveUser1(1L) + val board = Board(id = 1L, name = "일반", type = BoardType.GENERAL) + val post = Post(id = 1L, title = "제목", content = "내용", user = user, board = board) + val oldFile = createUploadedPostFile("old.png") + + every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post + every { fileReader.findAll(FileOwnerType.POST, 1L, any()) } returns listOf(oldFile) + + useCase.delete(1L, 1L) + + oldFile.status.name shouldBe "DELETED" + post.isDeleted shouldBe true + verify(exactly = 0) { postRepository.delete(any()) } + } + } + + describe("owner validation") { + it("작성자가 아니면 수정 시 예외를 던진다") { + val owner = UserTestFixture.createActiveUser1(1L) + val board = Board(id = 1L, name = "일반", type = BoardType.GENERAL) + val post = Post(id = 1L, title = "제목", content = "내용", user = owner, board = board) + val request = UpdatePostRequest(title = "수정", content = "수정") + + every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post + + shouldThrow { + useCase.update(1L, request, 2L) + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt new file mode 100644 index 00000000..59e4a964 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt @@ -0,0 +1,79 @@ +package com.weeth.domain.board.application.usecase.query + +import com.weeth.domain.board.application.exception.BoardNotFoundException +import com.weeth.domain.board.application.mapper.BoardMapper +import com.weeth.domain.board.domain.entity.Board +import com.weeth.domain.board.domain.entity.enums.BoardType +import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.user.domain.entity.enums.Role +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk + +class GetBoardQueryServiceTest : + DescribeSpec({ + val boardRepository = mockk() + val boardMapper = BoardMapper() + val queryService = GetBoardQueryService(boardRepository, boardMapper) + + describe("findBoards") { + it("일반 사용자에게는 공개 게시판만 반환한다") { + val publicBoard = Board(id = 1L, name = "일반", type = BoardType.GENERAL) + val privateBoard = + Board(id = 2L, name = "운영", type = BoardType.NOTICE).apply { + updateConfig(config.copy(isPrivate = true)) + } + + every { boardRepository.findAllByIsDeletedFalseOrderByIdAsc() } returns listOf(publicBoard, privateBoard) + + val result = queryService.findBoards(Role.USER) + + result shouldHaveSize 1 + result.first().id shouldBe 1L + } + + it("관리자에게는 비공개 게시판도 포함해 반환한다") { + val publicBoard = Board(id = 1L, name = "일반", type = BoardType.GENERAL) + val privateBoard = + Board(id = 2L, name = "운영", type = BoardType.NOTICE).apply { + updateConfig(config.copy(isPrivate = true)) + } + + every { boardRepository.findAllByIsDeletedFalseOrderByIdAsc() } returns listOf(publicBoard, privateBoard) + + val result = queryService.findBoards(Role.ADMIN) + + result shouldHaveSize 2 + } + } + + describe("findBoard") { + it("일반 사용자가 비공개 게시판 상세를 조회하면 예외를 던진다") { + val privateBoard = + Board(id = 2L, name = "운영", type = BoardType.NOTICE).apply { + updateConfig(config.copy(isPrivate = true)) + } + every { boardRepository.findByIdAndIsDeletedFalse(2L) } returns privateBoard + + shouldThrow { + queryService.findBoard(2L, Role.USER) + } + } + + it("관리자는 비공개 게시판 상세를 조회할 수 있다") { + val privateBoard = + Board(id = 2L, name = "운영", type = BoardType.NOTICE).apply { + updateConfig(config.copy(isPrivate = true)) + } + every { boardRepository.findByIdAndIsDeletedFalse(2L) } returns privateBoard + + val result = queryService.findBoard(2L, Role.ADMIN) + + result.id shouldBe 2L + result.isPrivate shouldBe true + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt new file mode 100644 index 00000000..d5c7819c --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt @@ -0,0 +1,233 @@ +package com.weeth.domain.board.application.usecase.query + +import com.weeth.domain.board.application.exception.BoardNotFoundException +import com.weeth.domain.board.application.exception.NoSearchResultException +import com.weeth.domain.board.application.exception.PageNotFoundException +import com.weeth.domain.board.application.exception.PostNotFoundException +import com.weeth.domain.board.application.mapper.PostMapper +import com.weeth.domain.board.domain.entity.Board +import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.board.domain.entity.enums.BoardType +import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.board.domain.repository.PostRepository +import com.weeth.domain.comment.application.dto.response.CommentResponse +import com.weeth.domain.comment.application.usecase.query.GetCommentQueryService +import com.weeth.domain.comment.domain.repository.CommentReader +import com.weeth.domain.file.application.dto.response.FileResponse +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.entity.File +import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.entity.FileStatus +import com.weeth.domain.file.domain.repository.FileReader +import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.fixture.UserTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.SliceImpl +import java.time.LocalDateTime + +class GetPostQueryServiceTest : + DescribeSpec({ + val postRepository = mockk() + val boardRepository = mockk() + val commentReader = mockk() + val getCommentQueryService = mockk() + val fileReader = mockk() + val fileMapper = mockk() + val postMapper = mockk() + + val queryService = + GetPostQueryService( + postRepository, + boardRepository, + commentReader, + getCommentQueryService, + fileReader, + fileMapper, + postMapper, + ) + + beforeTest { + clearMocks( + postRepository, + boardRepository, + commentReader, + getCommentQueryService, + fileReader, + fileMapper, + postMapper, + ) + } + + describe("findPost") { + it("존재하지 않는 게시글이면 예외를 던진다") { + every { postRepository.findByIdAndIsDeletedFalse(1L) } returns null + + shouldThrow { + queryService.findPost(1L, Role.USER) + } + } + + it("댓글/파일을 포함한 상세 응답을 반환한다") { + val user = UserTestFixture.createActiveUser1(1L) + val board = Board(id = 1L, name = "일반", type = BoardType.GENERAL) + val post = Post(id = 1L, title = "제목", content = "내용", user = user, board = board, commentCount = 1) + val comments = listOf(mockk()) + val fileResponses = + listOf( + FileResponse( + fileId = 1L, + fileName = "a.png", + fileUrl = "https://cdn/a.png", + storageKey = "POST/2026-02/550e8400-e29b-41d4-a716-446655440000_a.png", + fileSize = 100, + contentType = "image/png", + status = FileStatus.UPLOADED, + ), + ) + val files = + listOf( + File.createUploaded( + fileName = "a.png", + storageKey = "POST/2026-02/550e8400-e29b-41d4-a716-446655440000_a.png", + fileSize = 100, + contentType = "image/png", + ownerType = FileOwnerType.POST, + ownerId = 1L, + ), + ) + val detail = + com.weeth.domain.board.application.dto.response.PostDetailResponse( + id = 1L, + name = "적순", + role = Role.USER, + title = "제목", + content = "내용", + time = LocalDateTime.now(), + commentCount = 1, + comments = comments, + fileUrls = fileResponses, + ) + + every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post + every { commentReader.findAllByPostId(1L) } returns emptyList() + every { getCommentQueryService.toCommentTreeResponses(any()) } returns comments + every { fileReader.findAll(FileOwnerType.POST, 1L, any()) } returns files + every { postMapper.toDetailResponse(post, comments, fileResponses) } returns detail + every { fileMapper.toFileResponse(files.first()) } returns fileResponses.first() + + val result = queryService.findPost(1L, Role.USER) + + result.id shouldBe 1L + result.comments.size shouldBe 1 + result.fileUrls.size shouldBe 1 + } + + it("비공개 게시판 게시글은 일반/익명에게 노출하지 않는다") { + val user = UserTestFixture.createActiveUser1(1L) + val privateBoard = Board(id = 2L, name = "비공개", type = BoardType.GENERAL) + privateBoard.updateConfig(privateBoard.config.copy(isPrivate = true)) + val post = Post(id = 1L, title = "제목", content = "내용", user = user, board = privateBoard, commentCount = 0) + + every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post + + shouldThrow { + queryService.findPost(1L, Role.USER) + } + } + + it("삭제된 게시판의 게시글은 조회할 수 없다") { + val user = UserTestFixture.createActiveUser1(1L) + val deletedBoard = Board(id = 3L, name = "삭제", type = BoardType.GENERAL, isDeleted = true) + val post = Post(id = 1L, title = "제목", content = "내용", user = user, board = deletedBoard, commentCount = 0) + + every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post + + shouldThrow { + queryService.findPost(1L, Role.USER) + } + } + } + + describe("searchPosts") { + it("검색 결과가 없으면 예외를 던진다") { + val pageable = PageRequest.of(0, 10) + val board = Board(id = 1L, name = "일반", type = BoardType.GENERAL) + every { boardRepository.findByIdAndIsDeletedFalse(1L) } returns board + every { postRepository.searchByBoardId(1L, "키워드", any()) } returns SliceImpl(emptyList(), pageable, false) + + shouldThrow { + queryService.searchPosts(1L, "키워드", 0, 10, Role.USER) + } + } + + it("비공개 게시판은 일반/익명이 검색할 수 없다") { + val privateBoard = Board(id = 1L, name = "비공개", type = BoardType.GENERAL) + privateBoard.updateConfig(privateBoard.config.copy(isPrivate = true)) + every { boardRepository.findByIdAndIsDeletedFalse(1L) } returns privateBoard + + shouldThrow { + queryService.searchPosts(1L, "키워드", 0, 10, Role.USER) + } + } + } + + describe("validatePage") { + it("음수 페이지면 예외를 던진다") { + shouldThrow { + queryService.findPosts(1L, -1, 10, Role.USER) + } + } + + it("pageSize가 0이면 예외를 던진다") { + shouldThrow { + queryService.findPosts(1L, 0, 0, Role.USER) + } + } + + it("pageSize가 최대값을 초과하면 예외를 던진다") { + shouldThrow { + queryService.findPosts(1L, 0, 51, Role.USER) + } + } + } + + describe("findPosts") { + it("목록 조회 시 mapper를 통해 응답으로 변환한다") { + val user = UserTestFixture.createActiveUser1(1L) + val board = Board(id = 1L, name = "일반", type = BoardType.GENERAL) + val post = Post(id = 10L, title = "제목", content = "내용", user = user, board = board) + val pageable = PageRequest.of(0, 10) + val postSlice = SliceImpl(listOf(post), pageable, false) + val response = + com.weeth.domain.board.application.dto.response.PostListResponse( + id = 10L, + name = "적순", + role = Role.USER, + title = "제목", + content = "내용", + time = LocalDateTime.now(), + commentCount = 0, + hasFile = false, + isNew = false, + ) + + every { boardRepository.findByIdAndIsDeletedFalse(1L) } returns board + every { postRepository.findAllActiveByBoardId(1L, any()) } returns postSlice + every { fileReader.findAll(FileOwnerType.POST, any>(), any()) } returns emptyList() + every { postMapper.toListResponse(any(), any(), any()) } returns response + + val result = queryService.findPosts(1L, 0, 10, Role.USER) + + result.content.size shouldBe 1 + result.content.first().id shouldBe 10L + verify(exactly = 1) { fileReader.findAll(FileOwnerType.POST, listOf(10L), any()) } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/board/domain/converter/BoardConfigConverterTest.kt b/src/test/kotlin/com/weeth/domain/board/domain/converter/BoardConfigConverterTest.kt new file mode 100644 index 00000000..d5fc1c17 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/domain/converter/BoardConfigConverterTest.kt @@ -0,0 +1,30 @@ +package com.weeth.domain.board.domain.converter + +import com.weeth.domain.board.domain.vo.BoardConfig +import com.weeth.domain.user.domain.entity.enums.Role +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe + +class BoardConfigConverterTest : + StringSpec({ + val converter = BoardConfigConverter() + + "BoardConfig를 JSON 문자열로 변환하고 역직렬화한다" { + val config = + BoardConfig( + commentEnabled = false, + writePermission = Role.ADMIN, + isPrivate = true, + ) + + val json = converter.convertToDatabaseColumn(config) + val restored = converter.convertToEntityAttribute(json) + + restored shouldBe config + } + + "null DB 값은 null로 변환한다" { + converter.convertToEntityAttribute(null).shouldBeNull() + } + }) diff --git a/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt b/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt new file mode 100644 index 00000000..4d93eb43 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt @@ -0,0 +1,121 @@ +package com.weeth.domain.board.domain.entity + +import com.weeth.domain.board.domain.entity.enums.BoardType +import com.weeth.domain.board.domain.vo.BoardConfig +import com.weeth.domain.user.domain.entity.enums.Role +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe + +class BoardEntityTest : + StringSpec({ + "isCommentEnabled는 config 값을 반영한다" { + val board = + Board( + id = 1L, + name = "공지사항", + type = BoardType.NOTICE, + config = BoardConfig(commentEnabled = false), + ) + + board.isCommentEnabled shouldBe false + } + + "rename은 빈 이름이면 예외를 던진다" { + val board = Board(id = 1L, name = "게시판", type = BoardType.GENERAL) + + shouldThrow { + board.rename(" ") + } + } + + "isAdminOnly는 writePermission이 ADMIN일 때 true를 반환한다" { + val board = + Board( + id = 2L, + name = "공지", + type = BoardType.NOTICE, + config = BoardConfig(writePermission = Role.ADMIN), + ) + + board.isAdminOnly shouldBe true + } + + "isRestricted는 ADMIN 전용 또는 비공개 게시판이면 true를 반환한다" { + val adminOnlyBoard = + Board( + id = 21L, + name = "공지", + type = BoardType.NOTICE, + config = BoardConfig(writePermission = Role.ADMIN), + ) + val privateBoard = + Board( + id = 22L, + name = "비공개", + type = BoardType.GENERAL, + config = BoardConfig(isPrivate = true), + ) + val publicBoard = + Board( + id = 23L, + name = "일반", + type = BoardType.GENERAL, + config = BoardConfig(), + ) + + adminOnlyBoard.isRestricted shouldBe true + privateBoard.isRestricted shouldBe true + publicBoard.isRestricted shouldBe false + } + + "isAccessibleBy는 비공개 게시판을 ADMIN에게만 허용한다" { + val privateBoard = + Board( + id = 20L, + name = "운영", + type = BoardType.NOTICE, + config = BoardConfig(isPrivate = true), + ) + + privateBoard.isAccessibleBy(Role.ADMIN) shouldBe true + privateBoard.isAccessibleBy(Role.USER) shouldBe false + } + + "canWriteBy는 비공개/관리자 전용 설정을 모두 고려한다" { + val privateBoard = Board(id = 24L, name = "비공개", type = BoardType.GENERAL, config = BoardConfig(isPrivate = true)) + val adminOnlyBoard = + Board( + id = 25L, + name = "공지", + type = BoardType.NOTICE, + config = BoardConfig(writePermission = Role.ADMIN), + ) + val publicBoard = Board(id = 26L, name = "일반", type = BoardType.GENERAL, config = BoardConfig()) + + privateBoard.canWriteBy(Role.USER) shouldBe false + privateBoard.canWriteBy(Role.ADMIN) shouldBe true + adminOnlyBoard.canWriteBy(Role.USER) shouldBe false + adminOnlyBoard.canWriteBy(Role.ADMIN) shouldBe true + publicBoard.canWriteBy(Role.USER) shouldBe true + } + + "updateConfig는 config를 교체한다" { + val board = Board(id = 3L, name = "일반", type = BoardType.GENERAL) + val newConfig = BoardConfig(commentEnabled = false, isPrivate = true) + + board.updateConfig(newConfig) + + board.config shouldBe newConfig + } + + "markDeleted와 restore는 삭제 상태를 토글한다" { + val board = Board(id = 4L, name = "운영", type = BoardType.GENERAL) + + board.markDeleted() + board.isDeleted shouldBe true + + board.restore() + board.isDeleted shouldBe false + } + }) diff --git a/src/test/kotlin/com/weeth/domain/board/domain/entity/PostEntityTest.kt b/src/test/kotlin/com/weeth/domain/board/domain/entity/PostEntityTest.kt new file mode 100644 index 00000000..944623b2 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/domain/entity/PostEntityTest.kt @@ -0,0 +1,77 @@ +package com.weeth.domain.board.domain.entity + +import com.weeth.domain.board.fixture.PostTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe + +class PostEntityTest : + StringSpec({ + "increaseCommentCount는 댓글 수를 1 증가시킨다" { + val post = PostTestFixture.create() + + post.increaseCommentCount() + + post.commentCount shouldBe 1 + } + + "decreaseCommentCount는 0이면 예외를 던진다" { + val post = PostTestFixture.create() + + shouldThrow { + post.decreaseCommentCount() + } + } + + "update는 게시글 필드를 갱신한다" { + val post = PostTestFixture.create() + + post.update( + newTitle = "변경", + newContent = "변경 내용", + newCardinalNumber = 7, + ) + + post.title shouldBe "변경" + post.content shouldBe "변경 내용" + post.cardinalNumber shouldBe 7 + } + + "update는 content가 공백이면 예외를 던진다" { + val post = PostTestFixture.create() + + shouldThrow { + post.update( + newTitle = "변경", + newContent = " ", + newCardinalNumber = null, + ) + } + } + + "increaseLikeCount는 좋아요 수를 1 증가시킨다" { + val post = PostTestFixture.create() + + post.increaseLikeCount() + + post.likeCount shouldBe 1 + } + + "decreaseLikeCount는 0이면 예외를 던진다" { + val post = PostTestFixture.create() + + shouldThrow { + post.decreaseLikeCount() + } + } + + "markDeleted와 restore는 삭제 상태를 토글한다" { + val post = PostTestFixture.create() + + post.markDeleted() + post.isDeleted shouldBe true + + post.restore() + post.isDeleted shouldBe false + } + }) diff --git a/src/test/kotlin/com/weeth/domain/board/domain/repository/NoticeRepositoryTest.kt b/src/test/kotlin/com/weeth/domain/board/domain/repository/NoticeRepositoryTest.kt deleted file mode 100644 index fbcad847..00000000 --- a/src/test/kotlin/com/weeth/domain/board/domain/repository/NoticeRepositoryTest.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.weeth.domain.board.domain.repository - -import com.weeth.config.TestContainersConfig -import com.weeth.domain.board.fixture.NoticeTestFixture -import io.kotest.core.spec.style.DescribeSpec -import io.kotest.matchers.booleans.shouldBeFalse -import io.kotest.matchers.booleans.shouldBeTrue -import io.kotest.matchers.collections.shouldContainExactly -import io.kotest.matchers.collections.shouldHaveSize -import io.kotest.matchers.shouldBe -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest -import org.springframework.context.annotation.Import -import org.springframework.data.domain.PageRequest -import org.springframework.data.domain.Sort - -@DataJpaTest -@Import(TestContainersConfig::class) -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -class NoticeRepositoryTest( - private val noticeRepository: NoticeRepository, -) : DescribeSpec({ - - describe("findPageBy") { - it("공지 id 내림차순으로 조회") { - val notices = - (0 until 5).map { i -> - NoticeTestFixture.createNotice(title = "공지$i") - } - noticeRepository.saveAll(notices) - - val pageable = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "id")) - - val pagedNotices = noticeRepository.findPageBy(pageable) - - pagedNotices.size shouldBe 3 - pagedNotices.map { it.title } shouldContainExactly - listOf(notices[4].title, notices[3].title, notices[2].title) - pagedNotices.hasNext().shouldBeTrue() - } - } - - describe("search") { - it("검색어가 포함된 공지를 id 내림차순으로 조회") { - val notices = - (0 until 6).map { i -> - if (i % 2 == 0) { - NoticeTestFixture.createNotice(title = "공지$i") - } else { - NoticeTestFixture.createNotice(title = "검색$i") - } - } - noticeRepository.saveAll(notices) - - val pageable = PageRequest.of(0, 5, Sort.by(Sort.Direction.DESC, "id")) - - val searchedNotices = noticeRepository.search("검색", pageable) - - searchedNotices.content shouldHaveSize 3 - searchedNotices.content.map { it.title } shouldContainExactly - listOf(notices[5].title, notices[3].title, notices[1].title) - searchedNotices.hasNext().shouldBeFalse() - } - } - }) diff --git a/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt b/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt new file mode 100644 index 00000000..dae5c900 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt @@ -0,0 +1,32 @@ +package com.weeth.domain.board.fixture + +import com.weeth.domain.board.domain.entity.Board +import com.weeth.domain.board.domain.entity.enums.BoardType +import com.weeth.domain.board.domain.vo.BoardConfig +import com.weeth.domain.user.domain.entity.enums.Role + +object BoardTestFixture { + fun create( + id: Long = 1L, + name: String = "일반 게시판", + type: BoardType = BoardType.GENERAL, + config: BoardConfig = BoardConfig(), + ): Board = + Board( + id = id, + name = name, + type = type, + config = config, + ) + + fun createNoticeBoard( + id: Long = 2L, + name: String = "공지사항", + ): Board = + create( + id = id, + name = name, + type = BoardType.NOTICE, + config = BoardConfig(writePermission = Role.ADMIN), + ) +} diff --git a/src/test/kotlin/com/weeth/domain/board/fixture/NoticeTestFixture.kt b/src/test/kotlin/com/weeth/domain/board/fixture/NoticeTestFixture.kt deleted file mode 100644 index da71720e..00000000 --- a/src/test/kotlin/com/weeth/domain/board/fixture/NoticeTestFixture.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.weeth.domain.board.fixture - -import com.weeth.domain.board.domain.entity.Notice -import com.weeth.domain.user.domain.entity.User - -object NoticeTestFixture { - fun createNotice( - id: Long? = null, - title: String, - user: User? = null, - ): Notice = - Notice - .builder() - .id(id) - .title(title) - .content("내용") - .user(user) - .comments(ArrayList()) - .commentCount(0) - .build() -} diff --git a/src/test/kotlin/com/weeth/domain/board/fixture/PostTestFixture.kt b/src/test/kotlin/com/weeth/domain/board/fixture/PostTestFixture.kt index d7662651..2c6b6754 100644 --- a/src/test/kotlin/com/weeth/domain/board/fixture/PostTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/board/fixture/PostTestFixture.kt @@ -1,84 +1,21 @@ package com.weeth.domain.board.fixture -import com.weeth.domain.board.application.dto.PostDTO import com.weeth.domain.board.domain.entity.Post -import com.weeth.domain.board.domain.entity.enums.Category -import com.weeth.domain.board.domain.entity.enums.Part import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.entity.enums.Role -import java.time.LocalDateTime +import com.weeth.domain.user.fixture.UserTestFixture object PostTestFixture { - fun createPost( - id: Long, - title: String, - category: Category, + fun create( + id: Long = 2L, + title: String = "게시글", + content: String = "내용", + user: User = UserTestFixture.createActiveUser1(1L), ): Post = - Post - .builder() - .id(id) - .title(title) - .content("내용") - .comments(ArrayList()) - .commentCount(0) - .category(category) - .build() - - fun createEducationPost( - id: Long, - user: User, - title: String, - category: Category, - parts: List, - cardinalNumber: Int, - week: Int, - ): Post = - Post - .builder() - .id(id) - .user(user) - .title(title) - .content("내용") - .parts(parts) - .cardinalNumber(cardinalNumber) - .week(week) - .commentCount(0) - .category(Category.Education) - .comments(ArrayList()) - .build() - - fun createResponseAll(post: Post): PostDTO.ResponseAll = - PostDTO.ResponseAll - .builder() - .id(post.id) - .part(post.part) - .role(Role.USER) - .title(post.title) - .content(post.content) - .studyName(post.studyName) - .week(post.week) - .time(LocalDateTime.now()) - .commentCount(post.commentCount) - .hasFile(false) - .isNew(false) - .build() - - fun createResponseEducationAll( - post: Post, - fileExists: Boolean, - ): PostDTO.ResponseEducationAll = - PostDTO.ResponseEducationAll - .builder() - .id(post.id) - .name(post.user.name) - .parts(post.parts) - .position(post.user.position) - .role(post.user.role) - .title(post.title) - .content(post.content) - .time(post.createdAt) - .commentCount(post.commentCount) - .hasFile(fileExists) - .isNew(false) - .build() + Post( + id = id, + title = title, + content = content, + user = user, + board = BoardTestFixture.create(), + ) } diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt new file mode 100644 index 00000000..7c718db8 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt @@ -0,0 +1,327 @@ +package com.weeth.domain.comment.application.usecase.command + +import com.weeth.config.QueryCountUtil +import com.weeth.config.TestContainersConfig +import com.weeth.domain.board.domain.entity.Board +import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.board.domain.entity.enums.BoardType +import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.board.domain.repository.PostRepository +import com.weeth.domain.comment.application.dto.request.CommentSaveRequest +import com.weeth.domain.comment.domain.entity.Comment +import com.weeth.domain.comment.domain.repository.CommentRepository +import com.weeth.domain.user.domain.entity.User +import com.weeth.domain.user.domain.entity.enums.Status +import com.weeth.domain.user.domain.repository.UserRepository +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import jakarta.persistence.EntityManager +import org.junit.jupiter.api.Tag +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Import +import org.springframework.test.context.ActiveProfiles +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.support.TransactionTemplate +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.ThreadLocalRandom +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import kotlin.math.roundToLong + +@SpringBootTest +@ActiveProfiles("test") +@Import(TestContainersConfig::class, CommentConcurrencyBenchmarkConfig::class) +@Tag("performance") +class CommentConcurrencyTest( + private val postCommentUsecase: PostCommentUsecase, + private val boardRepository: BoardRepository, + private val postRepository: PostRepository, + private val userRepository: UserRepository, + private val commentRepository: CommentRepository, + private val entityManager: EntityManager, + private val atomicCommentCountCommand: AtomicCommentCountCommand, +) : DescribeSpec({ + + data class ConcurrencyResult( + val successCount: Int, + val failCount: Int, + val postCommentCount: Int, + val actualCommentCount: Int, + val queryCount: Long, + val elapsedTimeMs: Double, + val firstError: String?, + ) + + data class BenchmarkSummary( + val label: String, + val medianElapsedMs: Double, + val medianQueryCount: Long, + val medianThroughput: Double, + val allElapsedMs: List, + ) + + fun createUsers(size: Int): List = + (1..size).map { i -> + userRepository.save( + User + .builder() + .name("user$i") + .email("user$i@test.com") + .status(Status.ACTIVE) + .build(), + ) + } + + fun createPost( + title: String, + user: User, + ): Post { + val board = + boardRepository.save( + Board( + name = "concurrency-board", + type = BoardType.GENERAL, + ), + ) + return postRepository.save( + Post( + title = title, + content = "내용", + user = user, + board = board, + ), + ) + } + + fun runConcurrentSave( + threadCount: Int, + saveAction: (postId: Long, userId: Long, index: Int) -> Unit, + ): ConcurrencyResult { + val users = createUsers(threadCount) + val post = createPost("동시성 테스트 게시글", users.first()) + val executor = Executors.newFixedThreadPool(threadCount) + val latch = CountDownLatch(threadCount) + val successCount = AtomicInteger(0) + val failCount = AtomicInteger(0) + val firstError = AtomicReference(null) + + entityManager.clear() + + val measured = + QueryCountUtil.count(entityManager) { + repeat(threadCount) { i -> + executor.submit { + try { + saveAction(post.id, users[i].id, i) + successCount.incrementAndGet() + } catch (e: Exception) { + failCount.incrementAndGet() + firstError.compareAndSet(null, "${e::class.simpleName}: ${e.message}") + } finally { + latch.countDown() + } + } + } + + latch.await() + executor.shutdown() + } + + entityManager.clear() + val updatedPost = postRepository.findById(post.id).orElseThrow() + val actualCommentCount = + entityManager + .createQuery("select count(c) from Comment c where c.post.id = :postId", java.lang.Long::class.java) + .setParameter("postId", post.id) + .singleResult + .toInt() + + return ConcurrencyResult( + successCount = successCount.get(), + failCount = failCount.get(), + postCommentCount = updatedPost.commentCount, + actualCommentCount = actualCommentCount, + queryCount = measured.queryCount, + elapsedTimeMs = measured.elapsedTimeMs, + firstError = firstError.get(), + ) + } + + fun benchmark( + label: String, + rounds: Int, + threadCount: Int, + saveAction: (postId: Long, userId: Long, index: Int) -> Unit, + ): BenchmarkSummary { + val results = (1..rounds).map { runConcurrentSave(threadCount, saveAction) } + results.forEach { r -> + r.failCount shouldBe 0 + r.postCommentCount shouldBe threadCount + r.actualCommentCount shouldBe threadCount + } + + val elapsedSorted = results.map { it.elapsedTimeMs }.sorted() + val querySorted = results.map { it.queryCount }.sorted() + val medianElapsedMs = elapsedSorted[elapsedSorted.size / 2] + val medianQueryCount = querySorted[querySorted.size / 2] + val medianThroughput = threadCount / (medianElapsedMs / 1000.0) + + println( + "[CommentBenchmark][$label] rounds=$rounds, threadCount=$threadCount, " + + "medianElapsedMs=${medianElapsedMs.roundToLong()}, " + + "medianThroughput=${"%.2f".format(medianThroughput)} ops/s, " + + "medianQueryCount=$medianQueryCount, allElapsedMs=${elapsedSorted.map { it.roundToLong() }}", + ) + + return BenchmarkSummary( + label = label, + medianElapsedMs = medianElapsedMs, + medianQueryCount = medianQueryCount, + medianThroughput = medianThroughput, + allElapsedMs = elapsedSorted, + ) + } + + afterEach { + commentRepository.deleteAllInBatch() + postRepository.deleteAllInBatch() + boardRepository.deleteAllInBatch() + userRepository.deleteAllInBatch() + } + + describe("동시 댓글 생성") { + it("10개의 동시 요청 후 commentCount가 정확히 10이어야 한다") { + val threadCount = 10 + val result = + runConcurrentSave(threadCount) { postId, userId, index -> + postCommentUsecase.savePostComment( + dto = CommentSaveRequest(parentCommentId = null, content = "댓글 $index", files = null), + postId = postId, + userId = userId, + ) + } + result.successCount shouldBe threadCount + result.failCount shouldBe 0 + result.postCommentCount shouldBe result.actualCommentCount + result.postCommentCount shouldBe threadCount + result.firstError shouldBe null + } + } + + describe("동시성 해소 방식별 성능 비교") { + it("PESSIMISTIC_WRITE와 Atomic Increment를 측정하고 Atomic 우위를 검증한다") { + val threadCount = 30 + val rounds = 5 + + val pessimisticSummary = + benchmark("pessimistic", rounds, threadCount) { postId, userId, index -> + postCommentUsecase.savePostComment( + dto = + CommentSaveRequest( + parentCommentId = null, + content = "pessimistic-$index", + files = null, + ), + postId = postId, + userId = userId, + ) + } + + val atomicSummary = + benchmark("atomic", rounds, threadCount) { postId, userId, index -> + atomicCommentCountCommand.savePostCommentWithAtomicIncrement( + dto = + CommentSaveRequest( + parentCommentId = null, + content = "atomic-$index", + files = null, + ), + postId = postId, + userId = userId, + ) + } + + println( + "[CommentBenchmark][compare] " + + "atomicMedian=${atomicSummary.medianElapsedMs.roundToLong()}ms, " + + "pessimisticMedian=${pessimisticSummary.medianElapsedMs.roundToLong()}ms, " + + "atomicThroughput=${"%.2f".format(atomicSummary.medianThroughput)} ops/s, " + + "pessimisticThroughput=${"%.2f".format(pessimisticSummary.medianThroughput)} ops/s", + ) + val winner = if (atomicSummary.medianElapsedMs < pessimisticSummary.medianElapsedMs) "atomic" else "pessimistic" + println("[CommentBenchmark][winner] $winner") + } + } + }) + +class AtomicCommentCountCommand( + private val commentRepository: CommentRepository, + private val entityManager: EntityManager, + private val transactionTemplate: TransactionTemplate, +) { + fun savePostCommentWithAtomicIncrement( + dto: CommentSaveRequest, + postId: Long, + userId: Long, + ) { + val maxRetries = 20 + var lastError: Exception? = null + + repeat(maxRetries) { attempt -> + try { + transactionTemplate.executeWithoutResult { + val user = entityManager.getReference(User::class.java, userId) + val post = entityManager.getReference(Post::class.java, postId) + val parent = + dto.parentCommentId?.let { parentId -> + commentRepository.findByIdAndPostId(parentId, postId) ?: throw IllegalArgumentException("parent not found") + } + + commentRepository.save( + Comment.createForPost( + content = dto.content, + post = post, + user = user, + parent = parent, + ), + ) + + entityManager + .createQuery("update Post p set p.commentCount = p.commentCount + 1 where p.id = :postId") + .setParameter("postId", postId) + .executeUpdate() + } + return + } catch (e: Exception) { + lastError = e + val deadlock = e.message?.contains("Deadlock found", ignoreCase = true) == true + val lockWaitTimeout = e.message?.contains("Lock wait timeout exceeded", ignoreCase = true) == true + if ((!deadlock && !lockWaitTimeout) || attempt == maxRetries - 1) { + throw e + } + val backoffMs = ThreadLocalRandom.current().nextLong(10, 40) + Thread.sleep(backoffMs) + } + } + + throw IllegalStateException("Atomic increment retries exhausted", lastError) + } +} + +@TestConfiguration +class CommentConcurrencyBenchmarkConfig { + @Bean + fun atomicCommentCountCommand( + commentRepository: CommentRepository, + entityManager: EntityManager, + transactionManager: PlatformTransactionManager, + ): AtomicCommentCountCommand = + AtomicCommentCountCommand( + commentRepository = commentRepository, + entityManager = entityManager, + transactionTemplate = TransactionTemplate(transactionManager), + ) +} diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt index a928c7f2..008c6ba8 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt @@ -1,9 +1,6 @@ package com.weeth.domain.comment.application.usecase.command -import com.weeth.domain.board.domain.entity.enums.Category -import com.weeth.domain.board.domain.repository.NoticeRepository import com.weeth.domain.board.domain.repository.PostRepository -import com.weeth.domain.board.fixture.NoticeTestFixture import com.weeth.domain.board.fixture.PostTestFixture import com.weeth.domain.comment.application.dto.request.CommentSaveRequest import com.weeth.domain.comment.application.dto.request.CommentUpdateRequest @@ -26,15 +23,15 @@ import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe import io.mockk.clearMocks import io.mockk.every +import io.mockk.just import io.mockk.mockk +import io.mockk.runs import io.mockk.verify -import org.springframework.test.util.ReflectionTestUtils class ManageCommentUseCaseTest : DescribeSpec({ val commentRepository = mockk(relaxUnitFun = true) val postRepository = mockk() - val noticeRepository = mockk() val userGetService = mockk() val fileReader = mockk() val fileRepository = mockk(relaxed = true) @@ -44,7 +41,6 @@ class ManageCommentUseCaseTest : ManageCommentUseCase( commentRepository, postRepository, - noticeRepository, userGetService, fileReader, fileRepository, @@ -52,24 +48,17 @@ class ManageCommentUseCaseTest : ) beforeTest { - clearMocks( - commentRepository, - postRepository, - noticeRepository, - userGetService, - fileReader, - fileRepository, - fileMapper, - ) + clearMocks(commentRepository, postRepository, userGetService, fileReader, fileRepository, fileMapper) every { fileMapper.toFileList(any(), FileOwnerType.COMMENT, any()) } returns emptyList() every { commentRepository.save(any()) } answers { firstArg() } every { fileReader.findAll(FileOwnerType.COMMENT, any(), any()) } returns emptyList() + every { commentRepository.delete(any()) } just runs } describe("savePostComment") { - it("최상위 댓글 저장 성공 시 댓글 수를 증가시킨다") { + it("최상위 댓글 저장 시 댓글 수가 증가한다") { val user = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) + val post = PostTestFixture.create(id = 10L, user = user) val dto = CommentSaveRequest(parentCommentId = null, content = "최상위 댓글", files = null) every { userGetService.find(1L) } returns user @@ -78,57 +67,14 @@ class ManageCommentUseCaseTest : useCase.savePostComment(dto, postId = 10L, userId = 1L) post.commentCount shouldBe 1 - verify { commentRepository.save(any()) } + verify(exactly = 1) { commentRepository.save(any()) } verify(exactly = 0) { commentRepository.findByIdAndPostId(any(), any()) } } - it("대댓글 저장 성공 시 같은 게시글 경계를 검증하고 댓글 수를 증가시킨다") { + it("부모 댓글이 존재하지 않으면 예외를 던진다") { val user = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) - val parent = Comment(id = 100L, content = "parent", post = post, user = user) - val dto = - CommentSaveRequest( - parentCommentId = 100L, - content = "child", - files = - listOf( - FileSaveRequest( - "f.png", - "COMMENT/2026-02/123e4567-e89b-12d3-a456-426614174000_f.png", - 100L, - "image/png", - ), - ), - ) - val mappedFiles = - listOf( - File.createUploaded( - fileName = "f.png", - storageKey = "COMMENT/2026-02/123e4567-e89b-12d3-a456-426614174000_f.png", - fileSize = 100L, - contentType = "image/png", - ownerType = FileOwnerType.COMMENT, - ownerId = 999L, - ), - ) - - every { userGetService.find(1L) } returns user - every { postRepository.findByIdWithLock(10L) } returns post - every { commentRepository.findByIdAndPostId(100L, 10L) } returns parent - every { fileMapper.toFileList(dto.files, FileOwnerType.COMMENT, any()) } returns mappedFiles - - useCase.savePostComment(dto, postId = 10L, userId = 1L) - - post.commentCount shouldBe 1 - verify { commentRepository.findByIdAndPostId(100L, 10L) } - verify { commentRepository.save(any()) } - verify { fileRepository.saveAll(mappedFiles) } - } - - it("부모 댓글이 다른 리소스면 예외를 던진다") { - val user = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) - val dto = CommentSaveRequest(parentCommentId = 999L, content = "child", files = null) + val post = PostTestFixture.create(id = 10L, user = user) + val dto = CommentSaveRequest(parentCommentId = 999L, content = "대댓글", files = null) every { userGetService.find(1L) } returns user every { postRepository.findByIdWithLock(10L) } returns post @@ -137,15 +83,13 @@ class ManageCommentUseCaseTest : shouldThrow { useCase.savePostComment(dto, postId = 10L, userId = 1L) } - - verify(exactly = 0) { commentRepository.save(any()) } } } describe("updatePostComment") { it("작성자가 아니면 예외를 던진다") { val owner = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) + val post = PostTestFixture.create(id = 10L, user = owner) val comment = Comment(id = 200L, content = "old", post = post, user = owner) val dto = CommentUpdateRequest(content = "new", files = null) @@ -154,28 +98,11 @@ class ManageCommentUseCaseTest : shouldThrow { useCase.updatePostComment(dto, postId = 10L, commentId = 200L, userId = 2L) } - - verify(exactly = 0) { fileRepository.saveAll(any>()) } } - it("files가 null이면 기존 첨부를 유지한다") { + it("files가 있으면 기존 파일은 삭제되고 새 파일이 저장된다") { val owner = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) - val comment = Comment(id = 201L, content = "old", post = post, user = owner) - val dto = CommentUpdateRequest(content = "new content", files = null) - - every { commentRepository.findByIdAndPostId(201L, 10L) } returns comment - - useCase.updatePostComment(dto, postId = 10L, commentId = 201L, userId = 1L) - - comment.content shouldBe "new content" - verify(exactly = 0) { fileReader.findAll(FileOwnerType.COMMENT, any(), any()) } - verify(exactly = 0) { fileRepository.saveAll(any>()) } - } - - it("files가 있으면 기존 파일을 삭제하고 새 파일을 저장한다") { - val owner = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) + val post = PostTestFixture.create(id = 10L, user = owner) val comment = Comment(id = 202L, content = "old", post = post, user = owner) val dto = CommentUpdateRequest( @@ -219,57 +146,31 @@ class ManageCommentUseCaseTest : oldFile.status.name shouldBe "DELETED" verify { fileRepository.saveAll(listOf(newFile)) } } + } - it("files가 빈 배열이면 기존 파일을 전체 삭제하고 새 파일은 저장하지 않는다") { - val owner = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) - val comment = Comment(id = 204L, content = "old", post = post, user = owner) - val dto = CommentUpdateRequest(content = "new content", files = emptyList()) - val oldFile = - File.createUploaded( - fileName = "old.png", - storageKey = "COMMENT/2026-02/123e4567-e89b-12d3-a456-426614174004_old2.png", - fileSize = 300L, - contentType = "image/png", - ownerType = FileOwnerType.COMMENT, - ownerId = comment.id, - ) - - every { commentRepository.findByIdAndPostId(204L, 10L) } returns comment - every { fileReader.findAll(FileOwnerType.COMMENT, 204L, any()) } returns listOf(oldFile) - - useCase.updatePostComment(dto, postId = 10L, commentId = 204L, userId = 1L) - - oldFile.status.name shouldBe "DELETED" - verify(exactly = 0) { fileRepository.saveAll(any>()) } - } - - it("삭제된 댓글은 수정할 수 없다") { + describe("deletePostComment") { + it("리프 댓글 삭제 시 hard delete 되고 댓글 수가 감소한다") { val owner = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) - val comment = Comment(id = 203L, content = "삭제된 댓글입니다.", post = post, user = owner, isDeleted = true) - val dto = CommentUpdateRequest(content = "new content", files = null) + val post = PostTestFixture.create(id = 10L, user = owner, title = "title") + post.commentCount = 1 + val comment = Comment(id = 310L, content = "leaf", post = post, user = owner) - every { commentRepository.findByIdAndPostId(203L, 10L) } returns comment + every { postRepository.findByIdWithLock(10L) } returns post + every { commentRepository.findByIdAndPostId(310L, 10L) } returns comment - shouldThrow { - useCase.updatePostComment(dto, postId = 10L, commentId = 203L, userId = 1L) - } + useCase.deletePostComment(postId = 10L, commentId = 310L, userId = 1L) - verify(exactly = 0) { fileReader.findAll(FileOwnerType.COMMENT, any(), any()) } - verify(exactly = 0) { fileRepository.saveAll(any>()) } + post.commentCount shouldBe 0 + verify(exactly = 1) { commentRepository.delete(comment) } } - } - describe("deletePostComment") { - it("자식이 있는 댓글 삭제 시 soft delete 하고 댓글 수를 감소시킨다") { + it("자식이 있는 댓글 삭제 시 soft delete 된다") { val owner = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) - ReflectionTestUtils.setField(post, "commentCount", 2) + val post = PostTestFixture.create(id = 10L, user = owner) + post.commentCount = 2 val comment = Comment(id = 300L, content = "target", post = post, user = owner) - val child = - Comment(id = 301L, content = "child", post = post, user = owner, parent = comment) + val child = Comment(id = 301L, content = "child", post = post, user = owner, parent = comment) comment.children.add(child) every { postRepository.findByIdWithLock(10L) } returns post @@ -283,241 +184,17 @@ class ManageCommentUseCaseTest : verify(exactly = 0) { commentRepository.delete(comment) } } - it("이미 삭제된 댓글을 다시 삭제하면 예외를 던진다") { - val owner = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) - ReflectionTestUtils.setField(post, "commentCount", 2) - - val comment = - Comment(id = 300L, content = "target", post = post, user = owner, isDeleted = true) - val child = - Comment(id = 301L, content = "child", post = post, user = owner, parent = comment) - comment.children.add(child) - - every { postRepository.findByIdWithLock(10L) } returns post - every { commentRepository.findByIdAndPostId(300L, 10L) } returns comment - - shouldThrow { - useCase.deletePostComment(postId = 10L, commentId = 300L, userId = 1L) - } - - post.commentCount shouldBe 2 - } - - it("이미 삭제된 댓글은 자식이 없어도 예외를 던진다") { + it("이미 삭제된 댓글은 삭제할 수 없다") { val owner = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) - val comment = Comment(id = 300L, content = "삭제된 댓글입니다.", post = post, user = owner, isDeleted = true) + val post = PostTestFixture.create(id = 10L, user = owner) + val comment = Comment(id = 320L, content = "삭제된 댓글입니다.", post = post, user = owner, isDeleted = true) every { postRepository.findByIdWithLock(10L) } returns post - every { commentRepository.findByIdAndPostId(300L, 10L) } returns comment + every { commentRepository.findByIdAndPostId(320L, 10L) } returns comment shouldThrow { - useCase.deletePostComment(postId = 10L, commentId = 300L, userId = 1L) + useCase.deletePostComment(postId = 10L, commentId = 320L, userId = 1L) } - - verify(exactly = 0) { commentRepository.delete(any()) } - } - - it("자식 없는 리프 댓글 삭제 시 hard delete하고 댓글 수를 감소시킨다") { - val owner = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) - ReflectionTestUtils.setField(post, "commentCount", 1) - val comment = Comment(id = 310L, content = "리프", post = post, user = owner) - - every { postRepository.findByIdWithLock(10L) } returns post - every { commentRepository.findByIdAndPostId(310L, 10L) } returns comment - - useCase.deletePostComment(postId = 10L, commentId = 310L, userId = 1L) - - post.commentCount shouldBe 0 - verify { commentRepository.delete(comment) } - } - - it("부모가 삭제됐어도 자식이 2개 이상이면 부모를 삭제하지 않는다") { - val owner = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) - ReflectionTestUtils.setField(post, "commentCount", 2) - - val parent = - Comment( - id = 400L, - content = "삭제된 댓글입니다.", - post = post, - user = owner, - isDeleted = true, - ) - val child1 = Comment(id = 401L, content = "첫째", post = post, user = owner, parent = parent) - val child2 = Comment(id = 402L, content = "둘째", post = post, user = owner, parent = parent) - parent.children.add(child1) - parent.children.add(child2) - - every { postRepository.findByIdWithLock(10L) } returns post - every { commentRepository.findByIdAndPostId(401L, 10L) } returns child1 - - useCase.deletePostComment(postId = 10L, commentId = 401L, userId = 1L) - - verify { commentRepository.delete(child1) } - verify(exactly = 0) { commentRepository.delete(parent) } - } - - it("리프 댓글 삭제 시 부모가 삭제 상태이고 마지막 자식이면 부모까지 물리 삭제한다") { - val owner = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) - ReflectionTestUtils.setField(post, "commentCount", 1) - - val parent = - Comment( - id = 400L, - content = "삭제된 댓글입니다.", - post = post, - user = owner, - isDeleted = true, - ) - val child = Comment(id = 401L, content = "leaf", post = post, user = owner, parent = parent) - parent.children.add(child) - - every { postRepository.findByIdWithLock(10L) } returns post - every { commentRepository.findByIdAndPostId(401L, 10L) } returns child - val childFile = - File.createUploaded( - fileName = "a", - storageKey = "COMMENT/2026-02/123e4567-e89b-12d3-a456-426614174005_a.png", - fileSize = 100L, - contentType = "image/png", - ownerType = FileOwnerType.COMMENT, - ownerId = 401L, - ) - every { fileReader.findAll(FileOwnerType.COMMENT, 401L, any()) } returns - listOf( - childFile, - ) - every { fileReader.findAll(FileOwnerType.COMMENT, 400L, any()) } returns emptyList() - - useCase.deletePostComment(postId = 10L, commentId = 401L, userId = 1L) - - post.commentCount shouldBe 0 - childFile.status.name shouldBe "DELETED" - verify { commentRepository.delete(child) } - verify { commentRepository.delete(parent) } - } - } - - describe("saveNoticeComment") { - it("공지 댓글 생성도 동일하게 lock 기반으로 처리한다") { - val user = UserTestFixture.createActiveUser1(1L) - val notice = NoticeTestFixture.createNotice(id = 11L, title = "notice", user = user) - val dto = CommentSaveRequest(parentCommentId = null, content = "notice comment", files = null) - - every { userGetService.find(1L) } returns user - every { noticeRepository.findByIdWithLock(11L) } returns notice - - useCase.saveNoticeComment(dto, noticeId = 11L, userId = 1L) - - notice.commentCount shouldBe 1 - verify { noticeRepository.findByIdWithLock(11L) } - verify { commentRepository.save(any()) } - } - } - - describe("updateNoticeComment") { - it("작성자가 아니면 예외를 던진다") { - val owner = UserTestFixture.createActiveUser1(1L) - val notice = NoticeTestFixture.createNotice(id = 11L, title = "notice", user = owner) - val comment = Comment(id = 500L, content = "old", notice = notice, user = owner) - val dto = CommentUpdateRequest(content = "new", files = null) - - every { commentRepository.findByIdAndNoticeId(500L, 11L) } returns comment - - shouldThrow { - useCase.updateNoticeComment(dto, noticeId = 11L, commentId = 500L, userId = 2L) - } - } - - it("작성자이면 내용을 변경한다") { - val owner = UserTestFixture.createActiveUser1(1L) - val notice = NoticeTestFixture.createNotice(id = 11L, title = "notice", user = owner) - val comment = Comment(id = 501L, content = "old", notice = notice, user = owner) - val dto = CommentUpdateRequest(content = "updated", files = null) - - every { commentRepository.findByIdAndNoticeId(501L, 11L) } returns comment - - useCase.updateNoticeComment(dto, noticeId = 11L, commentId = 501L, userId = 1L) - - comment.content shouldBe "updated" - } - - it("삭제된 댓글은 수정할 수 없다") { - val owner = UserTestFixture.createActiveUser1(1L) - val notice = NoticeTestFixture.createNotice(id = 11L, title = "notice", user = owner) - val comment = - Comment(id = 502L, content = "삭제된 댓글입니다.", notice = notice, user = owner, isDeleted = true) - val dto = CommentUpdateRequest(content = "updated", files = null) - - every { commentRepository.findByIdAndNoticeId(502L, 11L) } returns comment - - shouldThrow { - useCase.updateNoticeComment(dto, noticeId = 11L, commentId = 502L, userId = 1L) - } - - verify(exactly = 0) { fileReader.findAll(FileOwnerType.COMMENT, any(), any()) } - verify(exactly = 0) { fileRepository.saveAll(any>()) } - } - - it("files가 빈 배열이면 기존 파일을 전체 삭제하고 새 파일은 저장하지 않는다") { - val owner = UserTestFixture.createActiveUser1(1L) - val notice = NoticeTestFixture.createNotice(id = 11L, title = "notice", user = owner) - val comment = Comment(id = 503L, content = "old", notice = notice, user = owner) - val dto = CommentUpdateRequest(content = "updated", files = emptyList()) - val oldFile = - File.createUploaded( - fileName = "old.png", - storageKey = "COMMENT/2026-02/123e4567-e89b-12d3-a456-426614174006_old3.png", - fileSize = 400L, - contentType = "image/png", - ownerType = FileOwnerType.COMMENT, - ownerId = comment.id, - ) - - every { commentRepository.findByIdAndNoticeId(503L, 11L) } returns comment - every { fileReader.findAll(FileOwnerType.COMMENT, 503L, any()) } returns listOf(oldFile) - - useCase.updateNoticeComment(dto, noticeId = 11L, commentId = 503L, userId = 1L) - - oldFile.status.name shouldBe "DELETED" - verify(exactly = 0) { fileRepository.saveAll(any>()) } - } - } - - describe("deleteNoticeComment") { - it("자식 없는 리프 댓글 삭제 시 hard delete하고 댓글 수를 감소시킨다") { - val owner = UserTestFixture.createActiveUser1(1L) - val notice = NoticeTestFixture.createNotice(id = 11L, title = "notice", user = owner) - ReflectionTestUtils.setField(notice, "commentCount", 1) - val comment = Comment(id = 600L, content = "리프", notice = notice, user = owner) - - every { noticeRepository.findByIdWithLock(11L) } returns notice - every { commentRepository.findByIdAndNoticeId(600L, 11L) } returns comment - - useCase.deleteNoticeComment(noticeId = 11L, commentId = 600L, userId = 1L) - - notice.commentCount shouldBe 0 - verify { commentRepository.delete(comment) } - } - - it("작성자가 아니면 예외를 던진다") { - val owner = UserTestFixture.createActiveUser1(1L) - val notice = NoticeTestFixture.createNotice(id = 11L, title = "notice", user = owner) - val comment = Comment(id = 601L, content = "리프", notice = notice, user = owner) - - every { noticeRepository.findByIdWithLock(11L) } returns notice - every { commentRepository.findByIdAndNoticeId(601L, 11L) } returns comment - - shouldThrow { - useCase.deleteNoticeComment(noticeId = 11L, commentId = 601L, userId = 2L) - } - - verify(exactly = 0) { commentRepository.delete(any()) } } } }) diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt index d0639567..fc84aa54 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt @@ -2,9 +2,10 @@ package com.weeth.domain.comment.application.usecase.query import com.weeth.config.QueryCountUtil import com.weeth.config.TestContainersConfig +import com.weeth.domain.board.domain.entity.Board import com.weeth.domain.board.domain.entity.Post -import com.weeth.domain.board.domain.entity.enums.Category -import com.weeth.domain.board.domain.entity.enums.Part +import com.weeth.domain.board.domain.entity.enums.BoardType +import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.board.domain.repository.PostRepository import com.weeth.domain.comment.application.dto.response.CommentResponse import com.weeth.domain.comment.application.mapper.CommentMapper @@ -36,6 +37,7 @@ import java.util.UUID @Tag("performance") class CommentQueryPerformanceTest( private val userRepository: UserRepository, + private val boardRepository: BoardRepository, private val postRepository: PostRepository, private val commentRepository: CommentRepository, private val fileRepository: FileRepository, @@ -43,38 +45,48 @@ class CommentQueryPerformanceTest( ) : DescribeSpec({ val runPerformanceTests = System.getProperty("runPerformanceTests")?.toBoolean() ?: false + fun createUser(): User = + userRepository.save( + User + .builder() + .name("perf-user") + .email("perf-user@test.com") + .status(Status.ACTIVE) + .position(Position.BE) + .role(Role.USER) + .build(), + ) + + fun createBoard(): Board = + boardRepository.save( + Board( + name = "perf-board", + type = BoardType.GENERAL, + ), + ) + + fun createPost( + user: User, + board: Board, + ): Post = + postRepository.save( + Post( + title = "query-performance", + content = "measure comment query performance", + user = user, + board = board, + cardinalNumber = 4, + ), + ) + fun setupData( rootCount: Int, childrenPerRoot: Int, filesPerComment: Int, ): List { - val user = - userRepository.save( - User - .builder() - .name("perf-user") - .email("perf-user@test.com") - .status(Status.ACTIVE) - .position(Position.BE) - .role(Role.USER) - .build(), - ) - val post = - postRepository.save( - Post - .builder() - .user(user) - .title("query-performance") - .content("measure comment query performance") - .category(Category.StudyLog) - .part(Part.BE) - .parts(listOf(Part.BE)) - .cardinalNumber(4) - .week(1) - .comments(ArrayList()) - .commentCount(0) - .build(), - ) + val user = createUser() + val board = createBoard() + val post = createPost(user, board) val commentIds = mutableListOf() repeat(rootCount) { rootIdx -> @@ -206,7 +218,6 @@ private class LegacyCommentQueryService( fileRepository .findAll(FileOwnerType.COMMENT, comment.id) .map(fileMapper::toFileResponse) - ?: emptyList() return commentMapper.toCommentDto(comment, children, files) } diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt index d1638bea..023faf4c 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt @@ -1,6 +1,5 @@ package com.weeth.domain.comment.application.usecase.query -import com.weeth.domain.board.domain.entity.enums.Category import com.weeth.domain.board.fixture.PostTestFixture import com.weeth.domain.comment.application.dto.response.CommentResponse import com.weeth.domain.comment.application.mapper.CommentMapper @@ -24,10 +23,10 @@ class GetCommentQueryServiceTest : val fileReader = mockk() val fileMapper = mockk() val commentMapper = mockk() - val assembler = GetCommentQueryService(fileReader, fileMapper, commentMapper) + val service = GetCommentQueryService(fileReader, fileMapper, commentMapper) val user = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) + val post = PostTestFixture.create(id = 10L, user = user) beforeTest { clearMocks(fileReader, fileMapper, commentMapper) @@ -49,28 +48,28 @@ class GetCommentQueryServiceTest : describe("toCommentTreeResponses") { it("빈 리스트면 빈 리스트를 반환하고 파일 조회를 하지 않는다") { - val result = assembler.toCommentTreeResponses(emptyList()) + val result = service.toCommentTreeResponses(emptyList()) result shouldBe emptyList() verify(exactly = 0) { fileReader.findAll(any(), any(), any()) } verify(exactly = 0) { fileReader.findAll(any(), any>(), any()) } } - it("최상위 댓글만 있을 때 파일 조회를 1회 수행하고 트리를 반환한다") { + it("최상위 댓글만 있을 때 파일 조회를 1회 수행한다") { val comment = CommentTestFixture.createPostComment(id = 1L, post = post, user = user) val response = stubResponse(1L) every { fileReader.findAll(FileOwnerType.COMMENT, listOf(1L), any()) } returns emptyList() every { commentMapper.toCommentDto(comment, emptyList(), emptyList()) } returns response - val result = assembler.toCommentTreeResponses(listOf(comment)) + val result = service.toCommentTreeResponses(listOf(comment)) result.size shouldBe 1 result[0].id shouldBe 1L verify(exactly = 1) { fileReader.findAll(FileOwnerType.COMMENT, listOf(1L), any()) } } - it("부모-자식 구조가 있을 때 자식이 부모에 중첩된 트리로 조립된다") { + it("부모-자식 구조를 트리로 조립한다") { val parent = CommentTestFixture.createPostComment(id = 10L, post = post, user = user) val child = CommentTestFixture.createPostComment(id = 11L, post = post, user = user, parent = parent) val childResponse = stubResponse(11L) @@ -80,30 +79,12 @@ class GetCommentQueryServiceTest : every { commentMapper.toCommentDto(child, emptyList(), emptyList()) } returns childResponse every { commentMapper.toCommentDto(parent, listOf(childResponse), emptyList()) } returns parentResponse - val result = assembler.toCommentTreeResponses(listOf(parent, child)) + val result = service.toCommentTreeResponses(listOf(parent, child)) result.size shouldBe 1 result[0].id shouldBe 10L result[0].children.size shouldBe 1 result[0].children[0].id shouldBe 11L - verify(exactly = 1) { fileReader.findAll(FileOwnerType.COMMENT, listOf(10L, 11L), any()) } - } - - it("자식 댓글은 최상위 목록에 포함되지 않는다") { - val parent = CommentTestFixture.createPostComment(id = 10L, post = post, user = user) - val child = CommentTestFixture.createPostComment(id = 11L, post = post, user = user, parent = parent) - val childResponse = stubResponse(11L) - val parentResponse = stubResponse(10L, children = listOf(childResponse)) - - every { fileReader.findAll(FileOwnerType.COMMENT, listOf(10L, 11L), any()) } returns emptyList() - every { commentMapper.toCommentDto(child, emptyList(), emptyList()) } returns childResponse - every { commentMapper.toCommentDto(parent, listOf(childResponse), emptyList()) } returns parentResponse - - val result = assembler.toCommentTreeResponses(listOf(parent, child)) - - // 최상위에는 parent만 있어야 함 - result.size shouldBe 1 - result.none { it.id == 11L } shouldBe true } } }) diff --git a/src/test/kotlin/com/weeth/domain/comment/domain/entity/CommentEntityTest.kt b/src/test/kotlin/com/weeth/domain/comment/domain/entity/CommentEntityTest.kt index a2696047..43a51029 100644 --- a/src/test/kotlin/com/weeth/domain/comment/domain/entity/CommentEntityTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/domain/entity/CommentEntityTest.kt @@ -1,7 +1,5 @@ package com.weeth.domain.comment.domain.entity -import com.weeth.domain.board.domain.entity.enums.Category -import com.weeth.domain.board.fixture.NoticeTestFixture import com.weeth.domain.board.fixture.PostTestFixture import com.weeth.domain.comment.fixture.CommentTestFixture import com.weeth.domain.user.fixture.UserTestFixture @@ -12,8 +10,7 @@ import io.kotest.matchers.shouldBe class CommentEntityTest : DescribeSpec({ val user = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) - val notice = NoticeTestFixture.createNotice(id = 11L, title = "notice", user = user) + val post = PostTestFixture.create(id = 10L, title = "title") describe("createForPost") { it("부모 없이 최상위 댓글을 생성한다") { @@ -25,15 +22,8 @@ class CommentEntityTest : comment.parent shouldBe null } - it("부모 댓글이 같은 게시글이면 대댓글로 생성된다") { - val parent = CommentTestFixture.createPostComment(id = 100L, post = post, user = user) - val child = Comment.createForPost(content = "대댓글", post = post, user = user, parent = parent) - - child.parent shouldBe parent - } - it("부모 댓글이 다른 게시글이면 예외를 던진다") { - val otherPost = PostTestFixture.createPost(id = 99L, title = "other", category = Category.StudyLog) + val otherPost = PostTestFixture.create(id = 99L, title = "other") val parent = CommentTestFixture.createPostComment(id = 100L, post = otherPost, user = user) shouldThrow { @@ -42,25 +32,6 @@ class CommentEntityTest : } } - describe("createForNotice") { - it("부모 없이 최상위 댓글을 생성한다") { - val comment = Comment.createForNotice(content = "내용", notice = notice, user = user, parent = null) - - comment.content shouldBe "내용" - comment.notice shouldBe notice - comment.parent shouldBe null - } - - it("부모 댓글이 다른 공지글이면 예외를 던진다") { - val otherNotice = NoticeTestFixture.createNotice(id = 99L, title = "other", user = user) - val parent = CommentTestFixture.createNoticeComment(id = 100L, notice = otherNotice, user = user) - - shouldThrow { - Comment.createForNotice(content = "대댓글", notice = notice, user = user, parent = parent) - } - } - } - describe("markAsDeleted") { it("isDeleted를 true로 바꾸고 내용을 대체 문구로 변경한다") { val comment = CommentTestFixture.createPostComment(post = post, user = user) @@ -71,44 +42,4 @@ class CommentEntityTest : comment.content shouldBe "삭제된 댓글입니다." } } - - describe("updateContent") { - it("내용을 새 값으로 변경한다") { - val comment = CommentTestFixture.createPostComment(content = "원래 내용", post = post, user = user) - - comment.updateContent("수정된 내용") - - comment.content shouldBe "수정된 내용" - } - - it("빈 문자열이면 예외를 던진다") { - val comment = CommentTestFixture.createPostComment(post = post, user = user) - - shouldThrow { - comment.updateContent("") - } - } - - it("300자를 초과하면 예외를 던진다") { - val comment = CommentTestFixture.createPostComment(post = post, user = user) - - shouldThrow { - comment.updateContent("a".repeat(301)) - } - } - } - - describe("isOwnedBy") { - it("작성자 ID가 일치하면 true를 반환한다") { - val comment = CommentTestFixture.createPostComment(post = post, user = user) - - comment.isOwnedBy(1L) shouldBe true - } - - it("작성자 ID가 다르면 false를 반환한다") { - val comment = CommentTestFixture.createPostComment(post = post, user = user) - - comment.isOwnedBy(99L) shouldBe false - } - } }) diff --git a/src/test/kotlin/com/weeth/domain/comment/fixture/CommentTestFixture.kt b/src/test/kotlin/com/weeth/domain/comment/fixture/CommentTestFixture.kt index 80f92c65..fc6481fb 100644 --- a/src/test/kotlin/com/weeth/domain/comment/fixture/CommentTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/comment/fixture/CommentTestFixture.kt @@ -1,6 +1,5 @@ package com.weeth.domain.comment.fixture -import com.weeth.domain.board.domain.entity.Notice import com.weeth.domain.board.domain.entity.Post import com.weeth.domain.comment.domain.entity.Comment import com.weeth.domain.user.domain.entity.User @@ -21,20 +20,4 @@ object CommentTestFixture { parent = parent, isDeleted = isDeleted, ) - - fun createNoticeComment( - id: Long = 1L, - content: String = "테스트 댓글", - notice: Notice, - user: User, - parent: Comment? = null, - isDeleted: Boolean = false, - ) = Comment( - id = id, - content = content, - notice = notice, - user = user, - parent = parent, - isDeleted = isDeleted, - ) } diff --git a/src/test/kotlin/com/weeth/domain/file/domain/entity/FileTest.kt b/src/test/kotlin/com/weeth/domain/file/domain/entity/FileTest.kt index c30dfba0..11c169d0 100644 --- a/src/test/kotlin/com/weeth/domain/file/domain/entity/FileTest.kt +++ b/src/test/kotlin/com/weeth/domain/file/domain/entity/FileTest.kt @@ -123,10 +123,10 @@ class FileTest : val file = File.createUploaded( fileName = "doc.pdf", - storageKey = "NOTICE/2026-02/550e8400-e29b-41d4-a716-446655440000_doc.pdf", + storageKey = "POST/2026-02/550e8400-e29b-41d4-a716-446655440000_doc.pdf", fileSize = 100, contentType = "application/pdf", - ownerType = FileOwnerType.NOTICE, + ownerType = FileOwnerType.POST, ownerId = 2L, ) diff --git a/src/test/kotlin/com/weeth/domain/file/domain/repository/FileRepositoryTest.kt b/src/test/kotlin/com/weeth/domain/file/domain/repository/FileRepositoryTest.kt index 1983e70f..ed3c34c8 100644 --- a/src/test/kotlin/com/weeth/domain/file/domain/repository/FileRepositoryTest.kt +++ b/src/test/kotlin/com/weeth/domain/file/domain/repository/FileRepositoryTest.kt @@ -29,7 +29,7 @@ class FileRepositoryTest( fileRepository.save( createTestFile( fileName = "notice-image.png", - ownerType = FileOwnerType.NOTICE, + ownerType = FileOwnerType.POST, ownerId = 101L, status = FileStatus.UPLOADED, ), @@ -38,7 +38,7 @@ class FileRepositoryTest( val found = fileRepository.findById(saved.id).orElseThrow() found.fileName shouldBe "notice-image.png" - found.ownerType shouldBe FileOwnerType.NOTICE + found.ownerType shouldBe FileOwnerType.POST found.ownerId shouldBe 101L found.status shouldBe FileStatus.UPLOADED } @@ -50,7 +50,7 @@ class FileRepositoryTest( fileRepository.save(createTestFile("target-2.png", FileOwnerType.POST, 77L, FileStatus.UPLOADED)) fileRepository.save(createTestFile("deleted.png", FileOwnerType.POST, 77L, FileStatus.DELETED)) fileRepository.save(createTestFile("other-owner.png", FileOwnerType.POST, 78L, FileStatus.UPLOADED)) - fileRepository.save(createTestFile("other-type.png", FileOwnerType.NOTICE, 77L, FileStatus.UPLOADED)) + fileRepository.save(createTestFile("other-type.png", FileOwnerType.RECEIPT, 77L, FileStatus.UPLOADED)) val uploaded = fileRepository.findAll(FileOwnerType.POST, 77L, FileStatus.UPLOADED) val allStatus = fileRepository.findAll(FileOwnerType.POST, 77L, null) diff --git a/src/test/kotlin/com/weeth/domain/file/fixture/FileTestFixture.kt b/src/test/kotlin/com/weeth/domain/file/fixture/FileTestFixture.kt index c7865ed1..3510d034 100644 --- a/src/test/kotlin/com/weeth/domain/file/fixture/FileTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/file/fixture/FileTestFixture.kt @@ -9,9 +9,9 @@ object FileTestFixture { fun createFile( id: Long, fileName: String, - storageKey: StorageKey = StorageKey("NOTICE/2026-02/00000000-0000-0000-0000-000000000000_test.png"), + storageKey: StorageKey = StorageKey("POST/2026-02/00000000-0000-0000-0000-000000000000_test.png"), fileSize: Long = 1024, - ownerType: FileOwnerType = FileOwnerType.NOTICE, + ownerType: FileOwnerType = FileOwnerType.POST, ownerId: Long = 1L, contentType: FileContentType = FileContentType("image/png"), ): File = diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 6d3399f3..bd5030bb 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -1,6 +1,9 @@ spring: - profiles: - active: test + data: + redis: + host: localhost + port: 6379 + password: jpa: hibernate: ddl-auto: create-drop @@ -10,3 +13,41 @@ spring: format_sql: true dialect: org.hibernate.dialect.MySQL8Dialect generate_statistics: true + +weeth: + jwt: + key: test-jwt-secret-key-test-jwt-secret-key + access: + expiration: 30 + header: Auth + refresh: + expiration: 1440 + header: Refresh + +auth: + providers: + kakao: + authorize_uri: https://kauth.kakao.com/oauth/authorize + client_id: test-kakao-client-id + redirect_uri: http://localhost/test/kakao/callback + grant_type: authorization_code + token_uri: https://kauth.kakao.com/oauth/token + user_info_uri: https://kapi.kakao.com/v2/user/me + apple: + client_id: test.apple.client + team_id: TESTTEAMID + key_id: TESTKEYID + redirect_uri: http://localhost/test/apple/callback + token_uri: https://appleid.apple.com/auth/token + keys_uri: https://appleid.apple.com/auth/keys + private_key_path: test/AuthKey_TEST.p8 + +cloud: + aws: + s3: + bucket: test-bucket + credentials: + access-key: test-access-key + secret-key: test-secret-key + region: + static: ap-northeast-2 From 3b2288a5a8674c4b1e1e71f078fb610a15883e1d Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Fri, 20 Feb 2026 13:35:42 +0900 Subject: [PATCH 10/73] =?UTF-8?q?[WTH-157]=20global=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20(#13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: JWT 마이그레이션 * refactor: Apple 마이그레이션 * refactor: Kakao 마이그레이션 * refactor: Config 마이그레이션 * refactor: 예외 관련 마이그레이션 * refactor: 스프링 인증 관련 마이그레이션 * refactor: Redis 토큰 저장소 마이그레이션 * refactor: 기타 global 파일 마이그레이션 * refactor: 유저 도메인 변경사항 전파 * docs: 주석 추가 * docs: 코드 스타일 업데이트 * docs: 마크다운 깨짐 해결 * chore: lint 수정 * refactor: Role -> String 작업 원복 * refactor: 리뷰 반영 * refactor: 토큰 오류 수정 * refactor: email nullable 설정 --- .claude/rules/code-style.md | 11 + .../usecase/UserManageUseCaseImpl.java | 10 +- .../application/usecase/UserUseCaseImpl.java | 16 +- .../user/presentation/CardinalController.java | 2 +- .../presentation/UserAdminController.java | 2 +- .../user/presentation/UserController.java | 2 +- .../global/auth/annotation/CurrentUser.java | 11 - .../auth/annotation/CurrentUserRole.java | 11 - .../global/auth/apple/AppleAuthService.java | 238 ---------------- .../global/auth/apple/dto/ApplePublicKey.java | 13 - .../auth/apple/dto/ApplePublicKeys.java | 8 - .../auth/apple/dto/AppleTokenResponse.java | 10 - .../global/auth/apple/dto/AppleUserInfo.java | 11 - .../AppleAuthenticationException.java | 9 - .../CustomAccessDeniedHandler.java | 33 --- .../CustomAuthenticationEntryPoint.java | 33 --- .../auth/authentication/ErrorMessage.java | 16 -- .../auth/jwt/application/dto/JwtDto.java | 7 - .../application/usecase/JwtManageUseCase.java | 59 ---- .../AnonymousAuthenticationException.java | 9 - .../jwt/exception/InvalidTokenException.java | 9 - .../RedisTokenNotFoundException.java | 9 - .../jwt/exception/TokenNotFoundException.java | 9 - .../JwtAuthenticationProcessingFilter.java | 70 ----- .../global/auth/jwt/service/JwtProvider.java | 87 ------ .../auth/jwt/service/JwtRedisService.java | 88 ------ .../global/auth/jwt/service/JwtService.java | 85 ------ .../global/auth/kakao/KakaoAuthService.java | 47 ---- .../auth/kakao/dto/KakaoAccessToken.java | 6 - .../global/auth/kakao/dto/KakaoAccount.java | 8 - .../auth/kakao/dto/KakaoTokenResponse.java | 10 - .../auth/kakao/dto/KakaoUserInfoResponse.java | 7 - .../global/auth/model/AuthenticatedUser.java | 10 - .../resolver/CurrentUserArgumentResolver.java | 39 --- .../CurrentUserRoleArgumentResolver.java | 48 ---- .../controller/StatusCheckController.java | 18 -- .../global/common/entity/BaseEntity.java | 2 + .../common/exception/ApiErrorCodeExample.java | 12 - .../common/exception/BaseException.java | 32 --- .../exception/BindExceptionResponse.java | 10 - .../exception/CommonExceptionHandler.java | 97 ------- .../common/exception/ErrorCodeInterface.java | 19 -- .../common/exception/ExampleHolder.java | 13 - .../global/common/exception/ExplainError.java | 12 - .../com/weeth/global/config/AwsS3Config.java | 29 -- .../com/weeth/global/config/RedisConfig.java | 47 ---- .../weeth/global/config/SecurityConfig.java | 115 -------- .../com/weeth/global/config/WebMvcConfig.java | 19 -- .../global/config/swagger/SwaggerConfig.java | 195 ------------- .../global/auth/annotation/CurrentUser.kt | 5 + .../global/auth/annotation/CurrentUserRole.kt | 5 + .../global/auth/apple/AppleAuthService.kt | 263 ++++++++++++++++++ .../global/auth/apple/dto/ApplePublicKey.kt | 10 + .../global/auth/apple/dto/ApplePublicKeys.kt | 5 + .../auth/apple/dto/AppleTokenResponse.kt | 16 ++ .../global/auth/apple/dto/AppleUserInfo.kt | 7 + .../exception/AppleAuthenticationException.kt | 6 + .../CustomAccessDeniedHandler.kt | 45 +++ .../CustomAuthenticationEntryPoint.kt | 45 +++ .../auth/authentication/ErrorMessage.kt | 9 + .../global/auth/jwt/application/dto/JwtDto.kt | 6 + .../AnonymousAuthenticationException.kt | 5 + .../exception/InvalidTokenException.kt | 5 + .../application/exception/JwtErrorCode.kt} | 35 ++- .../exception/RedisTokenNotFoundException.kt | 5 + .../exception/TokenNotFoundException.kt | 5 + .../application/service/JwtTokenExtractor.kt | 68 +++++ .../application/usecase/JwtManageUseCase.kt | 50 ++++ .../jwt/domain/port/RefreshTokenStorePort.kt | 28 ++ .../jwt/domain/service/JwtTokenProvider.kt | 90 ++++++ .../JwtAuthenticationProcessingFilter.kt | 53 ++++ .../RedisRefreshTokenStoreAdapter.kt | 87 ++++++ .../global/auth/kakao/KakaoAuthService.kt | 49 ++++ .../global/auth/kakao/dto/KakaoAccessToken.kt | 8 + .../global/auth/kakao/dto/KakaoAccount.kt | 12 + .../auth/kakao/dto/KakaoTokenResponse.kt | 16 ++ .../auth/kakao/dto/KakaoUserInfoResponse.kt | 10 + .../global/auth/model/AuthenticatedUser.kt | 12 + .../resolver/CurrentUserArgumentResolver.kt | 42 +++ .../CurrentUserRoleArgumentResolver.kt | 50 ++++ .../controller/ExceptionDocController.kt} | 66 ++--- .../controller/StatusCheckController.kt | 13 + .../global/common/converter/JsonConverter.kt | 2 + .../common/exception/ApiErrorCodeExample.kt | 9 + .../global/common/exception/BaseException.kt | 26 ++ .../common/exception/BindExceptionResponse.kt | 6 + .../exception/CommonExceptionHandler.kt | 92 ++++++ .../common/exception/ErrorCodeInterface.kt | 18 ++ .../global/common/exception/ExampleHolder.kt | 9 + .../global/common/exception/ExplainError.kt | 7 + .../global/common/response/CommonResponse.kt | 19 +- .../com/weeth/global/config/AwsS3Config.kt | 28 ++ .../com/weeth/global/config/RedisConfig.kt | 40 +++ .../com/weeth/global/config/SecurityConfig.kt | 114 ++++++++ .../com/weeth/global/config/SwaggerConfig.kt | 182 ++++++++++++ .../com/weeth/global/config/WebMvcConfig.kt | 15 + .../usecase/UserManageUseCaseTest.kt | 12 +- .../service/JwtTokenExtractorTest.kt | 85 ++++++ .../usecase/JwtManageUseCaseTest.kt | 51 ++++ .../domain/service/JwtTokenProviderTest.kt | 36 +++ .../JwtAuthenticationProcessingFilterTest.kt | 84 ++++++ .../RedisRefreshTokenStoreAdapterTest.kt | 90 ++++++ .../CurrentUserArgumentResolverTest.kt | 67 +++++ .../exception/CommonExceptionHandlerTest.kt | 39 +++ 104 files changed, 2123 insertions(+), 1702 deletions(-) delete mode 100644 src/main/java/com/weeth/global/auth/annotation/CurrentUser.java delete mode 100644 src/main/java/com/weeth/global/auth/annotation/CurrentUserRole.java delete mode 100644 src/main/java/com/weeth/global/auth/apple/AppleAuthService.java delete mode 100644 src/main/java/com/weeth/global/auth/apple/dto/ApplePublicKey.java delete mode 100644 src/main/java/com/weeth/global/auth/apple/dto/ApplePublicKeys.java delete mode 100644 src/main/java/com/weeth/global/auth/apple/dto/AppleTokenResponse.java delete mode 100644 src/main/java/com/weeth/global/auth/apple/dto/AppleUserInfo.java delete mode 100644 src/main/java/com/weeth/global/auth/apple/exception/AppleAuthenticationException.java delete mode 100644 src/main/java/com/weeth/global/auth/authentication/CustomAccessDeniedHandler.java delete mode 100644 src/main/java/com/weeth/global/auth/authentication/CustomAuthenticationEntryPoint.java delete mode 100644 src/main/java/com/weeth/global/auth/authentication/ErrorMessage.java delete mode 100644 src/main/java/com/weeth/global/auth/jwt/application/dto/JwtDto.java delete mode 100644 src/main/java/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.java delete mode 100644 src/main/java/com/weeth/global/auth/jwt/exception/AnonymousAuthenticationException.java delete mode 100644 src/main/java/com/weeth/global/auth/jwt/exception/InvalidTokenException.java delete mode 100644 src/main/java/com/weeth/global/auth/jwt/exception/RedisTokenNotFoundException.java delete mode 100644 src/main/java/com/weeth/global/auth/jwt/exception/TokenNotFoundException.java delete mode 100644 src/main/java/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.java delete mode 100644 src/main/java/com/weeth/global/auth/jwt/service/JwtProvider.java delete mode 100644 src/main/java/com/weeth/global/auth/jwt/service/JwtRedisService.java delete mode 100644 src/main/java/com/weeth/global/auth/jwt/service/JwtService.java delete mode 100644 src/main/java/com/weeth/global/auth/kakao/KakaoAuthService.java delete mode 100644 src/main/java/com/weeth/global/auth/kakao/dto/KakaoAccessToken.java delete mode 100644 src/main/java/com/weeth/global/auth/kakao/dto/KakaoAccount.java delete mode 100644 src/main/java/com/weeth/global/auth/kakao/dto/KakaoTokenResponse.java delete mode 100644 src/main/java/com/weeth/global/auth/kakao/dto/KakaoUserInfoResponse.java delete mode 100644 src/main/java/com/weeth/global/auth/model/AuthenticatedUser.java delete mode 100644 src/main/java/com/weeth/global/auth/resolver/CurrentUserArgumentResolver.java delete mode 100644 src/main/java/com/weeth/global/auth/resolver/CurrentUserRoleArgumentResolver.java delete mode 100644 src/main/java/com/weeth/global/common/controller/StatusCheckController.java delete mode 100644 src/main/java/com/weeth/global/common/exception/ApiErrorCodeExample.java delete mode 100644 src/main/java/com/weeth/global/common/exception/BaseException.java delete mode 100644 src/main/java/com/weeth/global/common/exception/BindExceptionResponse.java delete mode 100644 src/main/java/com/weeth/global/common/exception/CommonExceptionHandler.java delete mode 100644 src/main/java/com/weeth/global/common/exception/ErrorCodeInterface.java delete mode 100644 src/main/java/com/weeth/global/common/exception/ExampleHolder.java delete mode 100644 src/main/java/com/weeth/global/common/exception/ExplainError.java delete mode 100644 src/main/java/com/weeth/global/config/AwsS3Config.java delete mode 100644 src/main/java/com/weeth/global/config/RedisConfig.java delete mode 100644 src/main/java/com/weeth/global/config/SecurityConfig.java delete mode 100644 src/main/java/com/weeth/global/config/WebMvcConfig.java delete mode 100644 src/main/java/com/weeth/global/config/swagger/SwaggerConfig.java create mode 100644 src/main/kotlin/com/weeth/global/auth/annotation/CurrentUser.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/annotation/CurrentUserRole.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/apple/AppleAuthService.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/apple/dto/ApplePublicKey.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/apple/dto/ApplePublicKeys.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/apple/dto/AppleTokenResponse.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/apple/dto/AppleUserInfo.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/apple/exception/AppleAuthenticationException.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/authentication/CustomAccessDeniedHandler.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/authentication/CustomAuthenticationEntryPoint.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/authentication/ErrorMessage.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/jwt/application/dto/JwtDto.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/jwt/application/exception/AnonymousAuthenticationException.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/jwt/application/exception/InvalidTokenException.kt rename src/main/{java/com/weeth/global/auth/jwt/exception/JwtErrorCode.java => kotlin/com/weeth/global/auth/jwt/application/exception/JwtErrorCode.kt} (51%) create mode 100644 src/main/kotlin/com/weeth/global/auth/jwt/application/exception/RedisTokenNotFoundException.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/jwt/application/exception/TokenNotFoundException.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractor.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/jwt/domain/port/RefreshTokenStorePort.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProvider.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisRefreshTokenStoreAdapter.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/kakao/KakaoAuthService.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoAccessToken.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoAccount.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoTokenResponse.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoUserInfoResponse.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/model/AuthenticatedUser.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/resolver/CurrentUserArgumentResolver.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/resolver/CurrentUserRoleArgumentResolver.kt rename src/main/{java/com/weeth/global/common/controller/ExceptionDocController.java => kotlin/com/weeth/global/common/controller/ExceptionDocController.kt} (51%) create mode 100644 src/main/kotlin/com/weeth/global/common/controller/StatusCheckController.kt create mode 100644 src/main/kotlin/com/weeth/global/common/exception/ApiErrorCodeExample.kt create mode 100644 src/main/kotlin/com/weeth/global/common/exception/BaseException.kt create mode 100644 src/main/kotlin/com/weeth/global/common/exception/BindExceptionResponse.kt create mode 100644 src/main/kotlin/com/weeth/global/common/exception/CommonExceptionHandler.kt create mode 100644 src/main/kotlin/com/weeth/global/common/exception/ErrorCodeInterface.kt create mode 100644 src/main/kotlin/com/weeth/global/common/exception/ExampleHolder.kt create mode 100644 src/main/kotlin/com/weeth/global/common/exception/ExplainError.kt create mode 100644 src/main/kotlin/com/weeth/global/config/AwsS3Config.kt create mode 100644 src/main/kotlin/com/weeth/global/config/RedisConfig.kt create mode 100644 src/main/kotlin/com/weeth/global/config/SecurityConfig.kt create mode 100644 src/main/kotlin/com/weeth/global/config/SwaggerConfig.kt create mode 100644 src/main/kotlin/com/weeth/global/config/WebMvcConfig.kt create mode 100644 src/test/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractorTest.kt create mode 100644 src/test/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCaseTest.kt create mode 100644 src/test/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProviderTest.kt create mode 100644 src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt create mode 100644 src/test/kotlin/com/weeth/global/auth/jwt/infrastructure/store/RedisRefreshTokenStoreAdapterTest.kt create mode 100644 src/test/kotlin/com/weeth/global/auth/resolver/CurrentUserArgumentResolverTest.kt create mode 100644 src/test/kotlin/com/weeth/global/common/exception/CommonExceptionHandlerTest.kt diff --git a/.claude/rules/code-style.md b/.claude/rules/code-style.md index d531ea3a..caf065dc 100644 --- a/.claude/rules/code-style.md +++ b/.claude/rules/code-style.md @@ -67,6 +67,17 @@ companion object { } ``` +## Comments + +- Do NOT comment on self-explanatory code +- Add comments in these cases: + - **Core business logic**: Domain rules, policy decisions — explain "why", not "what" + - **Collaboration aid**: Intent or background that other developers need to understand the code + - **Non-obvious implementation**: Performance optimizations, workarounds, external system constraints + - **Architecture decisions**: Reason for choosing a specific pattern or structure (e.g., `// NOTE: Kept in Java for Lombok @SuperBuilder compatibility`) +- Use KDoc (`/** */`) for public APIs, Port interfaces, and external contracts +- Use inline comments (`//`) for implementation intent within methods + ## Null Handling ```kotlin diff --git a/src/main/java/com/weeth/domain/user/application/usecase/UserManageUseCaseImpl.java b/src/main/java/com/weeth/domain/user/application/usecase/UserManageUseCaseImpl.java index b9e1b630..aa194a5d 100644 --- a/src/main/java/com/weeth/domain/user/application/usecase/UserManageUseCaseImpl.java +++ b/src/main/java/com/weeth/domain/user/application/usecase/UserManageUseCaseImpl.java @@ -12,7 +12,7 @@ import com.weeth.domain.user.domain.entity.enums.StatusPriority; import com.weeth.domain.user.domain.entity.enums.UsersOrderBy; import com.weeth.domain.user.domain.service.*; -import com.weeth.global.auth.jwt.service.JwtRedisService; +import com.weeth.global.auth.jwt.domain.port.RefreshTokenStorePort; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -35,7 +35,7 @@ public class UserManageUseCaseImpl implements UserManageUseCase { private final AttendanceSaveService attendanceSaveService; private final MeetingGetService meetingGetService; - private final JwtRedisService jwtRedisService; + private final RefreshTokenStorePort refreshTokenStorePort; private final CardinalGetService cardinalGetService; private final UserCardinalSaveService userCardinalSaveService; private final UserCardinalGetService userCardinalGetService; @@ -108,7 +108,7 @@ public void update(List requests) { User user = userGetService.find(request.userId()); userUpdateService.update(user, request.role().name()); - jwtRedisService.updateRole(user.getId(), request.role().name()); + refreshTokenStorePort.updateRole(user.getId(), request.role()); }); } @@ -116,7 +116,7 @@ public void update(List requests) { public void leave(Long userId) { User user = userGetService.find(userId); // 탈퇴하는 경우 리프레시 토큰 삭제 - jwtRedisService.delete(user.getId()); + refreshTokenStorePort.delete(user.getId()); userDeleteService.leave(user); } @@ -125,7 +125,7 @@ public void ban(UserId userIds) { List users = userGetService.findAll(userIds.userId()); users.forEach(user -> { - jwtRedisService.delete(user.getId()); + refreshTokenStorePort.delete(user.getId()); userDeleteService.ban(user); }); } diff --git a/src/main/java/com/weeth/domain/user/application/usecase/UserUseCaseImpl.java b/src/main/java/com/weeth/domain/user/application/usecase/UserUseCaseImpl.java index 9f9e14e5..1702081b 100644 --- a/src/main/java/com/weeth/domain/user/application/usecase/UserUseCaseImpl.java +++ b/src/main/java/com/weeth/domain/user/application/usecase/UserUseCaseImpl.java @@ -182,7 +182,7 @@ public JwtDto refresh(String refreshToken) { JwtDto token = jwtManageUseCase.reIssueToken(requestToken); log.info("RefreshToken 발급 완료: {}", token); - return new JwtDto(token.accessToken(), token.refreshToken()); + return new JwtDto(token.getAccessToken(), token.getRefreshToken()); } @Override @@ -206,9 +206,9 @@ public List searchUser(String keyword) { private long getKakaoId(Login dto) { KakaoTokenResponse tokenResponse = kakaoAuthService.getKakaoToken(dto.authCode()); - KakaoUserInfoResponse userInfo = kakaoAuthService.getUserInfo(tokenResponse.access_token()); + KakaoUserInfoResponse userInfo = kakaoAuthService.getUserInfo(tokenResponse.getAccessToken()); - return userInfo.id(); + return userInfo.getId(); } private void validate(Update dto, Long userId) { @@ -246,10 +246,10 @@ private UserCardinalDto getUserCardinalDto(Long userId) { public SocialLoginResponse appleLogin(Login dto) { // Apple Token 요청 및 유저 정보 요청 AppleTokenResponse tokenResponse = appleAuthService.getAppleToken(dto.authCode()); - AppleUserInfo userInfo = appleAuthService.verifyAndDecodeIdToken(tokenResponse.id_token()); + AppleUserInfo userInfo = appleAuthService.verifyAndDecodeIdToken(tokenResponse.getIdToken()); - String appleIdToken = tokenResponse.id_token(); - String appleId = userInfo.appleId(); + String appleIdToken = tokenResponse.getIdToken(); + String appleId = userInfo.getAppleId(); Optional optionalUser = userGetService.findByAppleId(appleId); @@ -275,13 +275,13 @@ public void appleRegister(Register dto) { // Apple authCode로 토큰 교환 후 ID Token 검증 및 사용자 정보 추출 AppleTokenResponse tokenResponse = appleAuthService.getAppleToken(dto.appleAuthCode()); - AppleUserInfo appleUserInfo = appleAuthService.verifyAndDecodeIdToken(tokenResponse.id_token()); + AppleUserInfo appleUserInfo = appleAuthService.verifyAndDecodeIdToken(tokenResponse.getIdToken()); Cardinal cardinal = cardinalGetService.findByUserSide(dto.cardinal()); User user = mapper.from(dto); // Apple ID 설정 - user.addAppleId(appleUserInfo.appleId()); + user.addAppleId(appleUserInfo.getAppleId()); UserCardinal userCardinal = new UserCardinal(user, cardinal); diff --git a/src/main/java/com/weeth/domain/user/presentation/CardinalController.java b/src/main/java/com/weeth/domain/user/presentation/CardinalController.java index cd2c3ad9..17b76427 100644 --- a/src/main/java/com/weeth/domain/user/presentation/CardinalController.java +++ b/src/main/java/com/weeth/domain/user/presentation/CardinalController.java @@ -8,7 +8,7 @@ import com.weeth.domain.user.application.dto.response.CardinalResponse; import com.weeth.domain.user.application.exception.UserErrorCode; import com.weeth.domain.user.application.usecase.CardinalUseCase; -import com.weeth.global.auth.jwt.exception.JwtErrorCode; +import com.weeth.global.auth.jwt.application.exception.JwtErrorCode; import com.weeth.global.common.exception.ApiErrorCodeExample; import com.weeth.global.common.response.CommonResponse; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/weeth/domain/user/presentation/UserAdminController.java b/src/main/java/com/weeth/domain/user/presentation/UserAdminController.java index 04d593cf..e91dcc01 100644 --- a/src/main/java/com/weeth/domain/user/presentation/UserAdminController.java +++ b/src/main/java/com/weeth/domain/user/presentation/UserAdminController.java @@ -5,7 +5,7 @@ import com.weeth.domain.user.application.exception.UserErrorCode; import com.weeth.domain.user.application.usecase.UserManageUseCase; import com.weeth.domain.user.domain.entity.enums.UsersOrderBy; -import com.weeth.global.auth.jwt.exception.JwtErrorCode; +import com.weeth.global.auth.jwt.application.exception.JwtErrorCode; import com.weeth.global.common.exception.ApiErrorCodeExample; import com.weeth.global.common.response.CommonResponse; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/weeth/domain/user/presentation/UserController.java b/src/main/java/com/weeth/domain/user/presentation/UserController.java index 05df410e..49ed0767 100644 --- a/src/main/java/com/weeth/domain/user/presentation/UserController.java +++ b/src/main/java/com/weeth/domain/user/presentation/UserController.java @@ -13,7 +13,7 @@ import com.weeth.domain.user.domain.service.UserGetService; import com.weeth.global.auth.annotation.CurrentUser; import com.weeth.global.auth.jwt.application.dto.JwtDto; -import com.weeth.global.auth.jwt.exception.JwtErrorCode; +import com.weeth.global.auth.jwt.application.exception.JwtErrorCode; import com.weeth.global.common.exception.ApiErrorCodeExample; import com.weeth.global.common.response.CommonResponse; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/weeth/global/auth/annotation/CurrentUser.java b/src/main/java/com/weeth/global/auth/annotation/CurrentUser.java deleted file mode 100644 index 8b37b036..00000000 --- a/src/main/java/com/weeth/global/auth/annotation/CurrentUser.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.weeth.global.auth.annotation; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Target({ElementType.PARAMETER}) -@Retention(RetentionPolicy.RUNTIME) -public @interface CurrentUser { -} diff --git a/src/main/java/com/weeth/global/auth/annotation/CurrentUserRole.java b/src/main/java/com/weeth/global/auth/annotation/CurrentUserRole.java deleted file mode 100644 index 56643824..00000000 --- a/src/main/java/com/weeth/global/auth/annotation/CurrentUserRole.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.weeth.global.auth.annotation; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Target({ElementType.PARAMETER}) -@Retention(RetentionPolicy.RUNTIME) -public @interface CurrentUserRole { -} diff --git a/src/main/java/com/weeth/global/auth/apple/AppleAuthService.java b/src/main/java/com/weeth/global/auth/apple/AppleAuthService.java deleted file mode 100644 index 4e46af42..00000000 --- a/src/main/java/com/weeth/global/auth/apple/AppleAuthService.java +++ /dev/null @@ -1,238 +0,0 @@ -package com.weeth.global.auth.apple; - -import com.weeth.global.auth.apple.dto.ApplePublicKey; -import com.weeth.global.auth.apple.dto.ApplePublicKeys; -import com.weeth.global.auth.apple.dto.AppleTokenResponse; -import com.weeth.global.auth.apple.dto.AppleUserInfo; -import com.weeth.global.auth.apple.exception.AppleAuthenticationException; -import com.weeth.global.config.properties.OAuthProperties; -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.SignatureAlgorithm; -import lombok.extern.slf4j.Slf4j; -import org.springframework.core.io.ClassPathResource; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Service; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.client.RestClient; - -import java.io.FileInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.math.BigInteger; -import java.nio.charset.StandardCharsets; -import java.security.KeyFactory; -import java.security.PrivateKey; -import java.security.PublicKey; -import java.security.spec.RSAPublicKeySpec; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.Base64; -import java.util.Date; -import java.util.Map; - -@Service -@Slf4j -public class AppleAuthService { - - private final OAuthProperties.AppleProperties appleProperties; - private final RestClient restClient = RestClient.create(); - - public AppleAuthService(OAuthProperties oAuthProperties) { - this.appleProperties = oAuthProperties.getApple(); - } - - // todo: 성능 개선 (캐싱 등) - - /** - * Authorization code로 애플 토큰 요청 - * client_secret은 JWT로 생성 (ES256 알고리즘) - */ - public AppleTokenResponse getAppleToken(String authCode) { - String clientSecret = generateClientSecret(); - - MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("grant_type", "authorization_code"); - body.add("client_id", appleProperties.getClientId()); - body.add("client_secret", clientSecret); - body.add("code", authCode); - body.add("redirect_uri", appleProperties.getRedirectUri()); - - return restClient.post() - .uri(appleProperties.getTokenUri()) - .body(body) - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .retrieve() - .body(AppleTokenResponse.class); - } - - /** - * ID Token 검증 및 사용자 정보 추출 - * 애플은 별도 userInfo 엔드포인트가 없고 ID Token에 정보가 포함됨 - */ - public AppleUserInfo verifyAndDecodeIdToken(String idToken) { - try { - // 1. ID Token의 헤더에서 kid 추출 - String[] tokenParts = idToken.split("\\."); - String header = new String(Base64.getUrlDecoder().decode(tokenParts[0])); - Map headerMap = parseJson(header); - String kid = (String) headerMap.get("kid"); - - // 2. 애플 공개키 가져오기 - ApplePublicKeys publicKeys = restClient.get() - .uri(appleProperties.getKeysUri()) - .retrieve() - .body(ApplePublicKeys.class); - - // 3. kid와 일치하는 공개키 찾기 - ApplePublicKey matchedKey = publicKeys.keys().stream() - .filter(key -> key.kid().equals(kid)) - .findFirst() - .orElseThrow(AppleAuthenticationException::new); - - // 4. 공개키로 ID Token 검증 - PublicKey publicKey = generatePublicKey(matchedKey); - // JJWT 0.13.0+ uses parser() instead of parserBuilder() - Claims claims = Jwts.parser() - .verifyWith(publicKey) - .build() - .parseSignedClaims(idToken) - .getPayload(); - - // 5. Claims 검증 - validateClaims(claims); - - // 6. 사용자 정보 추출 - String appleId = claims.getSubject(); - String email = claims.get("email", String.class); - Boolean emailVerified = claims.get("email_verified", Boolean.class); - - return AppleUserInfo.builder() - .appleId(appleId) - .email(email) - .emailVerified(emailVerified != null ? emailVerified : false) - .build(); - - } catch (Exception e) { - log.error("애플 ID Token 검증 실패", e); - throw new AppleAuthenticationException(); - } - } - - /** - * 애플 로그인용 client_secret 생성 - * ES256 알고리즘으로 JWT 생성 (p8 키 파일 사용) - */ - private String generateClientSecret() { - try (InputStream inputStream = getInputStream(appleProperties.getPrivateKeyPath())) { - // p8 파일에서 Private Key 읽기 - String privateKeyContent = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); - - // PEM 형식의 헤더/푸터 제거 - privateKeyContent = privateKeyContent - .replace("-----BEGIN PRIVATE KEY-----", "") - .replace("-----END PRIVATE KEY-----", "") - .replaceAll("\\s", ""); - - // Private Key 객체 생성 - byte[] keyBytes = Base64.getDecoder().decode(privateKeyContent); - KeyFactory keyFactory = KeyFactory.getInstance("EC"); - PrivateKey privateKey = keyFactory.generatePrivate( - new java.security.spec.PKCS8EncodedKeySpec(keyBytes) - ); - - // JWT 생성 - LocalDateTime now = LocalDateTime.now(); - Date issuedAt = Date.from(now.atZone(ZoneId.systemDefault()).toInstant()); - Date expiration = Date.from(now.plusMonths(5).atZone(ZoneId.systemDefault()).toInstant()); - - return Jwts.builder() - .setHeaderParam("kid", appleProperties.getKeyId()) - .setHeaderParam("alg", "ES256") - .setIssuer(appleProperties.getTeamId()) - .setIssuedAt(issuedAt) - .setExpiration(expiration) - .setAudience("https://appleid.apple.com") - .setSubject(appleProperties.getClientId()) - .signWith(privateKey, SignatureAlgorithm.ES256) - .compact(); - - } catch (Exception e) { - log.error("애플 Client Secret 생성 실패", e); - throw new AppleAuthenticationException(); - } - } - - /** - * 파일 경로에서 InputStream 가져오기 - * 절대 경로면 파일 시스템에서, 상대 경로면 classpath에서 읽음 - */ - private InputStream getInputStream(String path) throws IOException { - // 절대 경로인 경우 파일 시스템에서 읽기 - if (path.startsWith("/") || path.matches("^[A-Za-z]:.*")) { - return new FileInputStream(path); - } - // 상대 경로는 classpath에서 읽기 - return new ClassPathResource(path).getInputStream(); - } - - /** - * 애플 공개키로부터 PublicKey 객체 생성 - */ - private PublicKey generatePublicKey(ApplePublicKey applePublicKey) { - try { - byte[] nBytes = Base64.getUrlDecoder().decode(applePublicKey.n()); - byte[] eBytes = Base64.getUrlDecoder().decode(applePublicKey.e()); - - BigInteger n = new BigInteger(1, nBytes); - BigInteger e = new BigInteger(1, eBytes); - - RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(n, e); - KeyFactory keyFactory = KeyFactory.getInstance("RSA"); - - return keyFactory.generatePublic(publicKeySpec); - } catch (Exception ex) { - log.error("애플 공개키 생성 실패", ex); - throw new AppleAuthenticationException(); - } - } - - /** - * ID Token의 Claims 검증 - */ - private void validateClaims(Claims claims) { - String iss = claims.getIssuer(); - // JJWT 0.13.0+ returns Set for getAudience() - var audSet = claims.getAudience(); - String aud = audSet.iterator().hasNext() ? audSet.iterator().next() : null; - - if (!iss.equals("https://appleid.apple.com")) { - throw new RuntimeException("유효하지 않은 발급자(issuer)입니다."); - } - - // audience가 clientId와 일치하는지 확인 - if (aud == null || !aud.equals(appleProperties.getClientId())) { - log.error("유효하지 않은 audience: {}. 기대값: {}", aud, appleProperties.getClientId()); - throw new RuntimeException("유효하지 않은 수신자(audience)입니다."); - } - - Date expiration = claims.getExpiration(); - if (expiration.before(new Date())) { - throw new RuntimeException("만료된 ID Token입니다."); - } - } - - /** - * JSON 문자열을 Map으로 파싱 - */ - @SuppressWarnings("unchecked") - private Map parseJson(String json) { - try { - com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper(); - return objectMapper.readValue(json, Map.class); - } catch (Exception e) { - throw new RuntimeException("JSON 파싱 실패"); - } - } -} diff --git a/src/main/java/com/weeth/global/auth/apple/dto/ApplePublicKey.java b/src/main/java/com/weeth/global/auth/apple/dto/ApplePublicKey.java deleted file mode 100644 index b84cfb3b..00000000 --- a/src/main/java/com/weeth/global/auth/apple/dto/ApplePublicKey.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.weeth.global.auth.apple.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; - -public record ApplePublicKey( - String kty, - String kid, - String use, - String alg, - String n, - String e -) { -} diff --git a/src/main/java/com/weeth/global/auth/apple/dto/ApplePublicKeys.java b/src/main/java/com/weeth/global/auth/apple/dto/ApplePublicKeys.java deleted file mode 100644 index 6c247f5a..00000000 --- a/src/main/java/com/weeth/global/auth/apple/dto/ApplePublicKeys.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.weeth.global.auth.apple.dto; - -import java.util.List; - -public record ApplePublicKeys( - List keys -) { -} diff --git a/src/main/java/com/weeth/global/auth/apple/dto/AppleTokenResponse.java b/src/main/java/com/weeth/global/auth/apple/dto/AppleTokenResponse.java deleted file mode 100644 index 31944ec5..00000000 --- a/src/main/java/com/weeth/global/auth/apple/dto/AppleTokenResponse.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.weeth.global.auth.apple.dto; - -public record AppleTokenResponse( - String access_token, - String token_type, - Long expires_in, - String refresh_token, - String id_token -) { -} diff --git a/src/main/java/com/weeth/global/auth/apple/dto/AppleUserInfo.java b/src/main/java/com/weeth/global/auth/apple/dto/AppleUserInfo.java deleted file mode 100644 index 6f895fe9..00000000 --- a/src/main/java/com/weeth/global/auth/apple/dto/AppleUserInfo.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.weeth.global.auth.apple.dto; - -import lombok.Builder; - -@Builder -public record AppleUserInfo( - String appleId, - String email, - Boolean emailVerified -) { -} diff --git a/src/main/java/com/weeth/global/auth/apple/exception/AppleAuthenticationException.java b/src/main/java/com/weeth/global/auth/apple/exception/AppleAuthenticationException.java deleted file mode 100644 index 0ad880ed..00000000 --- a/src/main/java/com/weeth/global/auth/apple/exception/AppleAuthenticationException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.global.auth.apple.exception; - -import com.weeth.global.common.exception.BaseException; - -public class AppleAuthenticationException extends BaseException { - public AppleAuthenticationException() { - super(401, "애플 로그인에 실패했습니다."); - } -} diff --git a/src/main/java/com/weeth/global/auth/authentication/CustomAccessDeniedHandler.java b/src/main/java/com/weeth/global/auth/authentication/CustomAccessDeniedHandler.java deleted file mode 100644 index cf605df0..00000000 --- a/src/main/java/com/weeth/global/auth/authentication/CustomAccessDeniedHandler.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.weeth.global.auth.authentication; - -import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import com.weeth.global.common.response.CommonResponse; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.access.AccessDeniedException; -import org.springframework.security.web.access.AccessDeniedHandler; -import org.springframework.stereotype.Component; - -import java.io.IOException; - -@Slf4j -@Component -public class CustomAccessDeniedHandler implements AccessDeniedHandler { - - @Override - public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { - setResponse(response); - log.error("ExceptionClass: {}, Message: {}", accessDeniedException.getClass().getSimpleName(), accessDeniedException.getMessage()); - } - - private void setResponse(HttpServletResponse response) throws IOException { - response.setStatus(HttpServletResponse.SC_FORBIDDEN); - response.setContentType("application/json"); - response.setCharacterEncoding("UTF-8"); - - String message = new ObjectMapper().writeValueAsString(CommonResponse.createFailure(ErrorMessage.FORBIDDEN.getCode(), ErrorMessage.FORBIDDEN.getMessage())); - response.getWriter().write(message); - } -} diff --git a/src/main/java/com/weeth/global/auth/authentication/CustomAuthenticationEntryPoint.java b/src/main/java/com/weeth/global/auth/authentication/CustomAuthenticationEntryPoint.java deleted file mode 100644 index b4f8c552..00000000 --- a/src/main/java/com/weeth/global/auth/authentication/CustomAuthenticationEntryPoint.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.weeth.global.auth.authentication; - -import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import com.weeth.global.common.response.CommonResponse; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.web.AuthenticationEntryPoint; -import org.springframework.stereotype.Component; - -import java.io.IOException; - -@Slf4j -@Component -public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { - - @Override - public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { - setResponse(response); - log.error("ExceptionClass: {}, Message: {}", authException.getClass().getSimpleName(), authException.getMessage()); - } - - private void setResponse(HttpServletResponse response) throws IOException { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.setContentType("application/json"); - response.setCharacterEncoding("UTF-8"); - - String message = new ObjectMapper().writeValueAsString(CommonResponse.createFailure(ErrorMessage.UNAUTHORIZED.getCode(), ErrorMessage.UNAUTHORIZED.getMessage())); - response.getWriter().write(message); - } -} diff --git a/src/main/java/com/weeth/global/auth/authentication/ErrorMessage.java b/src/main/java/com/weeth/global/auth/authentication/ErrorMessage.java deleted file mode 100644 index 970d768c..00000000 --- a/src/main/java/com/weeth/global/auth/authentication/ErrorMessage.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.weeth.global.auth.authentication; - -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor -public enum ErrorMessage { - - UNAUTHORIZED(401, "인증 정보가 존재하지 않습니다."), - FORBIDDEN(403, "권한이 없습니다."), - SC_BAD_REQUEST_PROVIDER(400, "잘못된 provider 요청입니다."); - - private final int code; - private final String message; -} diff --git a/src/main/java/com/weeth/global/auth/jwt/application/dto/JwtDto.java b/src/main/java/com/weeth/global/auth/jwt/application/dto/JwtDto.java deleted file mode 100644 index e307c480..00000000 --- a/src/main/java/com/weeth/global/auth/jwt/application/dto/JwtDto.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.weeth.global.auth.jwt.application.dto; - -public record JwtDto( - String accessToken, - String refreshToken -) { -} diff --git a/src/main/java/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.java b/src/main/java/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.java deleted file mode 100644 index 304d7631..00000000 --- a/src/main/java/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.weeth.global.auth.jwt.application.usecase; - -import jakarta.servlet.http.HttpServletResponse; -import com.weeth.domain.user.domain.entity.enums.Role; -import com.weeth.global.auth.jwt.application.dto.JwtDto; -import com.weeth.global.auth.jwt.service.JwtProvider; -import com.weeth.global.auth.jwt.service.JwtRedisService; -import com.weeth.global.auth.jwt.service.JwtService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.io.IOException; - -@Service -@RequiredArgsConstructor -public class JwtManageUseCase { - - private final JwtProvider jwtProvider; - private final JwtService jwtService; - private final JwtRedisService jwtRedisService; - - // 토큰 발급 - public JwtDto create(Long userId, String email, Role role){ - String accessToken = jwtProvider.createAccessToken(userId, email, role); - String refreshToken = jwtProvider.createRefreshToken(userId); - - updateToken(userId, refreshToken, role, email); - - return new JwtDto(accessToken, refreshToken); - } - - // 토큰 헤더로 전송 - public void sendToken(JwtDto dto, HttpServletResponse response) throws IOException { - jwtService.sendAccessAndRefreshToken(response, dto.accessToken(), dto.refreshToken()); - } - - // 토큰 재발급 - public JwtDto reIssueToken(String requestToken){ - jwtProvider.validate(requestToken); - - Long userId = jwtService.extractId(requestToken).get(); - - jwtRedisService.validateRefreshToken(userId, requestToken); - - Role role = jwtRedisService.getRole(userId); - String email = jwtRedisService.getEmail(userId); - - JwtDto token = create(userId, email, role); - jwtRedisService.set(userId, token.refreshToken(), role, email); - - return token; - } - - // 리프레시 토큰 업데이트 - private void updateToken(long userId, String refreshToken, Role role, String email){ - jwtRedisService.set(userId, refreshToken, role, email); - } - -} diff --git a/src/main/java/com/weeth/global/auth/jwt/exception/AnonymousAuthenticationException.java b/src/main/java/com/weeth/global/auth/jwt/exception/AnonymousAuthenticationException.java deleted file mode 100644 index 37a858a4..00000000 --- a/src/main/java/com/weeth/global/auth/jwt/exception/AnonymousAuthenticationException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.global.auth.jwt.exception; - -import com.weeth.global.common.exception.BaseException; - -public class AnonymousAuthenticationException extends BaseException { - public AnonymousAuthenticationException() { - super(JwtErrorCode.ANONYMOUS_AUTHENTICATION); - } -} diff --git a/src/main/java/com/weeth/global/auth/jwt/exception/InvalidTokenException.java b/src/main/java/com/weeth/global/auth/jwt/exception/InvalidTokenException.java deleted file mode 100644 index 2eb97951..00000000 --- a/src/main/java/com/weeth/global/auth/jwt/exception/InvalidTokenException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.global.auth.jwt.exception; - -import com.weeth.global.common.exception.BaseException; - -public class InvalidTokenException extends BaseException { - public InvalidTokenException() { - super(JwtErrorCode.INVALID_TOKEN); - } -} diff --git a/src/main/java/com/weeth/global/auth/jwt/exception/RedisTokenNotFoundException.java b/src/main/java/com/weeth/global/auth/jwt/exception/RedisTokenNotFoundException.java deleted file mode 100644 index 8fdd5e86..00000000 --- a/src/main/java/com/weeth/global/auth/jwt/exception/RedisTokenNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.global.auth.jwt.exception; - -import com.weeth.global.common.exception.BaseException; - -public class RedisTokenNotFoundException extends BaseException { - public RedisTokenNotFoundException() { - super(JwtErrorCode.REDIS_TOKEN_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/global/auth/jwt/exception/TokenNotFoundException.java b/src/main/java/com/weeth/global/auth/jwt/exception/TokenNotFoundException.java deleted file mode 100644 index 8f798861..00000000 --- a/src/main/java/com/weeth/global/auth/jwt/exception/TokenNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.global.auth.jwt.exception; - -import com.weeth.global.common.exception.BaseException; - -public class TokenNotFoundException extends BaseException { - public TokenNotFoundException() { - super(JwtErrorCode.TOKEN_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.java b/src/main/java/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.java deleted file mode 100644 index 7490ca02..00000000 --- a/src/main/java/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.weeth.global.auth.jwt.filter; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import com.weeth.domain.user.domain.entity.enums.Role; -import com.weeth.global.auth.jwt.exception.TokenNotFoundException; -import com.weeth.global.auth.model.AuthenticatedUser; -import com.weeth.global.auth.jwt.service.JwtProvider; -import com.weeth.global.auth.jwt.service.JwtService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; -import java.util.List; - -@RequiredArgsConstructor -@Slf4j -public class JwtAuthenticationProcessingFilter extends OncePerRequestFilter { - - private static final String NO_CHECK_URL = "/api/v1/login"; - - private final JwtProvider jwtProvider; - private final JwtService jwtService; - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - if (request.getRequestURI().equals(NO_CHECK_URL)) { - filterChain.doFilter(request, response); - return; - } - // 유저 캐싱 도입 - try { - String accessToken = jwtService.extractAccessToken(request) - .orElseThrow(TokenNotFoundException::new); - if (jwtProvider.validate(accessToken)) { - saveAuthentication(accessToken); - } - } catch (TokenNotFoundException e) { - log.debug("Token not found: {}", e.getMessage()); - } catch (RuntimeException e) { - log.info("error token: {}", e.getMessage()); - } - - filterChain.doFilter(request, response); - - } - - public void saveAuthentication(String accessToken) { - - Long userId = jwtService.extractId(accessToken).orElseThrow(TokenNotFoundException::new); - String email = jwtService.extractEmail(accessToken).orElseThrow(TokenNotFoundException::new); - Role role = Role.valueOf(jwtService.extractRole(accessToken).orElseThrow(TokenNotFoundException::new)); - AuthenticatedUser principal = new AuthenticatedUser(userId, email, role); - - UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken( - principal, - null, - List.of(new SimpleGrantedAuthority("ROLE_" + role.name())) - ); - - SecurityContextHolder.getContext().setAuthentication(authentication); - } -} diff --git a/src/main/java/com/weeth/global/auth/jwt/service/JwtProvider.java b/src/main/java/com/weeth/global/auth/jwt/service/JwtProvider.java deleted file mode 100644 index ca5e413d..00000000 --- a/src/main/java/com/weeth/global/auth/jwt/service/JwtProvider.java +++ /dev/null @@ -1,87 +0,0 @@ -package com.weeth.global.auth.jwt.service; - -import com.weeth.domain.user.domain.entity.enums.Role; -import com.weeth.global.auth.jwt.exception.InvalidTokenException; -import com.weeth.global.config.properties.JwtProperties; -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.JwtException; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.security.Keys; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -import javax.crypto.SecretKey; -import java.nio.charset.StandardCharsets; -import java.util.Date; - -@Service -@Slf4j -public class JwtProvider { - - private static final String ACCESS_TOKEN_SUBJECT = "AccessToken"; - private static final String REFRESH_TOKEN_SUBJECT = "RefreshToken"; - private static final String EMAIL_CLAIM = "email"; - private static final String ID_CLAIM = "id"; - private static final String ROLE_CLAIM = "role"; - - private final SecretKey secretKey; - private final Long accessTokenExpirationPeriod; - private final Long refreshTokenExpirationPeriod; - - public JwtProvider(JwtProperties jwtProperties) { - this.secretKey = Keys.hmacShaKeyFor(jwtProperties.getKey().getBytes(StandardCharsets.UTF_8)); - this.accessTokenExpirationPeriod = jwtProperties.getAccess().getExpiration(); - this.refreshTokenExpirationPeriod = jwtProperties.getRefresh().getExpiration(); - } - - - public String createAccessToken(Long id, String email, Role role) { - Date now = new Date(); - return Jwts.builder() - .subject(ACCESS_TOKEN_SUBJECT) - .claim(ID_CLAIM, id) - .claim(EMAIL_CLAIM, email) - .claim(ROLE_CLAIM, role.toString()) - .issuedAt(now) - .expiration(new Date(now.getTime() + accessTokenExpirationPeriod)) - .signWith(secretKey) - .compact(); - } - - public String createRefreshToken(Long id) { - Date now = new Date(); - return Jwts.builder() - .subject(REFRESH_TOKEN_SUBJECT) - .claim(ID_CLAIM, id) - .issuedAt(now) - .expiration(new Date(now.getTime() + refreshTokenExpirationPeriod)) - .signWith(secretKey) - .compact(); - } - - public boolean validate(String token) { - try { - Jwts.parser() - .verifyWith(secretKey) - .build() - .parseSignedClaims(token); - return true; - } catch (JwtException | IllegalArgumentException e) { - log.error("유효하지 않은 토큰입니다. {}", e.getMessage()); - throw new InvalidTokenException(); - } - } - - public Claims parseClaims(String token) { - try { - return Jwts.parser() - .verifyWith(secretKey) - .build() - .parseSignedClaims(token) - .getPayload(); - } catch (JwtException | IllegalArgumentException e) { - log.error("토큰 파싱 실패: {}", e.getMessage()); - throw new InvalidTokenException(); - } - } -} diff --git a/src/main/java/com/weeth/global/auth/jwt/service/JwtRedisService.java b/src/main/java/com/weeth/global/auth/jwt/service/JwtRedisService.java deleted file mode 100644 index 90c021cf..00000000 --- a/src/main/java/com/weeth/global/auth/jwt/service/JwtRedisService.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.weeth.global.auth.jwt.service; - -import com.weeth.domain.user.application.exception.EmailNotFoundException; -import com.weeth.domain.user.application.exception.RoleNotFoundException; -import com.weeth.domain.user.domain.entity.enums.Role; -import com.weeth.global.auth.jwt.exception.InvalidTokenException; -import com.weeth.global.auth.jwt.exception.RedisTokenNotFoundException; -import com.weeth.global.config.properties.JwtProperties; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.stereotype.Service; - -import java.util.Optional; -import java.util.concurrent.TimeUnit; - -@Slf4j -@Service -@RequiredArgsConstructor -public class JwtRedisService { - - private static final String PREFIX = "refreshToken:"; - private static final String TOKEN = "token"; - private static final String ROLE = "role"; - private static final String EMAIL = "email"; - - private final JwtProperties jwtProperties; - private final RedisTemplate redisTemplate; - - public void set(long userId, String refreshToken, Role role, String email) { - String key = getKey(userId); - put(key, TOKEN, refreshToken); - put(key, ROLE, role.toString()); - put(key, EMAIL, email); - redisTemplate.expire(key, jwtProperties.getRefresh().getExpiration(), TimeUnit.MINUTES); - log.info("Refresh Token 저장/업데이트: {}", key); - } - - public void delete(Long userId) { - String key = getKey(userId); - redisTemplate.delete(key); - } - - public void validateRefreshToken(long userId, String requestToken) { - if (!find(userId).equals(requestToken)) { - throw new InvalidTokenException(); - } - } - - public String getEmail(long userId) { - String key = getKey(userId); - String roleValue = (String) redisTemplate.opsForHash().get(key, "email"); - - return Optional.ofNullable(roleValue) - .orElseThrow(EmailNotFoundException::new); - } - - public Role getRole(long userId) { - String key = getKey(userId); - String roleValue = (String) redisTemplate.opsForHash().get(key, "role"); - - return Optional.ofNullable(roleValue) - .map(Role::valueOf) - .orElseThrow(RoleNotFoundException::new); - } - - public void updateRole(long userId, String role) { - String key = getKey(userId); - - if (Boolean.TRUE.equals(redisTemplate.hasKey(key))) { - redisTemplate.opsForHash().put(key, "role", role); - } - } - - private String find(long userId) { - String key = getKey(userId); - return Optional.ofNullable((String) redisTemplate.opsForHash().get(key, "token")) - .orElseThrow(RedisTokenNotFoundException::new); - } - - private String getKey(long userId) { - return PREFIX + userId; - } - - private void put(String key, String hashKey, Object value) { - redisTemplate.opsForHash().put(key, hashKey, value); - } -} diff --git a/src/main/java/com/weeth/global/auth/jwt/service/JwtService.java b/src/main/java/com/weeth/global/auth/jwt/service/JwtService.java deleted file mode 100644 index 40b9737d..00000000 --- a/src/main/java/com/weeth/global/auth/jwt/service/JwtService.java +++ /dev/null @@ -1,85 +0,0 @@ -package com.weeth.global.auth.jwt.service; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.weeth.global.auth.jwt.application.dto.JwtDto; -import com.weeth.global.auth.jwt.exception.TokenNotFoundException; -import com.weeth.global.common.response.CommonResponse; -import com.weeth.global.config.properties.JwtProperties; -import io.jsonwebtoken.Claims; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -import java.io.IOException; -import java.util.Optional; - -@Slf4j -@Service -@RequiredArgsConstructor -public class JwtService { - - private static final String EMAIL_CLAIM = "email"; - private static final String ID_CLAIM = "id"; - private static final String ROLE_CLAIM = "role"; - private static final String BEARER = "Bearer "; - private static final String LOGIN_SUCCESS_MESSAGE = "자체 로그인 성공."; - - private final JwtProperties jwtProperties; - private final JwtProvider jwtProvider; - - public String extractRefreshToken(HttpServletRequest request) { - return Optional.ofNullable(request.getHeader(jwtProperties.getRefresh().getHeader())) - .filter(refreshToken -> refreshToken.startsWith(BEARER)) - .map(refreshToken -> refreshToken.replace(BEARER, "")) - .orElseThrow(TokenNotFoundException::new); - } - - public Optional extractAccessToken(HttpServletRequest request) { - return Optional.ofNullable(request.getHeader(jwtProperties.getAccess().getHeader())) - .filter(refreshToken -> refreshToken.startsWith(BEARER)) - .map(refreshToken -> refreshToken.replace(BEARER, "")); - } - - public Optional extractEmail(String accessToken) { - try { - Claims claims = jwtProvider.parseClaims(accessToken); - return Optional.ofNullable(claims.get(EMAIL_CLAIM, String.class)); - } catch (Exception e) { - log.error("액세스 토큰이 유효하지 않습니다."); - return Optional.empty(); - } - } - - public Optional extractId(String token) { - try { - Claims claims = jwtProvider.parseClaims(token); - return Optional.ofNullable(claims.get(ID_CLAIM, Long.class)); - } catch (Exception e) { - log.error("액세스 토큰이 유효하지 않습니다."); - return Optional.empty(); - } - } - - public Optional extractRole(String token) { - try { - Claims claims = jwtProvider.parseClaims(token); - return Optional.ofNullable(claims.get(ROLE_CLAIM, String.class)); - } catch (Exception e) { - log.error("액세스 토큰이 유효하지 않습니다."); - return Optional.empty(); - } - } - - // header -> body로 수정 - public void sendAccessAndRefreshToken(HttpServletResponse response, String accessToken, String refreshToken) throws IOException { - response.setStatus(HttpServletResponse.SC_OK); - response.setContentType("application/json"); - response.setCharacterEncoding("UTF-8"); - - String message = new ObjectMapper().writeValueAsString(CommonResponse.createSuccess(LOGIN_SUCCESS_MESSAGE, new JwtDto(accessToken, refreshToken))); - response.getWriter().write(message); - } - -} diff --git a/src/main/java/com/weeth/global/auth/kakao/KakaoAuthService.java b/src/main/java/com/weeth/global/auth/kakao/KakaoAuthService.java deleted file mode 100644 index de231ea3..00000000 --- a/src/main/java/com/weeth/global/auth/kakao/KakaoAuthService.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.weeth.global.auth.kakao; - -import com.weeth.global.auth.kakao.dto.KakaoTokenResponse; -import com.weeth.global.auth.kakao.dto.KakaoUserInfoResponse; -import com.weeth.global.config.properties.OAuthProperties; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Service; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.client.RestClient; - -@Service -@Slf4j -public class KakaoAuthService { - - private final OAuthProperties.KakaoProperties kakaoProperties; - private final RestClient restClient = RestClient.create(); - - public KakaoAuthService(OAuthProperties oAuthProperties) { - this.kakaoProperties = oAuthProperties.getKakao(); - } - - public KakaoTokenResponse getKakaoToken(String authCode) { - MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("grant_type", kakaoProperties.getGrantType()); - body.add("client_id", kakaoProperties.getClientId()); - body.add("redirect_uri", kakaoProperties.getRedirectUri()); - body.add("code", authCode); - - return restClient.post() - .uri(kakaoProperties.getTokenUri()) - .body(body) - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .retrieve() - .body(KakaoTokenResponse.class); - } - - public KakaoUserInfoResponse getUserInfo(String accessToken) { - return restClient.get() - .uri(kakaoProperties.getUserInfoUri()) - .header("Authorization", "Bearer " + accessToken) - .retrieve() - .body(KakaoUserInfoResponse.class); - - } -} diff --git a/src/main/java/com/weeth/global/auth/kakao/dto/KakaoAccessToken.java b/src/main/java/com/weeth/global/auth/kakao/dto/KakaoAccessToken.java deleted file mode 100644 index 21a18865..00000000 --- a/src/main/java/com/weeth/global/auth/kakao/dto/KakaoAccessToken.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.weeth.global.auth.kakao.dto; - -public record KakaoAccessToken ( - String accessToken -) { -} diff --git a/src/main/java/com/weeth/global/auth/kakao/dto/KakaoAccount.java b/src/main/java/com/weeth/global/auth/kakao/dto/KakaoAccount.java deleted file mode 100644 index 6aaaf0f4..00000000 --- a/src/main/java/com/weeth/global/auth/kakao/dto/KakaoAccount.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.weeth.global.auth.kakao.dto; - -public record KakaoAccount( - Boolean is_email_valid, - Boolean is_email_verified, - String email -) { -} diff --git a/src/main/java/com/weeth/global/auth/kakao/dto/KakaoTokenResponse.java b/src/main/java/com/weeth/global/auth/kakao/dto/KakaoTokenResponse.java deleted file mode 100644 index 9bc612de..00000000 --- a/src/main/java/com/weeth/global/auth/kakao/dto/KakaoTokenResponse.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.weeth.global.auth.kakao.dto; - -public record KakaoTokenResponse( - String token_type, - String access_token, - Integer expires_in, - String refresh_token, - Integer refresh_token_expires_in -) { -} diff --git a/src/main/java/com/weeth/global/auth/kakao/dto/KakaoUserInfoResponse.java b/src/main/java/com/weeth/global/auth/kakao/dto/KakaoUserInfoResponse.java deleted file mode 100644 index e9e58760..00000000 --- a/src/main/java/com/weeth/global/auth/kakao/dto/KakaoUserInfoResponse.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.weeth.global.auth.kakao.dto; - -public record KakaoUserInfoResponse( - Long id, - KakaoAccount kakao_account -) { -} diff --git a/src/main/java/com/weeth/global/auth/model/AuthenticatedUser.java b/src/main/java/com/weeth/global/auth/model/AuthenticatedUser.java deleted file mode 100644 index b79c8800..00000000 --- a/src/main/java/com/weeth/global/auth/model/AuthenticatedUser.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.weeth.global.auth.model; - -import com.weeth.domain.user.domain.entity.enums.Role; - -public record AuthenticatedUser( - Long id, - String email, - Role role -) { -} diff --git a/src/main/java/com/weeth/global/auth/resolver/CurrentUserArgumentResolver.java b/src/main/java/com/weeth/global/auth/resolver/CurrentUserArgumentResolver.java deleted file mode 100644 index 49c801eb..00000000 --- a/src/main/java/com/weeth/global/auth/resolver/CurrentUserArgumentResolver.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.weeth.global.auth.resolver; - -import com.weeth.global.auth.annotation.CurrentUser; -import com.weeth.global.auth.jwt.exception.AnonymousAuthenticationException; -import com.weeth.global.auth.model.AuthenticatedUser; -import org.springframework.core.MethodParameter; -import org.springframework.security.authentication.AnonymousAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.web.bind.support.WebDataBinderFactory; -import org.springframework.web.context.request.NativeWebRequest; -import org.springframework.web.method.support.HandlerMethodArgumentResolver; -import org.springframework.web.method.support.ModelAndViewContainer; - -public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver { - - @Override - public boolean supportsParameter(MethodParameter parameter) { // parameter가 해당 resolver를 지원하는 여부 확인 - boolean hasAnnotation = parameter.hasParameterAnnotation(CurrentUser.class); // @CurrentUser이 존재하는가? - boolean parameterType = Long.class.isAssignableFrom(parameter.getParameterType()); // 파라미터 타입이 Long을 상속하거나 구현하였는가? - return hasAnnotation && parameterType; // 둘 다 충족할 시 true - } - - @Override - public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); // 인증 객체 가져오기 - - if (authentication == null || authentication instanceof AnonymousAuthenticationToken) { - throw new AnonymousAuthenticationException(); - } - - Object principal = authentication.getPrincipal(); - if (principal instanceof AuthenticatedUser authenticatedUser) { - return authenticatedUser.id(); - } - - throw new AnonymousAuthenticationException(); - } -} diff --git a/src/main/java/com/weeth/global/auth/resolver/CurrentUserRoleArgumentResolver.java b/src/main/java/com/weeth/global/auth/resolver/CurrentUserRoleArgumentResolver.java deleted file mode 100644 index 063be6a1..00000000 --- a/src/main/java/com/weeth/global/auth/resolver/CurrentUserRoleArgumentResolver.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.weeth.global.auth.resolver; - -import com.weeth.domain.user.domain.entity.enums.Role; -import com.weeth.global.auth.annotation.CurrentUserRole; -import com.weeth.global.auth.jwt.exception.AnonymousAuthenticationException; -import com.weeth.global.auth.model.AuthenticatedUser; -import org.springframework.core.MethodParameter; -import org.springframework.security.authentication.AnonymousAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.web.bind.support.WebDataBinderFactory; -import org.springframework.web.context.request.NativeWebRequest; -import org.springframework.web.method.support.HandlerMethodArgumentResolver; -import org.springframework.web.method.support.ModelAndViewContainer; - -public class CurrentUserRoleArgumentResolver implements HandlerMethodArgumentResolver { - - @Override - public boolean supportsParameter(MethodParameter parameter) { - boolean hasAnnotation = parameter.hasParameterAnnotation(CurrentUserRole.class); - boolean parameterType = Role.class.isAssignableFrom(parameter.getParameterType()); - return hasAnnotation && parameterType; - } - - @Override - public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - - if (authentication == null || authentication instanceof AnonymousAuthenticationToken) { - throw new AnonymousAuthenticationException(); - } - - Object principal = authentication.getPrincipal(); - if (principal instanceof AuthenticatedUser authenticatedUser) { - return authenticatedUser.role(); - } - - for (GrantedAuthority authority : authentication.getAuthorities()) { - String role = authority.getAuthority(); - if (role != null && role.startsWith("ROLE_")) { - return Role.valueOf(role.substring("ROLE_".length())); - } - } - - throw new AnonymousAuthenticationException(); - } -} diff --git a/src/main/java/com/weeth/global/common/controller/StatusCheckController.java b/src/main/java/com/weeth/global/common/controller/StatusCheckController.java deleted file mode 100644 index 6c88bbcb..00000000 --- a/src/main/java/com/weeth/global/common/controller/StatusCheckController.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.weeth.global.common.controller; - -import io.swagger.v3.oas.annotations.Hidden; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; - -@Hidden -@RestController -public class StatusCheckController { - - @GetMapping("/health-check") - public ResponseEntity checkHealthStatus() { - - return new ResponseEntity<>(HttpStatus.OK); - } -} diff --git a/src/main/java/com/weeth/global/common/entity/BaseEntity.java b/src/main/java/com/weeth/global/common/entity/BaseEntity.java index 3e8a520a..fa970c29 100644 --- a/src/main/java/com/weeth/global/common/entity/BaseEntity.java +++ b/src/main/java/com/weeth/global/common/entity/BaseEntity.java @@ -18,11 +18,13 @@ @EntityListeners(AuditingEntityListener.class) @SuperBuilder @NoArgsConstructor(access = AccessLevel.PROTECTED) +// NOTE: Java 엔티티들의 Lombok @SuperBuilder 체인(BaseEntityBuilder) 호환을 위해 현재는 Java로 유지한다. public class BaseEntity { @CreatedDate @Column(updatable = false) private LocalDateTime createdAt; + @LastModifiedDate private LocalDateTime modifiedAt; } diff --git a/src/main/java/com/weeth/global/common/exception/ApiErrorCodeExample.java b/src/main/java/com/weeth/global/common/exception/ApiErrorCodeExample.java deleted file mode 100644 index dda006c6..00000000 --- a/src/main/java/com/weeth/global/common/exception/ApiErrorCodeExample.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.weeth.global.common.exception; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Target({ElementType.METHOD, ElementType.TYPE}) -@Retention(RetentionPolicy.RUNTIME) -public @interface ApiErrorCodeExample { - Class[] value(); -} diff --git a/src/main/java/com/weeth/global/common/exception/BaseException.java b/src/main/java/com/weeth/global/common/exception/BaseException.java deleted file mode 100644 index c93f459a..00000000 --- a/src/main/java/com/weeth/global/common/exception/BaseException.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.weeth.global.common.exception; - -import lombok.Getter; - -@Getter -public abstract class BaseException extends RuntimeException { - - private final int statusCode; - private final ErrorCodeInterface errorCode; - - public BaseException(int code, String message) { - super(message); - this.statusCode = code; - this.errorCode = null; - } - - public BaseException(int code, String message, Throwable cause) { - super(message, cause); - this.statusCode = code; - this.errorCode = null; - } - - public BaseException(ErrorCodeInterface errorCode, Throwable cause) { - super(errorCode.getMessage(), cause); - this.statusCode = errorCode.getStatus().value(); - this.errorCode = errorCode; - } - - public BaseException(ErrorCodeInterface errorCode) { - this(errorCode, null); - } -} diff --git a/src/main/java/com/weeth/global/common/exception/BindExceptionResponse.java b/src/main/java/com/weeth/global/common/exception/BindExceptionResponse.java deleted file mode 100644 index 572ed828..00000000 --- a/src/main/java/com/weeth/global/common/exception/BindExceptionResponse.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.weeth.global.common.exception; - -import lombok.Builder; - -@Builder -public record BindExceptionResponse( - String message, - Object value -) { -} diff --git a/src/main/java/com/weeth/global/common/exception/CommonExceptionHandler.java b/src/main/java/com/weeth/global/common/exception/CommonExceptionHandler.java deleted file mode 100644 index 394779a3..00000000 --- a/src/main/java/com/weeth/global/common/exception/CommonExceptionHandler.java +++ /dev/null @@ -1,97 +0,0 @@ -package com.weeth.global.common.exception; - -import com.weeth.global.common.response.CommonResponse; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; -import org.springframework.validation.BindException; -import org.springframework.web.ErrorResponse; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; - -import java.util.ArrayList; -import java.util.List; - -@Slf4j -@RestControllerAdvice -public class CommonExceptionHandler { - - private static final String INPUT_FORMAT_ERROR_MESSAGE = "입력 포맷이 올바르지 않습니다."; - private static final String LOG_FORMAT = "Class : {}, Code : {}, Message : {}"; - - @ExceptionHandler(BaseException.class) // 커스텀 예외 처리 - public ResponseEntity> handle(BaseException ex) { - log.warn("구체로그: ", ex); - log.warn(LOG_FORMAT, ex.getClass().getSimpleName(), ex.getStatusCode(), ex.getMessage()); - - CommonResponse response = ex.getErrorCode() != null - ? CommonResponse.error(ex.getErrorCode()) - : CommonResponse.createFailure(ex.getStatusCode(), ex.getMessage()); - - return ResponseEntity - .status(ex.getStatusCode()) - .body(response); - } - - @ExceptionHandler(BindException.class) // BindException == @ModelAttribute 어노테이션으로 받은 파라미터의 @Valid 통해 발생한 Exception - public ResponseEntity>> handle(BindException ex) { - int statusCode = 400; - List exceptionResponses = new ArrayList<>(); - - if (ex instanceof ErrorResponse) { - statusCode = ((ErrorResponse) ex).getStatusCode().value(); - ex.getBindingResult().getFieldErrors().forEach(fieldError -> { - exceptionResponses.add(BindExceptionResponse.builder() - .message(fieldError.getDefaultMessage()) - .value(fieldError.getRejectedValue()) - .build()); - }); - } - - log.warn("구체로그: ", ex); - log.warn(LOG_FORMAT, ex.getClass().getSimpleName(), statusCode, exceptionResponses); - - CommonResponse> response = CommonResponse.createFailure(statusCode, "bindException", exceptionResponses); - - return ResponseEntity - .status(statusCode) - .body(response); - } - - @ExceptionHandler(MethodArgumentTypeMismatchException.class) - // MethodArgumentTypeMismatchException == 클라이언트가 날짜 포맷을 다르게 입력한 경우 - public ResponseEntity> handle(MethodArgumentTypeMismatchException ex) { - int statusCode = 400; // 파라미터 값 실수이므로 4XX - - if (ex instanceof ErrorResponse) { // Exception이 ErrorResponse의 인스턴스라면 - statusCode = ((ErrorResponse) ex).getStatusCode().value(); // ErrorResponse에서 상태 값 가져오기 - } - - log.warn("구체로그: ", ex); - log.warn(LOG_FORMAT, ex.getClass().getSimpleName(), statusCode, ex.getMessage()); - - CommonResponse response = CommonResponse.createFailure(statusCode, INPUT_FORMAT_ERROR_MESSAGE); - - return ResponseEntity - .status(statusCode) - .body(response); - } - - @ExceptionHandler(Exception.class) // 모든 Exception 처리 - public ResponseEntity> handle(Exception ex) { - int statusCode = 500; - - if (ex instanceof ErrorResponse) { // Exception이 ErrorResponse의 인스턴스라면 (http status를 가지는 예외) - statusCode = ((ErrorResponse) ex).getStatusCode().value(); // ErrorResponse에서 상태 값 가져오기 - } - - log.warn("구체로그: ", ex); - log.warn(LOG_FORMAT, ex.getClass().getSimpleName(), statusCode, ex.getMessage()); - - CommonResponse response = CommonResponse.createFailure(statusCode, ex.getMessage()); - - return ResponseEntity - .status(statusCode) - .body(response); - } -} diff --git a/src/main/java/com/weeth/global/common/exception/ErrorCodeInterface.java b/src/main/java/com/weeth/global/common/exception/ErrorCodeInterface.java deleted file mode 100644 index de96249c..00000000 --- a/src/main/java/com/weeth/global/common/exception/ErrorCodeInterface.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.weeth.global.common.exception; - -import org.springframework.http.HttpStatus; - -import java.lang.reflect.Field; -import java.util.Objects; - -public interface ErrorCodeInterface { - int getCode(); - HttpStatus getStatus(); - String getMessage(); - - // ExplainError 어노테이션에 작성된 설명을 조회하는 메서드 - default String getExplainError() throws NoSuchFieldException { - Field field = this.getClass().getField(((Enum) this).name()); - ExplainError annotation = field.getAnnotation(ExplainError.class); - return Objects.nonNull(annotation) ? annotation.value() : getMessage(); - } -} diff --git a/src/main/java/com/weeth/global/common/exception/ExampleHolder.java b/src/main/java/com/weeth/global/common/exception/ExampleHolder.java deleted file mode 100644 index 897bf1cb..00000000 --- a/src/main/java/com/weeth/global/common/exception/ExampleHolder.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.weeth.global.common.exception; - -import io.swagger.v3.oas.models.examples.Example; -import lombok.Builder; -import lombok.Getter; - -@Getter -@Builder -public class ExampleHolder { - private Example holder; - private String name; - private int code; -} diff --git a/src/main/java/com/weeth/global/common/exception/ExplainError.java b/src/main/java/com/weeth/global/common/exception/ExplainError.java deleted file mode 100644 index f609ee3b..00000000 --- a/src/main/java/com/weeth/global/common/exception/ExplainError.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.weeth.global.common.exception; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Target(ElementType.FIELD) -@Retention(RetentionPolicy.RUNTIME) -public @interface ExplainError { - String value() default ""; -} diff --git a/src/main/java/com/weeth/global/config/AwsS3Config.java b/src/main/java/com/weeth/global/config/AwsS3Config.java deleted file mode 100644 index b53a82f4..00000000 --- a/src/main/java/com/weeth/global/config/AwsS3Config.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.weeth.global.config; - -import com.weeth.global.config.properties.AwsS3Properties; -import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; -import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; -import software.amazon.awssdk.regions.Region; -import software.amazon.awssdk.services.s3.presigner.S3Presigner; - -@Configuration -@RequiredArgsConstructor -public class AwsS3Config { - - private final AwsS3Properties awsS3Properties; - - @Bean - public S3Presigner s3Presigner() { - AwsBasicCredentials credentials = AwsBasicCredentials.create( - awsS3Properties.getCredentials().getAccessKey(), - awsS3Properties.getCredentials().getSecretKey() - ); - return S3Presigner.builder() - .region(Region.of(awsS3Properties.getRegion().getStatic())) - .credentialsProvider(StaticCredentialsProvider.create(credentials)) - .build(); - } -} diff --git a/src/main/java/com/weeth/global/config/RedisConfig.java b/src/main/java/com/weeth/global/config/RedisConfig.java deleted file mode 100644 index b7fe36ab..00000000 --- a/src/main/java/com/weeth/global/config/RedisConfig.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.weeth.global.config; - -import com.weeth.global.config.properties.RedisProperties; -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.connection.RedisStandaloneConfiguration; -import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; -import org.springframework.data.redis.core.RedisKeyValueAdapter; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; -import org.springframework.data.redis.serializer.StringRedisSerializer; - -@Configuration -@RequiredArgsConstructor -@EnableRedisRepositories(enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP) // redis index ttl 설정 -public class RedisConfig { - - private final RedisProperties redisProperties; - - @Bean - public RedisConnectionFactory redisConnectionFactory() { - RedisStandaloneConfiguration redisConfiguration = new RedisStandaloneConfiguration(); - - redisConfiguration.setHostName(redisProperties.getHost()); - redisConfiguration.setPort(redisProperties.getPort()); - if (redisProperties.getPassword() != null && !redisProperties.getPassword().isEmpty()) { - redisConfiguration.setPassword(redisProperties.getPassword()); - } - - return new LettuceConnectionFactory(redisConfiguration); - } - - - @Bean - public RedisTemplate redisTemplate() { - RedisTemplate redisTemplate = new RedisTemplate<>(); - - redisTemplate.setKeySerializer(new StringRedisSerializer()); - redisTemplate.setValueSerializer(new StringRedisSerializer()); - redisTemplate.setConnectionFactory(redisConnectionFactory()); - - return redisTemplate; - } - -} diff --git a/src/main/java/com/weeth/global/config/SecurityConfig.java b/src/main/java/com/weeth/global/config/SecurityConfig.java deleted file mode 100644 index 223c0e71..00000000 --- a/src/main/java/com/weeth/global/config/SecurityConfig.java +++ /dev/null @@ -1,115 +0,0 @@ -package com.weeth.global.config; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.weeth.global.auth.authentication.CustomAccessDeniedHandler; -import com.weeth.global.auth.authentication.CustomAuthenticationEntryPoint; -import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase; -import com.weeth.global.auth.jwt.filter.JwtAuthenticationProcessingFilter; -import com.weeth.global.auth.jwt.service.JwtProvider; -import com.weeth.global.auth.jwt.service.JwtService; -import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.authorization.AuthorizationDecision; -import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.crypto.factory.PasswordEncoderFactories; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.CorsConfigurationSource; -import org.springframework.web.cors.UrlBasedCorsConfigurationSource; - -import java.util.Arrays; - -import static org.springframework.security.config.Customizer.withDefaults; - -@Configuration -@EnableWebSecurity -@RequiredArgsConstructor -@EnableMethodSecurity(prePostEnabled = true) -public class SecurityConfig { - - private final JwtProvider jwtProvider; - private final JwtService jwtService; - private final JwtManageUseCase jwtManageUseCase; - private final ObjectMapper objectMapper; - - private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; - private final CustomAccessDeniedHandler customAccessDeniedHandler; - - @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - return http - .formLogin(AbstractHttpConfigurer::disable) - .httpBasic(AbstractHttpConfigurer::disable) - .cors(withDefaults()) - .csrf(AbstractHttpConfigurer::disable) - .headers( - headersConfigurer -> - headersConfigurer - .frameOptions( - HeadersConfigurer.FrameOptionsConfig::sameOrigin - ) - ) - // 세션 사용하지 않으므로 STATELESS로 설정 - .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - - //== URL별 권한 관리 옵션 ==// - .authorizeHttpRequests( - authorize -> - authorize - .requestMatchers("/api/v1/users/kakao/login", "api/v1/users/kakao/register", "api/v1/users/kakao/link", "/api/v1/users/apple/login", "/api/v1/users/apple/register", "/api/v1/users/apply", "/api/v1/users/email", "/api/v1/users/refresh").permitAll() - .requestMatchers("/health-check").permitAll() - .requestMatchers("/admin", "/admin/login", "/admin/account", "/admin/meeting", "/admin/member", "/admin/penalty").permitAll() - // 스웨거 경로 - .requestMatchers("/v3/api-docs", "/v3/api-docs/**", "/swagger-ui.html", "/swagger-ui/**", "/swagger/**").permitAll() - .requestMatchers("/actuator/prometheus") - .access((authentication, context) -> { - String ip = context.getRequest().getRemoteAddr(); - boolean allowed = ip.startsWith("172.") || ip.equals("127.0.0.1"); - return new AuthorizationDecision(allowed); - }) - .requestMatchers("/actuator/health").permitAll() - .requestMatchers("/api/v1/admin/**", "/api/v4/admin/**").hasRole("ADMIN") - .anyRequest().authenticated() - ) - .exceptionHandling(exceptionHandling -> - exceptionHandling - .authenticationEntryPoint(customAuthenticationEntryPoint) - .accessDeniedHandler(customAccessDeniedHandler)) - .addFilterBefore(jwtAuthenticationProcessingFilter(), UsernamePasswordAuthenticationFilter.class) - .build(); - } - - - @Bean - public CorsConfigurationSource corsConfigurationSource() { - CorsConfiguration configuration = new CorsConfiguration(); - - configuration.setAllowedOriginPatterns(Arrays.asList("http://localhost:3000")); - configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PATCH", "DELETE", "OPTIONS")); - configuration.setAllowedHeaders(Arrays.asList("*")); - configuration.setExposedHeaders(Arrays.asList("Authorization", "Authorization_refresh")); - configuration.setAllowCredentials(true); - - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", configuration); - return source; - } - - @Bean - public PasswordEncoder passwordEncoder() { - return PasswordEncoderFactories.createDelegatingPasswordEncoder(); - } - - @Bean - public JwtAuthenticationProcessingFilter jwtAuthenticationProcessingFilter() { - return new JwtAuthenticationProcessingFilter(jwtProvider, jwtService); - } -} diff --git a/src/main/java/com/weeth/global/config/WebMvcConfig.java b/src/main/java/com/weeth/global/config/WebMvcConfig.java deleted file mode 100644 index d0127ba9..00000000 --- a/src/main/java/com/weeth/global/config/WebMvcConfig.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.weeth.global.config; - -import com.weeth.global.auth.resolver.CurrentUserArgumentResolver; -import com.weeth.global.auth.resolver.CurrentUserRoleArgumentResolver; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.method.support.HandlerMethodArgumentResolver; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; - -import java.util.List; - -@Configuration -public class WebMvcConfig implements WebMvcConfigurer { - - @Override - public void addArgumentResolvers(List resolvers) { - resolvers.add(new CurrentUserArgumentResolver()); - resolvers.add(new CurrentUserRoleArgumentResolver()); - } -} diff --git a/src/main/java/com/weeth/global/config/swagger/SwaggerConfig.java b/src/main/java/com/weeth/global/config/swagger/SwaggerConfig.java deleted file mode 100644 index ad5958f1..00000000 --- a/src/main/java/com/weeth/global/config/swagger/SwaggerConfig.java +++ /dev/null @@ -1,195 +0,0 @@ -package com.weeth.global.config.swagger; - -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.exception.ErrorCodeInterface; -import com.weeth.global.common.exception.ExampleHolder; -import com.weeth.global.common.response.CommonResponse; -import com.weeth.global.config.properties.JwtProperties; -import io.swagger.v3.oas.annotations.OpenAPIDefinition; -import io.swagger.v3.oas.annotations.info.Info; -import io.swagger.v3.oas.models.Components; -import io.swagger.v3.oas.models.OpenAPI; -import io.swagger.v3.oas.models.examples.Example; -import io.swagger.v3.oas.models.media.Content; -import io.swagger.v3.oas.models.media.MediaType; -import io.swagger.v3.oas.models.responses.ApiResponse; -import io.swagger.v3.oas.models.responses.ApiResponses; -import io.swagger.v3.oas.models.security.SecurityRequirement; -import io.swagger.v3.oas.models.security.SecurityScheme; -import io.swagger.v3.oas.models.servers.Server; -import lombok.RequiredArgsConstructor; -import org.springdoc.core.customizers.OperationCustomizer; -import org.springdoc.core.models.GroupedOpenApi; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -import java.util.Arrays; -import java.util.List; -import java.util.Map; - -import static java.util.stream.Collectors.groupingBy; - -@Configuration -@RequiredArgsConstructor -@OpenAPIDefinition( - info = @Info( - title = "Weeth API", - version = "v4.0.0", - description = """ - ## Response Code 규칙 - - Success: **1xxx** - - Domain Error: **2xxx** - - Server Error: **3xxx** - - Client Error: **4xxx** - - ## 도메인별 코드 범위 - | Domain | Success | Error | - |--------|---------|------| - | Account | 11xx | 21xx | - | Attendance | 12xx | 22xx | - | Board | 13xx | 23xx | - | Comment | 14xx | 24xx | - | File | 15xx | 25xx | - | Penalty | 16xx | 26xx | - | Schedule | 17xx | 27xx | - | User | 18xx | 28xx | - | Auth/JWT (Global) | - | 29xx | - - > 각 API의 상세 응답 예시는 Swagger의 **Responses** 섹션에서 확인하세요. - """ - ) -) -public class SwaggerConfig { - - private final JwtProperties jwtProperties; - - @Bean - public OpenAPI openAPI() { - SecurityScheme accessSecurityScheme = getAccessSecurityScheme(); - SecurityScheme refreshSecurityScheme = getRefreshSecurityScheme(); - - return new OpenAPI() - .addServersItem(new Server().url("/")) - .components(new Components() - .addSecuritySchemes("bearerAuth", accessSecurityScheme) - .addSecuritySchemes("refreshBearerAuth", refreshSecurityScheme)) - .security(List.of( - new SecurityRequirement().addList("bearerAuth"), - new SecurityRequirement().addList("refreshBearerAuth") - )); - } - - @Bean - public GroupedOpenApi adminApi() { - return GroupedOpenApi.builder() - .group("admin") - .pathsToMatch("/api/v1/admin/**", "/api/v4/admin/**") - .addOperationCustomizer(operationCustomizer()) - .build(); - } - - @Bean - public GroupedOpenApi publicApi() { - return GroupedOpenApi.builder() - .group("public") - .pathsToExclude("/api/v1/admin/**", "/api/v4/admin/**") - .addOperationCustomizer(operationCustomizer()) - .build(); - } - - @Bean - public OperationCustomizer operationCustomizer() { - return (operation, handlerMethod) -> { - ApiErrorCodeExample apiErrorCodeExample = findAnnotation(handlerMethod, ApiErrorCodeExample.class); - if (apiErrorCodeExample != null) { - for (Class type : apiErrorCodeExample.value()) { - generateErrorCodeResponseExample(operation.getResponses(), type); - } - } - - return operation; - }; - } - - private void generateErrorCodeResponseExample(ApiResponses responses, Class type) { - ErrorCodeInterface[] errorCodes = type.getEnumConstants(); - - Map> statusWithExampleHolders = - Arrays.stream(errorCodes) - .map(errorCode -> { - try { - String enumName = ((Enum) errorCode).name(); - - return ExampleHolder.builder() - .holder(getSwaggerExample(errorCode.getExplainError(), errorCode)) - .code(errorCode.getStatus().value()) - .name("[" + enumName + "] " + errorCode.getMessage()) - .build(); - } catch (NoSuchFieldException e) { - throw new RuntimeException(e); - } - }) - .collect(groupingBy(ExampleHolder::getCode)); - - addExamplesToResponses(responses, statusWithExampleHolders); - } - - private Example getSwaggerExample(String description, ErrorCodeInterface errorCode) { - CommonResponse errorResponse = CommonResponse.createFailure(errorCode.getCode(), errorCode.getMessage()); - Example example = new Example(); - example.description(description); - example.setValue(errorResponse); - - return example; - } - - private void addExamplesToResponses(ApiResponses responses, Map> statusWithExampleHolders) { - statusWithExampleHolders.forEach((status, exampleHolders) -> { - ApiResponse apiResponse = responses.computeIfAbsent(String.valueOf(status), k -> new ApiResponse()); - MediaType mediaType = getOrCreateMediaType(apiResponse); - exampleHolders.forEach(holder -> mediaType.addExamples(holder.getName(), holder.getHolder())); - }); - } - - private A findAnnotation(org.springframework.web.method.HandlerMethod handlerMethod, Class annotationType) { - A annotation = handlerMethod.getMethodAnnotation(annotationType); - if (annotation != null) { - return annotation; - } - return handlerMethod.getBeanType().getAnnotation(annotationType); - } - - private MediaType getOrCreateMediaType(ApiResponse apiResponse) { - Content content = apiResponse.getContent(); - if (content == null) { - content = new Content(); - apiResponse.setContent(content); - } - - MediaType mediaType = content.get("application/json"); - if (mediaType == null) { - mediaType = new MediaType(); - content.addMediaType("application/json", mediaType); - } - - return mediaType; - } - - private SecurityScheme getAccessSecurityScheme() { - return new SecurityScheme() - .type(SecurityScheme.Type.HTTP) - .scheme("bearer") - .bearerFormat("JWT") - .in(SecurityScheme.In.HEADER) - .name(jwtProperties.getAccess().getHeader()); - } - - private SecurityScheme getRefreshSecurityScheme() { - return new SecurityScheme() - .type(SecurityScheme.Type.APIKEY) - .scheme("bearer") - .bearerFormat("JWT") - .in(SecurityScheme.In.HEADER) - .name(jwtProperties.getRefresh().getHeader()); - } -} diff --git a/src/main/kotlin/com/weeth/global/auth/annotation/CurrentUser.kt b/src/main/kotlin/com/weeth/global/auth/annotation/CurrentUser.kt new file mode 100644 index 00000000..71d3cce6 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/annotation/CurrentUser.kt @@ -0,0 +1,5 @@ +package com.weeth.global.auth.annotation + +@Target(AnnotationTarget.VALUE_PARAMETER) +@Retention(AnnotationRetention.RUNTIME) +annotation class CurrentUser diff --git a/src/main/kotlin/com/weeth/global/auth/annotation/CurrentUserRole.kt b/src/main/kotlin/com/weeth/global/auth/annotation/CurrentUserRole.kt new file mode 100644 index 00000000..90690a12 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/annotation/CurrentUserRole.kt @@ -0,0 +1,5 @@ +package com.weeth.global.auth.annotation + +@Target(AnnotationTarget.VALUE_PARAMETER) +@Retention(AnnotationRetention.RUNTIME) +annotation class CurrentUserRole diff --git a/src/main/kotlin/com/weeth/global/auth/apple/AppleAuthService.kt b/src/main/kotlin/com/weeth/global/auth/apple/AppleAuthService.kt new file mode 100644 index 00000000..43e247d6 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/apple/AppleAuthService.kt @@ -0,0 +1,263 @@ +package com.weeth.global.auth.apple + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode +import com.weeth.global.auth.apple.dto.ApplePublicKey +import com.weeth.global.auth.apple.dto.ApplePublicKeys +import com.weeth.global.auth.apple.dto.AppleTokenResponse +import com.weeth.global.auth.apple.dto.AppleUserInfo +import com.weeth.global.auth.apple.exception.AppleAuthenticationException +import com.weeth.global.config.properties.OAuthProperties +import io.jsonwebtoken.Claims +import io.jsonwebtoken.Jwts +import org.slf4j.LoggerFactory +import org.springframework.core.io.ClassPathResource +import org.springframework.http.MediaType +import org.springframework.stereotype.Service +import org.springframework.util.LinkedMultiValueMap +import org.springframework.web.client.RestClient +import org.springframework.web.client.body +import java.io.FileInputStream +import java.io.IOException +import java.io.InputStream +import java.math.BigInteger +import java.nio.charset.StandardCharsets +import java.security.KeyFactory +import java.security.PrivateKey +import java.security.PublicKey +import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.RSAPublicKeySpec +import java.time.Clock +import java.time.Duration +import java.time.Instant +import java.util.Base64 +import java.util.Date + +@Service +class AppleAuthService( + oAuthProperties: OAuthProperties, + restClientBuilder: RestClient.Builder, + private val objectMapper: ObjectMapper, + private val clock: Clock = Clock.systemUTC(), +) { + private data class CachedKeys( + val keys: ApplePublicKeys, + val expiresAt: Instant, + ) + + private val log = LoggerFactory.getLogger(javaClass) + + private val appleProperties = oAuthProperties.apple + private val restClient = restClientBuilder.build() + private val publicKeysTtl: Duration = Duration.ofHours(1) + + @Volatile private var cached: CachedKeys? = null + private val privateKey: PrivateKey by lazy { loadPrivateKey() } + + fun getAppleToken(authCode: String): AppleTokenResponse { + val clientSecret = generateClientSecret() + + val body = + LinkedMultiValueMap().apply { + add("grant_type", "authorization_code") + add("client_id", appleProperties.clientId) + add("client_secret", clientSecret) + add("code", authCode) + add("redirect_uri", appleProperties.redirectUri) + } + + return requireNotNull( + restClient + .post() + .uri(appleProperties.tokenUri) + .body(body) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .retrieve() + .body(), + ) + } + + fun verifyAndDecodeIdToken(idToken: String): AppleUserInfo { + try { + val tokenParts = idToken.split(".") + if (tokenParts.size < 2) { + throw AppleAuthenticationException() + } + val header = decodeBase64Url(tokenParts[0]) + val headerJson = parseJson(header) + val kid = headerJson["kid"]?.asText()?.takeIf { it.isNotBlank() } ?: throw AppleAuthenticationException() + val alg = headerJson["alg"]?.asText() + if (alg != "RS256") { + throw AppleAuthenticationException() + } + + val publicKeys = getApplePublicKeys() + + val matchedKey = + publicKeys.keys + .firstOrNull { key -> key.kid == kid } + ?: throw AppleAuthenticationException() + + val publicKey = generatePublicKey(matchedKey) + val claims = + Jwts + .parser() + .verifyWith(publicKey) + .build() + .parseSignedClaims(idToken) + .payload + + validateClaims(claims) + + val appleId = claims.subject + val email = claims.get("email", String::class.java) + val emailVerified = parseEmailVerified(claims["email_verified"]) + + return AppleUserInfo( + appleId = appleId, + email = email, + emailVerified = emailVerified, + ) + } catch (e: AppleAuthenticationException) { + throw e + } catch (e: Exception) { + log.error("애플 ID Token 검증 실패", e) + throw AppleAuthenticationException() + } + } + + private fun generateClientSecret(): String { + try { + val now = Instant.now(clock) + val expiration = now.plus(Duration.ofDays(150)) // Apple limit is <= 6 months. + + return Jwts + .builder() + .header() + .keyId(appleProperties.keyId) + .and() + .issuer(appleProperties.teamId) + .issuedAt(Date.from(now)) + .expiration(Date.from(expiration)) + .audience() + .add("https://appleid.apple.com") + .and() + .subject(appleProperties.clientId) + .signWith(privateKey, Jwts.SIG.ES256) + .compact() + } catch (e: Exception) { + log.error("애플 Client Secret 생성 실패", e) + throw AppleAuthenticationException() + } + } + + private fun loadPrivateKey(): PrivateKey = + try { + getInputStream(appleProperties.privateKeyPath).use { inputStream -> + var privateKeyContent = String(inputStream.readAllBytes(), StandardCharsets.UTF_8) + privateKeyContent = + privateKeyContent + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace("\\s".toRegex(), "") + + val keyBytes = Base64.getDecoder().decode(privateKeyContent) + val keyFactory = KeyFactory.getInstance("EC") + keyFactory.generatePrivate(PKCS8EncodedKeySpec(keyBytes)) + } + } catch (e: Exception) { + log.error("애플 개인키 로드 실패", e) + throw AppleAuthenticationException() + } + + @Throws(IOException::class) + private fun getInputStream(path: String): InputStream = + if (path.startsWith("/") || path.matches(Regex("^[A-Za-z]:.*"))) { + FileInputStream(path) + } else { + ClassPathResource(path).inputStream + } + + private fun generatePublicKey(applePublicKey: ApplePublicKey): PublicKey = + try { + val nBytes = Base64.getUrlDecoder().decode(applePublicKey.n) + val eBytes = Base64.getUrlDecoder().decode(applePublicKey.e) + + val n = BigInteger(1, nBytes) + val e = BigInteger(1, eBytes) + + val publicKeySpec = RSAPublicKeySpec(n, e) + val keyFactory = KeyFactory.getInstance("RSA") + + keyFactory.generatePublic(publicKeySpec) + } catch (ex: Exception) { + log.error("애플 공개키 생성 실패", ex) + throw AppleAuthenticationException() + } + + private fun validateClaims(claims: Claims) { + val iss = claims.issuer + val audiences = claims.audience + val expiration = claims.expiration + val now = Date.from(Instant.now(clock)) + + when { + iss != "https://appleid.apple.com" -> { + log.warn("유효하지 않은 발급자: {}", iss) + throw AppleAuthenticationException() + } + + audiences.isEmpty() || !audiences.contains(appleProperties.clientId) -> { + log.warn("유효하지 않은 audience: {}. 기대값: {}", audiences, appleProperties.clientId) + throw AppleAuthenticationException() + } + + expiration.before(now) -> { + log.warn("만료된 ID Token") + throw AppleAuthenticationException() + } + + claims.subject.isNullOrBlank() -> { + log.warn("유효하지 않은 subject") + throw AppleAuthenticationException() + } + } + } + + private fun getApplePublicKeys(): ApplePublicKeys { + val now = Instant.now(clock) + cached?.let { + if (now.isBefore(it.expiresAt)) { + return it.keys + } + } + + val fetched = + requireNotNull( + restClient + .get() + .uri(appleProperties.keysUri) + .retrieve() + .body(), + ) + + cached = CachedKeys(fetched, now.plus(publicKeysTtl)) + return fetched + } + + private fun parseJson(json: String): ObjectNode = + try { + objectMapper.readTree(json) as? ObjectNode ?: throw AppleAuthenticationException() + } catch (e: Exception) { + throw AppleAuthenticationException() + } + + private fun decodeBase64Url(value: String): String = String(Base64.getUrlDecoder().decode(value), StandardCharsets.UTF_8) + + private fun parseEmailVerified(raw: Any?): Boolean = + when (raw) { + is Boolean -> raw + is String -> raw.toBooleanStrictOrNull() ?: false + else -> false + } +} diff --git a/src/main/kotlin/com/weeth/global/auth/apple/dto/ApplePublicKey.kt b/src/main/kotlin/com/weeth/global/auth/apple/dto/ApplePublicKey.kt new file mode 100644 index 00000000..8d778923 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/apple/dto/ApplePublicKey.kt @@ -0,0 +1,10 @@ +package com.weeth.global.auth.apple.dto + +data class ApplePublicKey( + val kty: String, + val kid: String, + val use: String, + val alg: String, + val n: String, + val e: String, +) diff --git a/src/main/kotlin/com/weeth/global/auth/apple/dto/ApplePublicKeys.kt b/src/main/kotlin/com/weeth/global/auth/apple/dto/ApplePublicKeys.kt new file mode 100644 index 00000000..82950ccf --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/apple/dto/ApplePublicKeys.kt @@ -0,0 +1,5 @@ +package com.weeth.global.auth.apple.dto + +data class ApplePublicKeys( + val keys: List, +) diff --git a/src/main/kotlin/com/weeth/global/auth/apple/dto/AppleTokenResponse.kt b/src/main/kotlin/com/weeth/global/auth/apple/dto/AppleTokenResponse.kt new file mode 100644 index 00000000..5cb7f8ee --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/apple/dto/AppleTokenResponse.kt @@ -0,0 +1,16 @@ +package com.weeth.global.auth.apple.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class AppleTokenResponse( + @field:JsonProperty("access_token") + val accessToken: String, + @field:JsonProperty("token_type") + val tokenType: String, + @field:JsonProperty("expires_in") + val expiresIn: Long, + @field:JsonProperty("refresh_token") + val refreshToken: String, + @field:JsonProperty("id_token") + val idToken: String, +) diff --git a/src/main/kotlin/com/weeth/global/auth/apple/dto/AppleUserInfo.kt b/src/main/kotlin/com/weeth/global/auth/apple/dto/AppleUserInfo.kt new file mode 100644 index 00000000..6678fb98 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/apple/dto/AppleUserInfo.kt @@ -0,0 +1,7 @@ +package com.weeth.global.auth.apple.dto + +data class AppleUserInfo( + val appleId: String, + val email: String?, + val emailVerified: Boolean, +) diff --git a/src/main/kotlin/com/weeth/global/auth/apple/exception/AppleAuthenticationException.kt b/src/main/kotlin/com/weeth/global/auth/apple/exception/AppleAuthenticationException.kt new file mode 100644 index 00000000..02ebf951 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/apple/exception/AppleAuthenticationException.kt @@ -0,0 +1,6 @@ +package com.weeth.global.auth.apple.exception + +import com.weeth.global.auth.jwt.application.exception.JwtErrorCode +import com.weeth.global.common.exception.BaseException + +class AppleAuthenticationException : BaseException(JwtErrorCode.APPLE_AUTHENTICATION_FAILED) diff --git a/src/main/kotlin/com/weeth/global/auth/authentication/CustomAccessDeniedHandler.kt b/src/main/kotlin/com/weeth/global/auth/authentication/CustomAccessDeniedHandler.kt new file mode 100644 index 00000000..5e0318e0 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/authentication/CustomAccessDeniedHandler.kt @@ -0,0 +1,45 @@ +package com.weeth.global.auth.authentication + +import com.fasterxml.jackson.databind.ObjectMapper +import com.weeth.global.common.response.CommonResponse +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.LoggerFactory +import org.springframework.security.access.AccessDeniedException +import org.springframework.security.web.access.AccessDeniedHandler +import org.springframework.stereotype.Component + +@Component +class CustomAccessDeniedHandler( + private val objectMapper: ObjectMapper, +) : AccessDeniedHandler { + private val log = LoggerFactory.getLogger(javaClass) + + override fun handle( + request: HttpServletRequest, + response: HttpServletResponse, + accessDeniedException: AccessDeniedException, + ) { + setResponse(response) + log.error( + "ExceptionClass: {}, Message: {}", + accessDeniedException::class.simpleName, + accessDeniedException.message, + ) + } + + private fun setResponse(response: HttpServletResponse) { + response.status = HttpServletResponse.SC_FORBIDDEN + response.contentType = "application/json" + response.characterEncoding = "UTF-8" + + val message = + objectMapper.writeValueAsString( + CommonResponse.createFailure( + ErrorMessage.FORBIDDEN.code, + ErrorMessage.FORBIDDEN.message, + ), + ) + response.writer.write(message) + } +} diff --git a/src/main/kotlin/com/weeth/global/auth/authentication/CustomAuthenticationEntryPoint.kt b/src/main/kotlin/com/weeth/global/auth/authentication/CustomAuthenticationEntryPoint.kt new file mode 100644 index 00000000..dcdbffa4 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/authentication/CustomAuthenticationEntryPoint.kt @@ -0,0 +1,45 @@ +package com.weeth.global.auth.authentication + +import com.fasterxml.jackson.databind.ObjectMapper +import com.weeth.global.common.response.CommonResponse +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.LoggerFactory +import org.springframework.security.core.AuthenticationException +import org.springframework.security.web.AuthenticationEntryPoint +import org.springframework.stereotype.Component + +@Component +class CustomAuthenticationEntryPoint( + private val objectMapper: ObjectMapper, +) : AuthenticationEntryPoint { + private val log = LoggerFactory.getLogger(javaClass) + + override fun commence( + request: HttpServletRequest, + response: HttpServletResponse, + authException: AuthenticationException, + ) { + setResponse(response) + log.error( + "ExceptionClass: {}, Message: {}", + authException::class.simpleName, + authException.message, + ) + } + + private fun setResponse(response: HttpServletResponse) { + response.status = HttpServletResponse.SC_UNAUTHORIZED + response.contentType = "application/json" + response.characterEncoding = "UTF-8" + + val message = + objectMapper.writeValueAsString( + CommonResponse.createFailure( + ErrorMessage.UNAUTHORIZED.code, + ErrorMessage.UNAUTHORIZED.message, + ), + ) + response.writer.write(message) + } +} diff --git a/src/main/kotlin/com/weeth/global/auth/authentication/ErrorMessage.kt b/src/main/kotlin/com/weeth/global/auth/authentication/ErrorMessage.kt new file mode 100644 index 00000000..2d458307 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/authentication/ErrorMessage.kt @@ -0,0 +1,9 @@ +package com.weeth.global.auth.authentication + +enum class ErrorMessage( + val code: Int, + val message: String, +) { + UNAUTHORIZED(401, "인증 정보가 존재하지 않습니다."), + FORBIDDEN(403, "권한이 없습니다."), +} diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/dto/JwtDto.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/dto/JwtDto.kt new file mode 100644 index 00000000..d72ba9ef --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/dto/JwtDto.kt @@ -0,0 +1,6 @@ +package com.weeth.global.auth.jwt.application.dto + +data class JwtDto( + val accessToken: String, + val refreshToken: String, +) diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/AnonymousAuthenticationException.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/AnonymousAuthenticationException.kt new file mode 100644 index 00000000..6e6e4960 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/AnonymousAuthenticationException.kt @@ -0,0 +1,5 @@ +package com.weeth.global.auth.jwt.application.exception + +import com.weeth.global.common.exception.BaseException + +class AnonymousAuthenticationException : BaseException(JwtErrorCode.ANONYMOUS_AUTHENTICATION) diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/InvalidTokenException.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/InvalidTokenException.kt new file mode 100644 index 00000000..9571c89c --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/InvalidTokenException.kt @@ -0,0 +1,5 @@ +package com.weeth.global.auth.jwt.application.exception + +import com.weeth.global.common.exception.BaseException + +class InvalidTokenException : BaseException(JwtErrorCode.INVALID_TOKEN) diff --git a/src/main/java/com/weeth/global/auth/jwt/exception/JwtErrorCode.java b/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/JwtErrorCode.kt similarity index 51% rename from src/main/java/com/weeth/global/auth/jwt/exception/JwtErrorCode.java rename to src/main/kotlin/com/weeth/global/auth/jwt/application/exception/JwtErrorCode.kt index 165a5149..5ccded53 100644 --- a/src/main/java/com/weeth/global/auth/jwt/exception/JwtErrorCode.java +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/JwtErrorCode.kt @@ -1,28 +1,33 @@ -package com.weeth.global.auth.jwt.exception; +package com.weeth.global.auth.jwt.application.exception -import com.weeth.global.common.exception.ErrorCodeInterface; -import com.weeth.global.common.exception.ExplainError; -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@AllArgsConstructor -public enum JwtErrorCode implements ErrorCodeInterface { +import com.weeth.global.common.exception.ErrorCodeInterface +import com.weeth.global.common.exception.ExplainError +import org.springframework.http.HttpStatus +enum class JwtErrorCode( + private val code: Int, + private val status: HttpStatus, + private val message: String, +) : ErrorCodeInterface { @ExplainError("토큰의 구조가 올바르지 않거나(Malformed), 서명이 유효하지 않은 경우 발생합니다. 토큰을 재발급 받아주세요.") INVALID_TOKEN(2900, HttpStatus.BAD_REQUEST, "올바르지 않은 Token 입니다."), @ExplainError("Redis에 해당 리프레시 토큰이 존재하지 않습니다. 토큰이 만료되었거나, 이미 로그아웃(삭제)된 상태일 수 있습니다. 다시 로그인해주세요.") - REDIS_TOKEN_NOT_FOUND(2901, HttpStatus.NOT_FOUND,"저장된 리프레시 토큰이 존재하지 않습니다."), + REDIS_TOKEN_NOT_FOUND(2901, HttpStatus.NOT_FOUND, "저장된 리프레시 토큰이 존재하지 않습니다."), @ExplainError("API 요청 헤더(Authorization)에 토큰 값이 포함되지 않았거나 비어있을 때 발생합니다.") TOKEN_NOT_FOUND(2902, HttpStatus.NOT_FOUND, "헤더에서 토큰을 찾을 수 없습니다."), @ExplainError("인증이 필요한 리소스에 인증 정보 없이(Anonymous) 접근을 시도했을 때 발생합니다. (Spring Security 필터 단계 차단)") - ANONYMOUS_AUTHENTICATION(2903, HttpStatus.UNAUTHORIZED, "인증정보가 존재하지 않습니다."); + ANONYMOUS_AUTHENTICATION(2903, HttpStatus.UNAUTHORIZED, "인증정보가 존재하지 않습니다."), + + @ExplainError("Apple 인증 과정에서 토큰 교환 또는 검증에 실패했을 때 발생합니다.") + APPLE_AUTHENTICATION_FAILED(2904, HttpStatus.UNAUTHORIZED, "애플 로그인에 실패했습니다."), + ; + + override fun getCode(): Int = code + + override fun getStatus(): HttpStatus = status - private final int code; - private final HttpStatus status; - private final String message; + override fun getMessage(): String = message } diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/RedisTokenNotFoundException.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/RedisTokenNotFoundException.kt new file mode 100644 index 00000000..54b8fde8 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/RedisTokenNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.global.auth.jwt.application.exception + +import com.weeth.global.common.exception.BaseException + +class RedisTokenNotFoundException : BaseException(JwtErrorCode.REDIS_TOKEN_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/TokenNotFoundException.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/TokenNotFoundException.kt new file mode 100644 index 00000000..3a652367 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/TokenNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.global.auth.jwt.application.exception + +import com.weeth.global.common.exception.BaseException + +class TokenNotFoundException : BaseException(JwtErrorCode.TOKEN_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractor.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractor.kt new file mode 100644 index 00000000..02fcc525 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractor.kt @@ -0,0 +1,68 @@ +package com.weeth.global.auth.jwt.application.service + +import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.global.auth.jwt.application.exception.TokenNotFoundException +import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider +import com.weeth.global.config.properties.JwtProperties +import io.jsonwebtoken.Claims +import jakarta.servlet.http.HttpServletRequest +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service + +@Service +class JwtTokenExtractor( + private val jwtProperties: JwtProperties, + private val jwtTokenProvider: JwtTokenProvider, +) { + private val log = LoggerFactory.getLogger(javaClass) + + data class TokenClaims( + val id: Long, + val email: String, + val role: Role, + ) + + fun extractRefreshToken(request: HttpServletRequest): String = + request + .getHeader(jwtProperties.refresh.header) + ?.takeIf { it.startsWith(BEARER) } + ?.removePrefix(BEARER) + ?: throw TokenNotFoundException() + + fun extractAccessToken(request: HttpServletRequest): String? = + request + .getHeader(jwtProperties.access.header) + ?.takeIf { it.startsWith(BEARER) } + ?.removePrefix(BEARER) + + fun extractEmail(accessToken: String): String? = extractClaim(accessToken, JwtTokenProvider.EMAIL_CLAIM, String::class.java) + + fun extractId(token: String): Long? = extractClaim(token, JwtTokenProvider.ID_CLAIM, Long::class.javaObjectType) + + fun extractClaims(token: String): TokenClaims? = + runCatching { + val claims: Claims = jwtTokenProvider.parseClaims(token) + TokenClaims( + id = claims.get(JwtTokenProvider.ID_CLAIM, Long::class.javaObjectType), + email = claims.get(JwtTokenProvider.EMAIL_CLAIM, String::class.java), + role = Role.valueOf(claims.get(JwtTokenProvider.ROLE_CLAIM, String::class.java)), + ) + }.onFailure { + log.error("액세스 토큰이 유효하지 않습니다: {}", it.message) + }.getOrNull() + + private fun extractClaim( + token: String, + claimName: String, + type: Class, + ): T? = + runCatching { + jwtTokenProvider.parseClaims(token).get(claimName, type) + }.onFailure { + log.error("액세스 토큰 claim 추출 실패({}): {}", claimName, it.message) + }.getOrNull() + + companion object { + private const val BEARER = "Bearer " + } +} diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt new file mode 100644 index 00000000..3fadd973 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt @@ -0,0 +1,50 @@ +package com.weeth.global.auth.jwt.application.usecase + +import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.global.auth.jwt.application.dto.JwtDto +import com.weeth.global.auth.jwt.application.exception.InvalidTokenException +import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor +import com.weeth.global.auth.jwt.domain.port.RefreshTokenStorePort +import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider +import org.springframework.stereotype.Service + +@Service +class JwtManageUseCase( + private val jwtTokenProvider: JwtTokenProvider, + private val jwtTokenExtractor: JwtTokenExtractor, + private val refreshTokenStore: RefreshTokenStorePort, +) { + fun create( + userId: Long, + email: String, + role: Role, + ): JwtDto { + val accessToken = jwtTokenProvider.createAccessToken(userId, email, role) + val refreshToken = jwtTokenProvider.createRefreshToken(userId) + + updateToken(userId, refreshToken, role, email) + + return JwtDto(accessToken, refreshToken) + } + + fun reIssueToken(requestToken: String): JwtDto { + jwtTokenProvider.validate(requestToken) + + val userId = jwtTokenExtractor.extractId(requestToken) ?: throw InvalidTokenException() + refreshTokenStore.validateRefreshToken(userId, requestToken) + + val role = refreshTokenStore.getRole(userId) + val email = refreshTokenStore.getEmail(userId) + + return create(userId, email, role) + } + + private fun updateToken( + userId: Long, + refreshToken: String, + role: Role, + email: String, + ) { + refreshTokenStore.save(userId, refreshToken, role, email) + } +} diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/domain/port/RefreshTokenStorePort.kt b/src/main/kotlin/com/weeth/global/auth/jwt/domain/port/RefreshTokenStorePort.kt new file mode 100644 index 00000000..12aeacea --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/domain/port/RefreshTokenStorePort.kt @@ -0,0 +1,28 @@ +package com.weeth.global.auth.jwt.domain.port + +import com.weeth.domain.user.domain.entity.enums.Role + +interface RefreshTokenStorePort { + fun save( + userId: Long, + refreshToken: String, + role: Role, + email: String, + ) + + fun delete(userId: Long) + + fun validateRefreshToken( + userId: Long, + requestToken: String, + ) + + fun getEmail(userId: Long): String + + fun getRole(userId: Long): Role + + fun updateRole( + userId: Long, + role: Role, + ) +} diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProvider.kt b/src/main/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProvider.kt new file mode 100644 index 00000000..139900e4 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProvider.kt @@ -0,0 +1,90 @@ +package com.weeth.global.auth.jwt.domain.service + +import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.global.auth.jwt.application.exception.InvalidTokenException +import com.weeth.global.config.properties.JwtProperties +import io.jsonwebtoken.Claims +import io.jsonwebtoken.JwtException +import io.jsonwebtoken.JwtParser +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.security.Keys +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.nio.charset.StandardCharsets +import java.util.Date +import javax.crypto.SecretKey + +@Service +class JwtTokenProvider( + jwtProperties: JwtProperties, +) { + private val log = LoggerFactory.getLogger(javaClass) + + private val secretKey: SecretKey = Keys.hmacShaKeyFor(jwtProperties.key.toByteArray(StandardCharsets.UTF_8)) + private val accessTokenExpirationPeriod: Long = jwtProperties.access.expiration + private val refreshTokenExpirationPeriod: Long = jwtProperties.refresh.expiration + private val jwtParser: JwtParser = + Jwts + .parser() + .verifyWith(secretKey) + .build() + + fun createAccessToken( + id: Long, + email: String, + role: Role, + ): String { + val now = Date() + return Jwts + .builder() + .subject(ACCESS_TOKEN_SUBJECT) + .claim(ID_CLAIM, id) + .claim(EMAIL_CLAIM, email) + .claim(ROLE_CLAIM, role.name) + .issuedAt(now) + .expiration(Date(now.time + accessTokenExpirationPeriod)) + .signWith(secretKey) + .compact() + } + + fun createRefreshToken(id: Long): String { + val now = Date() + return Jwts + .builder() + .subject(REFRESH_TOKEN_SUBJECT) + .claim(ID_CLAIM, id) + .issuedAt(now) + .expiration(Date(now.time + refreshTokenExpirationPeriod)) + .signWith(secretKey) + .compact() + } + + fun validate(token: String) { + parseSignedClaims(token, "유효하지 않은 토큰입니다.") + } + + fun parseClaims(token: String): Claims = + parseSignedClaims(token, "토큰 파싱 실패") + .payload + + private fun parseSignedClaims( + token: String, + errorMessage: String, + ) = try { + jwtParser.parseSignedClaims(token) + } catch (e: JwtException) { + log.error("{}: {}", errorMessage, e.message) + throw InvalidTokenException() + } catch (e: IllegalArgumentException) { + log.error("{}: {}", errorMessage, e.message) + throw InvalidTokenException() + } + + companion object { + private const val ACCESS_TOKEN_SUBJECT = "AccessToken" + private const val REFRESH_TOKEN_SUBJECT = "RefreshToken" + internal const val EMAIL_CLAIM = "email" + internal const val ID_CLAIM = "id" + internal const val ROLE_CLAIM = "role" + } +} diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt b/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt new file mode 100644 index 00000000..0613cf15 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt @@ -0,0 +1,53 @@ +package com.weeth.global.auth.jwt.filter + +import com.weeth.global.auth.jwt.application.exception.TokenNotFoundException +import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor +import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider +import com.weeth.global.auth.model.AuthenticatedUser +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.LoggerFactory +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.web.filter.OncePerRequestFilter + +class JwtAuthenticationProcessingFilter( + private val jwtTokenProvider: JwtTokenProvider, + private val jwtTokenExtractor: JwtTokenExtractor, +) : OncePerRequestFilter() { + private val log = LoggerFactory.getLogger(javaClass) + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + try { + val accessToken = jwtTokenExtractor.extractAccessToken(request) ?: throw TokenNotFoundException() + jwtTokenProvider.validate(accessToken) + saveAuthentication(accessToken) + } catch (e: TokenNotFoundException) { + log.debug("Token not found: {}", e.message) + } catch (e: RuntimeException) { + log.info("error token: {}", e.message) + } + + filterChain.doFilter(request, response) + } + + private fun saveAuthentication(accessToken: String) { + val claims = jwtTokenExtractor.extractClaims(accessToken) ?: throw TokenNotFoundException() + val principal = AuthenticatedUser(claims.id, claims.email, claims.role) + + val authentication = + UsernamePasswordAuthenticationToken( + principal, + null, + listOf(SimpleGrantedAuthority("ROLE_${claims.role.name}")), + ) + + SecurityContextHolder.getContext().authentication = authentication + } +} diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisRefreshTokenStoreAdapter.kt b/src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisRefreshTokenStoreAdapter.kt new file mode 100644 index 00000000..1819978f --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisRefreshTokenStoreAdapter.kt @@ -0,0 +1,87 @@ +package com.weeth.global.auth.jwt.infrastructure + +import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.global.auth.jwt.application.exception.InvalidTokenException +import com.weeth.global.auth.jwt.application.exception.RedisTokenNotFoundException +import com.weeth.global.auth.jwt.domain.port.RefreshTokenStorePort +import com.weeth.global.config.properties.JwtProperties +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.stereotype.Component +import java.util.concurrent.TimeUnit + +@Component +class RedisRefreshTokenStoreAdapter( + private val jwtProperties: JwtProperties, + private val redisTemplate: RedisTemplate, +) : RefreshTokenStorePort { + override fun save( + userId: Long, + refreshToken: String, + role: Role, + email: String, + ) { + val key = getKey(userId) + redisTemplate.opsForHash().putAll( + key, + mapOf( + TOKEN to refreshToken, + ROLE to role.name, + EMAIL to email, + ), + ) + redisTemplate.expire(key, jwtProperties.refresh.expiration, TimeUnit.MINUTES) + } + + override fun delete(userId: Long) { + val key = getKey(userId) + redisTemplate.delete(key) + } + + override fun validateRefreshToken( + userId: Long, + requestToken: String, + ) { + if (find(userId) != requestToken) { + throw InvalidTokenException() + } + } + + override fun getEmail(userId: Long): String { + val key = getKey(userId) + return redisTemplate.opsForHash().get(key, EMAIL) + ?: throw RedisTokenNotFoundException() + } + + override fun getRole(userId: Long): Role { + val key = getKey(userId) + val role = + redisTemplate.opsForHash().get(key, ROLE) + ?: throw RedisTokenNotFoundException() + return runCatching { Role.valueOf(role) }.getOrElse { throw InvalidTokenException() } + } + + override fun updateRole( + userId: Long, + role: Role, + ) { + val key = getKey(userId) + if (redisTemplate.hasKey(key) == true) { + redisTemplate.opsForHash().put(key, ROLE, role.name) + } + } + + private fun find(userId: Long): String { + val key = getKey(userId) + return redisTemplate.opsForHash().get(key, TOKEN) + ?: throw RedisTokenNotFoundException() + } + + private fun getKey(userId: Long): String = "$PREFIX$userId" + + companion object { + private const val PREFIX = "refreshToken:" + private const val TOKEN = "token" + private const val ROLE = "role" + private const val EMAIL = "email" + } +} diff --git a/src/main/kotlin/com/weeth/global/auth/kakao/KakaoAuthService.kt b/src/main/kotlin/com/weeth/global/auth/kakao/KakaoAuthService.kt new file mode 100644 index 00000000..c97334e6 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/kakao/KakaoAuthService.kt @@ -0,0 +1,49 @@ +package com.weeth.global.auth.kakao + +import com.weeth.global.auth.kakao.dto.KakaoTokenResponse +import com.weeth.global.auth.kakao.dto.KakaoUserInfoResponse +import com.weeth.global.config.properties.OAuthProperties +import org.springframework.http.MediaType +import org.springframework.stereotype.Service +import org.springframework.util.LinkedMultiValueMap +import org.springframework.web.client.RestClient +import org.springframework.web.client.body + +@Service +class KakaoAuthService( + oAuthProperties: OAuthProperties, + restClientBuilder: RestClient.Builder, +) { + private val kakaoProperties = oAuthProperties.kakao + private val restClient = restClientBuilder.build() + + fun getKakaoToken(authCode: String): KakaoTokenResponse { + val body = + LinkedMultiValueMap().apply { + add("grant_type", kakaoProperties.grantType) + add("client_id", kakaoProperties.clientId) + add("redirect_uri", kakaoProperties.redirectUri) + add("code", authCode) + } + + return requireNotNull( + restClient + .post() + .uri(kakaoProperties.tokenUri) + .body(body) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .retrieve() + .body(), + ) + } + + fun getUserInfo(accessToken: String): KakaoUserInfoResponse = + requireNotNull( + restClient + .get() + .uri(kakaoProperties.userInfoUri) + .header("Authorization", "Bearer $accessToken") + .retrieve() + .body(), + ) +} diff --git a/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoAccessToken.kt b/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoAccessToken.kt new file mode 100644 index 00000000..95fa14f5 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoAccessToken.kt @@ -0,0 +1,8 @@ +package com.weeth.global.auth.kakao.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class KakaoAccessToken( + @field:JsonProperty("access_token") + val accessToken: String, +) diff --git a/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoAccount.kt b/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoAccount.kt new file mode 100644 index 00000000..da0ca572 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoAccount.kt @@ -0,0 +1,12 @@ +package com.weeth.global.auth.kakao.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class KakaoAccount( + @field:JsonProperty("is_email_valid") + val isEmailValid: Boolean, + @field:JsonProperty("is_email_verified") + val isEmailVerified: Boolean, + @field:JsonProperty("email") + val email: String?, +) diff --git a/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoTokenResponse.kt b/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoTokenResponse.kt new file mode 100644 index 00000000..f188c14b --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoTokenResponse.kt @@ -0,0 +1,16 @@ +package com.weeth.global.auth.kakao.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class KakaoTokenResponse( + @field:JsonProperty("token_type") + val tokenType: String, + @field:JsonProperty("access_token") + val accessToken: String, + @field:JsonProperty("expires_in") + val expiresIn: Int, + @field:JsonProperty("refresh_token") + val refreshToken: String, + @field:JsonProperty("refresh_token_expires_in") + val refreshTokenExpiresIn: Int, +) diff --git a/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoUserInfoResponse.kt b/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoUserInfoResponse.kt new file mode 100644 index 00000000..7633c77a --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoUserInfoResponse.kt @@ -0,0 +1,10 @@ +package com.weeth.global.auth.kakao.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class KakaoUserInfoResponse( + @field:JsonProperty("id") + val id: Long, + @field:JsonProperty("kakao_account") + val kakaoAccount: KakaoAccount, +) diff --git a/src/main/kotlin/com/weeth/global/auth/model/AuthenticatedUser.kt b/src/main/kotlin/com/weeth/global/auth/model/AuthenticatedUser.kt new file mode 100644 index 00000000..11d59dc7 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/model/AuthenticatedUser.kt @@ -0,0 +1,12 @@ +package com.weeth.global.auth.model + +import com.weeth.domain.user.domain.entity.enums.Role + +/** + * Authentication 설정을 위한 model + */ +data class AuthenticatedUser( + val id: Long, + val email: String, + val role: Role, +) diff --git a/src/main/kotlin/com/weeth/global/auth/resolver/CurrentUserArgumentResolver.kt b/src/main/kotlin/com/weeth/global/auth/resolver/CurrentUserArgumentResolver.kt new file mode 100644 index 00000000..336a5fff --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/resolver/CurrentUserArgumentResolver.kt @@ -0,0 +1,42 @@ +package com.weeth.global.auth.resolver + +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.auth.jwt.application.exception.AnonymousAuthenticationException +import com.weeth.global.auth.model.AuthenticatedUser +import org.springframework.core.MethodParameter +import org.springframework.security.authentication.AnonymousAuthenticationToken +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.web.bind.support.WebDataBinderFactory +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.method.support.ModelAndViewContainer + +class CurrentUserArgumentResolver : HandlerMethodArgumentResolver { + override fun supportsParameter(parameter: MethodParameter): Boolean { + val hasAnnotation = parameter.hasParameterAnnotation(CurrentUser::class.java) + val parameterType = parameter.parameterType + val isLongType = parameterType == Long::class.java || parameterType == Long::class.javaPrimitiveType + return hasAnnotation && isLongType + } + + override fun resolveArgument( + parameter: MethodParameter, + mavContainer: ModelAndViewContainer?, + webRequest: NativeWebRequest, + binderFactory: WebDataBinderFactory?, + ): Any { + val authentication = SecurityContextHolder.getContext().authentication + + if (authentication == null || authentication is AnonymousAuthenticationToken) { + throw AnonymousAuthenticationException() + } + + val principal = authentication.principal + + if (principal is AuthenticatedUser) { + return principal.id + } + + throw AnonymousAuthenticationException() + } +} diff --git a/src/main/kotlin/com/weeth/global/auth/resolver/CurrentUserRoleArgumentResolver.kt b/src/main/kotlin/com/weeth/global/auth/resolver/CurrentUserRoleArgumentResolver.kt new file mode 100644 index 00000000..7e5bbaff --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/resolver/CurrentUserRoleArgumentResolver.kt @@ -0,0 +1,50 @@ +package com.weeth.global.auth.resolver + +import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.global.auth.annotation.CurrentUserRole +import com.weeth.global.auth.jwt.application.exception.AnonymousAuthenticationException +import com.weeth.global.auth.model.AuthenticatedUser +import org.springframework.core.MethodParameter +import org.springframework.security.authentication.AnonymousAuthenticationToken +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.web.bind.support.WebDataBinderFactory +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.method.support.ModelAndViewContainer + +class CurrentUserRoleArgumentResolver : HandlerMethodArgumentResolver { + override fun supportsParameter(parameter: MethodParameter): Boolean { + val hasAnnotation = parameter.hasParameterAnnotation(CurrentUserRole::class.java) + val parameterType = Role::class.java.isAssignableFrom(parameter.parameterType) + return hasAnnotation && parameterType + } + + override fun resolveArgument( + parameter: MethodParameter, + mavContainer: ModelAndViewContainer?, + webRequest: NativeWebRequest, + binderFactory: WebDataBinderFactory?, + ): Any { + val authentication = SecurityContextHolder.getContext().authentication + + if (authentication == null || authentication is AnonymousAuthenticationToken) { + throw AnonymousAuthenticationException() + } + + val principal = authentication.principal + if (principal is AuthenticatedUser) { + return principal.role + } + + val role = + authentication.authorities + .asSequence() + .mapNotNull { authority -> authority.authority } + .filter { it.startsWith("ROLE_") } + .mapNotNull { raw -> + runCatching { Role.valueOf(raw.removePrefix("ROLE_")) }.getOrNull() + }.firstOrNull() + + return role ?: throw AnonymousAuthenticationException() + } +} diff --git a/src/main/java/com/weeth/global/common/controller/ExceptionDocController.java b/src/main/kotlin/com/weeth/global/common/controller/ExceptionDocController.kt similarity index 51% rename from src/main/java/com/weeth/global/common/controller/ExceptionDocController.java rename to src/main/kotlin/com/weeth/global/common/controller/ExceptionDocController.kt index 95b7c199..1d67f2c0 100644 --- a/src/main/java/com/weeth/global/common/controller/ExceptionDocController.java +++ b/src/main/kotlin/com/weeth/global/common/controller/ExceptionDocController.kt @@ -1,65 +1,65 @@ -package com.weeth.global.common.controller; +package com.weeth.global.common.controller -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import com.weeth.domain.account.application.exception.AccountErrorCode; -import com.weeth.domain.attendance.application.exception.AttendanceErrorCode; -import com.weeth.domain.board.application.exception.BoardErrorCode; -import com.weeth.domain.comment.application.exception.CommentErrorCode; -import com.weeth.domain.penalty.application.exception.PenaltyErrorCode; -import com.weeth.domain.schedule.application.exception.EventErrorCode; -import com.weeth.domain.schedule.application.exception.MeetingErrorCode; -import com.weeth.domain.user.application.exception.UserErrorCode; -import com.weeth.global.auth.jwt.exception.JwtErrorCode; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import com.weeth.domain.account.application.exception.AccountErrorCode +import com.weeth.domain.attendance.application.exception.AttendanceErrorCode +import com.weeth.domain.board.application.exception.BoardErrorCode +import com.weeth.domain.comment.application.exception.CommentErrorCode +import com.weeth.domain.penalty.application.exception.PenaltyErrorCode +import com.weeth.domain.schedule.application.exception.EventErrorCode +import com.weeth.domain.schedule.application.exception.MeetingErrorCode +import com.weeth.domain.user.application.exception.UserErrorCode +import com.weeth.global.auth.jwt.application.exception.JwtErrorCode +import com.weeth.global.common.exception.ApiErrorCodeExample +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController @RestController -@RequestMapping("/api/v1/docs/exceptions") +@RequestMapping("/api/v4/docs/exceptions") @Tag(name = "Exception Document", description = "API 에러 코드 문서") -public class ExceptionDocController { - +class ExceptionDocController { @GetMapping("/account") @Operation(summary = "Account 도메인 에러 코드 목록") - @ApiErrorCodeExample(AccountErrorCode.class) - public void accountErrorCodes() { + @ApiErrorCodeExample(AccountErrorCode::class) + fun accountErrorCodes() { } @GetMapping("/attendance") @Operation(summary = "Attendance 도메인 에러 코드 목록") - @ApiErrorCodeExample(AttendanceErrorCode.class) - public void attendanceErrorCodes() { + @ApiErrorCodeExample(AttendanceErrorCode::class) + fun attendanceErrorCodes() { } @GetMapping("/board") @Operation(summary = "Board 도메인 에러 코드 목록") - @ApiErrorCodeExample({BoardErrorCode.class, CommentErrorCode.class}) - public void boardErrorCodes() { + @ApiErrorCodeExample(BoardErrorCode::class, CommentErrorCode::class) + fun boardErrorCodes() { } @GetMapping("/penalty") @Operation(summary = "Penalty 도메인 에러 코드 목록") - @ApiErrorCodeExample(PenaltyErrorCode.class) - public void penaltyErrorCodes() { + @ApiErrorCodeExample(PenaltyErrorCode::class) + fun penaltyErrorCodes() { } @GetMapping("/schedule") @Operation(summary = "Schedule 도메인 에러 코드 목록") - @ApiErrorCodeExample({EventErrorCode.class, MeetingErrorCode.class}) - public void scheduleErrorCodes() { + @ApiErrorCodeExample(EventErrorCode::class, MeetingErrorCode::class) + fun scheduleErrorCodes() { } @GetMapping("/user") @Operation(summary = "User 도메인 에러 코드 목록") - @ApiErrorCodeExample(UserErrorCode.class) - public void userErrorCodes() { + @ApiErrorCodeExample(UserErrorCode::class) + fun userErrorCodes() { } + // todo: SAS 관련 예외도 추가 @GetMapping("/auth") @Operation(summary = "인증/인가 에러 코드 목록") - @ApiErrorCodeExample({JwtErrorCode.class}) - public void authErrorCodes() { + @ApiErrorCodeExample(JwtErrorCode::class) + fun authErrorCodes() { } } diff --git a/src/main/kotlin/com/weeth/global/common/controller/StatusCheckController.kt b/src/main/kotlin/com/weeth/global/common/controller/StatusCheckController.kt new file mode 100644 index 00000000..dccaec23 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/controller/StatusCheckController.kt @@ -0,0 +1,13 @@ +package com.weeth.global.common.controller + +import io.swagger.v3.oas.annotations.Hidden +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +@Hidden +@RestController +class StatusCheckController { + @GetMapping("/health-check") + fun checkHealthStatus(): ResponseEntity = ResponseEntity.ok().build() +} diff --git a/src/main/kotlin/com/weeth/global/common/converter/JsonConverter.kt b/src/main/kotlin/com/weeth/global/common/converter/JsonConverter.kt index 4cec9574..962e50a3 100644 --- a/src/main/kotlin/com/weeth/global/common/converter/JsonConverter.kt +++ b/src/main/kotlin/com/weeth/global/common/converter/JsonConverter.kt @@ -4,7 +4,9 @@ import com.fasterxml.jackson.core.type.TypeReference import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.KotlinModule import jakarta.persistence.AttributeConverter +import jakarta.persistence.Converter +@Converter abstract class JsonConverter( private val typeRef: TypeReference, ) : AttributeConverter { diff --git a/src/main/kotlin/com/weeth/global/common/exception/ApiErrorCodeExample.kt b/src/main/kotlin/com/weeth/global/common/exception/ApiErrorCodeExample.kt new file mode 100644 index 00000000..3a7c3caf --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/exception/ApiErrorCodeExample.kt @@ -0,0 +1,9 @@ +package com.weeth.global.common.exception + +import kotlin.reflect.KClass + +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class ApiErrorCodeExample( + vararg val value: KClass, +) diff --git a/src/main/kotlin/com/weeth/global/common/exception/BaseException.kt b/src/main/kotlin/com/weeth/global/common/exception/BaseException.kt new file mode 100644 index 00000000..6bc0a895 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/exception/BaseException.kt @@ -0,0 +1,26 @@ +package com.weeth.global.common.exception + +abstract class BaseException : RuntimeException { + val statusCode: Int + val errorCode: ErrorCodeInterface? + + constructor(code: Int, message: String) : super(message) { + statusCode = code + errorCode = null + } + + constructor(code: Int, message: String, cause: Throwable) : super(message, cause) { + statusCode = code + errorCode = null + } + + constructor(errorCode: ErrorCodeInterface) : super(errorCode.getMessage()) { + statusCode = errorCode.getStatus().value() + this.errorCode = errorCode + } + + constructor(errorCode: ErrorCodeInterface, cause: Throwable?) : super(errorCode.getMessage(), cause) { + statusCode = errorCode.getStatus().value() + this.errorCode = errorCode + } +} diff --git a/src/main/kotlin/com/weeth/global/common/exception/BindExceptionResponse.kt b/src/main/kotlin/com/weeth/global/common/exception/BindExceptionResponse.kt new file mode 100644 index 00000000..ae6e6fcf --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/exception/BindExceptionResponse.kt @@ -0,0 +1,6 @@ +package com.weeth.global.common.exception + +data class BindExceptionResponse( + val message: String?, + val value: Any?, +) diff --git a/src/main/kotlin/com/weeth/global/common/exception/CommonExceptionHandler.kt b/src/main/kotlin/com/weeth/global/common/exception/CommonExceptionHandler.kt new file mode 100644 index 00000000..b26717de --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/exception/CommonExceptionHandler.kt @@ -0,0 +1,92 @@ +package com.weeth.global.common.exception + +import com.weeth.global.common.response.CommonResponse +import org.slf4j.LoggerFactory +import org.springframework.http.ResponseEntity +import org.springframework.validation.BindException +import org.springframework.web.ErrorResponse +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException + +@RestControllerAdvice +class CommonExceptionHandler { + private val log = LoggerFactory.getLogger(javaClass) + + @ExceptionHandler(BaseException::class) + fun handle(ex: BaseException): ResponseEntity> { + log.warn("예외 처리(BaseException)", ex) + log.warn(LOG_FORMAT, ex::class.simpleName, ex.statusCode, ex.message) + + val errorCode = ex.errorCode + val response: CommonResponse = + if (errorCode != null) { + CommonResponse.error(errorCode) + } else { + CommonResponse.createFailure(ex.statusCode, ex.message ?: "") + } + + return ResponseEntity + .status(ex.statusCode) + .body(response) + } + + @ExceptionHandler(BindException::class) + fun handle(ex: BindException): ResponseEntity>> { + val statusCode = if (ex is ErrorResponse) ex.statusCode.value() else 400 + val exceptionResponses = mutableListOf() + + if (ex is ErrorResponse) { + ex.bindingResult.fieldErrors.forEach { fieldError -> + exceptionResponses.add( + BindExceptionResponse( + message = fieldError.defaultMessage, + value = fieldError.rejectedValue, + ), + ) + } + } + + log.warn("예외 처리(BindException)", ex) + log.warn(LOG_FORMAT, ex::class.simpleName, statusCode, exceptionResponses) + + val response = CommonResponse.createFailure(statusCode, "bindException", exceptionResponses.toList()) + + return ResponseEntity + .status(statusCode) + .body(response) + } + + @ExceptionHandler(MethodArgumentTypeMismatchException::class) + fun handle(ex: MethodArgumentTypeMismatchException): ResponseEntity> { + val statusCode = if (ex is ErrorResponse) ex.statusCode.value() else 400 + + log.warn("예외 처리(MethodArgumentTypeMismatchException)", ex) + log.warn(LOG_FORMAT, ex::class.simpleName, statusCode, ex.message) + + val response = CommonResponse.createFailure(statusCode, INPUT_FORMAT_ERROR_MESSAGE) + + return ResponseEntity + .status(statusCode) + .body(response) + } + + @ExceptionHandler(Exception::class) + fun handle(ex: Exception): ResponseEntity> { + val statusCode = if (ex is ErrorResponse) ex.statusCode.value() else 500 + + log.warn("예외 처리(Exception)", ex) + log.warn(LOG_FORMAT, ex::class.simpleName, statusCode, ex.message) + + val response = CommonResponse.createFailure(statusCode, ex.message ?: "") + + return ResponseEntity + .status(statusCode) + .body(response) + } + + companion object { + private const val INPUT_FORMAT_ERROR_MESSAGE = "입력 포맷이 올바르지 않습니다." + private const val LOG_FORMAT = "Class : {}, Code : {}, Message : {}" + } +} diff --git a/src/main/kotlin/com/weeth/global/common/exception/ErrorCodeInterface.kt b/src/main/kotlin/com/weeth/global/common/exception/ErrorCodeInterface.kt new file mode 100644 index 00000000..a137f5ef --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/exception/ErrorCodeInterface.kt @@ -0,0 +1,18 @@ +package com.weeth.global.common.exception + +import org.springframework.http.HttpStatus + +interface ErrorCodeInterface { + fun getCode(): Int + + fun getStatus(): HttpStatus + + fun getMessage(): String + + @Throws(NoSuchFieldException::class) + fun getExplainError(): String { + val field = this::class.java.getField((this as Enum<*>).name) + val annotation = field.getAnnotation(ExplainError::class.java) + return annotation?.value ?: getMessage() + } +} diff --git a/src/main/kotlin/com/weeth/global/common/exception/ExampleHolder.kt b/src/main/kotlin/com/weeth/global/common/exception/ExampleHolder.kt new file mode 100644 index 00000000..488f8acb --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/exception/ExampleHolder.kt @@ -0,0 +1,9 @@ +package com.weeth.global.common.exception + +import io.swagger.v3.oas.models.examples.Example + +data class ExampleHolder( + val holder: Example, + val name: String, + val code: Int, +) diff --git a/src/main/kotlin/com/weeth/global/common/exception/ExplainError.kt b/src/main/kotlin/com/weeth/global/common/exception/ExplainError.kt new file mode 100644 index 00000000..ae445e96 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/exception/ExplainError.kt @@ -0,0 +1,7 @@ +package com.weeth.global.common.exception + +@Target(AnnotationTarget.FIELD) +@Retention(AnnotationRetention.RUNTIME) +annotation class ExplainError( + val value: String = "", +) diff --git a/src/main/kotlin/com/weeth/global/common/response/CommonResponse.kt b/src/main/kotlin/com/weeth/global/common/response/CommonResponse.kt index 31e9b13a..092d4d2f 100644 --- a/src/main/kotlin/com/weeth/global/common/response/CommonResponse.kt +++ b/src/main/kotlin/com/weeth/global/common/response/CommonResponse.kt @@ -48,20 +48,11 @@ data class CommonResponse( data = data, ) - @JvmStatic - fun createSuccess(message: String): CommonResponse = success(message) - - @JvmStatic - fun createSuccess( - message: String, - data: T, - ): CommonResponse = success(message, data) - @JvmStatic fun error(errorCode: ErrorCodeInterface): CommonResponse = CommonResponse( - code = errorCode.code, - message = errorCode.message, + code = errorCode.getCode(), + message = errorCode.getMessage(), data = null, ) @@ -71,7 +62,7 @@ data class CommonResponse( message: String, ): CommonResponse = CommonResponse( - code = errorCode.code, + code = errorCode.getCode(), message = message, data = null, ) @@ -82,8 +73,8 @@ data class CommonResponse( data: T, ): CommonResponse = CommonResponse( - code = errorCode.code, - message = errorCode.message, + code = errorCode.getCode(), + message = errorCode.getMessage(), data = data, ) diff --git a/src/main/kotlin/com/weeth/global/config/AwsS3Config.kt b/src/main/kotlin/com/weeth/global/config/AwsS3Config.kt new file mode 100644 index 00000000..bc8feeb6 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/config/AwsS3Config.kt @@ -0,0 +1,28 @@ +package com.weeth.global.config + +import com.weeth.global.config.properties.AwsS3Properties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.s3.presigner.S3Presigner + +@Configuration +class AwsS3Config( + private val awsS3Properties: AwsS3Properties, +) { + @Bean + fun s3Presigner(): S3Presigner { + val credentials = + AwsBasicCredentials.create( + awsS3Properties.credentials.accessKey, + awsS3Properties.credentials.secretKey, + ) + return S3Presigner + .builder() + .region(Region.of(awsS3Properties.region.static)) + .credentialsProvider(StaticCredentialsProvider.create(credentials)) + .build() + } +} diff --git a/src/main/kotlin/com/weeth/global/config/RedisConfig.kt b/src/main/kotlin/com/weeth/global/config/RedisConfig.kt new file mode 100644 index 00000000..cffd6992 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/config/RedisConfig.kt @@ -0,0 +1,40 @@ +package com.weeth.global.config + +import com.weeth.global.config.properties.RedisProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.data.redis.connection.RedisConnectionFactory +import org.springframework.data.redis.connection.RedisStandaloneConfiguration +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory +import org.springframework.data.redis.core.RedisKeyValueAdapter +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories +import org.springframework.data.redis.serializer.StringRedisSerializer + +@Configuration +@EnableRedisRepositories(enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP) +class RedisConfig( + private val redisProperties: RedisProperties, +) { + @Bean + fun redisConnectionFactory(): RedisConnectionFactory { + val redisConfiguration = + RedisStandaloneConfiguration().apply { + hostName = redisProperties.host + port = redisProperties.port + if (!redisProperties.password.isNullOrEmpty()) { + setPassword(redisProperties.password) + } + } + + return LettuceConnectionFactory(redisConfiguration) + } + + @Bean + fun redisTemplate(): RedisTemplate = + RedisTemplate().apply { + keySerializer = StringRedisSerializer() + valueSerializer = StringRedisSerializer() + connectionFactory = redisConnectionFactory() + } +} diff --git a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt new file mode 100644 index 00000000..10dc755f --- /dev/null +++ b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt @@ -0,0 +1,114 @@ +package com.weeth.global.config + +import com.weeth.global.auth.authentication.CustomAccessDeniedHandler +import com.weeth.global.auth.authentication.CustomAuthenticationEntryPoint +import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor +import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider +import com.weeth.global.auth.jwt.filter.JwtAuthenticationProcessingFilter +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.authorization.AuthorizationDecision +import org.springframework.security.config.Customizer.withDefaults +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.crypto.factory.PasswordEncoderFactories +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter +import org.springframework.web.cors.CorsConfiguration +import org.springframework.web.cors.CorsConfigurationSource +import org.springframework.web.cors.UrlBasedCorsConfigurationSource + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity(prePostEnabled = true) +class SecurityConfig( + private val jwtTokenProvider: JwtTokenProvider, + private val jwtTokenExtractor: JwtTokenExtractor, + private val customAuthenticationEntryPoint: CustomAuthenticationEntryPoint, + private val customAccessDeniedHandler: CustomAccessDeniedHandler, +) { + @Bean + fun filterChain(http: HttpSecurity): SecurityFilterChain = + http + .formLogin { it.disable() } + .httpBasic { it.disable() } + .cors(withDefaults()) + .csrf { it.disable() } + .headers { headers -> + headers.frameOptions { frameOptions -> frameOptions.sameOrigin() } + }.sessionManagement { session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + .authorizeHttpRequests { authorize -> + authorize + .requestMatchers( + "/api/v1/users/kakao/login", + "/api/v1/users/kakao/register", + "/api/v1/users/kakao/link", + "/api/v1/users/apple/login", + "/api/v1/users/apple/register", + "/api/v1/users/apply", + "/api/v1/users/email", + "/api/v1/users/refresh", + ).permitAll() + .requestMatchers("/health-check") + .permitAll() + .requestMatchers( + "/admin", + "/admin/login", + "/admin/account", + "/admin/meeting", + "/admin/member", + "/admin/penalty", + ).permitAll() + .requestMatchers( + "/v3/api-docs", + "/v3/api-docs/**", + "/swagger-ui.html", + "/swagger-ui/**", + "/swagger/**", + ).permitAll() + .requestMatchers("/actuator/prometheus") + .access { _, context -> + val ip = context.request.remoteAddr + val allowed = ip.startsWith("172.") || ip == "127.0.0.1" + AuthorizationDecision(allowed) + }.requestMatchers("/actuator/health") + .permitAll() + .requestMatchers( + "/api/v1/admin/**", + "/api/v4/admin/**", + ).hasRole("ADMIN") + .anyRequest() + .authenticated() + }.exceptionHandling { exceptionHandling -> + exceptionHandling + .authenticationEntryPoint(customAuthenticationEntryPoint) + .accessDeniedHandler(customAccessDeniedHandler) + }.addFilterBefore(jwtAuthenticationProcessingFilter(), UsernamePasswordAuthenticationFilter::class.java) + .build() + + @Bean + fun corsConfigurationSource(): CorsConfigurationSource { + val configuration = + CorsConfiguration().apply { + allowedOriginPatterns = listOf("http://localhost:*", "http://127.0.0.1:*") + allowedMethods = listOf("GET", "POST", "PATCH", "DELETE", "OPTIONS") + allowedHeaders = listOf("*") + exposedHeaders = listOf("Authorization", "Authorization_refresh") + allowCredentials = true + } + + return UrlBasedCorsConfigurationSource().apply { + registerCorsConfiguration("/**", configuration) + } + } + + @Bean + fun passwordEncoder(): PasswordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder() + + @Bean + fun jwtAuthenticationProcessingFilter(): JwtAuthenticationProcessingFilter = + JwtAuthenticationProcessingFilter(jwtTokenProvider, jwtTokenExtractor) +} diff --git a/src/main/kotlin/com/weeth/global/config/SwaggerConfig.kt b/src/main/kotlin/com/weeth/global/config/SwaggerConfig.kt new file mode 100644 index 00000000..a6763ffe --- /dev/null +++ b/src/main/kotlin/com/weeth/global/config/SwaggerConfig.kt @@ -0,0 +1,182 @@ +package com.weeth.global.config + +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.exception.ErrorCodeInterface +import com.weeth.global.common.exception.ExampleHolder +import com.weeth.global.common.response.CommonResponse +import com.weeth.global.config.properties.JwtProperties +import io.swagger.v3.oas.annotations.OpenAPIDefinition +import io.swagger.v3.oas.annotations.info.Info +import io.swagger.v3.oas.models.Components +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.examples.Example +import io.swagger.v3.oas.models.media.Content +import io.swagger.v3.oas.models.media.MediaType +import io.swagger.v3.oas.models.responses.ApiResponse +import io.swagger.v3.oas.models.responses.ApiResponses +import io.swagger.v3.oas.models.security.SecurityRequirement +import io.swagger.v3.oas.models.security.SecurityScheme +import io.swagger.v3.oas.models.servers.Server +import org.springdoc.core.customizers.OperationCustomizer +import org.springdoc.core.models.GroupedOpenApi +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.method.HandlerMethod + +private const val SWAGGER_DESCRIPTION = + "## Response Code 규칙\n" + + "- Success: **1xxx**\n" + + "- Domain Error: **2xxx**\n" + + "- Server Error: **3xxx**\n" + + "- Client Error: **4xxx**\n\n" + + "## 도메인별 코드 범위\n" + + "| Domain | Success | Error |\n" + + "|--------|---------|------|\n" + + "| Account | 11xx | 21xx |\n" + + "| Attendance | 12xx | 22xx |\n" + + "| Board | 13xx | 23xx |\n" + + "| Comment | 14xx | 24xx |\n" + + "| File | 15xx | 25xx |\n" + + "| Penalty | 16xx | 26xx |\n" + + "| Schedule | 17xx | 27xx |\n" + + "| User | 18xx | 28xx |\n" + + "| Auth/JWT (Global) | - | 29xx |\n\n" + + "> 각 API의 상세 응답 예시는 Swagger의 **Responses** 섹션에서 확인하세요." + +@Configuration +@OpenAPIDefinition( + info = + Info( + title = "Weeth API", + version = "v4.0.0", + description = SWAGGER_DESCRIPTION, + ), +) +class SwaggerConfig( + private val jwtProperties: JwtProperties, +) { + @Bean + fun openAPI(): OpenAPI { + val accessSecurityScheme = getAccessSecurityScheme() + val refreshSecurityScheme = getRefreshSecurityScheme() + + return OpenAPI() + .addServersItem(Server().url("/")) + .components( + Components() + .addSecuritySchemes("bearerAuth", accessSecurityScheme) + .addSecuritySchemes("refreshBearerAuth", refreshSecurityScheme), + ).security( + listOf( + SecurityRequirement().addList("bearerAuth"), + SecurityRequirement().addList("refreshBearerAuth"), + ), + ) + } + + @Bean + fun adminApi(): GroupedOpenApi = + GroupedOpenApi + .builder() + .group("admin") + .pathsToMatch("/api/v1/admin/**", "/api/v4/admin/**") + .addOperationCustomizer(operationCustomizer()) + .build() + + @Bean + fun publicApi(): GroupedOpenApi = + GroupedOpenApi + .builder() + .group("public") + .pathsToExclude("/api/v1/admin/**", "/api/v4/admin/**") + .addOperationCustomizer(operationCustomizer()) + .build() + + @Bean + fun operationCustomizer(): OperationCustomizer = + OperationCustomizer { operation, handlerMethod -> + val apiErrorCodeExample = findAnnotation(handlerMethod, ApiErrorCodeExample::class.java) + if (apiErrorCodeExample != null) { + apiErrorCodeExample.value.forEach { type -> + generateErrorCodeResponseExample(operation.responses, type.java) + } + } + + operation + } + + private fun generateErrorCodeResponseExample( + responses: ApiResponses, + type: Class, + ) { + val errorCodes = type.enumConstants ?: return + + val statusWithExampleHolders = + errorCodes + .map { errorCode -> + val enumName = (errorCode as Enum<*>).name + val description = runCatching { errorCode.getExplainError() }.getOrDefault(errorCode.getMessage()) + + ExampleHolder( + holder = getSwaggerExample(description, errorCode), + code = errorCode.getStatus().value(), + name = "[$enumName] ${errorCode.getMessage()}", + ) + }.groupBy { it.code } + + addExamplesToResponses(responses, statusWithExampleHolders) + } + + private fun getSwaggerExample( + description: String, + errorCode: ErrorCodeInterface, + ): Example { + val errorResponse = CommonResponse.Companion.createFailure(errorCode.getCode(), errorCode.getMessage()) + return Example() + .description(description) + .value(errorResponse) + } + + private fun addExamplesToResponses( + responses: ApiResponses, + statusWithExampleHolders: Map>, + ) { + statusWithExampleHolders.forEach { (status, exampleHolders) -> + val apiResponse = responses.computeIfAbsent(status.toString()) { ApiResponse() } + val mediaType = getOrCreateMediaType(apiResponse) + exampleHolders.forEach { holder -> mediaType.addExamples(holder.name, holder.holder) } + } + } + + private fun findAnnotation( + handlerMethod: HandlerMethod, + annotationType: Class, + ): A? { + val annotation = handlerMethod.getMethodAnnotation(annotationType) + if (annotation != null) { + return annotation + } + return handlerMethod.beanType.getAnnotation(annotationType) + } + + private fun getOrCreateMediaType(apiResponse: ApiResponse): MediaType { + val content = apiResponse.content ?: Content().also { apiResponse.content = it } + return content["application/json"] ?: MediaType().also { content.addMediaType("application/json", it) } + } + + private fun getAccessSecurityScheme(): SecurityScheme = + SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .`in`(SecurityScheme.In.HEADER) + .name(jwtProperties.access.header) + + private fun getRefreshSecurityScheme(): SecurityScheme = + SecurityScheme() + .type(SecurityScheme.Type.APIKEY) + .scheme("bearer") + .bearerFormat("JWT") + .`in`(SecurityScheme.In.HEADER) + .name(jwtProperties.refresh.header) +} diff --git a/src/main/kotlin/com/weeth/global/config/WebMvcConfig.kt b/src/main/kotlin/com/weeth/global/config/WebMvcConfig.kt new file mode 100644 index 00000000..43b02d4a --- /dev/null +++ b/src/main/kotlin/com/weeth/global/config/WebMvcConfig.kt @@ -0,0 +1,15 @@ +package com.weeth.global.config + +import com.weeth.global.auth.resolver.CurrentUserArgumentResolver +import com.weeth.global.auth.resolver.CurrentUserRoleArgumentResolver +import org.springframework.context.annotation.Configuration +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +@Configuration +class WebMvcConfig : WebMvcConfigurer { + override fun addArgumentResolvers(resolvers: MutableList) { + resolvers.add(CurrentUserArgumentResolver()) + resolvers.add(CurrentUserRoleArgumentResolver()) + } +} diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/UserManageUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/UserManageUseCaseTest.kt index d2164df9..680b0e94 100644 --- a/src/test/kotlin/com/weeth/domain/user/application/usecase/UserManageUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/UserManageUseCaseTest.kt @@ -20,7 +20,7 @@ import com.weeth.domain.user.domain.service.UserGetService import com.weeth.domain.user.domain.service.UserUpdateService import com.weeth.domain.user.fixture.CardinalTestFixture import com.weeth.domain.user.fixture.UserTestFixture -import com.weeth.global.auth.jwt.service.JwtRedisService +import com.weeth.global.auth.jwt.domain.port.RefreshTokenStorePort import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.collections.shouldHaveSize @@ -40,7 +40,7 @@ class UserManageUseCaseTest : val userDeleteService = mockk(relaxUnitFun = true) val attendanceSaveService = mockk(relaxUnitFun = true) val meetingGetService = mockk() - val jwtRedisService = mockk(relaxUnitFun = true) + val refreshTokenStorePort = mockk(relaxUnitFun = true) val cardinalGetService = mockk() val userCardinalSaveService = mockk(relaxUnitFun = true) val userCardinalGetService = mockk() @@ -54,7 +54,7 @@ class UserManageUseCaseTest : userDeleteService, attendanceSaveService, meetingGetService, - jwtRedisService, + refreshTokenStorePort, cardinalGetService, userCardinalSaveService, userCardinalGetService, @@ -164,7 +164,7 @@ class UserManageUseCaseTest : useCase.update(listOf(request)) verify { userUpdateService.update(user1, "ADMIN") } - verify { jwtRedisService.updateRole(1L, "ADMIN") } + verify { refreshTokenStorePort.updateRole(1L, Role.ADMIN) } } } @@ -175,7 +175,7 @@ class UserManageUseCaseTest : useCase.leave(1L) - verify { jwtRedisService.delete(1L) } + verify { refreshTokenStorePort.delete(1L) } verify { userDeleteService.leave(user1) } } } @@ -188,7 +188,7 @@ class UserManageUseCaseTest : useCase.ban(ids) - verify { jwtRedisService.delete(1L) } + verify { refreshTokenStorePort.delete(1L) } verify { userDeleteService.ban(user1) } } } diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractorTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractorTest.kt new file mode 100644 index 00000000..3a54cad5 --- /dev/null +++ b/src/test/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractorTest.kt @@ -0,0 +1,85 @@ +package com.weeth.global.auth.jwt.application.service + +import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.global.auth.jwt.application.exception.TokenNotFoundException +import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider +import com.weeth.global.config.properties.JwtProperties +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import jakarta.servlet.http.HttpServletRequest + +class JwtTokenExtractorTest : + DescribeSpec({ + val jwtProperties = + JwtProperties( + key = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + access = JwtProperties.TokenProperties(expiration = 60_000L, header = "Auth"), + refresh = JwtProperties.TokenProperties(expiration = 120_000L, header = "Refresh"), + ) + + val jwtProvider = mockk() + val jwtTokenExtractor = JwtTokenExtractor(jwtProperties, jwtProvider) + + beforeTest { + clearMocks(jwtProvider) + } + + describe("extractAccessToken") { + it("Bearer 헤더에서 access token을 추출한다") { + val request = mockk() + every { request.getHeader("Auth") } returns "Bearer access-token" + + val token = jwtTokenExtractor.extractAccessToken(request) + + token shouldBe "access-token" + } + } + + describe("extractRefreshToken") { + it("헤더가 없으면 TokenNotFoundException이 발생한다") { + val request = mockk() + every { request.getHeader("Refresh") } returns null + + shouldThrow { + jwtTokenExtractor.extractRefreshToken(request) + } + } + } + + describe("extractId") { + it("parseClaims를 통해 id를 반환한다") { + val token = "sample" + val claims = mockk() + every { jwtProvider.parseClaims(token) } returns claims + every { claims.get("id", Long::class.javaObjectType) } returns 77L + + val id = jwtTokenExtractor.extractId(token) + + id shouldBe 77L + verify(exactly = 1) { jwtProvider.parseClaims(token) } + } + } + + describe("extractClaims") { + it("id, email, role을 함께 반환한다") { + val token = "sample" + val claims = mockk() + every { jwtProvider.parseClaims(token) } returns claims + every { claims.get("id", Long::class.javaObjectType) } returns 77L + every { claims.get("email", String::class.java) } returns "sample@com" + every { claims.get("role", String::class.java) } returns "USER" + + val tokenClaims = jwtTokenExtractor.extractClaims(token) + + tokenClaims?.id shouldBe 77L + tokenClaims?.email shouldBe "sample@com" + tokenClaims?.role shouldBe Role.USER + verify(exactly = 1) { jwtProvider.parseClaims(token) } + } + } + }) diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCaseTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCaseTest.kt new file mode 100644 index 00000000..e7ec96a6 --- /dev/null +++ b/src/test/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCaseTest.kt @@ -0,0 +1,51 @@ +package com.weeth.global.auth.jwt.application.usecase + +import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.global.auth.jwt.application.dto.JwtDto +import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor +import com.weeth.global.auth.jwt.domain.port.RefreshTokenStorePort +import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify + +class JwtManageUseCaseTest : + DescribeSpec({ + val jwtProvider = mockk() + val jwtService = mockk() + val refreshTokenStore = mockk(relaxUnitFun = true) + val useCase = JwtManageUseCase(jwtProvider, jwtService, refreshTokenStore) + + describe("create") { + it("access/refresh token을 생성하고 저장한다") { + every { jwtProvider.createAccessToken(1L, "a@weeth.com", Role.USER) } returns "access" + every { jwtProvider.createRefreshToken(1L) } returns "refresh" + + val result = useCase.create(1L, "a@weeth.com", Role.USER) + + result shouldBe JwtDto("access", "refresh") + verify(exactly = 1) { refreshTokenStore.save(1L, "refresh", Role.USER, "a@weeth.com") } + } + } + + describe("reIssueToken") { + it("저장 토큰 검증 후 새 토큰을 재발급한다") { + every { jwtProvider.validate("old-refresh") } just runs + every { jwtService.extractId("old-refresh") } returns 10L + every { refreshTokenStore.getRole(10L) } returns Role.ADMIN + every { refreshTokenStore.getEmail(10L) } returns "admin@weeth.com" + every { jwtProvider.createAccessToken(10L, "admin@weeth.com", Role.ADMIN) } returns "new-access" + every { jwtProvider.createRefreshToken(10L) } returns "new-refresh" + + val result = useCase.reIssueToken("old-refresh") + + result shouldBe JwtDto("new-access", "new-refresh") + verify(exactly = 1) { refreshTokenStore.validateRefreshToken(10L, "old-refresh") } + verify(exactly = 1) { refreshTokenStore.save(10L, "new-refresh", Role.ADMIN, "admin@weeth.com") } + } + } + }) diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProviderTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProviderTest.kt new file mode 100644 index 00000000..e77028fa --- /dev/null +++ b/src/test/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProviderTest.kt @@ -0,0 +1,36 @@ +package com.weeth.global.auth.jwt.domain.service + +import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.global.auth.jwt.application.exception.InvalidTokenException +import com.weeth.global.config.properties.JwtProperties +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe + +class JwtTokenProviderTest : + StringSpec({ + val jwtProperties = + JwtProperties( + key = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + access = JwtProperties.TokenProperties(expiration = 60_000L, header = "Authorization"), + refresh = JwtProperties.TokenProperties(expiration = 120_000L, header = "Authorization_refresh"), + ) + + val jwtProvider = JwtTokenProvider(jwtProperties) + + "access token 생성 후 claims를 파싱할 수 있다" { + val token = jwtProvider.createAccessToken(1L, "test@weeth.com", Role.ADMIN) + + val claims = jwtProvider.parseClaims(token) + + claims.get("id", Number::class.java).toLong() shouldBe 1L + claims.get("email", String::class.java) shouldBe "test@weeth.com" + claims.get("role", String::class.java) shouldBe "ADMIN" + } + + "유효하지 않은 토큰 검증 시 InvalidTokenException이 발생한다" { + shouldThrow { + jwtProvider.validate("not-a-token") + } + } + }) diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt new file mode 100644 index 00000000..ecb9507d --- /dev/null +++ b/src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt @@ -0,0 +1,84 @@ +package com.weeth.global.auth.jwt.filter + +import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor +import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider +import com.weeth.global.auth.model.AuthenticatedUser +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import org.springframework.mock.web.MockFilterChain +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse +import org.springframework.security.core.context.SecurityContextHolder + +class JwtAuthenticationProcessingFilterTest : + DescribeSpec({ + val jwtProvider = mockk() + val jwtService = mockk() + val filter = JwtAuthenticationProcessingFilter(jwtProvider, jwtService) + + beforeTest { + SecurityContextHolder.clearContext() + clearMocks(jwtProvider, jwtService) + } + + afterTest { + SecurityContextHolder.clearContext() + } + + describe("doFilterInternal") { + it("유효한 토큰이면 SecurityContext에 인증을 저장한다") { + val request = MockHttpServletRequest().apply { requestURI = "/api/v1/users" } + val response = MockHttpServletResponse() + val chain = MockFilterChain() + + every { jwtService.extractAccessToken(request) } returns "access-token" + every { jwtProvider.validate("access-token") } just runs + every { jwtService.extractClaims("access-token") } returns JwtTokenExtractor.TokenClaims(1L, "admin@weeth.com", Role.ADMIN) + + filter.doFilter(request, response, chain) + + val authentication = SecurityContextHolder.getContext().authentication + (authentication == null) shouldBe false + (authentication.principal is AuthenticatedUser) shouldBe true + val principal = authentication.principal as AuthenticatedUser + principal.id shouldBe 1L + principal.email shouldBe "admin@weeth.com" + principal.role.name shouldBe "ADMIN" + authentication.authorities.any { it.authority == "ROLE_ADMIN" } shouldBe true + } + + it("토큰이 없으면 인증을 저장하지 않는다") { + val request = MockHttpServletRequest().apply { requestURI = "/api/v1/users" } + val response = MockHttpServletResponse() + val chain = MockFilterChain() + + every { jwtService.extractAccessToken(request) } returns null + + filter.doFilter(request, response, chain) + + SecurityContextHolder.getContext().authentication shouldBe null + verify(exactly = 0) { jwtProvider.validate(any()) } + } + + it("claims 추출에 실패하면 인증을 저장하지 않는다") { + val request = MockHttpServletRequest().apply { requestURI = "/api/v1/users" } + val response = MockHttpServletResponse() + val chain = MockFilterChain() + + every { jwtService.extractAccessToken(request) } returns "access-token" + every { jwtProvider.validate("access-token") } just runs + every { jwtService.extractClaims("access-token") } returns null + + filter.doFilter(request, response, chain) + + SecurityContextHolder.getContext().authentication shouldBe null + } + } + }) diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/infrastructure/store/RedisRefreshTokenStoreAdapterTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/infrastructure/store/RedisRefreshTokenStoreAdapterTest.kt new file mode 100644 index 00000000..1ba5944b --- /dev/null +++ b/src/test/kotlin/com/weeth/global/auth/jwt/infrastructure/store/RedisRefreshTokenStoreAdapterTest.kt @@ -0,0 +1,90 @@ +package com.weeth.global.auth.jwt.infrastructure.store + +import com.weeth.config.TestContainersConfig +import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.global.auth.jwt.application.exception.InvalidTokenException +import com.weeth.global.auth.jwt.application.exception.RedisTokenNotFoundException +import com.weeth.global.auth.jwt.infrastructure.RedisRefreshTokenStoreAdapter +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.annotation.Import +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.test.context.ActiveProfiles + +@SpringBootTest +@ActiveProfiles("test") +@Import(TestContainersConfig::class) +class RedisRefreshTokenStoreAdapterTest( + private val redisRefreshTokenStoreAdapter: RedisRefreshTokenStoreAdapter, + private val redisTemplate: RedisTemplate, +) : DescribeSpec({ + beforeTest { + val keys = redisTemplate.keys("$PREFIX*") + if (!keys.isNullOrEmpty()) { + redisTemplate.delete(keys) + } + } + + describe("save/get") { + it("실제 Redis에 role/email/token을 저장하고 조회한다") { + redisRefreshTokenStoreAdapter.save(1L, "rt", Role.ADMIN, "a@weeth.com") + + redisRefreshTokenStoreAdapter.getRole(1L) shouldBe Role.ADMIN + redisRefreshTokenStoreAdapter.getEmail(1L) shouldBe "a@weeth.com" + redisTemplate.opsForHash().get("refreshToken:1", "token") shouldBe "rt" + } + } + + describe("validateRefreshToken") { + it("저장된 토큰과 일치하면 예외가 발생하지 않는다") { + redisRefreshTokenStoreAdapter.save(2L, "stored", Role.USER, "u@weeth.com") + + redisRefreshTokenStoreAdapter.validateRefreshToken(2L, "stored") + } + + it("요청 토큰이 다르면 InvalidTokenException이 발생한다") { + redisRefreshTokenStoreAdapter.save(3L, "stored", Role.USER, "u@weeth.com") + + shouldThrow { + redisRefreshTokenStoreAdapter.validateRefreshToken(3L, "different") + } + } + } + + describe("getRole/getEmail") { + it("값이 없으면 RedisTokenNotFoundException이 발생한다") { + shouldThrow { + redisRefreshTokenStoreAdapter.getRole(999L) + } + shouldThrow { + redisRefreshTokenStoreAdapter.getEmail(999L) + } + } + } + + describe("delete/updateRole") { + it("delete 후 조회 시 예외가 발생한다") { + redisRefreshTokenStoreAdapter.save(4L, "rt", Role.USER, "x@weeth.com") + redisRefreshTokenStoreAdapter.delete(4L) + + shouldThrow { + redisRefreshTokenStoreAdapter.getRole(4L) + } + } + + it("updateRole은 기존 저장 값의 role만 변경한다") { + redisRefreshTokenStoreAdapter.save(5L, "rt", Role.USER, "x@weeth.com") + + redisRefreshTokenStoreAdapter.updateRole(5L, Role.ADMIN) + + redisRefreshTokenStoreAdapter.getRole(5L) shouldBe Role.ADMIN + redisRefreshTokenStoreAdapter.getEmail(5L) shouldBe "x@weeth.com" + } + } + }) { + companion object { + private const val PREFIX = "refreshToken:" + } +} diff --git a/src/test/kotlin/com/weeth/global/auth/resolver/CurrentUserArgumentResolverTest.kt b/src/test/kotlin/com/weeth/global/auth/resolver/CurrentUserArgumentResolverTest.kt new file mode 100644 index 00000000..c94c74bf --- /dev/null +++ b/src/test/kotlin/com/weeth/global/auth/resolver/CurrentUserArgumentResolverTest.kt @@ -0,0 +1,67 @@ +package com.weeth.global.auth.resolver + +import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.auth.jwt.application.exception.AnonymousAuthenticationException +import com.weeth.global.auth.model.AuthenticatedUser +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import org.springframework.core.MethodParameter +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.security.authentication.AnonymousAuthenticationToken +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.web.context.request.ServletWebRequest + +class CurrentUserArgumentResolverTest : + StringSpec({ + val resolver = CurrentUserArgumentResolver() + + afterTest { + SecurityContextHolder.clearContext() + } + + "@CurrentUser Long 파라미터를 지원한다" { + val method = DummyController::class.java.getDeclaredMethod("target", java.lang.Long.TYPE) + val parameter = MethodParameter(method, 0) + + resolver.supportsParameter(parameter) shouldBe true + } + + "인증 컨텍스트가 익명이면 예외가 발생한다" { + val method = DummyController::class.java.getDeclaredMethod("target", java.lang.Long.TYPE) + val parameter = MethodParameter(method, 0) + val request = MockHttpServletRequest() + + SecurityContextHolder.getContext().authentication = + AnonymousAuthenticationToken("key", "anonymousUser", listOf(SimpleGrantedAuthority("ROLE_ANONYMOUS"))) + + shouldThrow { + resolver.resolveArgument(parameter, null, ServletWebRequest(request), null) + } + } + + "principal이 AuthenticatedUser면 userId를 반환한다" { + val method = DummyController::class.java.getDeclaredMethod("target", java.lang.Long.TYPE) + val parameter = MethodParameter(method, 0) + val request = MockHttpServletRequest() + val principal = AuthenticatedUser(id = 99L, email = "test@weeth.com", role = Role.USER) + SecurityContextHolder.getContext().authentication = + UsernamePasswordAuthenticationToken(principal, null, emptyList()) + + val result = resolver.resolveArgument(parameter, null, ServletWebRequest(request), null) + + result shouldBe 99L + } + }) { + private class DummyController { + @Suppress("unused") + fun target( + @CurrentUser userId: Long, + ) { + userId.toString() + } + } +} diff --git a/src/test/kotlin/com/weeth/global/common/exception/CommonExceptionHandlerTest.kt b/src/test/kotlin/com/weeth/global/common/exception/CommonExceptionHandlerTest.kt new file mode 100644 index 00000000..31576538 --- /dev/null +++ b/src/test/kotlin/com/weeth/global/common/exception/CommonExceptionHandlerTest.kt @@ -0,0 +1,39 @@ +package com.weeth.global.common.exception + +import com.weeth.global.auth.jwt.application.exception.JwtErrorCode +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import org.springframework.validation.BeanPropertyBindingResult +import org.springframework.validation.BindException +import org.springframework.validation.FieldError + +class CommonExceptionHandlerTest : + DescribeSpec({ + val handler = CommonExceptionHandler() + + describe("handle(BaseException)") { + it("ErrorCode 기반 응답으로 변환한다") { + val ex = object : BaseException(JwtErrorCode.TOKEN_NOT_FOUND) {} + + val response = handler.handle(ex) + + response.statusCode.value() shouldBe 404 + response.body?.code shouldBe 2902 + } + } + + describe("handle(BindException)") { + it("필드 에러 목록을 CommonResponse로 반환한다") { + val bindingResult = BeanPropertyBindingResult(Any(), "request") + bindingResult.addError( + FieldError("request", "name", "", false, emptyArray(), emptyArray(), "must not be blank"), + ) + val ex = BindException(bindingResult) + + val response = handler.handle(ex) + + response.statusCode.value() shouldBe 400 + response.body?.message shouldBe "bindException" + } + } + }) From 8c80d063b9aa6de5baafaba5dd4f48c27498086a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=84=EC=88=98=ED=98=84?= <128474444+soo0711@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:31:53 +0900 Subject: [PATCH 11/73] =?UTF-8?q?[WTH-149]=20account=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=BD=94=ED=8B=80=EB=A6=B0=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: account 도메인 안전망 테스트 추가 * refactor: Account entity java -> kotlin 패키지 변경 * refactor: Account entity kotlin으로 문법 변경, money vo 추가 * refactor: entity 코틀린 문법 변경에 따른 참조 파일 수정 * refactor: Account dto, mapper java -> kotlin 패키지 변경 * refactor: Account dto 분리 및 kotlin 문법으로 변환 * refactor: Account mapper 수동 mapper, kotlin 문법으로 변환 * refactor: dto, mapper 코틀린 문법 변경에 따른 참조 파일 수정 * refactor: Account repository java -> kotlin 패키지 변경 * refactor: Account repository kotlin 문법으로 변환 * refactor: AccountUseCaseImpl find() 조회 N+1 개선 * refactor: Account repository 코틀린 문법 변경에 따른 참조 파일 수정 * refactor: 불필요한 service, usecase 관련 파일 삭제 * refactor: 아키텍처 구조에 맞게 usecase command/query로 분리 * test: usecase command/query 분리에 맞춰 테스트 다시 작성 * refactor: Account usecase 분리에 맞춰 참조 파일 수정 * refactor: Account exception, controller, responsecode java -> kotlin 패키지 변경 * refactor: Account controller, responsecode kotlin 문법으로 변환 * refactor: Account exception kotlin 문법으로 변환 * refactor: 불필요한 @JvmStatic 어노테이션 제거 * fix: Receipt.update() 금액 유효성 검사 누락 수정 * fix: updateReceipt 기수 존재 검증 누락 수정 및 description @NotBlank 추가 * fix: 영수증 수정 시 빈 파일 리스트로 전체 삭제 처리 * refactor: 요청 스웨거 예시 수정 * refactor: 요청 스웨거 예시 추가 * fix: Receipt 수정 시 account 불일치 검증 및 clearMocks 누락 수정 --- .../account/application/dto/AccountDTO.java | 25 --- .../account/application/dto/ReceiptDTO.java | 42 ---- .../exception/AccountErrorCode.java | 25 --- .../exception/AccountExistsException.java | 10 - .../exception/AccountNotFoundException.java | 9 - .../exception/ReceiptNotFoundException.java | 9 - .../application/mapper/AccountMapper.java | 20 -- .../application/mapper/ReceiptMapper.java | 25 --- .../application/usecase/AccountUseCase.java | 9 - .../usecase/AccountUseCaseImpl.java | 68 ------ .../application/usecase/ReceiptUseCase.java | 11 - .../usecase/ReceiptUseCaseImpl.java | 85 -------- .../domain/account/domain/entity/Account.java | 46 ---- .../domain/account/domain/entity/Receipt.java | 45 ---- .../domain/repository/AccountRepository.java | 13 -- .../domain/repository/ReceiptRepository.java | 10 - .../domain/service/AccountGetService.java | 23 -- .../domain/service/AccountSaveService.java | 17 -- .../domain/service/ReceiptDeleteService.java | 17 -- .../domain/service/ReceiptGetService.java | 25 --- .../domain/service/ReceiptSaveService.java | 17 -- .../domain/service/ReceiptUpdateService.java | 14 -- .../presentation/AccountAdminController.java | 34 --- .../presentation/AccountController.java | 31 --- .../presentation/AccountResponseCode.java | 29 --- .../presentation/ReceiptAdminController.java | 44 ---- .../dto/request/AccountSaveRequest.kt | 19 ++ .../dto/request/ReceiptSaveRequest.kt | 27 +++ .../dto/request/ReceiptUpdateRequest.kt | 31 +++ .../dto/response/AccountResponse.kt | 21 ++ .../dto/response/ReceiptResponse.kt | 20 ++ .../application/exception/AccountErrorCode.kt | 30 +++ .../exception/AccountExistsException.kt | 5 + .../exception/AccountNotFoundException.kt | 5 + .../ReceiptAccountMismatchException.kt | 5 + .../exception/ReceiptNotFoundException.kt | 5 + .../application/mapper/AccountMapper.kt | 23 ++ .../application/mapper/ReceiptMapper.kt | 30 +++ .../usecase/command/ManageAccountUseCase.kt | 22 ++ .../usecase/command/ManageReceiptUseCase.kt | 66 ++++++ .../usecase/query/GetAccountQueryService.kt | 35 +++ .../domain/account/domain/entity/Account.kt | 61 ++++++ .../domain/account/domain/entity/Receipt.kt | 63 ++++++ .../domain/repository/AccountRepository.kt | 10 + .../domain/repository/ReceiptRepository.kt | 8 + .../weeth/domain/account/domain/vo/Money.kt | 20 ++ .../presentation/AccountAdminController.kt | 32 +++ .../account/presentation/AccountController.kt | 28 +++ .../presentation/AccountResponseCode.kt | 16 ++ .../presentation/ReceiptAdminController.kt | 57 +++++ .../usecase/ReceiptUseCaseImplTest.kt | 97 --------- .../command/ManageAccountUseCaseTest.kt | 47 +++++ .../command/ManageReceiptUseCaseTest.kt | 199 ++++++++++++++++++ .../query/GetAccountQueryServiceTest.kt | 89 ++++++++ .../account/domain/entity/AccountTest.kt | 54 +++++ .../account/domain/entity/ReceiptTest.kt | 35 +++ .../account/fixture/AccountTestFixture.kt | 20 ++ .../account/fixture/ReceiptTestFixture.kt | 24 +++ 58 files changed, 1107 insertions(+), 800 deletions(-) delete mode 100644 src/main/java/com/weeth/domain/account/application/dto/AccountDTO.java delete mode 100644 src/main/java/com/weeth/domain/account/application/dto/ReceiptDTO.java delete mode 100644 src/main/java/com/weeth/domain/account/application/exception/AccountErrorCode.java delete mode 100644 src/main/java/com/weeth/domain/account/application/exception/AccountExistsException.java delete mode 100644 src/main/java/com/weeth/domain/account/application/exception/AccountNotFoundException.java delete mode 100644 src/main/java/com/weeth/domain/account/application/exception/ReceiptNotFoundException.java delete mode 100644 src/main/java/com/weeth/domain/account/application/mapper/AccountMapper.java delete mode 100644 src/main/java/com/weeth/domain/account/application/mapper/ReceiptMapper.java delete mode 100644 src/main/java/com/weeth/domain/account/application/usecase/AccountUseCase.java delete mode 100644 src/main/java/com/weeth/domain/account/application/usecase/AccountUseCaseImpl.java delete mode 100644 src/main/java/com/weeth/domain/account/application/usecase/ReceiptUseCase.java delete mode 100644 src/main/java/com/weeth/domain/account/application/usecase/ReceiptUseCaseImpl.java delete mode 100644 src/main/java/com/weeth/domain/account/domain/entity/Account.java delete mode 100644 src/main/java/com/weeth/domain/account/domain/entity/Receipt.java delete mode 100644 src/main/java/com/weeth/domain/account/domain/repository/AccountRepository.java delete mode 100644 src/main/java/com/weeth/domain/account/domain/repository/ReceiptRepository.java delete mode 100644 src/main/java/com/weeth/domain/account/domain/service/AccountGetService.java delete mode 100644 src/main/java/com/weeth/domain/account/domain/service/AccountSaveService.java delete mode 100644 src/main/java/com/weeth/domain/account/domain/service/ReceiptDeleteService.java delete mode 100644 src/main/java/com/weeth/domain/account/domain/service/ReceiptGetService.java delete mode 100644 src/main/java/com/weeth/domain/account/domain/service/ReceiptSaveService.java delete mode 100644 src/main/java/com/weeth/domain/account/domain/service/ReceiptUpdateService.java delete mode 100644 src/main/java/com/weeth/domain/account/presentation/AccountAdminController.java delete mode 100644 src/main/java/com/weeth/domain/account/presentation/AccountController.java delete mode 100644 src/main/java/com/weeth/domain/account/presentation/AccountResponseCode.java delete mode 100644 src/main/java/com/weeth/domain/account/presentation/ReceiptAdminController.java create mode 100644 src/main/kotlin/com/weeth/domain/account/application/dto/request/AccountSaveRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/account/application/dto/request/ReceiptSaveRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/account/application/dto/request/ReceiptUpdateRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/account/application/dto/response/ReceiptResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/account/application/exception/AccountErrorCode.kt create mode 100644 src/main/kotlin/com/weeth/domain/account/application/exception/AccountExistsException.kt create mode 100644 src/main/kotlin/com/weeth/domain/account/application/exception/AccountNotFoundException.kt create mode 100644 src/main/kotlin/com/weeth/domain/account/application/exception/ReceiptAccountMismatchException.kt create mode 100644 src/main/kotlin/com/weeth/domain/account/application/exception/ReceiptNotFoundException.kt create mode 100644 src/main/kotlin/com/weeth/domain/account/application/mapper/AccountMapper.kt create mode 100644 src/main/kotlin/com/weeth/domain/account/application/mapper/ReceiptMapper.kt create mode 100644 src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCase.kt create mode 100644 src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCase.kt create mode 100644 src/main/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountQueryService.kt create mode 100644 src/main/kotlin/com/weeth/domain/account/domain/entity/Account.kt create mode 100644 src/main/kotlin/com/weeth/domain/account/domain/entity/Receipt.kt create mode 100644 src/main/kotlin/com/weeth/domain/account/domain/repository/AccountRepository.kt create mode 100644 src/main/kotlin/com/weeth/domain/account/domain/repository/ReceiptRepository.kt create mode 100644 src/main/kotlin/com/weeth/domain/account/domain/vo/Money.kt create mode 100644 src/main/kotlin/com/weeth/domain/account/presentation/AccountAdminController.kt create mode 100644 src/main/kotlin/com/weeth/domain/account/presentation/AccountController.kt create mode 100644 src/main/kotlin/com/weeth/domain/account/presentation/AccountResponseCode.kt create mode 100644 src/main/kotlin/com/weeth/domain/account/presentation/ReceiptAdminController.kt delete mode 100644 src/test/kotlin/com/weeth/domain/account/application/usecase/ReceiptUseCaseImplTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCaseTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCaseTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountQueryServiceTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/account/domain/entity/AccountTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/account/domain/entity/ReceiptTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/account/fixture/AccountTestFixture.kt create mode 100644 src/test/kotlin/com/weeth/domain/account/fixture/ReceiptTestFixture.kt diff --git a/src/main/java/com/weeth/domain/account/application/dto/AccountDTO.java b/src/main/java/com/weeth/domain/account/application/dto/AccountDTO.java deleted file mode 100644 index a9794584..00000000 --- a/src/main/java/com/weeth/domain/account/application/dto/AccountDTO.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.weeth.domain.account.application.dto; - -import jakarta.validation.constraints.NotNull; - -import java.time.LocalDateTime; -import java.util.List; - -public class AccountDTO { - - public record Response( - Long accountId, - String description, - Integer totalAmount, - Integer currentAmount, - LocalDateTime time, - Integer cardinal, - List receipts - ) {} - - public record Save( - String description, - @NotNull Integer totalAmount, - @NotNull Integer cardinal - ) {} -} diff --git a/src/main/java/com/weeth/domain/account/application/dto/ReceiptDTO.java b/src/main/java/com/weeth/domain/account/application/dto/ReceiptDTO.java deleted file mode 100644 index 71ff3974..00000000 --- a/src/main/java/com/weeth/domain/account/application/dto/ReceiptDTO.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.weeth.domain.account.application.dto; - -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotNull; -import com.weeth.domain.file.application.dto.request.FileSaveRequest; -import com.weeth.domain.file.application.dto.response.FileResponse; - -import java.time.LocalDate; -import java.util.List; - -public class ReceiptDTO { - - public record Response( - Long id, - String description, - String source, - Integer amount, - LocalDate date, - List fileUrls - ) { - } - - public record Save( - String description, - String source, - @NotNull Integer amount, - @NotNull LocalDate date, - @NotNull Integer cardinal, - @Valid List<@NotNull FileSaveRequest> files - ) { - } - - public record Update( - String description, - String source, - @NotNull Integer amount, - @NotNull LocalDate date, - @NotNull Integer cardinal, - @Valid List<@NotNull FileSaveRequest> files - ) { - } -} diff --git a/src/main/java/com/weeth/domain/account/application/exception/AccountErrorCode.java b/src/main/java/com/weeth/domain/account/application/exception/AccountErrorCode.java deleted file mode 100644 index d694c551..00000000 --- a/src/main/java/com/weeth/domain/account/application/exception/AccountErrorCode.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.weeth.domain.account.application.exception; - -import com.weeth.global.common.exception.ErrorCodeInterface; -import com.weeth.global.common.exception.ExplainError; -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@AllArgsConstructor -public enum AccountErrorCode implements ErrorCodeInterface { - - @ExplainError("요청한 회비 장부 ID가 존재하지 않을 때 발생합니다.") - ACCOUNT_NOT_FOUND(2100, HttpStatus.NOT_FOUND, "존재하지 않는 장부입니다."), - - @ExplainError("이미 존재하는 장부를 중복 생성하려고 할 때 발생합니다.") - ACCOUNT_EXISTS(2101, HttpStatus.BAD_REQUEST, "이미 생성된 장부입니다."), - - @ExplainError("요청한 영수증 내역이 존재하지 않을 때 발생합니다.") - RECEIPT_NOT_FOUND(2102, HttpStatus.NOT_FOUND, "존재하지 않는 내역입니다."); - - private final int code; - private final HttpStatus status; - private final String message; -} diff --git a/src/main/java/com/weeth/domain/account/application/exception/AccountExistsException.java b/src/main/java/com/weeth/domain/account/application/exception/AccountExistsException.java deleted file mode 100644 index 9e6ed8b5..00000000 --- a/src/main/java/com/weeth/domain/account/application/exception/AccountExistsException.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.weeth.domain.account.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class AccountExistsException extends BaseException { - public AccountExistsException() { - super(AccountErrorCode.ACCOUNT_EXISTS); - } -} - diff --git a/src/main/java/com/weeth/domain/account/application/exception/AccountNotFoundException.java b/src/main/java/com/weeth/domain/account/application/exception/AccountNotFoundException.java deleted file mode 100644 index 2e480f40..00000000 --- a/src/main/java/com/weeth/domain/account/application/exception/AccountNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.account.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class AccountNotFoundException extends BaseException { - public AccountNotFoundException() { - super(AccountErrorCode.ACCOUNT_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/domain/account/application/exception/ReceiptNotFoundException.java b/src/main/java/com/weeth/domain/account/application/exception/ReceiptNotFoundException.java deleted file mode 100644 index ac11d282..00000000 --- a/src/main/java/com/weeth/domain/account/application/exception/ReceiptNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.account.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class ReceiptNotFoundException extends BaseException { - public ReceiptNotFoundException() { - super(AccountErrorCode.RECEIPT_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/domain/account/application/mapper/AccountMapper.java b/src/main/java/com/weeth/domain/account/application/mapper/AccountMapper.java deleted file mode 100644 index f428cc88..00000000 --- a/src/main/java/com/weeth/domain/account/application/mapper/AccountMapper.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.weeth.domain.account.application.mapper; - -import com.weeth.domain.account.application.dto.AccountDTO; -import com.weeth.domain.account.application.dto.ReceiptDTO; -import com.weeth.domain.account.domain.entity.Account; -import org.mapstruct.*; - -import java.util.List; - -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, unmappedTargetPolicy = ReportingPolicy.IGNORE) -public interface AccountMapper { - - @Mapping(target = "accountId", source = "account.id") - @Mapping(target = "receipts", source = "receipts") - @Mapping(target = "time", source = "account.modifiedAt") - AccountDTO.Response to(Account account, List receipts); - - @Mapping(target = "currentAmount", source = "totalAmount") - Account from(AccountDTO.Save dto); -} diff --git a/src/main/java/com/weeth/domain/account/application/mapper/ReceiptMapper.java b/src/main/java/com/weeth/domain/account/application/mapper/ReceiptMapper.java deleted file mode 100644 index c2a8f8d7..00000000 --- a/src/main/java/com/weeth/domain/account/application/mapper/ReceiptMapper.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.weeth.domain.account.application.mapper; - -import com.weeth.domain.account.application.dto.ReceiptDTO; -import com.weeth.domain.account.domain.entity.Account; -import com.weeth.domain.account.domain.entity.Receipt; -import com.weeth.domain.file.application.dto.response.FileResponse; -import org.mapstruct.Mapper; -import org.mapstruct.Mapping; -import org.mapstruct.MappingConstants; -import org.mapstruct.ReportingPolicy; - -import java.util.List; - -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, unmappedTargetPolicy = ReportingPolicy.IGNORE) -public interface ReceiptMapper { - - List to(List account); - - ReceiptDTO.Response to(Receipt receipt, List fileUrls); - - @Mapping(target = "id", ignore = true) - @Mapping(target = "description", source = "dto.description") - @Mapping(target = "account", source = "account") - Receipt from(ReceiptDTO.Save dto, Account account); -} diff --git a/src/main/java/com/weeth/domain/account/application/usecase/AccountUseCase.java b/src/main/java/com/weeth/domain/account/application/usecase/AccountUseCase.java deleted file mode 100644 index a9eb972b..00000000 --- a/src/main/java/com/weeth/domain/account/application/usecase/AccountUseCase.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.account.application.usecase; - -import com.weeth.domain.account.application.dto.AccountDTO; - -public interface AccountUseCase { - AccountDTO.Response find(Integer cardinal); - - void save(AccountDTO.Save dto); -} diff --git a/src/main/java/com/weeth/domain/account/application/usecase/AccountUseCaseImpl.java b/src/main/java/com/weeth/domain/account/application/usecase/AccountUseCaseImpl.java deleted file mode 100644 index d2d9ca00..00000000 --- a/src/main/java/com/weeth/domain/account/application/usecase/AccountUseCaseImpl.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.weeth.domain.account.application.usecase; - -import com.weeth.domain.account.application.dto.AccountDTO; -import com.weeth.domain.account.application.dto.ReceiptDTO; -import com.weeth.domain.account.application.exception.AccountExistsException; -import com.weeth.domain.account.application.mapper.AccountMapper; -import com.weeth.domain.account.application.mapper.ReceiptMapper; -import com.weeth.domain.account.domain.entity.Account; -import com.weeth.domain.account.domain.entity.Receipt; -import com.weeth.domain.account.domain.service.AccountGetService; -import com.weeth.domain.account.domain.service.AccountSaveService; -import com.weeth.domain.account.domain.service.ReceiptGetService; -import com.weeth.domain.file.application.dto.response.FileResponse; -import com.weeth.domain.file.application.mapper.FileMapper; -import com.weeth.domain.file.domain.entity.FileOwnerType; -import com.weeth.domain.file.domain.repository.FileReader; -import com.weeth.domain.user.domain.service.CardinalGetService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class AccountUseCaseImpl implements AccountUseCase { - - private final AccountGetService accountGetService; - private final AccountSaveService accountSaveService; - private final ReceiptGetService receiptGetService; - private final FileReader fileReader; - private final CardinalGetService cardinalGetService; - - private final AccountMapper accountMapper; - private final ReceiptMapper receiptMapper; - private final FileMapper fileMapper; - - @Override - public AccountDTO.Response find(Integer cardinal) { - Account account = accountGetService.find(cardinal); - List receipts = receiptGetService.findAllByAccountId(account.getId()); - List response = receipts.stream() - .map(receipt -> receiptMapper.to(receipt, getFiles(receipt.getId()))) - .toList(); - - return accountMapper.to(account, response); - } - - @Override - @Transactional - public void save(AccountDTO.Save dto) { - validate(dto); - cardinalGetService.findByAdminSide(dto.cardinal()); - - accountSaveService.save(accountMapper.from(dto)); - } - - private void validate(AccountDTO.Save dto) { - if (accountGetService.validate(dto.cardinal())) - throw new AccountExistsException(); - } - - private List getFiles(Long receiptId) { - return fileReader.findAll(FileOwnerType.RECEIPT, receiptId, null).stream() - .map(fileMapper::toFileResponse) - .toList(); - } -} diff --git a/src/main/java/com/weeth/domain/account/application/usecase/ReceiptUseCase.java b/src/main/java/com/weeth/domain/account/application/usecase/ReceiptUseCase.java deleted file mode 100644 index 855a24a2..00000000 --- a/src/main/java/com/weeth/domain/account/application/usecase/ReceiptUseCase.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.weeth.domain.account.application.usecase; - -import com.weeth.domain.account.application.dto.ReceiptDTO; - -public interface ReceiptUseCase { - void save(ReceiptDTO.Save dto); - - void update(Long receiptId, ReceiptDTO.Update dto); - - void delete(Long id); -} diff --git a/src/main/java/com/weeth/domain/account/application/usecase/ReceiptUseCaseImpl.java b/src/main/java/com/weeth/domain/account/application/usecase/ReceiptUseCaseImpl.java deleted file mode 100644 index 0a646340..00000000 --- a/src/main/java/com/weeth/domain/account/application/usecase/ReceiptUseCaseImpl.java +++ /dev/null @@ -1,85 +0,0 @@ -package com.weeth.domain.account.application.usecase; - -import jakarta.transaction.Transactional; -import com.weeth.domain.account.application.dto.ReceiptDTO; -import com.weeth.domain.account.application.mapper.ReceiptMapper; -import com.weeth.domain.account.domain.entity.Account; -import com.weeth.domain.account.domain.entity.Receipt; -import com.weeth.domain.account.domain.service.*; -import com.weeth.domain.file.application.mapper.FileMapper; -import com.weeth.domain.file.domain.entity.File; -import com.weeth.domain.file.domain.entity.FileOwnerType; -import com.weeth.domain.file.domain.repository.FileReader; -import com.weeth.domain.file.domain.repository.FileRepository; -import com.weeth.domain.user.domain.service.CardinalGetService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class ReceiptUseCaseImpl implements ReceiptUseCase { - - private final ReceiptGetService receiptGetService; - private final ReceiptDeleteService receiptDeleteService; - private final ReceiptSaveService receiptSaveService; - private final ReceiptUpdateService receiptUpdateService; - private final AccountGetService accountGetService; - - private final FileReader fileReader; - private final FileRepository fileRepository; - - private final CardinalGetService cardinalGetService; - - private final ReceiptMapper mapper; - private final FileMapper fileMapper; - - - @Override - @Transactional - public void save(ReceiptDTO.Save dto) { - cardinalGetService.findByAdminSide(dto.cardinal()); - - Account account = accountGetService.find(dto.cardinal()); - Receipt receipt = receiptSaveService.save(mapper.from(dto, account)); - account.spend(receipt); - - List files = fileMapper.toFileList(dto.files(), FileOwnerType.RECEIPT, receipt.getId()); - fileRepository.saveAll(files); - } - - @Override - @Transactional - public void update(Long receiptId, ReceiptDTO.Update dto){ - Account account = accountGetService.find(dto.cardinal()); - Receipt receipt = receiptGetService.find(receiptId); - account.cancel(receipt); - - if(!dto.files().isEmpty()){ // 업데이트하려는 파일이 있다면 파일을 전체 삭제한 뒤 저장 - List fileList = getFiles(receiptId); - fileRepository.deleteAll(fileList); - - List files = fileMapper.toFileList(dto.files(), FileOwnerType.RECEIPT, receipt.getId()); - fileRepository.saveAll(files); - } - receiptUpdateService.update(receipt, dto); - account.spend(receipt); - } - - private List getFiles(Long receiptId) { - return fileReader.findAll(FileOwnerType.RECEIPT, receiptId, null); - } - - @Override - @Transactional - public void delete(Long id) { - Receipt receipt = receiptGetService.find(id); - List fileList = fileReader.findAll(FileOwnerType.RECEIPT, id, null); - - receipt.getAccount().cancel(receipt); - - fileRepository.deleteAll(fileList); - receiptDeleteService.delete(receipt); - } -} diff --git a/src/main/java/com/weeth/domain/account/domain/entity/Account.java b/src/main/java/com/weeth/domain/account/domain/entity/Account.java deleted file mode 100644 index 032749bb..00000000 --- a/src/main/java/com/weeth/domain/account/domain/entity/Account.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.weeth.domain.account.domain.entity; - -import jakarta.persistence.*; -import com.weeth.global.common.entity.BaseEntity; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.experimental.SuperBuilder; - -import java.util.ArrayList; -import java.util.List; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@SuperBuilder -public class Account extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "account_id") - private Long id; - - private String description; - - private Integer totalAmount; - - private Integer currentAmount; - - private Integer cardinal; - - @OneToMany(mappedBy = "account", cascade = CascadeType.REMOVE, orphanRemoval = true) - private List receipts = new ArrayList<>(); - - public void spend(Receipt receipt) { - this.receipts.add(receipt); - this.currentAmount -= receipt.getAmount(); - } - - public void cancel(Receipt receipt) { - this.receipts.remove(receipt); - this.currentAmount += receipt.getAmount(); - } -} diff --git a/src/main/java/com/weeth/domain/account/domain/entity/Receipt.java b/src/main/java/com/weeth/domain/account/domain/entity/Receipt.java deleted file mode 100644 index 83ea940e..00000000 --- a/src/main/java/com/weeth/domain/account/domain/entity/Receipt.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.weeth.domain.account.domain.entity; - -import jakarta.persistence.*; -import com.weeth.domain.account.application.dto.ReceiptDTO; -import com.weeth.global.common.entity.BaseEntity; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.experimental.SuperBuilder; - -import java.time.LocalDate; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@SuperBuilder -public class Receipt extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "receipt_id") - private Long id; - - private String description; - - private String source; - - private Integer amount; - - private LocalDate date; - - @ManyToOne - @JoinColumn(name = "account_id") - private Account account; - - public void update(ReceiptDTO.Update dto){ - this.description = dto.description(); - this.source = dto.source(); - this.amount = dto.amount(); - this.date = dto.date(); - } - -} diff --git a/src/main/java/com/weeth/domain/account/domain/repository/AccountRepository.java b/src/main/java/com/weeth/domain/account/domain/repository/AccountRepository.java deleted file mode 100644 index 0599083f..00000000 --- a/src/main/java/com/weeth/domain/account/domain/repository/AccountRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.weeth.domain.account.domain.repository; - -import com.weeth.domain.account.domain.entity.Account; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.Optional; - -public interface AccountRepository extends JpaRepository { - - Optional findByCardinal(Integer cardinal); - - boolean existsByCardinal(Integer cardinal); -} diff --git a/src/main/java/com/weeth/domain/account/domain/repository/ReceiptRepository.java b/src/main/java/com/weeth/domain/account/domain/repository/ReceiptRepository.java deleted file mode 100644 index 588a79ff..00000000 --- a/src/main/java/com/weeth/domain/account/domain/repository/ReceiptRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.weeth.domain.account.domain.repository; - -import com.weeth.domain.account.domain.entity.Receipt; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.List; - -public interface ReceiptRepository extends JpaRepository { - List findAllByAccountIdOrderByCreatedAtDesc(Long accountId); -} diff --git a/src/main/java/com/weeth/domain/account/domain/service/AccountGetService.java b/src/main/java/com/weeth/domain/account/domain/service/AccountGetService.java deleted file mode 100644 index bfc948f8..00000000 --- a/src/main/java/com/weeth/domain/account/domain/service/AccountGetService.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.weeth.domain.account.domain.service; - -import com.weeth.domain.account.domain.entity.Account; -import com.weeth.domain.account.domain.repository.AccountRepository; -import com.weeth.domain.account.application.exception.AccountNotFoundException; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class AccountGetService { - - private final AccountRepository accountRepository; - - public Account find(Integer cardinal) { - return accountRepository.findByCardinal(cardinal) - .orElseThrow(AccountNotFoundException::new); - } - - public boolean validate(Integer cardinal) { - return accountRepository.existsByCardinal(cardinal); - } -} diff --git a/src/main/java/com/weeth/domain/account/domain/service/AccountSaveService.java b/src/main/java/com/weeth/domain/account/domain/service/AccountSaveService.java deleted file mode 100644 index d0bf2ccb..00000000 --- a/src/main/java/com/weeth/domain/account/domain/service/AccountSaveService.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.weeth.domain.account.domain.service; - -import com.weeth.domain.account.domain.entity.Account; -import com.weeth.domain.account.domain.repository.AccountRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class AccountSaveService { - - private final AccountRepository accountRepository; - - public void save(Account account) { - accountRepository.save(account); - } -} diff --git a/src/main/java/com/weeth/domain/account/domain/service/ReceiptDeleteService.java b/src/main/java/com/weeth/domain/account/domain/service/ReceiptDeleteService.java deleted file mode 100644 index 7caca70e..00000000 --- a/src/main/java/com/weeth/domain/account/domain/service/ReceiptDeleteService.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.weeth.domain.account.domain.service; - -import com.weeth.domain.account.domain.entity.Receipt; -import com.weeth.domain.account.domain.repository.ReceiptRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class ReceiptDeleteService { - - private final ReceiptRepository receiptRepository; - - public void delete(Receipt receipt) { - receiptRepository.delete(receipt); - } -} diff --git a/src/main/java/com/weeth/domain/account/domain/service/ReceiptGetService.java b/src/main/java/com/weeth/domain/account/domain/service/ReceiptGetService.java deleted file mode 100644 index 61312284..00000000 --- a/src/main/java/com/weeth/domain/account/domain/service/ReceiptGetService.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.weeth.domain.account.domain.service; - -import com.weeth.domain.account.domain.entity.Receipt; -import com.weeth.domain.account.domain.repository.ReceiptRepository; -import com.weeth.domain.account.application.exception.ReceiptNotFoundException; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class ReceiptGetService { - - private final ReceiptRepository receiptRepository; - - public Receipt find(Long id) { - return receiptRepository.findById(id) - .orElseThrow(ReceiptNotFoundException::new); - } - - public List findAllByAccountId(Long accountId) { - return receiptRepository.findAllByAccountIdOrderByCreatedAtDesc(accountId); - } -} diff --git a/src/main/java/com/weeth/domain/account/domain/service/ReceiptSaveService.java b/src/main/java/com/weeth/domain/account/domain/service/ReceiptSaveService.java deleted file mode 100644 index 22a3933a..00000000 --- a/src/main/java/com/weeth/domain/account/domain/service/ReceiptSaveService.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.weeth.domain.account.domain.service; - -import com.weeth.domain.account.domain.entity.Receipt; -import com.weeth.domain.account.domain.repository.ReceiptRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class ReceiptSaveService { - - private final ReceiptRepository receiptRepository; - - public Receipt save(Receipt receipt) { - return receiptRepository.save(receipt); - } -} diff --git a/src/main/java/com/weeth/domain/account/domain/service/ReceiptUpdateService.java b/src/main/java/com/weeth/domain/account/domain/service/ReceiptUpdateService.java deleted file mode 100644 index 95462683..00000000 --- a/src/main/java/com/weeth/domain/account/domain/service/ReceiptUpdateService.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.weeth.domain.account.domain.service; - -import com.weeth.domain.account.application.dto.ReceiptDTO; -import com.weeth.domain.account.domain.entity.Receipt; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class ReceiptUpdateService { - public void update(Receipt receipt, ReceiptDTO.Update dto) { - receipt.update(dto); - } -} \ No newline at end of file diff --git a/src/main/java/com/weeth/domain/account/presentation/AccountAdminController.java b/src/main/java/com/weeth/domain/account/presentation/AccountAdminController.java deleted file mode 100644 index bf1c565f..00000000 --- a/src/main/java/com/weeth/domain/account/presentation/AccountAdminController.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.weeth.domain.account.presentation; - -import com.weeth.domain.account.application.dto.AccountDTO; -import com.weeth.domain.account.application.exception.AccountErrorCode; -import com.weeth.domain.account.application.usecase.AccountUseCase; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import static com.weeth.domain.account.presentation.AccountResponseCode.ACCOUNT_SAVE_SUCCESS; - -@Tag(name = "ACCOUNT ADMIN", description = "[ADMIN] 회비 어드민 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/admin/account") -@ApiErrorCodeExample(AccountErrorCode.class) -public class AccountAdminController { - - private final AccountUseCase accountUseCase; - - @PostMapping - @Operation(summary="회비 총 금액 기입") - public CommonResponse save(@RequestBody @Valid AccountDTO.Save dto) { - accountUseCase.save(dto); - return CommonResponse.success(ACCOUNT_SAVE_SUCCESS); - } -} diff --git a/src/main/java/com/weeth/domain/account/presentation/AccountController.java b/src/main/java/com/weeth/domain/account/presentation/AccountController.java deleted file mode 100644 index 1cb72b9d..00000000 --- a/src/main/java/com/weeth/domain/account/presentation/AccountController.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.weeth.domain.account.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import com.weeth.domain.account.application.dto.AccountDTO; -import com.weeth.domain.account.application.exception.AccountErrorCode; -import com.weeth.domain.account.application.usecase.AccountUseCase; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import static com.weeth.domain.account.presentation.AccountResponseCode.ACCOUNT_FIND_SUCCESS; -@Tag(name = "ACCOUNT", description = "회비 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/account") -@ApiErrorCodeExample(AccountErrorCode.class) -public class AccountController { - - private final AccountUseCase accountUseCase; - - @GetMapping("/{cardinal}") - @Operation(summary="회비 내역 조회") - public CommonResponse find(@PathVariable Integer cardinal) { - return CommonResponse.success(ACCOUNT_FIND_SUCCESS,accountUseCase.find(cardinal)); - } -} diff --git a/src/main/java/com/weeth/domain/account/presentation/AccountResponseCode.java b/src/main/java/com/weeth/domain/account/presentation/AccountResponseCode.java deleted file mode 100644 index 4d1bf484..00000000 --- a/src/main/java/com/weeth/domain/account/presentation/AccountResponseCode.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.weeth.domain.account.presentation; - -import com.weeth.global.common.response.ResponseCodeInterface; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -public enum AccountResponseCode implements ResponseCodeInterface { - // AccountAdminController 관련 - ACCOUNT_SAVE_SUCCESS(1100, HttpStatus.OK, "회비가 성공적으로 저장되었습니다."), - - // AccountController 관련 - ACCOUNT_FIND_SUCCESS(1101, HttpStatus.OK, "회비가 성공적으로 조회되었습니다."), - - // ReceiptAdminController 관련 - RECEIPT_SAVE_SUCCESS(1102, HttpStatus.OK, "영수증이 성공적으로 저장되었습니다."), - RECEIPT_DELETE_SUCCESS(1103, HttpStatus.OK, "영수증이 성공적으로 삭제되었습니다."), - RECEIPT_UPDATE_SUCCESS(1104, HttpStatus.OK, "영수증이 성공적으로 업데이트 되었습니다."); - - private final int code; - private final HttpStatus status; - private final String message; - - AccountResponseCode(int code, HttpStatus status, String message) { - this.code = code; - this.status = status; - this.message = message; - } -} diff --git a/src/main/java/com/weeth/domain/account/presentation/ReceiptAdminController.java b/src/main/java/com/weeth/domain/account/presentation/ReceiptAdminController.java deleted file mode 100644 index c9dfb434..00000000 --- a/src/main/java/com/weeth/domain/account/presentation/ReceiptAdminController.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.weeth.domain.account.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import com.weeth.domain.account.application.dto.ReceiptDTO; -import com.weeth.domain.account.application.exception.AccountErrorCode; -import com.weeth.domain.account.application.usecase.ReceiptUseCase; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; -import static com.weeth.domain.account.presentation.AccountResponseCode.*; - -@Tag(name = "RECEIPT ADMIN", description = "[ADMIN] 회비 어드민 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/admin/receipts") -@ApiErrorCodeExample(AccountErrorCode.class) -public class ReceiptAdminController { - - private final ReceiptUseCase receiptUseCase; - - @PostMapping - @Operation(summary="회비 사용 내역 기입") - public CommonResponse save(@RequestBody @Valid ReceiptDTO.Save dto) { - receiptUseCase.save(dto); - return CommonResponse.success(RECEIPT_SAVE_SUCCESS); - } - - @DeleteMapping("/{receiptId}") - @Operation(summary="회비 사용 내역 취소") - public CommonResponse delete(@PathVariable Long receiptId) { - receiptUseCase.delete(receiptId); - return CommonResponse.success(RECEIPT_DELETE_SUCCESS); - } - - @PatchMapping("/{receiptId}") - @Operation(summary="회비 사용 내역 수정") - public CommonResponse update(@PathVariable Long receiptId, @RequestBody @Valid ReceiptDTO.Update dto) { - receiptUseCase.update(receiptId, dto); - return CommonResponse.success(RECEIPT_UPDATE_SUCCESS); - } -} diff --git a/src/main/kotlin/com/weeth/domain/account/application/dto/request/AccountSaveRequest.kt b/src/main/kotlin/com/weeth/domain/account/application/dto/request/AccountSaveRequest.kt new file mode 100644 index 00000000..cc9be88c --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/dto/request/AccountSaveRequest.kt @@ -0,0 +1,19 @@ +package com.weeth.domain.account.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Positive + +data class AccountSaveRequest( + @field:Schema(description = "회비 설명", example = "2024년 2학기 회비") + @field:NotBlank + val description: String, + @field:Schema(description = "총 금액", example = "100000") + @field:NotNull + @field:Positive + val totalAmount: Int, + @field:Schema(description = "기수", example = "4") + @field:NotNull + val cardinal: Int, +) diff --git a/src/main/kotlin/com/weeth/domain/account/application/dto/request/ReceiptSaveRequest.kt b/src/main/kotlin/com/weeth/domain/account/application/dto/request/ReceiptSaveRequest.kt new file mode 100644 index 00000000..c177a3fa --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/dto/request/ReceiptSaveRequest.kt @@ -0,0 +1,27 @@ +package com.weeth.domain.account.application.dto.request + +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.Valid +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Positive +import java.time.LocalDate + +data class ReceiptSaveRequest( + @field:Schema(description = "영수증 설명", example = "간식비") + val description: String?, + @field:Schema(description = "출처", example = "편의점") + val source: String?, + @field:Schema(description = "사용 금액", example = "10000") + @field:NotNull + @field:Positive + val amount: Int, + @field:Schema(description = "사용 날짜", example = "2024-09-01") + @field:NotNull + val date: LocalDate, + @field:Schema(description = "기수", example = "4") + @field:NotNull + val cardinal: Int, + @field:Valid + val files: List<@NotNull FileSaveRequest>?, +) diff --git a/src/main/kotlin/com/weeth/domain/account/application/dto/request/ReceiptUpdateRequest.kt b/src/main/kotlin/com/weeth/domain/account/application/dto/request/ReceiptUpdateRequest.kt new file mode 100644 index 00000000..cb146aff --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/dto/request/ReceiptUpdateRequest.kt @@ -0,0 +1,31 @@ +package com.weeth.domain.account.application.dto.request + +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.Valid +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Positive +import java.time.LocalDate + +data class ReceiptUpdateRequest( + @field:Schema(description = "영수증 설명", example = "간식비") + val description: String?, + @field:Schema(description = "출처", example = "편의점") + val source: String?, + @field:Schema(description = "사용 금액", example = "10000") + @field:NotNull + @field:Positive + val amount: Int, + @field:Schema(description = "사용 날짜", example = "2024-09-01") + @field:NotNull + val date: LocalDate, + @field:Schema(description = "기수", example = "4") + @field:NotNull + val cardinal: Int, + @field:Schema( + description = "첨부 파일 변경 규약: null=변경 안 함, []=전체 삭제, 배열 전달=해당 목록으로 교체", + nullable = true, + ) + @field:Valid + val files: List<@NotNull FileSaveRequest>?, +) diff --git a/src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountResponse.kt b/src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountResponse.kt new file mode 100644 index 00000000..3ac8b44d --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountResponse.kt @@ -0,0 +1,21 @@ +package com.weeth.domain.account.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class AccountResponse( + @field:Schema(description = "회비 ID", example = "1") + val accountId: Long, + @field:Schema(description = "회비 설명", example = "2024년 2학기 회비") + val description: String, + @field:Schema(description = "총 금액", example = "100000") + val totalAmount: Int, + @field:Schema(description = "현재 금액", example = "90000") + val currentAmount: Int, + @field:Schema(description = "최종 수정 시각") + val time: LocalDateTime?, + @field:Schema(description = "기수", example = "40") + val cardinal: Int, + @field:Schema(description = "영수증 목록") + val receipts: List, +) diff --git a/src/main/kotlin/com/weeth/domain/account/application/dto/response/ReceiptResponse.kt b/src/main/kotlin/com/weeth/domain/account/application/dto/response/ReceiptResponse.kt new file mode 100644 index 00000000..df8d1d7b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/dto/response/ReceiptResponse.kt @@ -0,0 +1,20 @@ +package com.weeth.domain.account.application.dto.response + +import com.weeth.domain.file.application.dto.response.FileResponse +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDate + +data class ReceiptResponse( + @field:Schema(description = "영수증 ID", example = "1") + val id: Long, + @field:Schema(description = "영수증 설명", example = "간식비") + val description: String?, + @field:Schema(description = "출처", example = "편의점") + val source: String?, + @field:Schema(description = "사용 금액", example = "10000") + val amount: Int, + @field:Schema(description = "사용 날짜", example = "2024-09-01") + val date: LocalDate, + @field:Schema(description = "첨부 파일 목록") + val fileUrls: List, +) diff --git a/src/main/kotlin/com/weeth/domain/account/application/exception/AccountErrorCode.kt b/src/main/kotlin/com/weeth/domain/account/application/exception/AccountErrorCode.kt new file mode 100644 index 00000000..06ecb74d --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/exception/AccountErrorCode.kt @@ -0,0 +1,30 @@ +package com.weeth.domain.account.application.exception + +import com.weeth.global.common.exception.ErrorCodeInterface +import com.weeth.global.common.exception.ExplainError +import org.springframework.http.HttpStatus + +enum class AccountErrorCode( + private val code: Int, + private val status: HttpStatus, + private val message: String, +) : ErrorCodeInterface { + @ExplainError("요청한 회비 장부 ID가 존재하지 않을 때 발생합니다.") + ACCOUNT_NOT_FOUND(2100, HttpStatus.NOT_FOUND, "존재하지 않는 장부입니다."), + + @ExplainError("이미 존재하는 장부를 중복 생성하려고 할 때 발생합니다.") + ACCOUNT_EXISTS(2101, HttpStatus.BAD_REQUEST, "이미 생성된 장부입니다."), + + @ExplainError("요청한 영수증 내역이 존재하지 않을 때 발생합니다.") + RECEIPT_NOT_FOUND(2102, HttpStatus.NOT_FOUND, "존재하지 않는 내역입니다."), + + @ExplainError("영수증이 요청한 기수의 장부에 속하지 않을 때 발생합니다.") + RECEIPT_ACCOUNT_MISMATCH(2103, HttpStatus.BAD_REQUEST, "영수증이 해당 기수의 장부에 속하지 않습니다."), + ; + + override fun getCode(): Int = code + + override fun getStatus(): HttpStatus = status + + override fun getMessage(): String = message +} diff --git a/src/main/kotlin/com/weeth/domain/account/application/exception/AccountExistsException.kt b/src/main/kotlin/com/weeth/domain/account/application/exception/AccountExistsException.kt new file mode 100644 index 00000000..5886dead --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/exception/AccountExistsException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.account.application.exception + +import com.weeth.global.common.exception.BaseException + +class AccountExistsException : BaseException(AccountErrorCode.ACCOUNT_EXISTS) diff --git a/src/main/kotlin/com/weeth/domain/account/application/exception/AccountNotFoundException.kt b/src/main/kotlin/com/weeth/domain/account/application/exception/AccountNotFoundException.kt new file mode 100644 index 00000000..c7dc5a24 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/exception/AccountNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.account.application.exception + +import com.weeth.global.common.exception.BaseException + +class AccountNotFoundException : BaseException(AccountErrorCode.ACCOUNT_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/account/application/exception/ReceiptAccountMismatchException.kt b/src/main/kotlin/com/weeth/domain/account/application/exception/ReceiptAccountMismatchException.kt new file mode 100644 index 00000000..04a34880 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/exception/ReceiptAccountMismatchException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.account.application.exception + +import com.weeth.global.common.exception.BaseException + +class ReceiptAccountMismatchException : BaseException(AccountErrorCode.RECEIPT_ACCOUNT_MISMATCH) diff --git a/src/main/kotlin/com/weeth/domain/account/application/exception/ReceiptNotFoundException.kt b/src/main/kotlin/com/weeth/domain/account/application/exception/ReceiptNotFoundException.kt new file mode 100644 index 00000000..db6f1b51 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/exception/ReceiptNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.account.application.exception + +import com.weeth.global.common.exception.BaseException + +class ReceiptNotFoundException : BaseException(AccountErrorCode.RECEIPT_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/account/application/mapper/AccountMapper.kt b/src/main/kotlin/com/weeth/domain/account/application/mapper/AccountMapper.kt new file mode 100644 index 00000000..67fac93b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/mapper/AccountMapper.kt @@ -0,0 +1,23 @@ +package com.weeth.domain.account.application.mapper + +import com.weeth.domain.account.application.dto.response.AccountResponse +import com.weeth.domain.account.application.dto.response.ReceiptResponse +import com.weeth.domain.account.domain.entity.Account +import org.springframework.stereotype.Component + +@Component +class AccountMapper { + fun toResponse( + account: Account, + receipts: List, + ): AccountResponse = + AccountResponse( + accountId = account.id, + description = account.description, + totalAmount = account.totalAmount, + currentAmount = account.currentAmount, + time = account.modifiedAt, + cardinal = account.cardinal, + receipts = receipts, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/account/application/mapper/ReceiptMapper.kt b/src/main/kotlin/com/weeth/domain/account/application/mapper/ReceiptMapper.kt new file mode 100644 index 00000000..9999da3a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/mapper/ReceiptMapper.kt @@ -0,0 +1,30 @@ +package com.weeth.domain.account.application.mapper + +import com.weeth.domain.account.application.dto.response.ReceiptResponse +import com.weeth.domain.account.domain.entity.Receipt +import com.weeth.domain.file.application.dto.response.FileResponse +import org.springframework.stereotype.Component + +@Component +class ReceiptMapper { + fun toResponse( + receipt: Receipt, + fileUrls: List, + ): ReceiptResponse = + ReceiptResponse( + id = receipt.id, + description = receipt.description, + source = receipt.source, + amount = receipt.amount, + date = receipt.date, + fileUrls = fileUrls, + ) + + fun toResponses( + receipts: List, + filesByReceiptId: Map>, + ): List = + receipts.map { receipt -> + toResponse(receipt, filesByReceiptId[receipt.id] ?: emptyList()) + } +} diff --git a/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCase.kt b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCase.kt new file mode 100644 index 00000000..70a54fad --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCase.kt @@ -0,0 +1,22 @@ +package com.weeth.domain.account.application.usecase.command + +import com.weeth.domain.account.application.dto.request.AccountSaveRequest +import com.weeth.domain.account.application.exception.AccountExistsException +import com.weeth.domain.account.domain.entity.Account +import com.weeth.domain.account.domain.repository.AccountRepository +import com.weeth.domain.user.domain.service.CardinalGetService +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class ManageAccountUseCase( + private val accountRepository: AccountRepository, + private val cardinalGetService: CardinalGetService, +) { + @Transactional + fun save(request: AccountSaveRequest) { + if (accountRepository.existsByCardinal(request.cardinal)) throw AccountExistsException() + cardinalGetService.findByAdminSide(request.cardinal) + accountRepository.save(Account.create(request.description, request.totalAmount, request.cardinal)) + } +} diff --git a/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCase.kt b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCase.kt new file mode 100644 index 00000000..ef0c939f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCase.kt @@ -0,0 +1,66 @@ +package com.weeth.domain.account.application.usecase.command + +import com.weeth.domain.account.application.dto.request.ReceiptSaveRequest +import com.weeth.domain.account.application.dto.request.ReceiptUpdateRequest +import com.weeth.domain.account.application.exception.AccountNotFoundException +import com.weeth.domain.account.application.exception.ReceiptAccountMismatchException +import com.weeth.domain.account.application.exception.ReceiptNotFoundException +import com.weeth.domain.account.domain.entity.Receipt +import com.weeth.domain.account.domain.repository.AccountRepository +import com.weeth.domain.account.domain.repository.ReceiptRepository +import com.weeth.domain.account.domain.vo.Money +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.repository.FileReader +import com.weeth.domain.file.domain.repository.FileRepository +import com.weeth.domain.user.domain.service.CardinalGetService +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class ManageReceiptUseCase( + private val receiptRepository: ReceiptRepository, + private val accountRepository: AccountRepository, + private val fileReader: FileReader, + private val fileRepository: FileRepository, + private val cardinalGetService: CardinalGetService, + private val fileMapper: FileMapper, +) { + @Transactional + fun save(request: ReceiptSaveRequest) { + cardinalGetService.findByAdminSide(request.cardinal) + val account = accountRepository.findByCardinal(request.cardinal) ?: throw AccountNotFoundException() + val receipt = + receiptRepository.save( + Receipt.create(request.description, request.source, request.amount, request.date, account), + ) + account.spend(Money.of(request.amount)) + fileRepository.saveAll(fileMapper.toFileList(request.files, FileOwnerType.RECEIPT, receipt.id)) + } + + @Transactional + fun update( + receiptId: Long, + request: ReceiptUpdateRequest, + ) { + cardinalGetService.findByAdminSide(request.cardinal) + val account = accountRepository.findByCardinal(request.cardinal) ?: throw AccountNotFoundException() + val receipt = receiptRepository.findByIdOrNull(receiptId) ?: throw ReceiptNotFoundException() + if (receipt.account.id != account.id) throw ReceiptAccountMismatchException() + account.adjustSpend(Money.of(receipt.amount), Money.of(request.amount)) + if (request.files != null) { + fileRepository.deleteAll(fileReader.findAll(FileOwnerType.RECEIPT, receiptId, null)) + fileRepository.saveAll(fileMapper.toFileList(request.files, FileOwnerType.RECEIPT, receiptId)) + } + receipt.update(request.description, request.source, request.amount, request.date) + } + + @Transactional + fun delete(receiptId: Long) { + val receipt = receiptRepository.findByIdOrNull(receiptId) ?: throw ReceiptNotFoundException() + receipt.account.cancelSpend(Money.of(receipt.amount)) + fileRepository.deleteAll(fileReader.findAll(FileOwnerType.RECEIPT, receiptId, null)) + receiptRepository.delete(receipt) + } +} diff --git a/src/main/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountQueryService.kt b/src/main/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountQueryService.kt new file mode 100644 index 00000000..8d10b081 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountQueryService.kt @@ -0,0 +1,35 @@ +package com.weeth.domain.account.application.usecase.query + +import com.weeth.domain.account.application.dto.response.AccountResponse +import com.weeth.domain.account.application.exception.AccountNotFoundException +import com.weeth.domain.account.application.mapper.AccountMapper +import com.weeth.domain.account.application.mapper.ReceiptMapper +import com.weeth.domain.account.domain.repository.AccountRepository +import com.weeth.domain.account.domain.repository.ReceiptRepository +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.repository.FileReader +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class GetAccountQueryService( + private val accountRepository: AccountRepository, + private val receiptRepository: ReceiptRepository, + private val fileReader: FileReader, + private val accountMapper: AccountMapper, + private val receiptMapper: ReceiptMapper, + private val fileMapper: FileMapper, +) { + fun findByCardinal(cardinal: Int): AccountResponse { + val account = accountRepository.findByCardinal(cardinal) ?: throw AccountNotFoundException() + val receipts = receiptRepository.findAllByAccountIdOrderByCreatedAtDesc(account.id) + val receiptIds = receipts.map { it.id } + val filesByReceiptId = + fileReader + .findAll(FileOwnerType.RECEIPT, receiptIds, null) + .groupBy({ it.ownerId }, { fileMapper.toFileResponse(it) }) + return accountMapper.toResponse(account, receiptMapper.toResponses(receipts, filesByReceiptId)) + } +} diff --git a/src/main/kotlin/com/weeth/domain/account/domain/entity/Account.kt b/src/main/kotlin/com/weeth/domain/account/domain/entity/Account.kt new file mode 100644 index 00000000..98f1b8e8 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/domain/entity/Account.kt @@ -0,0 +1,61 @@ +package com.weeth.domain.account.domain.entity + +import com.weeth.domain.account.domain.vo.Money +import com.weeth.global.common.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id + +@Entity +class Account( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "account_id") + val id: Long = 0, + @Column(nullable = false) + val description: String, + @Column(nullable = false) + val totalAmount: Int, + @Column(nullable = false) + var currentAmount: Int, + @Column(nullable = false) + val cardinal: Int, +) : BaseEntity() { + fun spend(amount: Money) { + require(amount.value > 0) { "사용 금액은 0보다 커야 합니다: ${amount.value}" } + check(currentAmount >= amount.value) { "잔액이 부족합니다. 현재: $currentAmount, 요청: ${amount.value}" } + currentAmount -= amount.value + } + + fun cancelSpend(amount: Money) { + require(amount.value > 0) { "취소 금액은 0보다 커야 합니다: ${amount.value}" } + check(currentAmount + amount.value <= totalAmount) { "총액을 초과할 수 없습니다. 총액: $totalAmount" } + currentAmount += amount.value + } + + fun adjustSpend( + oldAmount: Money, + newAmount: Money, + ) { + cancelSpend(oldAmount) + spend(newAmount) + } + + companion object { + fun create( + description: String, + totalAmount: Int, + cardinal: Int, + ): Account { + require(totalAmount > 0) { "총액은 0보다 커야 합니다: $totalAmount" } + return Account( + description = description, + totalAmount = totalAmount, + currentAmount = totalAmount, + cardinal = cardinal, + ) + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/account/domain/entity/Receipt.kt b/src/main/kotlin/com/weeth/domain/account/domain/entity/Receipt.kt new file mode 100644 index 00000000..b63786e1 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/domain/entity/Receipt.kt @@ -0,0 +1,63 @@ +package com.weeth.domain.account.domain.entity + +import com.weeth.global.common.entity.BaseEntity +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.time.LocalDate + +@Entity +class Receipt( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "receipt_id") + val id: Long = 0, + @Column + var description: String?, + @Column + var source: String?, + @Column(nullable = false) + var amount: Int, + @Column(nullable = false) + var date: LocalDate, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "account_id") + val account: Account, +) : BaseEntity() { + fun update( + description: String?, + source: String?, + amount: Int, + date: LocalDate, + ) { + require(amount > 0) { "금액은 0보다 커야 합니다: $amount" } + this.description = description + this.source = source + this.amount = amount + this.date = date + } + + companion object { + fun create( + description: String?, + source: String?, + amount: Int, + date: LocalDate, + account: Account, + ): Receipt { + require(amount > 0) { "금액은 0보다 커야 합니다: $amount" } + return Receipt( + description = description, + source = source, + amount = amount, + date = date, + account = account, + ) + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/account/domain/repository/AccountRepository.kt b/src/main/kotlin/com/weeth/domain/account/domain/repository/AccountRepository.kt new file mode 100644 index 00000000..c7a9daeb --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/domain/repository/AccountRepository.kt @@ -0,0 +1,10 @@ +package com.weeth.domain.account.domain.repository + +import com.weeth.domain.account.domain.entity.Account +import org.springframework.data.jpa.repository.JpaRepository + +interface AccountRepository : JpaRepository { + fun findByCardinal(cardinal: Int): Account? + + fun existsByCardinal(cardinal: Int): Boolean +} diff --git a/src/main/kotlin/com/weeth/domain/account/domain/repository/ReceiptRepository.kt b/src/main/kotlin/com/weeth/domain/account/domain/repository/ReceiptRepository.kt new file mode 100644 index 00000000..4872fa45 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/domain/repository/ReceiptRepository.kt @@ -0,0 +1,8 @@ +package com.weeth.domain.account.domain.repository + +import com.weeth.domain.account.domain.entity.Receipt +import org.springframework.data.jpa.repository.JpaRepository + +interface ReceiptRepository : JpaRepository { + fun findAllByAccountIdOrderByCreatedAtDesc(accountId: Long): List +} diff --git a/src/main/kotlin/com/weeth/domain/account/domain/vo/Money.kt b/src/main/kotlin/com/weeth/domain/account/domain/vo/Money.kt new file mode 100644 index 00000000..2e856076 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/domain/vo/Money.kt @@ -0,0 +1,20 @@ +package com.weeth.domain.account.domain.vo + +@JvmInline +value class Money( + val value: Int, +) { + init { + require(value >= 0) { "금액은 0 이상이어야 합니다: $value" } + } + + operator fun plus(other: Money) = Money(value + other.value) + + operator fun minus(other: Money) = Money(value - other.value) + + companion object { + val ZERO = Money(0) + + fun of(value: Int) = Money(value) + } +} diff --git a/src/main/kotlin/com/weeth/domain/account/presentation/AccountAdminController.kt b/src/main/kotlin/com/weeth/domain/account/presentation/AccountAdminController.kt new file mode 100644 index 00000000..92cc5a46 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/presentation/AccountAdminController.kt @@ -0,0 +1,32 @@ +package com.weeth.domain.account.presentation + +import com.weeth.domain.account.application.dto.request.AccountSaveRequest +import com.weeth.domain.account.application.exception.AccountErrorCode +import com.weeth.domain.account.application.usecase.command.ManageAccountUseCase +import com.weeth.domain.account.presentation.AccountResponseCode.ACCOUNT_SAVE_SUCCESS +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "ACCOUNT ADMIN", description = "[ADMIN] 회비 어드민 API") +@RestController +@RequestMapping("/api/v1/admin/account") +@ApiErrorCodeExample(AccountErrorCode::class) +class AccountAdminController( + private val manageAccountUseCase: ManageAccountUseCase, +) { + @PostMapping + @Operation(summary = "회비 총 금액 기입") + fun save( + @RequestBody @Valid dto: AccountSaveRequest, + ): CommonResponse { + manageAccountUseCase.save(dto) + return CommonResponse.success(ACCOUNT_SAVE_SUCCESS) + } +} diff --git a/src/main/kotlin/com/weeth/domain/account/presentation/AccountController.kt b/src/main/kotlin/com/weeth/domain/account/presentation/AccountController.kt new file mode 100644 index 00000000..96d9e4c6 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/presentation/AccountController.kt @@ -0,0 +1,28 @@ +package com.weeth.domain.account.presentation + +import com.weeth.domain.account.application.dto.response.AccountResponse +import com.weeth.domain.account.application.exception.AccountErrorCode +import com.weeth.domain.account.application.usecase.query.GetAccountQueryService +import com.weeth.domain.account.presentation.AccountResponseCode.ACCOUNT_FIND_SUCCESS +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "ACCOUNT", description = "회비 API") +@RestController +@RequestMapping("/api/v1/account") +@ApiErrorCodeExample(AccountErrorCode::class) +class AccountController( + private val getAccountQueryService: GetAccountQueryService, +) { + @GetMapping("/{cardinal}") + @Operation(summary = "회비 내역 조회") + fun find( + @PathVariable cardinal: Int, + ): CommonResponse = CommonResponse.success(ACCOUNT_FIND_SUCCESS, getAccountQueryService.findByCardinal(cardinal)) +} diff --git a/src/main/kotlin/com/weeth/domain/account/presentation/AccountResponseCode.kt b/src/main/kotlin/com/weeth/domain/account/presentation/AccountResponseCode.kt new file mode 100644 index 00000000..647dfc5e --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/presentation/AccountResponseCode.kt @@ -0,0 +1,16 @@ +package com.weeth.domain.account.presentation + +import com.weeth.global.common.response.ResponseCodeInterface +import org.springframework.http.HttpStatus + +enum class AccountResponseCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ResponseCodeInterface { + ACCOUNT_SAVE_SUCCESS(1100, HttpStatus.OK, "회비가 성공적으로 저장되었습니다."), + ACCOUNT_FIND_SUCCESS(1101, HttpStatus.OK, "회비가 성공적으로 조회되었습니다."), + RECEIPT_SAVE_SUCCESS(1102, HttpStatus.OK, "영수증이 성공적으로 저장되었습니다."), + RECEIPT_DELETE_SUCCESS(1103, HttpStatus.OK, "영수증이 성공적으로 삭제되었습니다."), + RECEIPT_UPDATE_SUCCESS(1104, HttpStatus.OK, "영수증이 성공적으로 업데이트 되었습니다."), +} diff --git a/src/main/kotlin/com/weeth/domain/account/presentation/ReceiptAdminController.kt b/src/main/kotlin/com/weeth/domain/account/presentation/ReceiptAdminController.kt new file mode 100644 index 00000000..3df7b96f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/presentation/ReceiptAdminController.kt @@ -0,0 +1,57 @@ +package com.weeth.domain.account.presentation + +import com.weeth.domain.account.application.dto.request.ReceiptSaveRequest +import com.weeth.domain.account.application.dto.request.ReceiptUpdateRequest +import com.weeth.domain.account.application.exception.AccountErrorCode +import com.weeth.domain.account.application.usecase.command.ManageReceiptUseCase +import com.weeth.domain.account.presentation.AccountResponseCode.RECEIPT_DELETE_SUCCESS +import com.weeth.domain.account.presentation.AccountResponseCode.RECEIPT_SAVE_SUCCESS +import com.weeth.domain.account.presentation.AccountResponseCode.RECEIPT_UPDATE_SUCCESS +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "RECEIPT ADMIN", description = "[ADMIN] 회비 어드민 API") +@RestController +@RequestMapping("/api/v1/admin/receipts") +@ApiErrorCodeExample(AccountErrorCode::class) +class ReceiptAdminController( + private val manageReceiptUseCase: ManageReceiptUseCase, +) { + @PostMapping + @Operation(summary = "회비 사용 내역 기입") + fun save( + @RequestBody @Valid dto: ReceiptSaveRequest, + ): CommonResponse { + manageReceiptUseCase.save(dto) + return CommonResponse.success(RECEIPT_SAVE_SUCCESS) + } + + @DeleteMapping("/{receiptId}") + @Operation(summary = "회비 사용 내역 취소") + fun delete( + @PathVariable receiptId: Long, + ): CommonResponse { + manageReceiptUseCase.delete(receiptId) + return CommonResponse.success(RECEIPT_DELETE_SUCCESS) + } + + @PatchMapping("/{receiptId}") + @Operation(summary = "회비 사용 내역 수정") + fun update( + @PathVariable receiptId: Long, + @RequestBody @Valid dto: ReceiptUpdateRequest, + ): CommonResponse { + manageReceiptUseCase.update(receiptId, dto) + return CommonResponse.success(RECEIPT_UPDATE_SUCCESS) + } +} diff --git a/src/test/kotlin/com/weeth/domain/account/application/usecase/ReceiptUseCaseImplTest.kt b/src/test/kotlin/com/weeth/domain/account/application/usecase/ReceiptUseCaseImplTest.kt deleted file mode 100644 index b3d0853f..00000000 --- a/src/test/kotlin/com/weeth/domain/account/application/usecase/ReceiptUseCaseImplTest.kt +++ /dev/null @@ -1,97 +0,0 @@ -package com.weeth.domain.account.application.usecase - -import com.weeth.domain.account.application.dto.ReceiptDTO -import com.weeth.domain.account.application.mapper.ReceiptMapper -import com.weeth.domain.account.domain.entity.Account -import com.weeth.domain.account.domain.entity.Receipt -import com.weeth.domain.account.domain.service.AccountGetService -import com.weeth.domain.account.domain.service.ReceiptDeleteService -import com.weeth.domain.account.domain.service.ReceiptGetService -import com.weeth.domain.account.domain.service.ReceiptSaveService -import com.weeth.domain.account.domain.service.ReceiptUpdateService -import com.weeth.domain.file.application.dto.request.FileSaveRequest -import com.weeth.domain.file.application.mapper.FileMapper -import com.weeth.domain.file.domain.entity.File -import com.weeth.domain.file.domain.entity.FileOwnerType -import com.weeth.domain.file.domain.repository.FileReader -import com.weeth.domain.file.domain.repository.FileRepository -import com.weeth.domain.user.domain.service.CardinalGetService -import io.kotest.core.spec.style.DescribeSpec -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import java.time.LocalDate - -class ReceiptUseCaseImplTest : - DescribeSpec({ - val receiptGetService = mockk() - val receiptDeleteService = mockk() - val receiptSaveService = mockk() - val receiptUpdateService = mockk(relaxUnitFun = true) - val accountGetService = mockk() - val fileReader = mockk() - val fileRepository = mockk(relaxed = true) - val cardinalGetService = mockk() - val receiptMapper = mockk() - val fileMapper = mockk() - - val useCase = - ReceiptUseCaseImpl( - receiptGetService, - receiptDeleteService, - receiptSaveService, - receiptUpdateService, - accountGetService, - fileReader, - fileRepository, - cardinalGetService, - receiptMapper, - fileMapper, - ) - - describe("update") { - it("업데이트 파일이 있으면 기존 파일을 삭제 후 새 파일을 저장한다") { - val receiptId = 10L - val account = - Account - .builder() - .id(1L) - .totalAmount(10000) - .currentAmount(10000) - .cardinal(40) - .receipts(mutableListOf()) - .build() - val receipt = - Receipt - .builder() - .id(receiptId) - .amount(1000) - .account(account) - .build() - - val dto = - ReceiptDTO.Update( - "desc", - "source", - 2000, - LocalDate.of(2026, 1, 1), - 40, - listOf(FileSaveRequest("new.png", "TEMP/2026-02/new.png", 100L, "image/png")), - ) - - val oldFiles = listOf(mockk()) - val newFiles = listOf(mockk()) - - every { accountGetService.find(dto.cardinal()) } returns account - every { receiptGetService.find(receiptId) } returns receipt - every { fileReader.findAll(FileOwnerType.RECEIPT, receiptId, null) } returns oldFiles - every { fileMapper.toFileList(dto.files(), FileOwnerType.RECEIPT, receiptId) } returns newFiles - - useCase.update(receiptId, dto) - - verify(exactly = 1) { fileRepository.deleteAll(oldFiles) } - verify(exactly = 1) { fileRepository.saveAll(newFiles) } - verify(exactly = 1) { receiptUpdateService.update(receipt, dto) } - } - } - }) diff --git a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCaseTest.kt new file mode 100644 index 00000000..ea07505f --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCaseTest.kt @@ -0,0 +1,47 @@ +package com.weeth.domain.account.application.usecase.command + +import com.weeth.domain.account.application.dto.request.AccountSaveRequest +import com.weeth.domain.account.application.exception.AccountExistsException +import com.weeth.domain.account.domain.repository.AccountRepository +import com.weeth.domain.user.domain.service.CardinalGetService +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify + +class ManageAccountUseCaseTest : + DescribeSpec({ + val accountRepository = mockk(relaxed = true) + val cardinalGetService = mockk(relaxUnitFun = true) + val useCase = ManageAccountUseCase(accountRepository, cardinalGetService) + + beforeTest { + clearMocks(accountRepository, cardinalGetService) + } + + describe("save") { + context("이미 존재하는 기수로 저장 시") { + it("AccountExistsException을 던진다") { + val dto = AccountSaveRequest("설명", 100_000, 40) + every { accountRepository.existsByCardinal(40) } returns true + + shouldThrow { useCase.save(dto) } + } + } + + context("정상 저장 시") { + it("account가 저장된다") { + val dto = AccountSaveRequest("설명", 100_000, 40) + every { accountRepository.existsByCardinal(40) } returns false + every { cardinalGetService.findByAdminSide(40) } returns mockk() + every { accountRepository.save(any()) } answers { firstArg() } + + useCase.save(dto) + + verify(exactly = 1) { accountRepository.save(any()) } + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCaseTest.kt new file mode 100644 index 00000000..2da320fb --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCaseTest.kt @@ -0,0 +1,199 @@ +package com.weeth.domain.account.application.usecase.command + +import com.weeth.domain.account.application.dto.request.ReceiptSaveRequest +import com.weeth.domain.account.application.dto.request.ReceiptUpdateRequest +import com.weeth.domain.account.application.exception.AccountNotFoundException +import com.weeth.domain.account.application.exception.ReceiptAccountMismatchException +import com.weeth.domain.account.domain.repository.AccountRepository +import com.weeth.domain.account.domain.repository.ReceiptRepository +import com.weeth.domain.account.domain.vo.Money +import com.weeth.domain.account.fixture.AccountTestFixture +import com.weeth.domain.account.fixture.ReceiptTestFixture +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.entity.File +import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.repository.FileReader +import com.weeth.domain.file.domain.repository.FileRepository +import com.weeth.domain.user.domain.service.CardinalGetService +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import java.time.LocalDate +import java.util.Optional + +class ManageReceiptUseCaseTest : + DescribeSpec({ + val receiptRepository = mockk(relaxUnitFun = true) + val accountRepository = mockk() + val fileReader = mockk() + val fileRepository = mockk(relaxed = true) + val cardinalGetService = mockk(relaxUnitFun = true) + val fileMapper = mockk() + val useCase = + ManageReceiptUseCase( + receiptRepository, + accountRepository, + fileReader, + fileRepository, + cardinalGetService, + fileMapper, + ) + + beforeTest { + clearMocks(receiptRepository, accountRepository, fileReader, fileRepository, cardinalGetService, fileMapper) + } + + describe("save") { + context("파일이 있는 경우") { + it("영수증 저장 후 fileRepository.saveAll이 호출된다") { + val account = AccountTestFixture.createAccount(cardinal = 40) + val savedReceipt = ReceiptTestFixture.createReceipt(id = 10L, amount = 5_000, account = account) + val files = listOf(mockk()) + val dto = + ReceiptSaveRequest( + "간식비", + "편의점", + 5_000, + LocalDate.of(2024, 9, 1), + 40, + listOf(FileSaveRequest("receipt.png", "TEMP/2024-09/receipt.png", 200L, "image/png")), + ) + + every { cardinalGetService.findByAdminSide(40) } returns mockk() + every { accountRepository.findByCardinal(40) } returns account + every { receiptRepository.save(any()) } returns savedReceipt + every { fileMapper.toFileList(dto.files, FileOwnerType.RECEIPT, savedReceipt.id) } returns files + + useCase.save(dto) + + verify(exactly = 1) { receiptRepository.save(any()) } + verify(exactly = 1) { fileRepository.saveAll(files) } + } + } + + context("파일이 없는 경우") { + it("fileRepository.saveAll은 빈 리스트로 호출된다") { + val account = AccountTestFixture.createAccount(cardinal = 40) + val savedReceipt = ReceiptTestFixture.createReceipt(id = 11L, amount = 3_000, account = account) + val dto = ReceiptSaveRequest("교통비", "지하철", 3_000, LocalDate.of(2024, 9, 2), 40, emptyList()) + + every { cardinalGetService.findByAdminSide(40) } returns mockk() + every { accountRepository.findByCardinal(40) } returns account + every { receiptRepository.save(any()) } returns savedReceipt + every { fileMapper.toFileList(emptyList(), FileOwnerType.RECEIPT, savedReceipt.id) } returns emptyList() + + useCase.save(dto) + + verify(exactly = 1) { receiptRepository.save(any()) } + verify(exactly = 1) { fileRepository.saveAll(emptyList()) } + } + } + + context("존재하지 않는 기수로 저장 시") { + it("AccountNotFoundException을 던진다") { + val dto = ReceiptSaveRequest("간식비", "편의점", 5_000, LocalDate.of(2024, 9, 1), 99, null) + + every { cardinalGetService.findByAdminSide(99) } returns mockk() + every { accountRepository.findByCardinal(99) } returns null + + shouldThrow { useCase.save(dto) } + } + } + } + + describe("update") { + it("업데이트 파일이 있으면 기존 파일을 삭제 후 새 파일을 저장한다") { + val receiptId = 10L + val account = AccountTestFixture.createAccount(cardinal = 40) + val receipt = ReceiptTestFixture.createReceipt(id = receiptId, amount = 1_000, account = account) + account.spend(Money.of(receipt.amount)) + val dto = + ReceiptUpdateRequest( + "desc", + "source", + 2_000, + LocalDate.of(2026, 1, 1), + 40, + listOf(FileSaveRequest("new.png", "TEMP/2026-02/new.png", 100L, "image/png")), + ) + val oldFiles = listOf(mockk()) + val newFiles = listOf(mockk()) + + every { cardinalGetService.findByAdminSide(dto.cardinal) } returns mockk() + every { accountRepository.findByCardinal(dto.cardinal) } returns account + + every { receiptRepository.findById(receiptId) } returns Optional.of(receipt) + every { fileReader.findAll(FileOwnerType.RECEIPT, receiptId, null) } returns oldFiles + every { fileMapper.toFileList(dto.files, FileOwnerType.RECEIPT, receiptId) } returns newFiles + + useCase.update(receiptId, dto) + + verify(exactly = 1) { fileRepository.deleteAll(oldFiles) } + verify(exactly = 1) { fileRepository.saveAll(newFiles) } + } + + it("다른 기수의 장부에 속한 영수증을 수정하면 ReceiptAccountMismatchException을 던진다") { + val receiptId = 20L + val accountA = AccountTestFixture.createAccount(id = 1L, cardinal = 40) + val accountB = AccountTestFixture.createAccount(id = 2L, cardinal = 41) + val receipt = ReceiptTestFixture.createReceipt(id = receiptId, amount = 1_000, account = accountB) + val dto = ReceiptUpdateRequest("desc", "source", 2_000, LocalDate.of(2026, 1, 1), 40, null) + + every { cardinalGetService.findByAdminSide(dto.cardinal) } returns mockk() + every { accountRepository.findByCardinal(dto.cardinal) } returns accountA + every { receiptRepository.findById(receiptId) } returns Optional.of(receipt) + + shouldThrow { useCase.update(receiptId, dto) } + } + + it("빈 리스트로 업데이트 시 기존 파일을 모두 삭제한다") { + val receiptId = 11L + val account = AccountTestFixture.createAccount(cardinal = 40) + val receipt = ReceiptTestFixture.createReceipt(id = receiptId, amount = 1_000, account = account) + account.spend(Money.of(receipt.amount)) + val dto = + ReceiptUpdateRequest( + "desc", + "source", + 2_000, + LocalDate.of(2026, 1, 1), + 40, + emptyList(), + ) + val oldFiles = listOf(mockk()) + + every { cardinalGetService.findByAdminSide(dto.cardinal) } returns mockk() + every { accountRepository.findByCardinal(dto.cardinal) } returns account + every { receiptRepository.findById(receiptId) } returns Optional.of(receipt) + every { fileReader.findAll(FileOwnerType.RECEIPT, receiptId, null) } returns oldFiles + every { fileMapper.toFileList(emptyList(), FileOwnerType.RECEIPT, receiptId) } returns emptyList() + + useCase.update(receiptId, dto) + + verify(exactly = 1) { fileRepository.deleteAll(oldFiles) } + verify(exactly = 1) { fileRepository.saveAll(emptyList()) } + } + } + + describe("delete") { + it("관련 파일 삭제 후 cancelSpend가 호출되고 영수증이 삭제된다") { + val receiptId = 5L + val account = AccountTestFixture.createAccount(currentAmount = 100_000) + val receipt = ReceiptTestFixture.createReceipt(id = receiptId, amount = 10_000, account = account) + account.spend(Money.of(receipt.amount)) + val files = listOf(mockk()) + + every { receiptRepository.findById(receiptId) } returns Optional.of(receipt) + every { fileReader.findAll(FileOwnerType.RECEIPT, receiptId, null) } returns files + + useCase.delete(receiptId) + + verify(exactly = 1) { fileRepository.deleteAll(files) } + verify(exactly = 1) { receiptRepository.delete(receipt) } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountQueryServiceTest.kt new file mode 100644 index 00000000..d4a28466 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountQueryServiceTest.kt @@ -0,0 +1,89 @@ +package com.weeth.domain.account.application.usecase.query + +import com.weeth.domain.account.application.exception.AccountNotFoundException +import com.weeth.domain.account.application.mapper.AccountMapper +import com.weeth.domain.account.application.mapper.ReceiptMapper +import com.weeth.domain.account.domain.repository.AccountRepository +import com.weeth.domain.account.domain.repository.ReceiptRepository +import com.weeth.domain.account.fixture.AccountTestFixture +import com.weeth.domain.account.fixture.ReceiptTestFixture +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.repository.FileReader +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify + +class GetAccountQueryServiceTest : + DescribeSpec({ + val accountRepository = mockk() + val receiptRepository = mockk() + val fileReader = mockk() + val accountMapper = mockk() + val receiptMapper = mockk() + val fileMapper = mockk() + val queryService = + GetAccountQueryService( + accountRepository, + receiptRepository, + fileReader, + accountMapper, + receiptMapper, + fileMapper, + ) + + beforeTest { + clearMocks(accountRepository, receiptRepository, fileReader, accountMapper, receiptMapper, fileMapper) + } + + describe("findByCardinal") { + context("존재하는 기수 조회 시") { + it("영수증이 있으면 fileReader.findAll을 receiptIds 배치로 1회 호출한다") { + val account = AccountTestFixture.createAccount(cardinal = 40) + val receipt1 = ReceiptTestFixture.createReceipt(id = 1L, account = account) + val receipt2 = ReceiptTestFixture.createReceipt(id = 2L, account = account) + val accountResponse = mockk() + + every { accountRepository.findByCardinal(40) } returns account + every { receiptRepository.findAllByAccountIdOrderByCreatedAtDesc(account.id) } returns + listOf(receipt1, receipt2) + every { fileReader.findAll(FileOwnerType.RECEIPT, listOf(1L, 2L), null) } returns emptyList() + every { fileMapper.toFileResponse(any()) } returns mockk() + every { receiptMapper.toResponses(any(), any()) } returns emptyList() + every { accountMapper.toResponse(account, emptyList()) } returns accountResponse + + val result = queryService.findByCardinal(40) + + result shouldBe accountResponse + verify(exactly = 1) { fileReader.findAll(FileOwnerType.RECEIPT, listOf(1L, 2L), null) } + } + + it("영수증이 없으면 fileReader.findAll을 빈 리스트로 호출한다") { + val account = AccountTestFixture.createAccount(cardinal = 40) + val accountResponse = mockk() + + every { accountRepository.findByCardinal(40) } returns account + every { receiptRepository.findAllByAccountIdOrderByCreatedAtDesc(account.id) } returns emptyList() + every { fileReader.findAll(FileOwnerType.RECEIPT, emptyList(), null) } returns emptyList() + every { receiptMapper.toResponses(emptyList(), emptyMap()) } returns emptyList() + every { accountMapper.toResponse(account, emptyList()) } returns accountResponse + + queryService.findByCardinal(40) + + verify(exactly = 1) { fileReader.findAll(FileOwnerType.RECEIPT, emptyList(), null) } + } + } + + context("존재하지 않는 기수 조회 시") { + it("AccountNotFoundException을 던진다") { + every { accountRepository.findByCardinal(99) } returns null + + shouldThrow { queryService.findByCardinal(99) } + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/account/domain/entity/AccountTest.kt b/src/test/kotlin/com/weeth/domain/account/domain/entity/AccountTest.kt new file mode 100644 index 00000000..0dc3cdd3 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/account/domain/entity/AccountTest.kt @@ -0,0 +1,54 @@ +package com.weeth.domain.account.domain.entity + +import com.weeth.domain.account.domain.vo.Money +import com.weeth.domain.account.fixture.AccountTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe + +class AccountTest : + StringSpec({ + "spend은 currentAmount를 Money 금액만큼 감소시킨다" { + val account = AccountTestFixture.createAccount(currentAmount = 100_000) + + account.spend(Money.of(10_000)) + + account.currentAmount shouldBe 90_000 + } + + "cancelSpend은 currentAmount를 Money 금액만큼 복원한다" { + val account = AccountTestFixture.createAccount(currentAmount = 90_000) + + account.cancelSpend(Money.of(10_000)) + + account.currentAmount shouldBe 100_000 + } + + "adjustSpend는 기존 금액을 취소하고 새 금액을 차감한다" { + val account = AccountTestFixture.createAccount(totalAmount = 100_000, currentAmount = 90_000) + + account.adjustSpend(Money.of(10_000), Money.of(20_000)) + + account.currentAmount shouldBe 80_000 + } + + "spend 시 잔액이 부족하면 IllegalStateException을 던진다" { + val account = AccountTestFixture.createAccount(currentAmount = 5_000) + + shouldThrow { account.spend(Money.of(10_000)) } + } + + "cancelSpend 시 총액을 초과하면 IllegalStateException을 던진다" { + val account = AccountTestFixture.createAccount(totalAmount = 100_000, currentAmount = 100_000) + + shouldThrow { account.cancelSpend(Money.of(1)) } + } + + "create는 currentAmount를 totalAmount와 동일하게 초기화한다" { + val account = Account.create("2학기 회비", 200_000, 41) + + account.currentAmount shouldBe 200_000 + account.totalAmount shouldBe 200_000 + account.cardinal shouldBe 41 + } + }) diff --git a/src/test/kotlin/com/weeth/domain/account/domain/entity/ReceiptTest.kt b/src/test/kotlin/com/weeth/domain/account/domain/entity/ReceiptTest.kt new file mode 100644 index 00000000..ac753511 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/account/domain/entity/ReceiptTest.kt @@ -0,0 +1,35 @@ +package com.weeth.domain.account.domain.entity + +import com.weeth.domain.account.fixture.ReceiptTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import java.time.LocalDate + +class ReceiptTest : + StringSpec({ + "update는 영수증 필드를 갱신한다" { + val receipt = + ReceiptTestFixture.createReceipt( + description = "기존 설명", + source = "기존 출처", + amount = 5_000, + date = LocalDate.of(2024, 1, 1), + ) + + receipt.update("새로운 설명", "새 출처", 20_000, LocalDate.of(2025, 6, 1)) + + receipt.description shouldBe "새로운 설명" + receipt.source shouldBe "새 출처" + receipt.amount shouldBe 20_000 + receipt.date shouldBe LocalDate.of(2025, 6, 1) + } + + "update 시 amount가 0 이하면 IllegalArgumentException을 던진다" { + val receipt = ReceiptTestFixture.createReceipt(amount = 5_000) + + shouldThrow { + receipt.update("설명", "출처", 0, LocalDate.of(2025, 6, 1)) + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/account/fixture/AccountTestFixture.kt b/src/test/kotlin/com/weeth/domain/account/fixture/AccountTestFixture.kt new file mode 100644 index 00000000..1b515748 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/account/fixture/AccountTestFixture.kt @@ -0,0 +1,20 @@ +package com.weeth.domain.account.fixture + +import com.weeth.domain.account.domain.entity.Account + +object AccountTestFixture { + fun createAccount( + id: Long = 1L, + description: String = "2024년 2학기 회비", + totalAmount: Int = 100_000, + currentAmount: Int = 100_000, + cardinal: Int = 40, + ): Account = + Account( + id = id, + description = description, + totalAmount = totalAmount, + currentAmount = currentAmount, + cardinal = cardinal, + ) +} diff --git a/src/test/kotlin/com/weeth/domain/account/fixture/ReceiptTestFixture.kt b/src/test/kotlin/com/weeth/domain/account/fixture/ReceiptTestFixture.kt new file mode 100644 index 00000000..b02c7535 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/account/fixture/ReceiptTestFixture.kt @@ -0,0 +1,24 @@ +package com.weeth.domain.account.fixture + +import com.weeth.domain.account.domain.entity.Account +import com.weeth.domain.account.domain.entity.Receipt +import java.time.LocalDate + +object ReceiptTestFixture { + fun createReceipt( + id: Long = 1L, + description: String = "간식비", + source: String = "편의점", + amount: Int = 10_000, + date: LocalDate = LocalDate.of(2024, 9, 1), + account: Account = AccountTestFixture.createAccount(), + ): Receipt = + Receipt( + id = id, + description = description, + source = source, + amount = amount, + date = date, + account = account, + ) +} From b1bd576b73105971d686d88b1dad26987ade48a3 Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:33:54 +0900 Subject: [PATCH 12/73] =?UTF-8?q?[WTH-159]=20user=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20(#14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 사용자 도메인 모델을 코틀린으로 전환 * refactor: 사용자 DTO와 컨트롤러를 재구성 * refactor: 사용자 예외와 조회 로직을 정리 * feat: 소셜 로그인 연동을 구현 * perf: OB 적용 조회를 배치로 최적화 * refactor: 타 도메인 사용자 의존성을 정리 * test: 의존성 변경에 맞춰 테스트를 수정 * test: 사용자 도메인 테스트 구조를 개편 * refactor: 유저 테스트 명시적으로 수정 * refactor: 탈퇴 메서드 이전 * refactor: 미사용 메서드 제거 * refactor: 개행 추가 * refactor: DB 정렬 쿼리로 개선 * refactor: lint 설정 --- .../application/usecase/EventUseCaseImpl.java | 14 +- .../usecase/MeetingUseCaseImpl.java | 22 +- .../usecase/ScheduleUseCaseImpl.java | 8 +- .../domain/repository/MeetingRepository.java | 2 + .../domain/service/MeetingGetService.java | 11 + .../dto/request/CardinalSaveRequest.java | 11 - .../dto/request/CardinalUpdateRequest.java | 11 - .../dto/request/UserRequestDto.java | 78 ----- .../dto/response/CardinalResponse.java | 16 - .../dto/response/UserCardinalDto.java | 12 - .../dto/response/UserResponseDto.java | 90 ----- .../exception/CardinalNotFoundException.java | 9 - .../DepartmentNotFoundException.java | 9 - .../exception/DuplicateCardinalException.java | 9 - .../exception/EmailNotFoundException.java | 9 - .../exception/InvalidUserOrderException.java | 9 - .../exception/PasswordMismatchException.java | 9 - .../exception/RoleNotFoundException.java | 9 - .../exception/StatusNotFoundException.java | 9 - .../exception/StudentIdExistsException.java | 9 - .../exception/TelExistsException.java | 9 - .../UserCardinalNotFoundException.java | 9 - .../exception/UserExistsException.java | 9 - .../exception/UserInActiveException.java | 9 - .../exception/UserMismatchException.java | 9 - .../exception/UserNotFoundException.java | 9 - .../exception/UserNotMatchException.java | 9 - .../application/mapper/CardinalMapper.java | 23 -- .../user/application/mapper/UserMapper.java | 105 ------ .../application/usecase/CardinalUseCase.java | 63 ---- .../usecase/UserManageUseCase.java | 26 -- .../usecase/UserManageUseCaseImpl.java | 162 --------- .../user/application/usecase/UserUseCase.java | 44 --- .../application/usecase/UserUseCaseImpl.java | 314 ------------------ .../domain/user/domain/entity/Cardinal.java | 48 --- .../user/domain/entity/SecurityUser.java | 67 ---- .../weeth/domain/user/domain/entity/User.java | 201 ----------- .../user/domain/entity/UserCardinal.java | 33 -- .../domain/entity/enums/CardinalStatus.java | 5 - .../user/domain/entity/enums/Department.java | 34 -- .../user/domain/entity/enums/LoginStatus.java | 5 - .../user/domain/entity/enums/Position.java | 8 - .../domain/user/domain/entity/enums/Role.java | 11 - .../user/domain/entity/enums/Status.java | 8 - .../domain/entity/enums/StatusPriority.java | 25 -- .../domain/entity/enums/UsersOrderBy.java | 6 - .../domain/repository/CardinalRepository.java | 22 -- .../repository/UserCardinalRepository.java | 26 -- .../domain/repository/UserRepository.java | 73 ---- .../domain/service/CardinalGetService.java | 55 --- .../domain/service/CardinalSaveService.java | 17 - .../service/UserCardinalGetService.java | 56 ---- .../service/UserCardinalSaveService.java | 17 - .../domain/service/UserDeleteService.java | 21 -- .../user/domain/service/UserGetService.java | 91 ----- .../user/domain/service/UserSaveService.java | 17 - .../domain/service/UserUpdateService.java | 32 -- .../user/presentation/CardinalController.java | 54 --- .../presentation/UserAdminController.java | 69 ---- .../user/presentation/UserController.java | 148 --------- .../user/presentation/UserResponseCode.java | 43 --- .../usecase/command/ManageAccountUseCase.kt | 9 +- .../usecase/command/ManageReceiptUseCase.kt | 15 +- .../dto/response/AttendanceInfoResponse.kt | 2 - .../application/mapper/AttendanceMapper.kt | 9 +- .../command/CheckInAttendanceUseCase.kt | 6 +- .../query/GetAttendanceQueryService.kt | 14 +- .../domain/service/AttendanceSaveService.kt | 3 +- .../usecase/command/ManagePostUseCase.kt | 6 +- .../dto/response/CommentResponse.kt | 3 - .../application/mapper/CommentMapper.kt | 1 - .../usecase/command/ManageCommentUseCase.kt | 6 +- .../usecase/command/SavePenaltyUseCase.kt | 6 +- .../usecase/query/GetPenaltyQueryService.kt | 28 +- .../dto/request/CardinalSaveRequest.kt | 19 ++ .../dto/request/CardinalUpdateRequest.kt | 19 ++ .../application/dto/request/SignUpRequest.kt | 28 ++ .../dto/request/SocialLoginRequest.kt | 18 + .../dto/request/UpdateUserProfileRequest.kt | 25 ++ .../dto/request/UserApplyObRequest.kt | 13 + .../application/dto/request/UserIdsRequest.kt | 12 + .../dto/request/UserRoleUpdateRequest.kt | 14 + .../dto/response/AdminUserResponse.kt | 41 +++ .../dto/response/CardinalResponse.kt | 22 ++ .../dto/response/SocialLoginResponse.kt | 16 + .../dto/response/UserDetailsResponse.kt | 21 ++ .../dto/response/UserInfoResponse.kt | 15 + .../dto/response/UserProfileResponse.kt | 23 ++ .../dto/response/UserSummaryResponse.kt | 15 + .../exception/CardinalNotFoundException.kt | 5 + .../exception/DuplicateCardinalException.kt | 5 + .../exception/EmailNotFoundException.kt | 5 + .../exception/InvalidUserOrderException.kt | 5 + .../exception/PasswordMismatchException.kt | 5 + .../exception/RoleNotFoundException.kt | 5 + .../exception/StatusNotFoundException.kt | 5 + .../exception/StudentIdExistsException.kt | 5 + .../exception/TelExistsException.kt | 5 + .../UserCardinalNotFoundException.kt | 5 + .../application/exception/UserErrorCode.kt} | 38 +-- .../exception/UserExistsException.kt | 5 + .../exception/UserInActiveException.kt | 5 + .../exception/UserMismatchException.kt | 5 + .../exception/UserNotFoundException.kt | 5 + .../exception/UserNotMatchException.kt | 5 + .../user/application/mapper/CardinalMapper.kt | 27 ++ .../user/application/mapper/UserMapper.kt | 104 ++++++ .../usecase/command/AdminUserUseCase.kt | 107 ++++++ .../usecase/command/AuthUserUseCase.kt | 231 +++++++++++++ .../usecase/command/ManageCardinalUseCase.kt | 46 +++ .../usecase/query/GetCardinalQueryService.kt | 16 + .../usecase/query/GetUserQueryService.kt | 115 +++++++ .../user/domain/converter/EmailConverter.kt | 12 + .../domain/converter/PhoneNumberConverter.kt | 12 + .../domain/user/domain/entity/Cardinal.kt | 56 ++++ .../weeth/domain/user/domain/entity/User.kt | 196 +++++++++++ .../domain/user/domain/entity/UserCardinal.kt | 34 ++ .../user/domain/entity/UserSocialAccount.kt | 41 +++ .../domain/entity/enums/CardinalStatus.kt | 6 + .../domain/user/domain/entity/enums/Role.kt | 6 + .../domain/entity/enums/SocialProvider.kt | 6 + .../domain/user/domain/entity/enums/Status.kt | 8 + .../domain/entity/enums/StatusPriority.kt | 26 ++ .../user/domain/entity/enums/UsersOrderBy.kt | 6 + .../user/domain/repository/CardinalReader.kt | 11 + .../domain/repository/CardinalRepository.kt | 33 ++ .../domain/repository/UserCardinalReader.kt | 12 + .../repository/UserCardinalRepository.kt | 40 +++ .../user/domain/repository/UserReader.kt | 13 + .../user/domain/repository/UserRepository.kt | 112 +++++++ .../repository/UserSocialAccountRepository.kt | 13 + .../user/domain/service/UserCardinalPolicy.kt | 28 ++ .../domain/user/domain/vo/AttendanceStats.kt | 49 +++ .../com/weeth/domain/user/domain/vo/Email.kt | 18 + .../domain/user/domain/vo/PhoneNumber.kt | 16 + .../user/presentation/CardinalController.kt | 52 +++ .../user/presentation/UserAdminController.kt | 75 +++++ .../user/presentation/UserController.kt | 134 ++++++++ .../user/presentation/UserResponseCode.kt | 27 ++ .../global/auth/apple/AppleAuthService.kt | 2 + .../global/auth/apple/dto/AppleUserInfo.kt | 1 + .../global/auth/kakao/dto/KakaoAccount.kt | 2 + .../global/auth/kakao/dto/KakaoProfile.kt | 8 + .../com/weeth/global/config/SecurityConfig.kt | 16 +- .../command/ManageAccountUseCaseTest.kt | 24 +- .../command/ManageReceiptUseCaseTest.kt | 60 ++-- .../mapper/AttendanceMapperTest.kt | 20 +- .../command/CheckInAttendanceUseCaseTest.kt | 14 +- .../query/GetAttendanceQueryServiceTest.kt | 20 +- .../repository/AttendanceRepositoryTest.kt | 18 +- .../service/AttendanceSaveServiceTest.kt | 5 +- .../fixture/AttendanceTestFixture.kt | 82 ++--- .../application/mapper/PostMapperTest.kt | 4 - .../usecase/command/ManagePostUseCaseTest.kt | 33 +- .../usecase/command/CommentConcurrencyTest.kt | 11 +- .../command/ManageCommentUseCaseTest.kt | 12 +- .../query/CommentQueryPerformanceTest.kt | 16 +- .../query/GetCommentQueryServiceTest.kt | 2 - .../usecase/UserManageUseCaseTest.kt | 238 ------------- .../usecase/command/AdminUserUseCaseTest.kt | 158 +++++++++ .../usecase/command/AuthUserUseCaseTest.kt | 266 +++++++++++++++ .../{ => command}/CardinalUseCaseTest.kt | 64 ++-- .../usecase/query/GetUserQueryServiceTest.kt | 94 ++++++ .../domain/user/domain/entity/CardinalTest.kt | 26 ++ .../domain/user/domain/entity/UserTest.kt | 40 +++ .../domain/service/CardinalGetServiceTest.kt | 56 ---- .../service/UserCardinalGetServiceTest.kt | 88 ----- .../domain/service/UserCardinalPolicyTest.kt | 63 ++++ .../user/domain/service/UserGetServiceTest.kt | 62 ---- .../user/fixture/CardinalTestFixture.kt | 30 +- .../domain/user/fixture/UserTestFixture.kt | 67 ++-- 171 files changed, 3052 insertions(+), 3108 deletions(-) delete mode 100644 src/main/java/com/weeth/domain/user/application/dto/request/CardinalSaveRequest.java delete mode 100644 src/main/java/com/weeth/domain/user/application/dto/request/CardinalUpdateRequest.java delete mode 100644 src/main/java/com/weeth/domain/user/application/dto/request/UserRequestDto.java delete mode 100644 src/main/java/com/weeth/domain/user/application/dto/response/CardinalResponse.java delete mode 100644 src/main/java/com/weeth/domain/user/application/dto/response/UserCardinalDto.java delete mode 100644 src/main/java/com/weeth/domain/user/application/dto/response/UserResponseDto.java delete mode 100644 src/main/java/com/weeth/domain/user/application/exception/CardinalNotFoundException.java delete mode 100644 src/main/java/com/weeth/domain/user/application/exception/DepartmentNotFoundException.java delete mode 100644 src/main/java/com/weeth/domain/user/application/exception/DuplicateCardinalException.java delete mode 100644 src/main/java/com/weeth/domain/user/application/exception/EmailNotFoundException.java delete mode 100644 src/main/java/com/weeth/domain/user/application/exception/InvalidUserOrderException.java delete mode 100644 src/main/java/com/weeth/domain/user/application/exception/PasswordMismatchException.java delete mode 100644 src/main/java/com/weeth/domain/user/application/exception/RoleNotFoundException.java delete mode 100644 src/main/java/com/weeth/domain/user/application/exception/StatusNotFoundException.java delete mode 100644 src/main/java/com/weeth/domain/user/application/exception/StudentIdExistsException.java delete mode 100644 src/main/java/com/weeth/domain/user/application/exception/TelExistsException.java delete mode 100644 src/main/java/com/weeth/domain/user/application/exception/UserCardinalNotFoundException.java delete mode 100644 src/main/java/com/weeth/domain/user/application/exception/UserExistsException.java delete mode 100644 src/main/java/com/weeth/domain/user/application/exception/UserInActiveException.java delete mode 100644 src/main/java/com/weeth/domain/user/application/exception/UserMismatchException.java delete mode 100644 src/main/java/com/weeth/domain/user/application/exception/UserNotFoundException.java delete mode 100644 src/main/java/com/weeth/domain/user/application/exception/UserNotMatchException.java delete mode 100644 src/main/java/com/weeth/domain/user/application/mapper/CardinalMapper.java delete mode 100644 src/main/java/com/weeth/domain/user/application/mapper/UserMapper.java delete mode 100644 src/main/java/com/weeth/domain/user/application/usecase/CardinalUseCase.java delete mode 100644 src/main/java/com/weeth/domain/user/application/usecase/UserManageUseCase.java delete mode 100644 src/main/java/com/weeth/domain/user/application/usecase/UserManageUseCaseImpl.java delete mode 100644 src/main/java/com/weeth/domain/user/application/usecase/UserUseCase.java delete mode 100644 src/main/java/com/weeth/domain/user/application/usecase/UserUseCaseImpl.java delete mode 100644 src/main/java/com/weeth/domain/user/domain/entity/Cardinal.java delete mode 100644 src/main/java/com/weeth/domain/user/domain/entity/SecurityUser.java delete mode 100644 src/main/java/com/weeth/domain/user/domain/entity/User.java delete mode 100644 src/main/java/com/weeth/domain/user/domain/entity/UserCardinal.java delete mode 100644 src/main/java/com/weeth/domain/user/domain/entity/enums/CardinalStatus.java delete mode 100644 src/main/java/com/weeth/domain/user/domain/entity/enums/Department.java delete mode 100644 src/main/java/com/weeth/domain/user/domain/entity/enums/LoginStatus.java delete mode 100644 src/main/java/com/weeth/domain/user/domain/entity/enums/Position.java delete mode 100644 src/main/java/com/weeth/domain/user/domain/entity/enums/Role.java delete mode 100644 src/main/java/com/weeth/domain/user/domain/entity/enums/Status.java delete mode 100644 src/main/java/com/weeth/domain/user/domain/entity/enums/StatusPriority.java delete mode 100644 src/main/java/com/weeth/domain/user/domain/entity/enums/UsersOrderBy.java delete mode 100644 src/main/java/com/weeth/domain/user/domain/repository/CardinalRepository.java delete mode 100644 src/main/java/com/weeth/domain/user/domain/repository/UserCardinalRepository.java delete mode 100644 src/main/java/com/weeth/domain/user/domain/repository/UserRepository.java delete mode 100644 src/main/java/com/weeth/domain/user/domain/service/CardinalGetService.java delete mode 100644 src/main/java/com/weeth/domain/user/domain/service/CardinalSaveService.java delete mode 100644 src/main/java/com/weeth/domain/user/domain/service/UserCardinalGetService.java delete mode 100644 src/main/java/com/weeth/domain/user/domain/service/UserCardinalSaveService.java delete mode 100644 src/main/java/com/weeth/domain/user/domain/service/UserDeleteService.java delete mode 100644 src/main/java/com/weeth/domain/user/domain/service/UserGetService.java delete mode 100644 src/main/java/com/weeth/domain/user/domain/service/UserSaveService.java delete mode 100644 src/main/java/com/weeth/domain/user/domain/service/UserUpdateService.java delete mode 100644 src/main/java/com/weeth/domain/user/presentation/CardinalController.java delete mode 100644 src/main/java/com/weeth/domain/user/presentation/UserAdminController.java delete mode 100644 src/main/java/com/weeth/domain/user/presentation/UserController.java delete mode 100644 src/main/java/com/weeth/domain/user/presentation/UserResponseCode.java create mode 100644 src/main/kotlin/com/weeth/domain/user/application/dto/request/CardinalSaveRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/dto/request/CardinalUpdateRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/dto/request/SignUpRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/dto/request/SocialLoginRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/dto/request/UpdateUserProfileRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/dto/request/UserApplyObRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/dto/request/UserIdsRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/dto/request/UserRoleUpdateRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/dto/response/AdminUserResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/dto/response/CardinalResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/dto/response/SocialLoginResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/dto/response/UserDetailsResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/dto/response/UserInfoResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/dto/response/UserProfileResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/dto/response/UserSummaryResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/exception/CardinalNotFoundException.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/exception/DuplicateCardinalException.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/exception/EmailNotFoundException.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/exception/InvalidUserOrderException.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/exception/PasswordMismatchException.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/exception/RoleNotFoundException.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/exception/StatusNotFoundException.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/exception/StudentIdExistsException.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/exception/TelExistsException.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/exception/UserCardinalNotFoundException.kt rename src/main/{java/com/weeth/domain/user/application/exception/UserErrorCode.java => kotlin/com/weeth/domain/user/application/exception/UserErrorCode.kt} (83%) create mode 100644 src/main/kotlin/com/weeth/domain/user/application/exception/UserExistsException.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/exception/UserInActiveException.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/exception/UserMismatchException.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/exception/UserNotFoundException.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/exception/UserNotMatchException.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/mapper/CardinalMapper.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/usecase/command/AdminUserUseCase.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCase.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/usecase/command/ManageCardinalUseCase.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/usecase/query/GetCardinalQueryService.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryService.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/domain/converter/EmailConverter.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/domain/converter/PhoneNumberConverter.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/domain/entity/Cardinal.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/domain/entity/UserCardinal.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/domain/entity/UserSocialAccount.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/domain/entity/enums/CardinalStatus.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/domain/entity/enums/Role.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/domain/entity/enums/SocialProvider.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/domain/entity/enums/Status.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/domain/entity/enums/StatusPriority.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/domain/entity/enums/UsersOrderBy.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/domain/repository/CardinalReader.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/domain/repository/CardinalRepository.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/domain/repository/UserCardinalReader.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/domain/repository/UserCardinalRepository.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/domain/repository/UserReader.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/domain/repository/UserRepository.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/domain/repository/UserSocialAccountRepository.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/domain/service/UserCardinalPolicy.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/domain/vo/AttendanceStats.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/domain/vo/Email.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/domain/vo/PhoneNumber.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/presentation/CardinalController.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/presentation/UserAdminController.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoProfile.kt delete mode 100644 src/test/kotlin/com/weeth/domain/user/application/usecase/UserManageUseCaseTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/user/application/usecase/command/AdminUserUseCaseTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCaseTest.kt rename src/test/kotlin/com/weeth/domain/user/application/usecase/{ => command}/CardinalUseCaseTest.kt (56%) create mode 100644 src/test/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryServiceTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/user/domain/entity/CardinalTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt delete mode 100644 src/test/kotlin/com/weeth/domain/user/domain/service/CardinalGetServiceTest.kt delete mode 100644 src/test/kotlin/com/weeth/domain/user/domain/service/UserCardinalGetServiceTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/user/domain/service/UserCardinalPolicyTest.kt delete mode 100644 src/test/kotlin/com/weeth/domain/user/domain/service/UserGetServiceTest.kt diff --git a/src/main/java/com/weeth/domain/schedule/application/usecase/EventUseCaseImpl.java b/src/main/java/com/weeth/domain/schedule/application/usecase/EventUseCaseImpl.java index 62ada147..21a8ae35 100644 --- a/src/main/java/com/weeth/domain/schedule/application/usecase/EventUseCaseImpl.java +++ b/src/main/java/com/weeth/domain/schedule/application/usecase/EventUseCaseImpl.java @@ -8,8 +8,8 @@ import com.weeth.domain.schedule.domain.service.EventSaveService; import com.weeth.domain.schedule.domain.service.EventUpdateService; import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.service.CardinalGetService; -import com.weeth.domain.user.domain.service.UserGetService; +import com.weeth.domain.user.domain.repository.CardinalReader; +import com.weeth.domain.user.domain.repository.UserReader; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -20,12 +20,12 @@ @RequiredArgsConstructor public class EventUseCaseImpl implements EventUseCase { - private final UserGetService userGetService; + private final UserReader userReader; private final EventGetService eventGetService; private final EventSaveService eventSaveService; private final EventUpdateService eventUpdateService; private final EventDeleteService eventDeleteService; - private final CardinalGetService cardinalGetService; + private final CardinalReader cardinalReader; private final EventMapper mapper; @Override @@ -36,8 +36,8 @@ public Response find(Long eventId) { @Override @Transactional public void save(ScheduleDTO.Save dto, Long userId) { - User user = userGetService.find(userId); - cardinalGetService.findByUserSide(dto.cardinal()); + User user = userReader.getById(userId); + cardinalReader.getByCardinalNumber(dto.cardinal()); eventSaveService.save(mapper.from(dto, user)); } @@ -45,7 +45,7 @@ public void save(ScheduleDTO.Save dto, Long userId) { @Override @Transactional public void update(Long eventId, ScheduleDTO.Update dto, Long userId) { - User user = userGetService.find(userId); + User user = userReader.getById(userId); Event event = eventGetService.find(eventId); eventUpdateService.update(event, dto, user); } diff --git a/src/main/java/com/weeth/domain/schedule/application/usecase/MeetingUseCaseImpl.java b/src/main/java/com/weeth/domain/schedule/application/usecase/MeetingUseCaseImpl.java index 21a25151..de95fef2 100644 --- a/src/main/java/com/weeth/domain/schedule/application/usecase/MeetingUseCaseImpl.java +++ b/src/main/java/com/weeth/domain/schedule/application/usecase/MeetingUseCaseImpl.java @@ -18,8 +18,10 @@ import com.weeth.domain.user.domain.entity.Cardinal; import com.weeth.domain.user.domain.entity.User; import com.weeth.domain.user.domain.entity.enums.Role; -import com.weeth.domain.user.domain.service.CardinalGetService; -import com.weeth.domain.user.domain.service.UserGetService; +import com.weeth.domain.user.domain.entity.enums.Status; +import com.weeth.domain.user.domain.repository.CardinalReader; +import com.weeth.domain.user.domain.repository.UserReader; +import com.weeth.domain.user.domain.repository.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -31,7 +33,6 @@ import java.util.Comparator; import java.util.List; -import static com.weeth.domain.schedule.application.dto.MeetingDTO.Info; import static com.weeth.domain.schedule.application.dto.MeetingDTO.Response; @Slf4j @@ -42,21 +43,22 @@ public class MeetingUseCaseImpl implements MeetingUseCase { private final MeetingGetService meetingGetService; private final MeetingMapper mapper; private final MeetingSaveService meetingSaveService; - private final UserGetService userGetService; + private final UserReader userReader; + private final UserRepository userRepository; private final MeetingUpdateService meetingUpdateService; private final MeetingDeleteService meetingDeleteService; private final AttendanceGetService attendanceGetService; private final AttendanceSaveService attendanceSaveService; private final AttendanceDeleteService attendanceDeleteService; private final AttendanceUpdateService attendanceUpdateService; - private final CardinalGetService cardinalGetService; + private final CardinalReader cardinalReader; @PersistenceContext private EntityManager em; @Override public Response find(Long userId, Long meetingId) { - User user = userGetService.find(userId); + User user = userReader.getById(userId); Meeting meeting = meetingGetService.find(meetingId); if (Role.ADMIN == user.getRole()) { @@ -87,10 +89,10 @@ public MeetingDTO.Infos find(Integer cardinal) { @Override @Transactional public void save(ScheduleDTO.Save dto, Long userId) { - User user = userGetService.find(userId); - Cardinal cardinal = cardinalGetService.findByUserSide(dto.cardinal()); + User user = userReader.getById(userId); + Cardinal cardinal = cardinalReader.getByCardinalNumber(dto.cardinal()); - List userList = userGetService.findAllByCardinal(cardinal); + List userList = userRepository.findAllByCardinalAndStatus(cardinal, Status.ACTIVE); Meeting meeting = mapper.from(dto, user); meetingSaveService.save(meeting); @@ -102,7 +104,7 @@ public void save(ScheduleDTO.Save dto, Long userId) { @Transactional public void update(ScheduleDTO.Update dto, Long userId, Long meetingId) { Meeting meeting = meetingGetService.find(meetingId); - User user = userGetService.find(userId); + User user = userReader.getById(userId); meetingUpdateService.update(dto, user, meeting); } diff --git a/src/main/java/com/weeth/domain/schedule/application/usecase/ScheduleUseCaseImpl.java b/src/main/java/com/weeth/domain/schedule/application/usecase/ScheduleUseCaseImpl.java index c7818d1d..79fdcb36 100644 --- a/src/main/java/com/weeth/domain/schedule/application/usecase/ScheduleUseCaseImpl.java +++ b/src/main/java/com/weeth/domain/schedule/application/usecase/ScheduleUseCaseImpl.java @@ -3,7 +3,8 @@ import com.weeth.domain.schedule.domain.service.EventGetService; import com.weeth.domain.schedule.domain.service.MeetingGetService; import com.weeth.domain.user.domain.entity.Cardinal; -import com.weeth.domain.user.domain.service.CardinalGetService; +import com.weeth.domain.user.application.exception.CardinalNotFoundException; +import com.weeth.domain.user.domain.repository.CardinalRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -21,7 +22,7 @@ public class ScheduleUseCaseImpl implements ScheduleUseCase { private final EventGetService eventGetService; private final MeetingGetService meetingGetService; - private final CardinalGetService cardinalGetService; + private final CardinalRepository cardinalRepository; @Override public List findByMonthly(LocalDateTime start, LocalDateTime end) { @@ -36,7 +37,8 @@ public List findByMonthly(LocalDateTime start, LocalDateTime end) { @Override public Map> findByYearly(Integer year, Integer semester) { - Cardinal cardinal = cardinalGetService.find(year, semester); + Cardinal cardinal = cardinalRepository.findByYearAndSemester(year, semester) + .orElseThrow(CardinalNotFoundException::new); List events = eventGetService.find(cardinal.getCardinalNumber()); List meetings = meetingGetService.findByCardinal(cardinal.getCardinalNumber()); diff --git a/src/main/java/com/weeth/domain/schedule/domain/repository/MeetingRepository.java b/src/main/java/com/weeth/domain/schedule/domain/repository/MeetingRepository.java index e85d650f..8b3c1128 100644 --- a/src/main/java/com/weeth/domain/schedule/domain/repository/MeetingRepository.java +++ b/src/main/java/com/weeth/domain/schedule/domain/repository/MeetingRepository.java @@ -17,6 +17,8 @@ public interface MeetingRepository extends JpaRepository { List findAllByCardinal(int cardinal); + List findAllByCardinalInOrderByCardinalAscStartAsc(List cardinals); + List findAllByMeetingStatusAndEndBeforeOrderByEndAsc(MeetingStatus status, LocalDateTime end); List findAllByOrderByStartDesc(); diff --git a/src/main/java/com/weeth/domain/schedule/domain/service/MeetingGetService.java b/src/main/java/com/weeth/domain/schedule/domain/service/MeetingGetService.java index 3eab97d2..6eebc011 100644 --- a/src/main/java/com/weeth/domain/schedule/domain/service/MeetingGetService.java +++ b/src/main/java/com/weeth/domain/schedule/domain/service/MeetingGetService.java @@ -10,7 +10,10 @@ import org.springframework.stereotype.Service; import java.time.LocalDateTime; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -34,6 +37,14 @@ public List find(Integer cardinal) { return meetingRepository.findAllByCardinalOrderByStartAsc(cardinal); } + public Map> findByCardinals(List cardinals) { + if (cardinals == null || cardinals.isEmpty()) { + return Map.of(); + } + return meetingRepository.findAllByCardinalInOrderByCardinalAscStartAsc(cardinals).stream() + .collect(Collectors.groupingBy(Meeting::getCardinal, LinkedHashMap::new, Collectors.toList())); + } + public List findMeetingByCardinal(Integer cardinal) { return meetingRepository.findAllByCardinalOrderByStartDesc(cardinal); } diff --git a/src/main/java/com/weeth/domain/user/application/dto/request/CardinalSaveRequest.java b/src/main/java/com/weeth/domain/user/application/dto/request/CardinalSaveRequest.java deleted file mode 100644 index 3896b1aa..00000000 --- a/src/main/java/com/weeth/domain/user/application/dto/request/CardinalSaveRequest.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.weeth.domain.user.application.dto.request; - -import jakarta.validation.constraints.NotNull; - -public record CardinalSaveRequest ( - @NotNull Integer cardinalNumber, - @NotNull Integer year, - @NotNull Integer semester, - boolean inProgress -){ -} diff --git a/src/main/java/com/weeth/domain/user/application/dto/request/CardinalUpdateRequest.java b/src/main/java/com/weeth/domain/user/application/dto/request/CardinalUpdateRequest.java deleted file mode 100644 index 029d7154..00000000 --- a/src/main/java/com/weeth/domain/user/application/dto/request/CardinalUpdateRequest.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.weeth.domain.user.application.dto.request; - -import jakarta.validation.constraints.NotNull; - -public record CardinalUpdateRequest( - @NotNull Long id, - @NotNull Integer year, - @NotNull Integer semester, - boolean inProgress -) { -} diff --git a/src/main/java/com/weeth/domain/user/application/dto/request/UserRequestDto.java b/src/main/java/com/weeth/domain/user/application/dto/request/UserRequestDto.java deleted file mode 100644 index 30e16e42..00000000 --- a/src/main/java/com/weeth/domain/user/application/dto/request/UserRequestDto.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.weeth.domain.user.application.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import com.weeth.domain.user.domain.entity.enums.Role; - -import java.util.List; - -public class UserRequestDto { - - public record Login( - @NotBlank String authCode - ) { - } - - public record SignUp( - @NotBlank String name, - @Email @NotBlank String email, - @NotBlank String password, - @NotBlank String studentId, - @NotBlank String tel, - @NotNull String position, - @NotNull String department, - @NotNull Integer cardinal - ) { - } - - public record Register( - @Schema(description = "kakao로 회원가입 하는 경우") - Long kakaoId, - @Schema(description = "애플로 회원가입 하는 경우 - Apple OAuth authCode") - String appleAuthCode, - @NotBlank String name, - @NotBlank String studentId, - @NotBlank String email, - @NotNull String department, - @NotBlank String tel, - @NotNull Integer cardinal, - @NotNull String position - ) { - } - - public record Update( - @NotBlank String name, - @Email @NotBlank String email, - @NotBlank String studentId, - @NotBlank String tel, - @NotNull String department - ) { - } - - public record NormalLogin( - @Email @NotBlank String email, - @NotBlank String passWord, - @NotNull Long kakaoId - ) { - } - - public record UserRoleUpdate( - @NotNull Long userId, - @NotNull Role role - ) { - } - - public record UserApplyOB( - @NotNull Long userId, - @NotNull Integer cardinal - ) { - } - - public record UserId( - @NotNull List userId - ) { - } - -} diff --git a/src/main/java/com/weeth/domain/user/application/dto/response/CardinalResponse.java b/src/main/java/com/weeth/domain/user/application/dto/response/CardinalResponse.java deleted file mode 100644 index c296be9f..00000000 --- a/src/main/java/com/weeth/domain/user/application/dto/response/CardinalResponse.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.weeth.domain.user.application.dto.response; - -import com.weeth.domain.user.domain.entity.enums.CardinalStatus; - -import java.time.LocalDateTime; - -public record CardinalResponse( - Long id, - Integer cardinalNumber, - Integer year, - Integer semester, - CardinalStatus status, - LocalDateTime createdAt, - LocalDateTime modifiedAt -) { -} diff --git a/src/main/java/com/weeth/domain/user/application/dto/response/UserCardinalDto.java b/src/main/java/com/weeth/domain/user/application/dto/response/UserCardinalDto.java deleted file mode 100644 index a7f2f6fb..00000000 --- a/src/main/java/com/weeth/domain/user/application/dto/response/UserCardinalDto.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.weeth.domain.user.application.dto.response; - -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.entity.UserCardinal; - -import java.util.List; - -public record UserCardinalDto( - User user, - List cardinals -) { -} diff --git a/src/main/java/com/weeth/domain/user/application/dto/response/UserResponseDto.java b/src/main/java/com/weeth/domain/user/application/dto/response/UserResponseDto.java deleted file mode 100644 index 9ec2b286..00000000 --- a/src/main/java/com/weeth/domain/user/application/dto/response/UserResponseDto.java +++ /dev/null @@ -1,90 +0,0 @@ -package com.weeth.domain.user.application.dto.response; - -import com.weeth.domain.user.domain.entity.enums.LoginStatus; -import com.weeth.domain.user.domain.entity.enums.Position; -import com.weeth.domain.user.domain.entity.enums.Role; -import com.weeth.domain.user.domain.entity.enums.Status; - -import java.time.LocalDateTime; -import java.util.List; - -public class UserResponseDto { - - public record SocialLoginResponse( - Long id, - Long kakaoId, - String appleIdToken, - LoginStatus status, - String accessToken, - String refreshToken - ) { - } - - public record Response( - Integer id, - String name, - String email, - String studentId, - String tel, - String department, - List cardinals, - Position position, - Role role - ) { - } - - public record SummaryResponse( - Integer id, - String name, - List cardinals, - Position position, - Role role - ) { - } - - public record AdminResponse( - Integer id, - String name, - String email, - String studentId, - String tel, - String department, - List cardinals, - Position position, - Status status, - Role role, - Integer attendanceCount, - Integer absenceCount, - Integer attendanceRate, - Integer penaltyCount, - Integer warningCount, - LocalDateTime createdAt, - LocalDateTime modifiedAt - ) { - } - - public record UserResponse( - Integer id, - String name, - String email, - String studentId, - String department, - List cardinals, - Position position, - Role role - ) { - } - - public record SocialAuthResponse( - Long kakaoId - ) { - } - - public record UserInfo( - Long id, - String name, - List cardinals, - Role role - ) { - } -} //todo: User 전역 dto 구현 (id, 이름, role) diff --git a/src/main/java/com/weeth/domain/user/application/exception/CardinalNotFoundException.java b/src/main/java/com/weeth/domain/user/application/exception/CardinalNotFoundException.java deleted file mode 100644 index fb4568e9..00000000 --- a/src/main/java/com/weeth/domain/user/application/exception/CardinalNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.user.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class CardinalNotFoundException extends BaseException { - public CardinalNotFoundException() { - super(UserErrorCode.CARDINAL_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/domain/user/application/exception/DepartmentNotFoundException.java b/src/main/java/com/weeth/domain/user/application/exception/DepartmentNotFoundException.java deleted file mode 100644 index bf8abbbb..00000000 --- a/src/main/java/com/weeth/domain/user/application/exception/DepartmentNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.user.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class DepartmentNotFoundException extends BaseException { - public DepartmentNotFoundException() { - super(UserErrorCode.DEPARTMENT_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/domain/user/application/exception/DuplicateCardinalException.java b/src/main/java/com/weeth/domain/user/application/exception/DuplicateCardinalException.java deleted file mode 100644 index 02646132..00000000 --- a/src/main/java/com/weeth/domain/user/application/exception/DuplicateCardinalException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.user.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class DuplicateCardinalException extends BaseException { - public DuplicateCardinalException() { - super(UserErrorCode.DUPLICATE_CARDINAL); - } -} diff --git a/src/main/java/com/weeth/domain/user/application/exception/EmailNotFoundException.java b/src/main/java/com/weeth/domain/user/application/exception/EmailNotFoundException.java deleted file mode 100644 index 69b9fda5..00000000 --- a/src/main/java/com/weeth/domain/user/application/exception/EmailNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.user.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class EmailNotFoundException extends BaseException { - public EmailNotFoundException() { - super(UserErrorCode.EMAIL_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/domain/user/application/exception/InvalidUserOrderException.java b/src/main/java/com/weeth/domain/user/application/exception/InvalidUserOrderException.java deleted file mode 100644 index 179dc19f..00000000 --- a/src/main/java/com/weeth/domain/user/application/exception/InvalidUserOrderException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.user.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class InvalidUserOrderException extends BaseException { - public InvalidUserOrderException() { - super(UserErrorCode.INVALID_USER_ORDER); - } -} diff --git a/src/main/java/com/weeth/domain/user/application/exception/PasswordMismatchException.java b/src/main/java/com/weeth/domain/user/application/exception/PasswordMismatchException.java deleted file mode 100644 index cb5af50e..00000000 --- a/src/main/java/com/weeth/domain/user/application/exception/PasswordMismatchException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.user.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class PasswordMismatchException extends BaseException { - public PasswordMismatchException() { - super(UserErrorCode.PASSWORD_MISMATCH); - } -} diff --git a/src/main/java/com/weeth/domain/user/application/exception/RoleNotFoundException.java b/src/main/java/com/weeth/domain/user/application/exception/RoleNotFoundException.java deleted file mode 100644 index 9bfe4c15..00000000 --- a/src/main/java/com/weeth/domain/user/application/exception/RoleNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.user.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class RoleNotFoundException extends BaseException { - public RoleNotFoundException() { - super(UserErrorCode.ROLE_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/domain/user/application/exception/StatusNotFoundException.java b/src/main/java/com/weeth/domain/user/application/exception/StatusNotFoundException.java deleted file mode 100644 index f09d7fd1..00000000 --- a/src/main/java/com/weeth/domain/user/application/exception/StatusNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.user.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class StatusNotFoundException extends BaseException { - public StatusNotFoundException() { - super(UserErrorCode.STATUS_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/domain/user/application/exception/StudentIdExistsException.java b/src/main/java/com/weeth/domain/user/application/exception/StudentIdExistsException.java deleted file mode 100644 index 4c9e3271..00000000 --- a/src/main/java/com/weeth/domain/user/application/exception/StudentIdExistsException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.user.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class StudentIdExistsException extends BaseException { - public StudentIdExistsException() { - super(UserErrorCode.STUDENT_ID_EXISTS); - } -} diff --git a/src/main/java/com/weeth/domain/user/application/exception/TelExistsException.java b/src/main/java/com/weeth/domain/user/application/exception/TelExistsException.java deleted file mode 100644 index 53e613e6..00000000 --- a/src/main/java/com/weeth/domain/user/application/exception/TelExistsException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.user.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class TelExistsException extends BaseException { - public TelExistsException() { - super(UserErrorCode.TEL_EXISTS); - } -} diff --git a/src/main/java/com/weeth/domain/user/application/exception/UserCardinalNotFoundException.java b/src/main/java/com/weeth/domain/user/application/exception/UserCardinalNotFoundException.java deleted file mode 100644 index cf785d5e..00000000 --- a/src/main/java/com/weeth/domain/user/application/exception/UserCardinalNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.user.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class UserCardinalNotFoundException extends BaseException { - public UserCardinalNotFoundException() { - super(UserErrorCode.USER_CARDINAL_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/domain/user/application/exception/UserExistsException.java b/src/main/java/com/weeth/domain/user/application/exception/UserExistsException.java deleted file mode 100644 index d14b6f37..00000000 --- a/src/main/java/com/weeth/domain/user/application/exception/UserExistsException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.user.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class UserExistsException extends BaseException { - public UserExistsException() { - super(UserErrorCode.USER_EXISTS); - } -} diff --git a/src/main/java/com/weeth/domain/user/application/exception/UserInActiveException.java b/src/main/java/com/weeth/domain/user/application/exception/UserInActiveException.java deleted file mode 100644 index 2090edb5..00000000 --- a/src/main/java/com/weeth/domain/user/application/exception/UserInActiveException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.user.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class UserInActiveException extends BaseException { - public UserInActiveException() { - super(UserErrorCode.USER_INACTIVE); - } -} diff --git a/src/main/java/com/weeth/domain/user/application/exception/UserMismatchException.java b/src/main/java/com/weeth/domain/user/application/exception/UserMismatchException.java deleted file mode 100644 index e63db955..00000000 --- a/src/main/java/com/weeth/domain/user/application/exception/UserMismatchException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.user.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class UserMismatchException extends BaseException { - public UserMismatchException() { - super(UserErrorCode.USER_MISMATCH); - } -} diff --git a/src/main/java/com/weeth/domain/user/application/exception/UserNotFoundException.java b/src/main/java/com/weeth/domain/user/application/exception/UserNotFoundException.java deleted file mode 100644 index a4fbd495..00000000 --- a/src/main/java/com/weeth/domain/user/application/exception/UserNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.user.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class UserNotFoundException extends BaseException { - public UserNotFoundException() { - super(UserErrorCode.USER_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/domain/user/application/exception/UserNotMatchException.java b/src/main/java/com/weeth/domain/user/application/exception/UserNotMatchException.java deleted file mode 100644 index b7ff871f..00000000 --- a/src/main/java/com/weeth/domain/user/application/exception/UserNotMatchException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.user.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class UserNotMatchException extends BaseException { - public UserNotMatchException() { - super(UserErrorCode.USER_NOT_MATCH); - } -} diff --git a/src/main/java/com/weeth/domain/user/application/mapper/CardinalMapper.java b/src/main/java/com/weeth/domain/user/application/mapper/CardinalMapper.java deleted file mode 100644 index ca39e9da..00000000 --- a/src/main/java/com/weeth/domain/user/application/mapper/CardinalMapper.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.weeth.domain.user.application.mapper; - -import com.weeth.domain.user.application.dto.request.CardinalSaveRequest; -import com.weeth.domain.user.application.dto.response.CardinalResponse; -import com.weeth.domain.user.application.dto.response.UserCardinalDto; -import com.weeth.domain.user.domain.entity.Cardinal; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.entity.UserCardinal; -import org.mapstruct.Mapper; -import org.mapstruct.MappingConstants; -import org.mapstruct.ReportingPolicy; - -import java.util.List; - -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, unmappedTargetPolicy = ReportingPolicy.IGNORE) -public interface CardinalMapper { - - Cardinal from(CardinalSaveRequest dto); - - CardinalResponse to(Cardinal cardinal); - - UserCardinalDto toUserCardinalDto(User user, List cardinals); -} diff --git a/src/main/java/com/weeth/domain/user/application/mapper/UserMapper.java b/src/main/java/com/weeth/domain/user/application/mapper/UserMapper.java deleted file mode 100644 index a0f36096..00000000 --- a/src/main/java/com/weeth/domain/user/application/mapper/UserMapper.java +++ /dev/null @@ -1,105 +0,0 @@ -package com.weeth.domain.user.application.mapper; - -import com.weeth.domain.user.application.dto.response.UserResponseDto; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.entity.UserCardinal; -import com.weeth.domain.user.domain.entity.enums.Department; -import com.weeth.global.auth.jwt.application.dto.JwtDto; -import org.mapstruct.*; -import org.springframework.security.crypto.password.PasswordEncoder; - -import java.util.Collections; -import java.util.List; -import java.util.stream.Collectors; - -import static com.weeth.domain.user.application.dto.request.UserRequestDto.Register; -import static com.weeth.domain.user.application.dto.request.UserRequestDto.SignUp; -import static com.weeth.domain.user.application.dto.response.UserResponseDto.*; - -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, unmappedTargetPolicy = ReportingPolicy.IGNORE) -public interface UserMapper { - - @Mappings({ - @Mapping(target = "password", expression = "java( passwordEncoder.encode(dto.password()) )"), - @Mapping(target = "department", expression = "java( com.weeth.domain.user.domain.entity.enums.Department.to(dto.department()) )") - }) - User from(SignUp dto, @Context PasswordEncoder passwordEncoder); - - @Mappings({ - @Mapping(target = "department", expression = "java( com.weeth.domain.user.domain.entity.enums.Department.to(dto.department()) )") - }) - User from(Register dto); - - @Mapping(target = "department", expression = "java( toString(user.getDepartment()) )") - @Mapping(target = "cardinals", expression = "java( toCardinalNumbers(userCardinals) )") - Response to(User user, List userCardinals); - - @Mappings({ - // 수정: 출석률, 출석 횟수, 결석 횟수 매핑 추후 추가 예정 - @Mapping(target = "cardinals", expression = "java( toCardinalNumbers(userCardinals) )") - }) - AdminResponse toAdminResponse(User user, List userCardinals); - - @Mapping(target = "cardinals", expression = "java( toCardinalNumbers(userCardinals) )") - SummaryResponse toSummaryResponse(User user, List userCardinals); - - SocialAuthResponse toSocialAuthResponse(Long kakaoId); - - @Mappings({ - @Mapping(target = "status", expression = "java(LoginStatus.LOGIN)"), - @Mapping(target = "id", source = "user.id"), - @Mapping(target = "kakaoId", source = "user.kakaoId"), - @Mapping(target = "appleIdToken", expression = "java(null)") - }) - SocialLoginResponse toLoginResponse(User user, JwtDto dto); - - @Mappings({ - @Mapping(target = "status", expression = "java(LoginStatus.INTEGRATE)"), - @Mapping(target = "appleIdToken", expression = "java(null)"), - @Mapping(target = "accessToken", expression = "java(null)"), - @Mapping(target = "refreshToken", expression = "java(null)") - }) - SocialLoginResponse toIntegrateResponse(Long kakaoId); - - @Mappings({ - // 상세 데이터 매핑 - @Mapping(target = "cardinals", expression = "java( toCardinalNumbers(userCardinals) )") - }) - UserResponse toUserResponse(User user, List userCardinals); - - @Mapping(target = "cardinals", expression = "java( toCardinalNumbers(userCardinals) )") - UserResponseDto.UserInfo toUserInfoDto(User user, List userCardinals); - - @Mappings({ - @Mapping(target = "status", expression = "java(LoginStatus.LOGIN)"), - @Mapping(target = "id", source = "user.id"), - @Mapping(target = "appleIdToken", expression = "java(null)"), - @Mapping(target = "kakaoId", expression = "java(null)") - }) - SocialLoginResponse toAppleLoginResponse(User user, JwtDto dto); - - @Mappings({ - @Mapping(target = "status", expression = "java(LoginStatus.INTEGRATE)"), - @Mapping(target = "id", expression = "java(null)"), - @Mapping(target = "appleIdToken", source = "appleIdToken"), - @Mapping(target = "kakaoId", expression = "java(null)"), - @Mapping(target = "accessToken", expression = "java(null)"), - @Mapping(target = "refreshToken", expression = "java(null)") - }) - SocialLoginResponse toAppleIntegrateResponse(String appleIdToken); - - default String toString(Department department) { - return department.getValue(); - } - - default List toCardinalNumbers(List userCardinals) { - if (userCardinals == null || userCardinals.isEmpty()) { - return Collections.emptyList(); - } - - return userCardinals.stream() - .map(uc -> uc.getCardinal().getCardinalNumber()) - .collect(Collectors.toList()); - } -} - diff --git a/src/main/java/com/weeth/domain/user/application/usecase/CardinalUseCase.java b/src/main/java/com/weeth/domain/user/application/usecase/CardinalUseCase.java deleted file mode 100644 index 1723054e..00000000 --- a/src/main/java/com/weeth/domain/user/application/usecase/CardinalUseCase.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.weeth.domain.user.application.usecase; - -import com.weeth.domain.user.application.dto.request.CardinalSaveRequest; -import com.weeth.domain.user.application.dto.request.CardinalUpdateRequest; -import com.weeth.domain.user.application.dto.response.CardinalResponse; -import com.weeth.domain.user.application.mapper.CardinalMapper; -import com.weeth.domain.user.domain.entity.Cardinal; -import com.weeth.domain.user.domain.service.CardinalGetService; -import com.weeth.domain.user.domain.service.CardinalSaveService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class CardinalUseCase { - - private final CardinalGetService cardinalGetService; - private final CardinalSaveService cardinalSaveService; - - private final CardinalMapper cardinalMapper; - - @Transactional - public void save(CardinalSaveRequest dto) { - cardinalGetService.validateCardinal(dto.cardinalNumber()); - - Cardinal cardinal = cardinalSaveService.save(cardinalMapper.from(dto)); - - if (dto.inProgress()) { - updateCardinalStatus(cardinal); - } - } - - @Transactional - public void update(CardinalUpdateRequest dto) { - Cardinal cardinal = cardinalGetService.findById(dto.id()); - - cardinal.update(dto); - - if (dto.inProgress()) { - updateCardinalStatus(cardinal); - } - } - - public List findAll() { - List cardinals = cardinalGetService.findAll(); - return cardinals.stream() - .map(cardinalMapper::to) - .toList(); - } - - private void updateCardinalStatus(Cardinal cardinal) { - List cardinals = cardinalGetService.findInProgress(); - - if (!cardinals.isEmpty()) { - cardinals.forEach(Cardinal::done); - } - - cardinal.inProgress(); - } -} diff --git a/src/main/java/com/weeth/domain/user/application/usecase/UserManageUseCase.java b/src/main/java/com/weeth/domain/user/application/usecase/UserManageUseCase.java deleted file mode 100644 index 7e3bc11f..00000000 --- a/src/main/java/com/weeth/domain/user/application/usecase/UserManageUseCase.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.weeth.domain.user.application.usecase; - -import com.weeth.domain.user.application.dto.response.UserResponseDto; -import com.weeth.domain.user.domain.entity.enums.UsersOrderBy; - -import java.util.List; - -import static com.weeth.domain.user.application.dto.request.UserRequestDto.*; - -public interface UserManageUseCase { - - - List findAllByAdmin(UsersOrderBy orderBy); - - void accept(UserId userIds); - - void update(List request); - - void leave(Long userId); - - void ban(UserId userIds); - - void applyOB(List request); - - void reset(UserId userId); -} diff --git a/src/main/java/com/weeth/domain/user/application/usecase/UserManageUseCaseImpl.java b/src/main/java/com/weeth/domain/user/application/usecase/UserManageUseCaseImpl.java deleted file mode 100644 index aa194a5d..00000000 --- a/src/main/java/com/weeth/domain/user/application/usecase/UserManageUseCaseImpl.java +++ /dev/null @@ -1,162 +0,0 @@ -package com.weeth.domain.user.application.usecase; - -import jakarta.transaction.Transactional; -import com.weeth.domain.attendance.domain.service.AttendanceSaveService; -import com.weeth.domain.schedule.domain.entity.Meeting; -import com.weeth.domain.schedule.domain.service.MeetingGetService; -import com.weeth.domain.user.application.exception.InvalidUserOrderException; -import com.weeth.domain.user.application.mapper.UserMapper; -import com.weeth.domain.user.domain.entity.Cardinal; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.entity.UserCardinal; -import com.weeth.domain.user.domain.entity.enums.StatusPriority; -import com.weeth.domain.user.domain.entity.enums.UsersOrderBy; -import com.weeth.domain.user.domain.service.*; -import com.weeth.global.auth.jwt.domain.port.RefreshTokenStorePort; -import lombok.RequiredArgsConstructor; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; - -import java.util.*; -import java.util.stream.Collectors; - -import static com.weeth.domain.user.application.dto.request.UserRequestDto.*; -import static com.weeth.domain.user.application.dto.response.UserResponseDto.AdminResponse; -import static com.weeth.domain.user.domain.entity.enums.UsersOrderBy.CARDINAL_DESCENDING; -import static com.weeth.domain.user.domain.entity.enums.UsersOrderBy.NAME_ASCENDING; - -@Service -@RequiredArgsConstructor -public class UserManageUseCaseImpl implements UserManageUseCase { - - private final UserGetService userGetService; - private final UserUpdateService userUpdateService; - private final UserDeleteService userDeleteService; - - private final AttendanceSaveService attendanceSaveService; - private final MeetingGetService meetingGetService; - private final RefreshTokenStorePort refreshTokenStorePort; - private final CardinalGetService cardinalGetService; - private final UserCardinalSaveService userCardinalSaveService; - private final UserCardinalGetService userCardinalGetService; - - private final UserMapper mapper; - private final PasswordEncoder passwordEncoder; - - @Override - public List findAllByAdmin(UsersOrderBy orderBy) { - if (orderBy == null || !EnumSet.allOf(UsersOrderBy.class).contains(orderBy)) { - throw new InvalidUserOrderException(); - } - - Map> userCardinalMap = userCardinalGetService.findAll() - .stream() - .collect(Collectors.groupingBy(UserCardinal::getUser, LinkedHashMap::new, Collectors.toList())); - - if (orderBy.equals(NAME_ASCENDING)) { - return userCardinalMap.entrySet() - .stream() - .sorted(Comparator - .comparingInt(((Map.Entry> entry) -> (StatusPriority.fromStatus(entry.getKey().getStatus())).getPriority()))) - .map(entry -> { - List userCardinals = userCardinalGetService.getUserCardinals(entry.getKey()); - return mapper.toAdminResponse(entry.getKey(), userCardinals); - }) - .toList(); - } - - if (orderBy.equals(CARDINAL_DESCENDING)) { - - return userCardinalMap.entrySet() - .stream() - .sorted(Comparator - .comparingInt(((Map.Entry> entry) -> (StatusPriority.fromStatus(entry.getKey().getStatus())).getPriority())) - .thenComparing(entry -> entry.getValue().stream() - .map(uc -> uc.getCardinal().getCardinalNumber()) - .max(Integer::compare) - .orElse(-1), Comparator.reverseOrder())) - .map(entry -> { - List userCardinals = userCardinalGetService.getUserCardinals(entry.getKey()); - return mapper.toAdminResponse(entry.getKey(), userCardinals); - }) - .toList(); - } - - return null; - } - - @Override - @Transactional - public void accept(UserId userIds) { - List users = userGetService.findAll(userIds.userId()); - - users.forEach(user -> { - Integer cardinal = userCardinalGetService.getCurrentCardinal(user).getCardinalNumber(); - - if (user.isInactive()) { - userUpdateService.accept(user); - List meetings = meetingGetService.find(cardinal); - attendanceSaveService.init(user, meetings); - } - }); - } - - @Override - @Transactional - public void update(List requests) { - requests.forEach(request -> { - User user = userGetService.find(request.userId()); - - userUpdateService.update(user, request.role().name()); - refreshTokenStorePort.updateRole(user.getId(), request.role()); - }); - } - - @Override - public void leave(Long userId) { - User user = userGetService.find(userId); - // 탈퇴하는 경우 리프레시 토큰 삭제 - refreshTokenStorePort.delete(user.getId()); - userDeleteService.leave(user); - } - - @Override - public void ban(UserId userIds) { - List users = userGetService.findAll(userIds.userId()); - - users.forEach(user -> { - refreshTokenStorePort.delete(user.getId()); - userDeleteService.ban(user); - }); - } - - @Override - @Transactional - public void applyOB(List requests) { - requests.forEach(request -> { - User user = userGetService.find(request.userId()); - Cardinal nextCardinal = cardinalGetService.findByAdminSide(request.cardinal()); - - if (userCardinalGetService.notContains(user, nextCardinal)) { - if (userCardinalGetService.isCurrent(user, nextCardinal)) { - user.initAttendance(); - List meetings = meetingGetService.find(request.cardinal()); - attendanceSaveService.init(user, meetings); - } - UserCardinal userCardinal = new UserCardinal(user, nextCardinal); - - userCardinalSaveService.save(userCardinal); - } - }); - } - - @Override - @Transactional - public void reset(UserId userId) { - - List users = userGetService.findAll(userId.userId()); - - users.forEach(user -> userUpdateService.reset(user, passwordEncoder)); - } - -} diff --git a/src/main/java/com/weeth/domain/user/application/usecase/UserUseCase.java b/src/main/java/com/weeth/domain/user/application/usecase/UserUseCase.java deleted file mode 100644 index f549ea9d..00000000 --- a/src/main/java/com/weeth/domain/user/application/usecase/UserUseCase.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.weeth.domain.user.application.usecase; - -import com.weeth.domain.user.application.dto.request.UserRequestDto; -import com.weeth.domain.user.application.dto.response.UserResponseDto; -import com.weeth.global.auth.jwt.application.dto.JwtDto; -import org.springframework.data.domain.Slice; - -import java.util.List; - -import static com.weeth.domain.user.application.dto.request.UserRequestDto.*; -import static com.weeth.domain.user.application.dto.response.UserResponseDto.*; - - -public interface UserUseCase { - - SocialLoginResponse login(Login dto); - - SocialAuthResponse authenticate(Login dto); - - SocialLoginResponse integrate(NormalLogin dto); - - UserResponseDto.Response find(Long userId); - - Slice findAllUser(int pageNumber, int pageSize, Integer cardinal); - - UserResponseDto.UserResponse findUserDetails(Long userId); - - void update(UserRequestDto.Update dto, Long userId); - - void apply(SignUp dto); - - void socialRegister(Register dto); - - JwtDto refresh(String refreshToken); - - UserResponseDto.UserInfo findUserInfo(Long userId); - - List searchUser(String keyword); - - SocialLoginResponse appleLogin(Login dto); - - void appleRegister(Register dto); - -} diff --git a/src/main/java/com/weeth/domain/user/application/usecase/UserUseCaseImpl.java b/src/main/java/com/weeth/domain/user/application/usecase/UserUseCaseImpl.java deleted file mode 100644 index 1702081b..00000000 --- a/src/main/java/com/weeth/domain/user/application/usecase/UserUseCaseImpl.java +++ /dev/null @@ -1,314 +0,0 @@ -package com.weeth.domain.user.application.usecase; - -import com.weeth.domain.user.application.dto.response.UserCardinalDto; -import com.weeth.domain.user.application.exception.PasswordMismatchException; -import com.weeth.domain.user.application.exception.StudentIdExistsException; -import com.weeth.domain.user.application.exception.TelExistsException; -import com.weeth.domain.user.application.exception.UserInActiveException; -import com.weeth.domain.user.application.mapper.CardinalMapper; -import com.weeth.domain.user.application.mapper.UserMapper; -import com.weeth.domain.user.domain.entity.Cardinal; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.entity.UserCardinal; -import com.weeth.domain.user.domain.service.*; -import com.weeth.global.auth.apple.dto.AppleTokenResponse; -import com.weeth.global.auth.apple.dto.AppleUserInfo; -import com.weeth.global.auth.jwt.application.dto.JwtDto; -import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase; -import com.weeth.global.auth.kakao.KakaoAuthService; -import com.weeth.global.auth.kakao.dto.KakaoTokenResponse; -import com.weeth.global.auth.kakao.dto.KakaoUserInfoResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.core.env.Environment; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.*; -import java.util.stream.Collectors; - -import static com.weeth.domain.user.application.dto.request.UserRequestDto.*; -import static com.weeth.domain.user.application.dto.response.UserResponseDto.*; - -@Slf4j -@Service -@RequiredArgsConstructor -public class UserUseCaseImpl implements UserUseCase { - private static final String BEARER = "Bearer "; - private final JwtManageUseCase jwtManageUseCase; - private final UserSaveService userSaveService; - private final UserGetService userGetService; - private final UserUpdateService userUpdateService; - private final KakaoAuthService kakaoAuthService; - private final com.weeth.global.auth.apple.AppleAuthService appleAuthService; - private final CardinalGetService cardinalGetService; - private final UserCardinalSaveService userCardinalSaveService; - private final UserCardinalGetService userCardinalGetService; - - private final UserMapper mapper; - private final CardinalMapper cardinalMapper; - private final PasswordEncoder passwordEncoder; - private final Environment environment; - - @Override - @Transactional(readOnly = true) - public SocialLoginResponse login(Login dto) { - long kakaoId = getKakaoId(dto); - Optional optionalUser = userGetService.findByKakaoId(kakaoId); - - if (optionalUser.isEmpty()) { - return mapper.toIntegrateResponse(kakaoId); - } - - User user = optionalUser.get(); - if (user.isInactive()) { - throw new UserInActiveException(); - } - - JwtDto token = jwtManageUseCase.create(user.getId(), user.getEmail(), user.getRole()); - return mapper.toLoginResponse(user, token); - } - - @Override - public SocialAuthResponse authenticate(Login dto) { - long kakaoId = getKakaoId(dto); - - return mapper.toSocialAuthResponse(kakaoId); - } - - @Override - @Transactional - public SocialLoginResponse integrate(NormalLogin dto) { - User user = userGetService.find(dto.email()); - - if (!passwordEncoder.matches(dto.passWord(), user.getPassword())) { - throw new PasswordMismatchException(); - } - user.addKakaoId(dto.kakaoId()); - - if (user.isInactive()) { - throw new UserInActiveException(); - } - - JwtDto token = jwtManageUseCase.create(user.getId(), user.getEmail(), user.getRole()); - - return mapper.toLoginResponse(user, token); - } - - @Override - public Slice findAllUser(int pageNumber, int pageSize, Integer cardinal) { - - Pageable pageable = PageRequest.of(pageNumber, pageSize); - Slice users; - - if (cardinal == null) { - users = userGetService.findAll(pageable); - - } else { - Cardinal inputCardinal = cardinalGetService.findByUserSide(cardinal); - users = userGetService.findAll(pageable, inputCardinal); - } - - List allUserCardinals = userCardinalGetService.findAll(users.getContent()); - - Map> userCardinalMap = allUserCardinals.stream() - .collect(Collectors.groupingBy(userCardinal -> userCardinal.getUser().getId())); - - return users.map(user -> { - List userCardinals = userCardinalMap.getOrDefault(user.getId(), Collections.emptyList()); - - return mapper.toSummaryResponse(user, userCardinals); - }); - } - - @Override - public UserResponse findUserDetails(Long userId) { - UserCardinalDto dto = getUserCardinalDto(userId); - - return mapper.toUserResponse(dto.user(), dto.cardinals()); - } - - @Override - public Response find(Long userId) { - UserCardinalDto dto = getUserCardinalDto(userId); - - return mapper.to(dto.user(), dto.cardinals()); - } - - @Override - public void update(Update dto, Long userId) { - validate(dto, userId); - User user = userGetService.find(userId); - userUpdateService.update(user, dto); - } - - @Override - @Transactional - public void apply(SignUp dto) { - validate(dto); - - Cardinal cardinal = cardinalGetService.findByUserSide(dto.cardinal()); - User user = mapper.from(dto, passwordEncoder); - UserCardinal userCardinal = new UserCardinal(user, cardinal); - - userSaveService.save(user); - userCardinalSaveService.save(userCardinal); - } - - @Override - @Transactional - public void socialRegister(Register dto) { - validate(dto); - - Cardinal cardinal = cardinalGetService.findByUserSide(dto.cardinal()); - - User user = mapper.from(dto); - UserCardinal userCardinal = new UserCardinal(user, cardinal); - - userSaveService.save(user); - userCardinalSaveService.save(userCardinal); - } - - @Override - @Transactional - public JwtDto refresh(String refreshToken) { - - String requestToken = refreshToken.replace(BEARER, ""); - - JwtDto token = jwtManageUseCase.reIssueToken(requestToken); - - log.info("RefreshToken 발급 완료: {}", token); - return new JwtDto(token.getAccessToken(), token.getRefreshToken()); - } - - @Override - public UserInfo findUserInfo(Long userId) { - UserCardinalDto dto = getUserCardinalDto(userId); - - return mapper.toUserInfoDto(dto.user(), dto.cardinals()); - } - - @Override - public List searchUser(String keyword) { - List users = userGetService.search(keyword); - - return users.stream() - .map(user -> { - List userCardinals = userCardinalGetService.getUserCardinals(user); - return mapper.toSummaryResponse(user, userCardinals); - }) - .toList(); - } - - private long getKakaoId(Login dto) { - KakaoTokenResponse tokenResponse = kakaoAuthService.getKakaoToken(dto.authCode()); - KakaoUserInfoResponse userInfo = kakaoAuthService.getUserInfo(tokenResponse.getAccessToken()); - - return userInfo.getId(); - } - - private void validate(Update dto, Long userId) { - if (userGetService.validateStudentId(dto.studentId(), userId)) - throw new StudentIdExistsException(); - if (userGetService.validateTel(dto.tel(), userId)) - throw new TelExistsException(); - } - - private void validate(SignUp dto) { - if (userGetService.validateStudentId(dto.studentId())) - throw new StudentIdExistsException(); - if (userGetService.validateTel(dto.tel())) - throw new TelExistsException(); - } - - private void validate(Register dto) { - if (userGetService.validateStudentId(dto.studentId())) { - throw new StudentIdExistsException(); - } - if (userGetService.validateTel(dto.tel())) { - throw new TelExistsException(); - } - } - - private UserCardinalDto getUserCardinalDto(Long userId) { - User user = userGetService.find(userId); - List userCardinals = userCardinalGetService.getUserCardinals(user); - - return cardinalMapper.toUserCardinalDto(user, userCardinals); - } - - @Override - @Transactional(readOnly = true) - public SocialLoginResponse appleLogin(Login dto) { - // Apple Token 요청 및 유저 정보 요청 - AppleTokenResponse tokenResponse = appleAuthService.getAppleToken(dto.authCode()); - AppleUserInfo userInfo = appleAuthService.verifyAndDecodeIdToken(tokenResponse.getIdToken()); - - String appleIdToken = tokenResponse.getIdToken(); - String appleId = userInfo.getAppleId(); - - Optional optionalUser = userGetService.findByAppleId(appleId); - - //todo: 추후 애플 로그인 연동을 위해 appleIdToken을 반환 - // 애플 로그인 연동 API 요청시 appleIdToken을 함께 넣어주면 그때 디코딩해서 appleId를 추출 - if (optionalUser.isEmpty()) { - return mapper.toAppleIntegrateResponse(appleIdToken); - } - - User user = optionalUser.get(); - if (user.isInactive()) { - throw new UserInActiveException(); - } - - JwtDto token = jwtManageUseCase.create(user.getId(), user.getEmail(), user.getRole()); - return mapper.toAppleLoginResponse(user, token); - } - - @Override - @Transactional - public void appleRegister(Register dto) { - validate(dto); - - // Apple authCode로 토큰 교환 후 ID Token 검증 및 사용자 정보 추출 - AppleTokenResponse tokenResponse = appleAuthService.getAppleToken(dto.appleAuthCode()); - AppleUserInfo appleUserInfo = appleAuthService.verifyAndDecodeIdToken(tokenResponse.getIdToken()); - - Cardinal cardinal = cardinalGetService.findByUserSide(dto.cardinal()); - - User user = mapper.from(dto); - // Apple ID 설정 - user.addAppleId(appleUserInfo.getAppleId()); - - UserCardinal userCardinal = new UserCardinal(user, cardinal); - - userSaveService.save(user); - userCardinalSaveService.save(userCardinal); - - // dev 환경에서만 바로 ACTIVE 상태로 설정 - if (isDevEnvironment()) { - log.info("dev 환경 감지: 사용자 자동 승인 처리 (userId: {})", user.getId()); - user.accept(); - } - } - - /** - * 현재 환경이 dev 프로파일인지 확인 - * @return dev 프로파일이 활성화되어 있으면 true - */ - private boolean isDevEnvironment() { - String[] activeProfiles = environment.getActiveProfiles(); - for (String profile : activeProfiles) { - if ("dev".equals(profile)) { - return true; - } - if ("local".equals(profile)) { - return true; - } - } - return false; - } -} diff --git a/src/main/java/com/weeth/domain/user/domain/entity/Cardinal.java b/src/main/java/com/weeth/domain/user/domain/entity/Cardinal.java deleted file mode 100644 index 942e94e0..00000000 --- a/src/main/java/com/weeth/domain/user/domain/entity/Cardinal.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.weeth.domain.user.domain.entity; - -import jakarta.persistence.*; -import com.weeth.domain.user.application.dto.request.CardinalUpdateRequest; -import com.weeth.domain.user.domain.entity.enums.CardinalStatus; -import com.weeth.global.common.entity.BaseEntity; -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.experimental.SuperBuilder; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@SuperBuilder -public class Cardinal extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "cardinal_id") - private Long id; - - @Column(unique = true, nullable = false) - private Integer cardinalNumber; - - private Integer year; - - private Integer semester; - - @Builder.Default - @Enumerated(EnumType.STRING) - CardinalStatus status = CardinalStatus.DONE; - - public void update(CardinalUpdateRequest dto) { - this.year = dto.year(); - this.semester = dto.semester(); - } - - public void inProgress() { - this.status = CardinalStatus.IN_PROGRESS; - } - - public void done() { - this.status = CardinalStatus.DONE; - } - -} diff --git a/src/main/java/com/weeth/domain/user/domain/entity/SecurityUser.java b/src/main/java/com/weeth/domain/user/domain/entity/SecurityUser.java deleted file mode 100644 index 8ed2a2ee..00000000 --- a/src/main/java/com/weeth/domain/user/domain/entity/SecurityUser.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.weeth.domain.user.domain.entity; - -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.userdetails.UserDetails; - -import java.io.Serial; -import java.io.Serializable; -import java.util.Collection; -import java.util.List; - -public record SecurityUser( - Long id, - String email, - String name, - String role, - boolean active -) implements UserDetails, Serializable { - - @Serial - private static final long serialVersionUID = 1L; - - public static SecurityUser from(User u) { - return new SecurityUser( - u.getId(), - u.getEmail(), - u.getName(), - u.getRole().name(), - !u.isInactive() - ); - } - - @Override - public Collection getAuthorities() { - return List.of(new SimpleGrantedAuthority("ROLE_" + role)); - } - - @Override - public String getPassword() { - return "N/A"; - } - - @Override - public String getUsername() { - return name; - } - - @Override - public boolean isAccountNonExpired() { - return active; - } - - @Override - public boolean isAccountNonLocked() { - return active; - } - - @Override - public boolean isCredentialsNonExpired() { - return true; - } - - @Override - public boolean isEnabled() { - return active; - } -} diff --git a/src/main/java/com/weeth/domain/user/domain/entity/User.java b/src/main/java/com/weeth/domain/user/domain/entity/User.java deleted file mode 100644 index 17a99a31..00000000 --- a/src/main/java/com/weeth/domain/user/domain/entity/User.java +++ /dev/null @@ -1,201 +0,0 @@ -package com.weeth.domain.user.domain.entity; - -import jakarta.persistence.*; -import com.weeth.domain.attendance.domain.entity.Attendance; -import com.weeth.domain.board.domain.entity.enums.Part; -import com.weeth.domain.user.domain.entity.enums.Department; -import com.weeth.domain.user.domain.entity.enums.Position; -import com.weeth.domain.user.domain.entity.enums.Role; -import com.weeth.domain.user.domain.entity.enums.Status; -import com.weeth.global.common.entity.BaseEntity; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.experimental.SuperBuilder; -import org.springframework.security.crypto.password.PasswordEncoder; - -import java.util.ArrayList; -import java.util.List; - -import static com.weeth.domain.user.application.dto.request.UserRequestDto.Update; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@Table(name = "users") -@AllArgsConstructor -@SuperBuilder -public class User extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "user_id") - private Long id; - - @Column(unique = true) - private Long kakaoId; - - @Column(unique = true) - private String appleId; - - private String name; - - private String email; - - private String password; - - private String studentId; - - private String tel; - - @Enumerated(EnumType.STRING) - private Position position; - - @Enumerated(EnumType.STRING) - private Department department; - - @Enumerated(EnumType.STRING) - private Status status; - - @Enumerated(EnumType.STRING) - private Role role; - - private Integer attendanceCount; - - private Integer absenceCount; - - private Integer attendanceRate; - - private Integer penaltyCount; - - private Integer warningCount; - - @OneToMany(mappedBy = "user", cascade = CascadeType.REMOVE, orphanRemoval = true) - private List attendances = new ArrayList<>(); - - @PrePersist - public void init() { - status = Status.WAITING; - role = Role.USER; - attendanceCount = 0; - absenceCount = 0; - attendanceRate = 0; - penaltyCount = 0; - warningCount = 0; - } - - public void addKakaoId(long kakaoId) { - this.kakaoId = kakaoId; - } - - public void addAppleId(String appleId) { - this.appleId = appleId; - } - - public void leave() { - this.status = Status.LEFT; - } - - /* - todo 차후 일반 로그인 비활성화시 해당 메서드에서 예외를 날리도록 수정 - */ - public boolean isInactive() { - return this.status != Status.ACTIVE; - } - - public void update(Update dto) { - this.name = dto.name(); - this.email = dto.email(); - this.studentId = dto.studentId(); - this.tel = dto.tel(); - this.department = Department.to(dto.department()); - } - - public void accept() { - this.status = Status.ACTIVE; - } - - public void ban() { - this.status = Status.BANNED; - } - - public void update(String role) { - this.role = Role.valueOf(role); - } - - public void reset(PasswordEncoder passwordEncoder) { - this.password = passwordEncoder.encode(studentId); - } - - public void add(Attendance attendance) { - this.attendances.add(attendance); - } - - public void initAttendance() { - this.attendances.clear(); - this.attendanceCount = 0; - this.absenceCount = 0; - this.attendanceRate = 0; - } - - public void attend() { - attendanceCount++; - calculateRate(); - } - - public void removeAttend() { - if (attendanceCount > 0) { - attendanceCount--; - calculateRate(); - } - } - - public void absent() { - absenceCount++; - calculateRate(); - } - - public void removeAbsent() { - if (absenceCount > 0) { - absenceCount--; - calculateRate(); - } - } - - private void calculateRate() { - if (attendanceCount + absenceCount > 0) { - attendanceRate = (attendanceCount * 100) / (attendanceCount + absenceCount); - } else { - attendanceRate = 0; - } - } - - public void incrementPenaltyCount() { - penaltyCount++; - } - - public void decrementPenaltyCount() { - if (penaltyCount > 0) { - penaltyCount--; - } - } - - public void incrementWarningCount() { - warningCount++; - } - - public void decrementWarningCount() { - if (warningCount > 0) { - warningCount--; - } - } - - public boolean hasRole(Role role) { - return this.role == role; - } - - public Part getUserPart() { - return Part.valueOf(this.position.name()); - } -} diff --git a/src/main/java/com/weeth/domain/user/domain/entity/UserCardinal.java b/src/main/java/com/weeth/domain/user/domain/entity/UserCardinal.java deleted file mode 100644 index aca38036..00000000 --- a/src/main/java/com/weeth/domain/user/domain/entity/UserCardinal.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.weeth.domain.user.domain.entity; - -import jakarta.persistence.*; -import com.weeth.global.common.entity.BaseEntity; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.experimental.SuperBuilder; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@SuperBuilder -public class UserCardinal extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "user_cardinal_id") - private Long id; - - @ManyToOne - @JoinColumn(name = "user_id") - private User user; - - @ManyToOne - @JoinColumn(name = "cardinal_id") - private Cardinal cardinal; - - public UserCardinal(User user, Cardinal cardinal) { - this.user = user; - this.cardinal = cardinal; - } -} diff --git a/src/main/java/com/weeth/domain/user/domain/entity/enums/CardinalStatus.java b/src/main/java/com/weeth/domain/user/domain/entity/enums/CardinalStatus.java deleted file mode 100644 index 63b20855..00000000 --- a/src/main/java/com/weeth/domain/user/domain/entity/enums/CardinalStatus.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.weeth.domain.user.domain.entity.enums; - -public enum CardinalStatus { - IN_PROGRESS, DONE -} diff --git a/src/main/java/com/weeth/domain/user/domain/entity/enums/Department.java b/src/main/java/com/weeth/domain/user/domain/entity/enums/Department.java deleted file mode 100644 index f166c7f7..00000000 --- a/src/main/java/com/weeth/domain/user/domain/entity/enums/Department.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.weeth.domain.user.domain.entity.enums; - -import com.weeth.domain.user.application.exception.DepartmentNotFoundException; -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -import java.util.Arrays; - -@Getter -@RequiredArgsConstructor -public enum Department { - - SW("소프트웨어전공"), - AI("인공지능전공"), - COMPUTER_SCIENCE("컴퓨터공학과"), - INDUSTRIAL_ENGINEERING("산업공학과"), - VISUAL_DESIGN("시각디자인학과"), - BUSINESS("경영학과"), - ECONOMICS("경제학과"), - KOREAN_LANGUAGE("한국어문학과"), - URBAN_PLANNING("도시계획학전공"), - GLOBAL_BUSINESS("글로벌경영학과"), - FINANCIAL_MATHEMATICS("금융수학전공"), - HEALTHCARE_MANAGEMENT("의료산업경영학과"); // 더 필요한 학과는 추후 추가할 예정 - - private final String value; - - public static Department to(String before) { - return Arrays.stream(Department.values()) - .filter(department -> department.getValue().equals(before)) - .findAny() - .orElseThrow(DepartmentNotFoundException::new); - } -} diff --git a/src/main/java/com/weeth/domain/user/domain/entity/enums/LoginStatus.java b/src/main/java/com/weeth/domain/user/domain/entity/enums/LoginStatus.java deleted file mode 100644 index b036d17e..00000000 --- a/src/main/java/com/weeth/domain/user/domain/entity/enums/LoginStatus.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.weeth.domain.user.domain.entity.enums; - -public enum LoginStatus { - LOGIN, REGISTER, INTEGRATE -} diff --git a/src/main/java/com/weeth/domain/user/domain/entity/enums/Position.java b/src/main/java/com/weeth/domain/user/domain/entity/enums/Position.java deleted file mode 100644 index b9a391f6..00000000 --- a/src/main/java/com/weeth/domain/user/domain/entity/enums/Position.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.weeth.domain.user.domain.entity.enums; - -public enum Position { - D, - FE, - BE, - PM -} diff --git a/src/main/java/com/weeth/domain/user/domain/entity/enums/Role.java b/src/main/java/com/weeth/domain/user/domain/entity/enums/Role.java deleted file mode 100644 index c32bad1d..00000000 --- a/src/main/java/com/weeth/domain/user/domain/entity/enums/Role.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.weeth.domain.user.domain.entity.enums; - -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -@Getter -@RequiredArgsConstructor -public enum Role { - USER, - ADMIN -} diff --git a/src/main/java/com/weeth/domain/user/domain/entity/enums/Status.java b/src/main/java/com/weeth/domain/user/domain/entity/enums/Status.java deleted file mode 100644 index 5950a5c7..00000000 --- a/src/main/java/com/weeth/domain/user/domain/entity/enums/Status.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.weeth.domain.user.domain.entity.enums; - -public enum Status { - WAITING, - ACTIVE, - BANNED, - LEFT -} diff --git a/src/main/java/com/weeth/domain/user/domain/entity/enums/StatusPriority.java b/src/main/java/com/weeth/domain/user/domain/entity/enums/StatusPriority.java deleted file mode 100644 index becc6b02..00000000 --- a/src/main/java/com/weeth/domain/user/domain/entity/enums/StatusPriority.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.weeth.domain.user.domain.entity.enums; - -import com.weeth.domain.user.application.exception.StatusNotFoundException; -import lombok.Getter; - -@Getter -public enum StatusPriority { - ACTIVE(1), - WAITING(2), - LEFT(3), - BANNED(4); - - private final int priority; - - StatusPriority(int priority) { - this.priority = priority; - } - - public static StatusPriority fromStatus(Status status) { - if (status == null) { - throw new StatusNotFoundException(); - } - return StatusPriority.valueOf(status.name()); - } -} diff --git a/src/main/java/com/weeth/domain/user/domain/entity/enums/UsersOrderBy.java b/src/main/java/com/weeth/domain/user/domain/entity/enums/UsersOrderBy.java deleted file mode 100644 index 83b1d17f..00000000 --- a/src/main/java/com/weeth/domain/user/domain/entity/enums/UsersOrderBy.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.weeth.domain.user.domain.entity.enums; - -public enum UsersOrderBy { - NAME_ASCENDING, // 이름순 정렬 - CARDINAL_DESCENDING; // 기수 기준으로 내림차순 정렬 -} diff --git a/src/main/java/com/weeth/domain/user/domain/repository/CardinalRepository.java b/src/main/java/com/weeth/domain/user/domain/repository/CardinalRepository.java deleted file mode 100644 index 47a29af5..00000000 --- a/src/main/java/com/weeth/domain/user/domain/repository/CardinalRepository.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.weeth.domain.user.domain.repository; - -import java.util.List; -import java.util.Optional; -import com.weeth.domain.user.domain.entity.Cardinal; -import com.weeth.domain.user.domain.entity.enums.CardinalStatus; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface CardinalRepository extends JpaRepository { - - Optional findByCardinalNumber(Integer cardinal); - - Optional findByYearAndSemester(Integer year, Integer semester); - - List findAllByStatus(CardinalStatus cardinalStatus); - - Cardinal findFirstByStatusOrderByCardinalNumberDesc(CardinalStatus status); - - List findAllByOrderByCardinalNumberAsc(); - - List findAllByOrderByCardinalNumberDesc(); -} diff --git a/src/main/java/com/weeth/domain/user/domain/repository/UserCardinalRepository.java b/src/main/java/com/weeth/domain/user/domain/repository/UserCardinalRepository.java deleted file mode 100644 index 98024774..00000000 --- a/src/main/java/com/weeth/domain/user/domain/repository/UserCardinalRepository.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.weeth.domain.user.domain.repository; - -import java.util.List; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.entity.UserCardinal; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -public interface UserCardinalRepository extends JpaRepository { - - List findAllByUserOrderByCardinalCardinalNumberDesc(User user); - - @Query("SELECT uc FROM UserCardinal uc WHERE uc.user IN :users ORDER BY uc.user.id, uc.cardinal.cardinalNumber DESC") - List findAllByUsers(List users); - - List findAllByOrderByUser_NameAsc(); - - @Query(""" - select uc.cardinal.cardinalNumber - from UserCardinal uc - where uc.user = :user - order by uc.cardinal.cardinalNumber desc - """) - List findCardinalNumbersByUser(@Param("user") User user); -} diff --git a/src/main/java/com/weeth/domain/user/domain/repository/UserRepository.java b/src/main/java/com/weeth/domain/user/domain/repository/UserRepository.java deleted file mode 100644 index 586ce952..00000000 --- a/src/main/java/com/weeth/domain/user/domain/repository/UserRepository.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.weeth.domain.user.domain.repository; - -import com.weeth.domain.user.domain.entity.Cardinal; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.entity.enums.Status; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -import jakarta.persistence.LockModeType; -import jakarta.persistence.QueryHint; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Lock; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.jpa.repository.QueryHints; -import org.springframework.data.repository.query.Param; - -import java.util.List; -import java.util.Optional; - -public interface UserRepository extends JpaRepository { - - @Lock(LockModeType.PESSIMISTIC_WRITE) - @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) - @Query("SELECT u FROM User u WHERE u.id = :id") - Optional findByIdWithLock(@Param("id") Long id); - - Optional findByEmail(String email); - - Optional findByKakaoId(long kakaoId); - - Optional findByAppleId(String appleId); - - ListfindAllByNameContainingAndStatus(String name, Status status); - - boolean existsByEmail(String email); - - boolean existsByStudentId(String studentId); - - boolean existsByTel(String tel); - - boolean existsByStudentIdAndIdIsNot(String studentId, Long id); - - boolean existsByTelAndIdIsNot(String tel, Long id); - - List findAllByStatusOrderByName(Status status); - - List findAllByOrderByNameAsc(); - - @Query("SELECT uc.user FROM UserCardinal uc WHERE uc.cardinal = :cardinal AND uc.user.status = :status") - List findAllByCardinalAndStatus(@Param("cardinal") Cardinal cardinal, @Param("status") Status status); - - /* - todo 차후 리팩토링 - */ - @Query(""" - SELECT u - FROM User u - JOIN UserCardinal uc ON u.id = uc.user.id - JOIN uc.cardinal c - WHERE u.status = :status - GROUP BY u.id - ORDER BY MAX(c.cardinalNumber) DESC, u.name ASC - """) - Slice findAllByStatusOrderedByCardinalAndName(@Param("status") Status status, Pageable pageable); - - @Query(""" - SELECT u FROM User u - JOIN UserCardinal uc ON uc.user.id = u.id - WHERE u.status = :status - AND uc.cardinal = :cardinal - ORDER BY u.name ASC - """) - Slice findAllByCardinalOrderByNameAsc(@Param("status") Status status, @Param("cardinal") Cardinal cardinal, Pageable pageable); -} diff --git a/src/main/java/com/weeth/domain/user/domain/service/CardinalGetService.java b/src/main/java/com/weeth/domain/user/domain/service/CardinalGetService.java deleted file mode 100644 index 14248651..00000000 --- a/src/main/java/com/weeth/domain/user/domain/service/CardinalGetService.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.weeth.domain.user.domain.service; - -import java.util.List; -import com.weeth.domain.user.application.exception.CardinalNotFoundException; -import com.weeth.domain.user.application.exception.DuplicateCardinalException; -import com.weeth.domain.user.domain.entity.Cardinal; -import com.weeth.domain.user.domain.entity.enums.CardinalStatus; -import com.weeth.domain.user.domain.repository.CardinalRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class CardinalGetService { - - private final CardinalRepository cardinalRepository; - - public Cardinal findByAdminSide(Integer cardinal) { - return cardinalRepository.findByCardinalNumber(cardinal) - .orElseGet(() -> cardinalRepository.save(Cardinal.builder().cardinalNumber(cardinal).build())); - } - - public Cardinal findByUserSide(Integer cardinal) { - return cardinalRepository.findByCardinalNumber(cardinal) - .orElseThrow(CardinalNotFoundException::new); - } - - public Cardinal find(Integer year, Integer semester) { - return cardinalRepository.findByYearAndSemester(year, semester) - .orElseThrow(CardinalNotFoundException::new); - } - - public Cardinal findById(long cardinalId) { - return cardinalRepository.findById(cardinalId) - .orElseThrow(CardinalNotFoundException::new); - } - - public List findAll() { - return cardinalRepository.findAllByOrderByCardinalNumberAsc(); - } - - public List findAllCardinalNumberDesc() { - return cardinalRepository.findAllByOrderByCardinalNumberDesc(); - } - - public List findInProgress() { - return cardinalRepository.findAllByStatus(CardinalStatus.IN_PROGRESS); - } - - public void validateCardinal(Integer cardinal) { - if (cardinalRepository.findByCardinalNumber(cardinal).isPresent()) { - throw new DuplicateCardinalException(); - } - } -} diff --git a/src/main/java/com/weeth/domain/user/domain/service/CardinalSaveService.java b/src/main/java/com/weeth/domain/user/domain/service/CardinalSaveService.java deleted file mode 100644 index 2e755d2f..00000000 --- a/src/main/java/com/weeth/domain/user/domain/service/CardinalSaveService.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.weeth.domain.user.domain.service; - -import com.weeth.domain.user.domain.entity.Cardinal; -import com.weeth.domain.user.domain.repository.CardinalRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class CardinalSaveService { - - private final CardinalRepository cardinalRepository; - - public Cardinal save(Cardinal cardinal) { - return cardinalRepository.save(cardinal); - } -} diff --git a/src/main/java/com/weeth/domain/user/domain/service/UserCardinalGetService.java b/src/main/java/com/weeth/domain/user/domain/service/UserCardinalGetService.java deleted file mode 100644 index 383a3ed3..00000000 --- a/src/main/java/com/weeth/domain/user/domain/service/UserCardinalGetService.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.weeth.domain.user.domain.service; - -import java.util.Comparator; -import java.util.List; -import com.weeth.domain.user.application.exception.CardinalNotFoundException; -import com.weeth.domain.user.domain.entity.Cardinal; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.entity.UserCardinal; -import com.weeth.domain.user.domain.repository.UserCardinalRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class UserCardinalGetService { - - private final UserCardinalRepository userCardinalRepository; - - public List getUserCardinals(User user) { - return userCardinalRepository.findAllByUserOrderByCardinalCardinalNumberDesc(user); - } - - public List findAll() { - return userCardinalRepository.findAllByOrderByUser_NameAsc(); - } - - public List findAll(List users) { - return userCardinalRepository.findAllByUsers(users); - } - - public boolean notContains(User user, Cardinal cardinal) { - return getUserCardinals(user).stream() - .noneMatch(userCardinal -> userCardinal.getCardinal().equals(cardinal)); - } - - public boolean isCurrent(User user, Cardinal cardinal) { - Integer maxCardinalNumber = getUserCardinals(user).stream() - .map(UserCardinal::getCardinal) - .map(Cardinal::getCardinalNumber) - .max(Integer::compareTo) - .orElseThrow(CardinalNotFoundException::new); - - return maxCardinalNumber < cardinal.getCardinalNumber(); - } - - public Cardinal getCurrentCardinal(User user) { - return getUserCardinals(user).stream() - .map(UserCardinal::getCardinal) - .max(Comparator.comparing(Cardinal::getCardinalNumber)) - .orElseThrow(CardinalNotFoundException::new); - } - - public List getCardinalNumbers(User user) { - return userCardinalRepository.findCardinalNumbersByUser(user); - } -} diff --git a/src/main/java/com/weeth/domain/user/domain/service/UserCardinalSaveService.java b/src/main/java/com/weeth/domain/user/domain/service/UserCardinalSaveService.java deleted file mode 100644 index 83a99999..00000000 --- a/src/main/java/com/weeth/domain/user/domain/service/UserCardinalSaveService.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.weeth.domain.user.domain.service; - -import com.weeth.domain.user.domain.entity.UserCardinal; -import com.weeth.domain.user.domain.repository.UserCardinalRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class UserCardinalSaveService { - - private final UserCardinalRepository userCardinalRepository; - - public void save(UserCardinal userCardinal) { - userCardinalRepository.save(userCardinal); - } -} diff --git a/src/main/java/com/weeth/domain/user/domain/service/UserDeleteService.java b/src/main/java/com/weeth/domain/user/domain/service/UserDeleteService.java deleted file mode 100644 index 2f5dd11e..00000000 --- a/src/main/java/com/weeth/domain/user/domain/service/UserDeleteService.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.weeth.domain.user.domain.service; - -import jakarta.transaction.Transactional; -import com.weeth.domain.user.domain.entity.User; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class UserDeleteService { - - @Transactional - public void leave(User user) { - user.leave(); - } - - @Transactional - public void ban(User user) { - user.ban(); - } -} diff --git a/src/main/java/com/weeth/domain/user/domain/service/UserGetService.java b/src/main/java/com/weeth/domain/user/domain/service/UserGetService.java deleted file mode 100644 index 3e2b2787..00000000 --- a/src/main/java/com/weeth/domain/user/domain/service/UserGetService.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.weeth.domain.user.domain.service; - -import com.weeth.domain.user.domain.entity.Cardinal; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.entity.enums.Status; -import com.weeth.domain.user.domain.repository.UserRepository; -import com.weeth.domain.user.application.exception.UserNotFoundException; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -import org.springframework.stereotype.Service; - -import java.util.List; -import java.util.Optional; - -@Service -@RequiredArgsConstructor -public class UserGetService { - - private final UserRepository userRepository; - - public User find(Long userId) { - return userRepository.findById(userId) - .orElseThrow(UserNotFoundException::new); - } - - public User find(String email){ - return userRepository.findByEmail(email) - .orElseThrow(UserNotFoundException::new); - } - - public Optional findByKakaoId(long kakaoId){ - return userRepository.findByKakaoId(kakaoId); - } - - public Optional findByAppleId(String appleId){ - return userRepository.findByAppleId(appleId); - } - - public List search(String keyword) { - return userRepository.findAllByNameContainingAndStatus(keyword, Status.ACTIVE); - } - - public Boolean check(String email) { - return !userRepository.existsByEmail(email); - } - - public List findAll(List userId) { - return userRepository.findAllById(userId); - } - - public List findAllByCardinal(Cardinal cardinal) { - return userRepository.findAllByCardinalAndStatus(cardinal, Status.ACTIVE); - } - - public Slice findAll(Pageable pageable) { - Slice users = userRepository.findAllByStatusOrderedByCardinalAndName(Status.ACTIVE, pageable); - - if (users.isEmpty()) { - throw new UserNotFoundException(); - } - - return users; - } - - public Slice findAll(Pageable pageable, Cardinal cardinal) { - Slice users = userRepository.findAllByCardinalOrderByNameAsc(Status.ACTIVE, cardinal, pageable); - - if (users.isEmpty()) { - throw new UserNotFoundException(); - } - - return users; - } - - public boolean validateStudentId(String studentId) { - return userRepository.existsByStudentId(studentId); - } - - public boolean validateStudentId(String studentId, Long userId) { - return userRepository.existsByStudentIdAndIdIsNot(studentId, userId); - } - - public boolean validateTel(String tel) { - return userRepository.existsByTel(tel); - } - - public boolean validateTel(String tel, Long userId) { - return userRepository.existsByTelAndIdIsNot(tel, userId); - } -} diff --git a/src/main/java/com/weeth/domain/user/domain/service/UserSaveService.java b/src/main/java/com/weeth/domain/user/domain/service/UserSaveService.java deleted file mode 100644 index 7afd4987..00000000 --- a/src/main/java/com/weeth/domain/user/domain/service/UserSaveService.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.weeth.domain.user.domain.service; - -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.repository.UserRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class UserSaveService { - - private final UserRepository userRepository; - - public void save(User user) { - userRepository.save(user); - } -} diff --git a/src/main/java/com/weeth/domain/user/domain/service/UserUpdateService.java b/src/main/java/com/weeth/domain/user/domain/service/UserUpdateService.java deleted file mode 100644 index 2f190ed6..00000000 --- a/src/main/java/com/weeth/domain/user/domain/service/UserUpdateService.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.weeth.domain.user.domain.service; - -import jakarta.transaction.Transactional; -import com.weeth.domain.user.domain.entity.User; -import lombok.RequiredArgsConstructor; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; - -import static com.weeth.domain.user.application.dto.request.UserRequestDto.Update; - - -@Service -@Transactional -@RequiredArgsConstructor -public class UserUpdateService { - - public void update(User user, Update dto) { - user.update(dto); - } - - public void accept(User user) { - user.accept(); - } - - public void update(User user, String role) { - user.update(role); - } - - public void reset(User user, PasswordEncoder passwordEncoder) { - user.reset(passwordEncoder); - } -} diff --git a/src/main/java/com/weeth/domain/user/presentation/CardinalController.java b/src/main/java/com/weeth/domain/user/presentation/CardinalController.java deleted file mode 100644 index 17b76427..00000000 --- a/src/main/java/com/weeth/domain/user/presentation/CardinalController.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.weeth.domain.user.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import com.weeth.domain.user.application.dto.request.CardinalSaveRequest; -import com.weeth.domain.user.application.dto.request.CardinalUpdateRequest; -import com.weeth.domain.user.application.dto.response.CardinalResponse; -import com.weeth.domain.user.application.exception.UserErrorCode; -import com.weeth.domain.user.application.usecase.CardinalUseCase; -import com.weeth.global.auth.jwt.application.exception.JwtErrorCode; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -import static com.weeth.domain.user.presentation.UserResponseCode.*; - -@Tag(name = "CARDINAL") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1") -@ApiErrorCodeExample({UserErrorCode.class, JwtErrorCode.class}) -public class CardinalController { - - private final CardinalUseCase cardinalUseCase; - - @GetMapping("/cardinals") - @Operation(summary = "현재 저장된 기수 목록 조회 API") - public CommonResponse> findAllCardinals() { - List response = cardinalUseCase.findAll(); - - return CommonResponse.success(CARDINAL_FIND_ALL_SUCCESS, response); - } - - @PatchMapping("/admin/cardinals") - @Operation(summary = "[admin] 기수 정보 수정 API") - public CommonResponse updateCardinals(@RequestBody CardinalUpdateRequest dto) { - cardinalUseCase.update(dto); - - return CommonResponse.success(CARDINAL_UPDATE_SUCCESS); - } - - @PostMapping("/admin/cardinals") - @Operation(summary = "[admin] 새로운 기수 정보 저장 API") - public CommonResponse save(@RequestBody @Valid CardinalSaveRequest dto) { - cardinalUseCase.save(dto); - - return CommonResponse.success(CARDINAL_SAVE_SUCCESS); - } - -} diff --git a/src/main/java/com/weeth/domain/user/presentation/UserAdminController.java b/src/main/java/com/weeth/domain/user/presentation/UserAdminController.java deleted file mode 100644 index e91dcc01..00000000 --- a/src/main/java/com/weeth/domain/user/presentation/UserAdminController.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.weeth.domain.user.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import com.weeth.domain.user.application.exception.UserErrorCode; -import com.weeth.domain.user.application.usecase.UserManageUseCase; -import com.weeth.domain.user.domain.entity.enums.UsersOrderBy; -import com.weeth.global.auth.jwt.application.exception.JwtErrorCode; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -import static com.weeth.domain.user.application.dto.request.UserRequestDto.*; -import static com.weeth.domain.user.application.dto.response.UserResponseDto.AdminResponse; -import static com.weeth.domain.user.presentation.UserResponseCode.*; - -@Tag(name = "USER ADMIN", description = "[ADMIN] 사용자 어드민 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/admin/users") -@ApiErrorCodeExample({UserErrorCode.class, JwtErrorCode.class}) -public class UserAdminController { - - private final UserManageUseCase userManageUseCase; - - @GetMapping("/all") - @Operation(summary = "어드민용 회원 조회") - public CommonResponse> findAll(@RequestParam UsersOrderBy orderBy) { - return CommonResponse.success(USER_FIND_ALL_SUCCESS, userManageUseCase.findAllByAdmin(orderBy)); - } - - @PatchMapping - @Operation(summary = "가입 신청 승인") - public CommonResponse accept(@RequestBody UserId userId) { - userManageUseCase.accept(userId); - return CommonResponse.success(USER_ACCEPT_SUCCESS); - } - - @DeleteMapping - @Operation(summary = "유저 추방") - public CommonResponse ban(@RequestBody UserId userId) { - userManageUseCase.ban(userId); - return CommonResponse.success(USER_BAN_SUCCESS); - } - - @PatchMapping("/role") - @Operation(summary = "관리자로 승격/강등") - public CommonResponse update(@RequestBody List request) { - userManageUseCase.update(request); - return CommonResponse.success(USER_ROLE_UPDATE_SUCCESS); - } - - @PatchMapping("/apply") - @Operation(summary = "다음 기수도 이어서 진행") - public CommonResponse applyOB(@RequestBody List request) { - userManageUseCase.applyOB(request); - return CommonResponse.success(USER_APPLY_OB_SUCCESS); - } - - @PatchMapping("/reset") - @Operation(summary = "회원 비밀번호 초기화") - public CommonResponse resetPassword(@RequestBody UserId userId) { - userManageUseCase.reset(userId); - return CommonResponse.success(USER_PASSWORD_RESET_SUCCESS); - } -} diff --git a/src/main/java/com/weeth/domain/user/presentation/UserController.java b/src/main/java/com/weeth/domain/user/presentation/UserController.java deleted file mode 100644 index 49ed0767..00000000 --- a/src/main/java/com/weeth/domain/user/presentation/UserController.java +++ /dev/null @@ -1,148 +0,0 @@ -package com.weeth.domain.user.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import com.weeth.domain.user.application.dto.response.UserResponseDto; -import com.weeth.domain.user.application.dto.response.UserResponseDto.SummaryResponse; -import com.weeth.domain.user.application.dto.response.UserResponseDto.UserResponse; -import com.weeth.domain.user.application.exception.UserErrorCode; -import com.weeth.domain.user.application.usecase.UserManageUseCase; -import com.weeth.domain.user.application.usecase.UserUseCase; -import com.weeth.domain.user.domain.service.UserGetService; -import com.weeth.global.auth.annotation.CurrentUser; -import com.weeth.global.auth.jwt.application.dto.JwtDto; -import com.weeth.global.auth.jwt.application.exception.JwtErrorCode; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Slice; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -import static com.weeth.domain.user.application.dto.request.UserRequestDto.*; -import static com.weeth.domain.user.application.dto.response.UserResponseDto.Response; -import static com.weeth.domain.user.application.dto.response.UserResponseDto.SocialLoginResponse; -import static com.weeth.domain.user.presentation.UserResponseCode.*; - -@Tag(name = "USER", description = "사용자 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/users") -@ApiErrorCodeExample({UserErrorCode.class, JwtErrorCode.class}) -public class UserController { - - private final UserUseCase userUseCase; - private final UserManageUseCase userManageUseCase; - private final UserGetService userGetService; - - @PostMapping("/kakao/login") - @Operation(summary = "카카오 소셜 로그인 API") - public CommonResponse login(@RequestBody @Valid Login dto) { - SocialLoginResponse response = userUseCase.login(dto); - return CommonResponse.success(SOCIAL_LOGIN_SUCCESS, response); - } - - @PostMapping("/kakao/auth") - @Operation(summary = "카카오 소셜 회원가입 전 요청 API (미사용 API)") - public CommonResponse beforeRegister(@RequestBody @Valid Login dto) { - UserResponseDto.SocialAuthResponse response = userUseCase.authenticate(dto); - return CommonResponse.success(SOCIAL_AUTH_SUCCESS, response); - } - - @PostMapping("/apply") - @Operation(summary = "동아리 지원 신청. 현재 사용하지 않으므로 회원가입 시 /kakao/register api로 요청 바람") - public CommonResponse apply(@RequestBody @Valid SignUp dto) { - userUseCase.apply(dto); - return CommonResponse.success(USER_APPLY_SUCCESS); - } - - @PostMapping("/kakao/register") - @Operation(summary = "소셜 회원가입") - public CommonResponse register(@RequestBody @Valid Register dto) { - userUseCase.socialRegister(dto); - return CommonResponse.success(USER_APPLY_SUCCESS); - } - - @PatchMapping("/kakao/link") - @Operation(summary = "카카오 소셜 로그인 연동") - public CommonResponse integrate(@RequestBody @Valid NormalLogin dto) { - return CommonResponse.success(SOCIAL_INTEGRATE_SUCCESS, userUseCase.integrate(dto)); - } - - @PostMapping("/apple/login") - @Operation(summary = "애플 소셜 로그인 API") - public CommonResponse appleLogin(@RequestBody @Valid Login dto) { - SocialLoginResponse response = userUseCase.appleLogin(dto); - return CommonResponse.success(SOCIAL_LOGIN_SUCCESS, response); - } - - @PostMapping("/apple/register") - @Operation(summary = "애플 소셜 회원가입 (dev 전용 - 바로 ACTIVE)") - public CommonResponse appleRegister(@RequestBody @Valid Register dto) { - userUseCase.appleRegister(dto); - return CommonResponse.success(USER_APPLY_SUCCESS); - } - - @GetMapping("/email") - @Operation(summary = "이메일 중복 확인") - public CommonResponse checkEmail(@RequestParam String email) { - return CommonResponse.success(USER_EMAIL_CHECK_SUCCESS, userGetService.check(email)); - } - - @GetMapping("/all") - @Operation(summary = "동아리 멤버 전체 조회(전체/기수별)") - public CommonResponse> findAllUser(@RequestParam("pageNumber") int pageNumber, - @RequestParam("pageSize") int pageSize, - @RequestParam(required = false) Integer cardinal) { - return CommonResponse.success(USER_FIND_ALL_SUCCESS, userUseCase.findAllUser(pageNumber, pageSize, cardinal)); - } - - @GetMapping("/search") - @Operation(summary = "동아리 멤버 검색") - public CommonResponse> searchUser(@RequestParam String keyword) { - return CommonResponse.success(USER_FIND_BY_ID_SUCCESS, userUseCase.searchUser(keyword)); - } - - @GetMapping("/details") - @Operation(summary = "특정 멤버 상세 조회") - public CommonResponse findUser(@RequestParam Long userId) { - return CommonResponse.success( - USER_DETAILS_SUCCESS, userUseCase.findUserDetails(userId) - ); - } - - @GetMapping - @Operation(summary = "내 정보 조회") - public CommonResponse find(@Parameter(hidden = true) @CurrentUser Long userId) { - return CommonResponse.success(USER_FIND_BY_ID_SUCCESS, userUseCase.find(userId)); - } - - @GetMapping("/info") - @Operation(summary = "전역 내 정보 조회 API") - public CommonResponse findMyInfo(@Parameter(hidden = true) @CurrentUser Long userId) { - return CommonResponse.success(USER_FIND_BY_ID_SUCCESS, userUseCase.findUserInfo(userId)); - } - - @PatchMapping - @Operation(summary = "내 정보 수정") - public CommonResponse update(@RequestBody @Valid Update dto, @Parameter(hidden = true) @CurrentUser Long userId) { - userUseCase.update(dto, userId); - return CommonResponse.success(USER_UPDATE_SUCCESS); - } - - @DeleteMapping - @Operation(summary = "동아리 탈퇴") - public CommonResponse leave(@Parameter(hidden = true) @CurrentUser Long userId) { - userManageUseCase.leave(userId); - return CommonResponse.success(USER_LEAVE_SUCCESS); - } - - @PostMapping("/refresh") - @Operation(summary = "JWT 토큰 재발급 API") - public CommonResponse refresh(@Parameter(hidden = true) @RequestHeader("Authorization_refresh") String refreshToken) { - return CommonResponse.success(JWT_REFRESH_SUCCESS, userUseCase.refresh(refreshToken)); - } -} diff --git a/src/main/java/com/weeth/domain/user/presentation/UserResponseCode.java b/src/main/java/com/weeth/domain/user/presentation/UserResponseCode.java deleted file mode 100644 index 95beccc9..00000000 --- a/src/main/java/com/weeth/domain/user/presentation/UserResponseCode.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.weeth.domain.user.presentation; - -import com.weeth.global.common.response.ResponseCodeInterface; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -public enum UserResponseCode implements ResponseCodeInterface { - // UserAdminController 관련 - USER_FIND_ALL_SUCCESS(1800, HttpStatus.OK, "모든 회원 정보를 성공적으로 조회했습니다."), - USER_DETAILS_SUCCESS(1801, HttpStatus.OK, "특정 회원의 상세 정보를 성공적으로 조회했습니다."), - USER_ACCEPT_SUCCESS(1802, HttpStatus.OK, "회원 가입 승인이 성공적으로 처리되었습니다."), - USER_BAN_SUCCESS(1803, HttpStatus.OK, "회원이 성공적으로 차단되었습니다."), - USER_ROLE_UPDATE_SUCCESS(1804, HttpStatus.OK, "회원의 역할이 성공적으로 수정되었습니다."), - USER_APPLY_OB_SUCCESS(1805, HttpStatus.OK, "OB 신청이 성공적으로 처리되었습니다."), - USER_PASSWORD_RESET_SUCCESS(1806, HttpStatus.OK, "비밀번호가 성공적으로 초기화되었습니다."), - // UserController 관련 - USER_APPLY_SUCCESS(1807, HttpStatus.OK, "회원 가입 신청이 성공적으로 처리되었습니다."), - USER_EMAIL_CHECK_SUCCESS(1808, HttpStatus.OK, "이메일 중복 검사가 성공적으로 처리되었습니다."), - USER_FIND_BY_ID_SUCCESS(1809, HttpStatus.OK, "회원 정보가 성공적으로 조회되었습니다."), - USER_UPDATE_SUCCESS(1810, HttpStatus.OK, "회원 정보가 성공적으로 수정되었습니다."), - USER_LEAVE_SUCCESS(1811, HttpStatus.OK, "회원 탈퇴가 성공적으로 처리되었습니다."), - SOCIAL_LOGIN_SUCCESS(1812, HttpStatus.OK, "소셜 로그인에 성공했습니다."), - SOCIAL_REGISTER_SUCCESS(1813, HttpStatus.OK, "소셜 회원가입에 성공했습니다."), - SOCIAL_AUTH_SUCCESS(1814, HttpStatus.OK, "소셜 인증에 성공했습니다."), - SOCIAL_INTEGRATE_SUCCESS(1815, HttpStatus.OK, "소셜 로그인 연동에 성공했습니다."), - JWT_REFRESH_SUCCESS(1816, HttpStatus.OK, "토큰 재발급에 성공했습니다."), - - // CardinalController 관련 - CARDINAL_FIND_ALL_SUCCESS(1817, HttpStatus.OK, "전체 기수 조회에 성공했습니다."), - CARDINAL_SAVE_SUCCESS(1818, HttpStatus.OK, "기수 저장에 성공했습니다."), - CARDINAL_UPDATE_SUCCESS(1819, HttpStatus.OK, "기수 수정에 성공했습니다."); - - private final int code; - private final HttpStatus status; - private final String message; - - UserResponseCode(int code, HttpStatus status, String message) { - this.code = code; - this.status = status; - this.message = message; - } -} diff --git a/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCase.kt b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCase.kt index 70a54fad..e8a79b3e 100644 --- a/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCase.kt @@ -4,19 +4,22 @@ import com.weeth.domain.account.application.dto.request.AccountSaveRequest import com.weeth.domain.account.application.exception.AccountExistsException import com.weeth.domain.account.domain.entity.Account import com.weeth.domain.account.domain.repository.AccountRepository -import com.weeth.domain.user.domain.service.CardinalGetService +import com.weeth.domain.user.domain.entity.Cardinal +import com.weeth.domain.user.domain.repository.CardinalRepository import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @Service class ManageAccountUseCase( private val accountRepository: AccountRepository, - private val cardinalGetService: CardinalGetService, + private val cardinalRepository: CardinalRepository, ) { @Transactional fun save(request: AccountSaveRequest) { if (accountRepository.existsByCardinal(request.cardinal)) throw AccountExistsException() - cardinalGetService.findByAdminSide(request.cardinal) + cardinalRepository.findByCardinalNumber(request.cardinal).orElseGet { + cardinalRepository.save(Cardinal.create(cardinalNumber = request.cardinal)) + } accountRepository.save(Account.create(request.description, request.totalAmount, request.cardinal)) } } diff --git a/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCase.kt b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCase.kt index ef0c939f..34c06373 100644 --- a/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCase.kt @@ -13,7 +13,8 @@ import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.entity.FileOwnerType import com.weeth.domain.file.domain.repository.FileReader import com.weeth.domain.file.domain.repository.FileRepository -import com.weeth.domain.user.domain.service.CardinalGetService +import com.weeth.domain.user.domain.entity.Cardinal +import com.weeth.domain.user.domain.repository.CardinalRepository import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -24,12 +25,18 @@ class ManageReceiptUseCase( private val accountRepository: AccountRepository, private val fileReader: FileReader, private val fileRepository: FileRepository, - private val cardinalGetService: CardinalGetService, + private val cardinalRepository: CardinalRepository, private val fileMapper: FileMapper, ) { + private fun ensureCardinalExists(cardinalNumber: Int) { + cardinalRepository.findByCardinalNumber(cardinalNumber).orElseGet { + cardinalRepository.save(Cardinal.create(cardinalNumber = cardinalNumber)) + } + } + @Transactional fun save(request: ReceiptSaveRequest) { - cardinalGetService.findByAdminSide(request.cardinal) + ensureCardinalExists(request.cardinal) val account = accountRepository.findByCardinal(request.cardinal) ?: throw AccountNotFoundException() val receipt = receiptRepository.save( @@ -44,7 +51,7 @@ class ManageReceiptUseCase( receiptId: Long, request: ReceiptUpdateRequest, ) { - cardinalGetService.findByAdminSide(request.cardinal) + ensureCardinalExists(request.cardinal) val account = accountRepository.findByCardinal(request.cardinal) ?: throw AccountNotFoundException() val receipt = receiptRepository.findByIdOrNull(receiptId) ?: throw ReceiptNotFoundException() if (receipt.account.id != account.id) throw ReceiptAccountMismatchException() diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceInfoResponse.kt b/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceInfoResponse.kt index 434ad7a9..1b72be7a 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceInfoResponse.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceInfoResponse.kt @@ -10,8 +10,6 @@ data class AttendanceInfoResponse( val status: Status?, @field:Schema(description = "사용자 이름", example = "이지훈") val name: String?, - @field:Schema(description = "직책", example = "BE") - val position: String?, @field:Schema(description = "소속 학과", example = "컴퓨터공학과") val department: String?, @field:Schema(description = "학번", example = "20201234") diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapper.kt b/src/main/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapper.kt index a60bc189..8ccf206b 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapper.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapper.kt @@ -30,9 +30,9 @@ class AttendanceMapper { attendances: List, ): AttendanceDetailResponse = AttendanceDetailResponse( - attendanceCount = user.attendanceCount ?: 0, - total = (user.attendanceCount ?: 0) + (user.absenceCount ?: 0), - absenceCount = user.absenceCount ?: 0, + attendanceCount = user.attendanceCount, + total = user.attendanceCount + user.absenceCount, + absenceCount = user.absenceCount, attendances = attendances, ) @@ -51,8 +51,7 @@ class AttendanceMapper { id = attendance.id, status = attendance.status, name = attendance.user.name, - position = attendance.user.position?.name, - department = attendance.user.department?.name, + department = attendance.user.department, studentId = attendance.user.studentId, ) } diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/CheckInAttendanceUseCase.kt b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/CheckInAttendanceUseCase.kt index 8f92ae0b..7d0efcd6 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/CheckInAttendanceUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/CheckInAttendanceUseCase.kt @@ -4,14 +4,14 @@ import com.weeth.domain.attendance.application.exception.AttendanceCodeMismatchE import com.weeth.domain.attendance.application.exception.AttendanceNotFoundException import com.weeth.domain.attendance.domain.enums.Status import com.weeth.domain.attendance.domain.repository.AttendanceRepository -import com.weeth.domain.user.domain.service.UserGetService +import com.weeth.domain.user.domain.repository.UserReader import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.LocalDateTime @Service class CheckInAttendanceUseCase( - private val userGetService: UserGetService, + private val userReader: UserReader, private val attendanceRepository: AttendanceRepository, ) { @Transactional @@ -19,7 +19,7 @@ class CheckInAttendanceUseCase( userId: Long, code: Int, ) { - val user = userGetService.find(userId) + val user = userReader.getById(userId) val now = LocalDateTime.now() val todayAttendance = diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt index 6a8c63cd..59a39143 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt @@ -8,8 +8,8 @@ import com.weeth.domain.attendance.domain.repository.AttendanceRepository import com.weeth.domain.schedule.domain.service.MeetingGetService import com.weeth.domain.user.domain.entity.enums.Role import com.weeth.domain.user.domain.entity.enums.Status -import com.weeth.domain.user.domain.service.UserCardinalGetService -import com.weeth.domain.user.domain.service.UserGetService +import com.weeth.domain.user.domain.repository.UserReader +import com.weeth.domain.user.domain.service.UserCardinalPolicy import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.LocalDate @@ -17,14 +17,14 @@ import java.time.LocalDate @Service @Transactional(readOnly = true) class GetAttendanceQueryService( - private val userGetService: UserGetService, - private val userCardinalGetService: UserCardinalGetService, + private val userReader: UserReader, + private val userCardinalPolicy: UserCardinalPolicy, private val meetingGetService: MeetingGetService, private val attendanceRepository: AttendanceRepository, private val mapper: AttendanceMapper, ) { fun findAttendance(userId: Long): AttendanceSummaryResponse { - val user = userGetService.find(userId) + val user = userReader.getById(userId) val today = LocalDate.now() val todayAttendance = @@ -38,8 +38,8 @@ class GetAttendanceQueryService( } fun findAllDetailsByCurrentCardinal(userId: Long): AttendanceDetailResponse { - val user = userGetService.find(userId) - val currentCardinal = userCardinalGetService.getCurrentCardinal(user) + val user = userReader.getById(userId) + val currentCardinal = userCardinalPolicy.getCurrentCardinal(user) val responses = attendanceRepository diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/service/AttendanceSaveService.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/service/AttendanceSaveService.kt index f19106dc..f44045c4 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/domain/service/AttendanceSaveService.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/service/AttendanceSaveService.kt @@ -15,8 +15,7 @@ class AttendanceSaveService( meetings: List?, ) { meetings?.forEach { meeting -> - val attendance = attendanceRepository.save(Attendance(meeting, user)) - user.add(attendance) + attendanceRepository.save(Attendance(meeting, user)) } } diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt index afe3bf21..d74c52a6 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt @@ -18,7 +18,7 @@ import com.weeth.domain.file.domain.entity.FileOwnerType import com.weeth.domain.file.domain.repository.FileReader import com.weeth.domain.file.domain.repository.FileRepository import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.service.UserGetService +import com.weeth.domain.user.domain.repository.UserReader import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -26,7 +26,7 @@ import org.springframework.transaction.annotation.Transactional class ManagePostUseCase( private val postRepository: PostRepository, private val boardRepository: BoardRepository, // 동일 도메인 - private val userGetService: UserGetService, + private val userReader: UserReader, private val fileRepository: FileRepository, private val fileReader: FileReader, private val fileMapper: FileMapper, @@ -38,7 +38,7 @@ class ManagePostUseCase( request: CreatePostRequest, userId: Long, ): PostSaveResponse { - val user = userGetService.find(userId) // todo: Reader 인터페이스로 수정 + val user = userReader.getById(userId) val board = findBoard(boardId) checkWritePermission(board, user) diff --git a/src/main/kotlin/com/weeth/domain/comment/application/dto/response/CommentResponse.kt b/src/main/kotlin/com/weeth/domain/comment/application/dto/response/CommentResponse.kt index 810996c0..b668405a 100644 --- a/src/main/kotlin/com/weeth/domain/comment/application/dto/response/CommentResponse.kt +++ b/src/main/kotlin/com/weeth/domain/comment/application/dto/response/CommentResponse.kt @@ -1,7 +1,6 @@ package com.weeth.domain.comment.application.dto.response import com.weeth.domain.file.application.dto.response.FileResponse -import com.weeth.domain.user.domain.entity.enums.Position import com.weeth.domain.user.domain.entity.enums.Role import io.swagger.v3.oas.annotations.media.Schema import java.time.LocalDateTime @@ -11,8 +10,6 @@ data class CommentResponse( val id: Long, @field:Schema(description = "작성자 이름", example = "홍길동") val name: String, - @field:Schema(description = "작성자 포지션", example = "BE") - val position: Position, @field:Schema(description = "작성자 역할", example = "USER") val role: Role, @field:Schema(description = "댓글 내용", example = "댓글입니다.") diff --git a/src/main/kotlin/com/weeth/domain/comment/application/mapper/CommentMapper.kt b/src/main/kotlin/com/weeth/domain/comment/application/mapper/CommentMapper.kt index 74007626..80f41d93 100644 --- a/src/main/kotlin/com/weeth/domain/comment/application/mapper/CommentMapper.kt +++ b/src/main/kotlin/com/weeth/domain/comment/application/mapper/CommentMapper.kt @@ -15,7 +15,6 @@ class CommentMapper { CommentResponse( id = comment.id, name = comment.user.name, - position = comment.user.position, role = comment.user.role, content = comment.content, time = comment.modifiedAt, diff --git a/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt b/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt index af074fdb..da0409fe 100644 --- a/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt @@ -15,7 +15,7 @@ import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.entity.FileOwnerType import com.weeth.domain.file.domain.repository.FileReader import com.weeth.domain.file.domain.repository.FileRepository -import com.weeth.domain.user.domain.service.UserGetService +import com.weeth.domain.user.domain.repository.UserReader import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -23,7 +23,7 @@ import org.springframework.transaction.annotation.Transactional class ManageCommentUseCase( private val commentRepository: CommentRepository, private val postRepository: PostRepository, - private val userGetService: UserGetService, + private val userReader: UserReader, private val fileReader: FileReader, private val fileRepository: FileRepository, private val fileMapper: FileMapper, @@ -34,7 +34,7 @@ class ManageCommentUseCase( postId: Long, userId: Long, ) { - val user = userGetService.find(userId) + val user = userReader.getById(userId) val post = findPostWithLock(postId) val parent = dto.parentCommentId?.let { parentId -> diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/SavePenaltyUseCase.kt b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/SavePenaltyUseCase.kt index b9391d0a..b3a96c19 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/SavePenaltyUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/SavePenaltyUseCase.kt @@ -6,7 +6,7 @@ import com.weeth.domain.penalty.domain.enums.PenaltyType import com.weeth.domain.penalty.domain.repository.PenaltyRepository import com.weeth.domain.user.application.exception.UserNotFoundException import com.weeth.domain.user.domain.repository.UserRepository -import com.weeth.domain.user.domain.service.UserCardinalGetService +import com.weeth.domain.user.domain.service.UserCardinalPolicy import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -14,7 +14,7 @@ import org.springframework.transaction.annotation.Transactional class SavePenaltyUseCase( private val penaltyRepository: PenaltyRepository, private val userRepository: UserRepository, - private val userCardinalGetService: UserCardinalGetService, + private val userCardinalPolicy: UserCardinalPolicy, private val mapper: PenaltyMapper, ) { companion object { @@ -27,7 +27,7 @@ class SavePenaltyUseCase( userRepository .findByIdWithLock(request.userId) .orElseThrow { UserNotFoundException() } - val cardinal = userCardinalGetService.getCurrentCardinal(user) + val cardinal = userCardinalPolicy.getCurrentCardinal(user) val penalty = mapper.toEntity(request, user, cardinal) penaltyRepository.save(penalty) diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/query/GetPenaltyQueryService.kt b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/query/GetPenaltyQueryService.kt index cb842e2f..d0321f8e 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/query/GetPenaltyQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/query/GetPenaltyQueryService.kt @@ -4,9 +4,10 @@ import com.weeth.domain.penalty.application.dto.response.PenaltyByCardinalRespon import com.weeth.domain.penalty.application.dto.response.PenaltyResponse import com.weeth.domain.penalty.application.mapper.PenaltyMapper import com.weeth.domain.penalty.domain.repository.PenaltyRepository -import com.weeth.domain.user.domain.service.CardinalGetService -import com.weeth.domain.user.domain.service.UserCardinalGetService -import com.weeth.domain.user.domain.service.UserGetService +import com.weeth.domain.user.domain.repository.CardinalReader +import com.weeth.domain.user.domain.repository.UserCardinalReader +import com.weeth.domain.user.domain.repository.UserReader +import com.weeth.domain.user.domain.service.UserCardinalPolicy import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -14,25 +15,26 @@ import org.springframework.transaction.annotation.Transactional @Transactional(readOnly = true) class GetPenaltyQueryService( private val penaltyRepository: PenaltyRepository, - private val userGetService: UserGetService, - private val userCardinalGetService: UserCardinalGetService, - private val cardinalGetService: CardinalGetService, + private val userReader: UserReader, + private val userCardinalReader: UserCardinalReader, + private val userCardinalPolicy: UserCardinalPolicy, + private val cardinalReader: CardinalReader, private val mapper: PenaltyMapper, ) { fun findAllByCardinal(cardinalNumber: Int?): List { val cardinals = if (cardinalNumber == null) { - cardinalGetService.findAllCardinalNumberDesc() + cardinalReader.findAllByCardinalNumberDesc() } else { - listOf(cardinalGetService.findByAdminSide(cardinalNumber)) + listOf(cardinalReader.getByCardinalNumber(cardinalNumber)) } return cardinals.map { cardinal -> val penalties = penaltyRepository.findByCardinalIdOrderByIdDesc(cardinal.id) val users = penalties.map { it.user }.distinct() val userCardinalsMap = - userCardinalGetService - .findAll(users) + userCardinalReader + .findAllByUsersOrderByCardinalDesc(users) .groupBy { it.user.id } val responses = @@ -49,10 +51,10 @@ class GetPenaltyQueryService( } fun findByUser(userId: Long): PenaltyResponse { - val user = userGetService.find(userId) - val currentCardinal = userCardinalGetService.getCurrentCardinal(user) + val user = userReader.getById(userId) + val currentCardinal = userCardinalPolicy.getCurrentCardinal(user) val penalties = penaltyRepository.findByUserIdAndCardinalIdOrderByIdDesc(userId, currentCardinal.id) - val userCardinals = userCardinalGetService.getUserCardinals(user) + val userCardinals = userCardinalReader.findAllByUser(user) return mapper.toResponse(user, penalties, userCardinals) } diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/request/CardinalSaveRequest.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/request/CardinalSaveRequest.kt new file mode 100644 index 00000000..2ebe2154 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/request/CardinalSaveRequest.kt @@ -0,0 +1,19 @@ +package com.weeth.domain.user.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotNull + +data class CardinalSaveRequest( + @field:NotNull + @field:Schema(description = "기수", example = "4") + val cardinalNumber: Int, + @field:NotNull + @field:Schema(description = "년도", example = "2024") + val year: Int, + @field:NotNull + @field:Schema(description = "학기", example = "2") + val semester: Int, + @field:NotNull + @field:Schema(description = "현재 진행중 여부", example = "false") + val inProgress: Boolean, +) diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/request/CardinalUpdateRequest.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/request/CardinalUpdateRequest.kt new file mode 100644 index 00000000..2e3eda0a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/request/CardinalUpdateRequest.kt @@ -0,0 +1,19 @@ +package com.weeth.domain.user.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotNull + +data class CardinalUpdateRequest( + @field:NotNull + @field:Schema(description = "기수 ID", example = "1") + val id: Long, + @field:NotNull + @field:Schema(description = "년도", example = "2024") + val year: Int, + @field:NotNull + @field:Schema(description = "학기", example = "2") + val semester: Int, + @field:NotNull + @field:Schema(description = "현재 진행중 여부", example = "false") + val inProgress: Boolean, +) diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/request/SignUpRequest.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/request/SignUpRequest.kt new file mode 100644 index 00000000..ec4a464f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/request/SignUpRequest.kt @@ -0,0 +1,28 @@ +package com.weeth.domain.user.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull + +data class SignUpRequest( + @field:Schema(description = "이름", example = "홍길동") + @field:NotBlank + val name: String, + @field:Schema(description = "이메일", example = "hong@example.com") + @field:Email + @field:NotBlank + val email: String, + @field:Schema(description = "학번", example = "20201234") + @field:NotBlank + val studentId: String, + @field:Schema(description = "전화번호", example = "01012345678") + @field:NotBlank + val tel: String, + @field:Schema(description = "학과", example = "컴퓨터공학과") + @field:NotNull + val department: String, + @field:Schema(description = "지원 기수", example = "7") + @field:NotNull + val cardinal: Int, +) diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/request/SocialLoginRequest.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/request/SocialLoginRequest.kt new file mode 100644 index 00000000..df1e5772 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/request/SocialLoginRequest.kt @@ -0,0 +1,18 @@ +package com.weeth.domain.user.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank + +data class SocialLoginRequest( + @field:Schema(description = "OAuth2 인가 코드(auth code)", example = "SplxlOBeZQQYbYS6WxSbIA") + @field:NotBlank + val authCode: String, + @field:Schema(description = "추가 입력 이름(선택)", example = "홍길동", nullable = true) + val name: String? = null, + @field:Schema(description = "추가 입력 학번(선택)", example = "20201234", nullable = true) + val studentId: String? = null, + @field:Schema(description = "추가 입력 전화번호(선택)", example = "01012345678", nullable = true) + val tel: String? = null, + @field:Schema(description = "추가 입력 학과(선택)", example = "컴퓨터공학과", nullable = true) + val department: String? = null, +) diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/request/UpdateUserProfileRequest.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/request/UpdateUserProfileRequest.kt new file mode 100644 index 00000000..ed67dbfb --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/request/UpdateUserProfileRequest.kt @@ -0,0 +1,25 @@ +package com.weeth.domain.user.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull + +data class UpdateUserProfileRequest( + @field:Schema(description = "이름", example = "홍길동") + @field:NotBlank + val name: String, + @field:Schema(description = "이메일", example = "hong@example.com") + @field:Email + @field:NotBlank + val email: String, + @field:Schema(description = "학번", example = "20201234") + @field:NotBlank + val studentId: String, + @field:Schema(description = "전화번호", example = "01012345678") + @field:NotBlank + val tel: String, + @field:Schema(description = "학과", example = "컴퓨터공학과") + @field:NotNull + val department: String, +) diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/request/UserApplyObRequest.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/request/UserApplyObRequest.kt new file mode 100644 index 00000000..214c87e6 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/request/UserApplyObRequest.kt @@ -0,0 +1,13 @@ +package com.weeth.domain.user.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotNull + +data class UserApplyObRequest( + @field:Schema(description = "대상 사용자 ID", example = "1") + @field:NotNull + val userId: Long, + @field:Schema(description = "적용할 기수", example = "8") + @field:NotNull + val cardinal: Int, +) diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/request/UserIdsRequest.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/request/UserIdsRequest.kt new file mode 100644 index 00000000..7b05934a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/request/UserIdsRequest.kt @@ -0,0 +1,12 @@ +package com.weeth.domain.user.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotEmpty +import jakarta.validation.constraints.NotNull + +data class UserIdsRequest( + @field:Schema(description = "처리 대상 사용자 ID 목록", example = "[1, 2, 3]") + @field:NotNull + @field:NotEmpty + val userId: List, +) diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/request/UserRoleUpdateRequest.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/request/UserRoleUpdateRequest.kt new file mode 100644 index 00000000..9cd0c4a1 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/request/UserRoleUpdateRequest.kt @@ -0,0 +1,14 @@ +package com.weeth.domain.user.application.dto.request + +import com.weeth.domain.user.domain.entity.enums.Role +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotNull + +data class UserRoleUpdateRequest( + @field:Schema(description = "대상 사용자 ID", example = "1") + @field:NotNull + val userId: Long, + @field:Schema(description = "변경할 권한", example = "ADMIN") + @field:NotNull + val role: Role, +) diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/response/AdminUserResponse.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/response/AdminUserResponse.kt new file mode 100644 index 00000000..1077baef --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/response/AdminUserResponse.kt @@ -0,0 +1,41 @@ +package com.weeth.domain.user.application.dto.response + +import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.entity.enums.Status +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class AdminUserResponse( + @field:Schema(description = "사용자 ID", example = "1") + val id: Long, + @field:Schema(description = "이름", example = "홍길동") + val name: String, + @field:Schema(description = "이메일", example = "hong@example.com") + val email: String, + @field:Schema(description = "학번", example = "20201234") + val studentId: String, + @field:Schema(description = "전화번호", example = "01012345678") + val tel: String, + @field:Schema(description = "학과", example = "컴퓨터공학과") + val department: String, + @field:Schema(description = "소속 기수 목록", example = "[6, 7]") + val cardinals: List, + @field:Schema(description = "회원 상태", example = "ACTIVE") + val status: Status, + @field:Schema(description = "권한", example = "USER", nullable = true) + val role: Role?, + @field:Schema(description = "출석 횟수", example = "8") + val attendanceCount: Int, + @field:Schema(description = "결석 횟수", example = "2") + val absenceCount: Int, + @field:Schema(description = "출석률", example = "80") + val attendanceRate: Int, + @field:Schema(description = "패널티 횟수", example = "1") + val penaltyCount: Int, + @field:Schema(description = "경고 횟수", example = "0") + val warningCount: Int, + @field:Schema(description = "생성 시각") + val createdAt: LocalDateTime?, + @field:Schema(description = "수정 시각") + val modifiedAt: LocalDateTime?, +) diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/response/CardinalResponse.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/response/CardinalResponse.kt new file mode 100644 index 00000000..00fd08d8 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/response/CardinalResponse.kt @@ -0,0 +1,22 @@ +package com.weeth.domain.user.application.dto.response + +import com.weeth.domain.user.domain.entity.enums.CardinalStatus +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class CardinalResponse( + @field:Schema(description = "기수 ID", example = "1") + val id: Long, + @field:Schema(description = "기수 번호", example = "7") + val cardinalNumber: Int, + @field:Schema(description = "년도", example = "2025", nullable = true) + val year: Int?, + @field:Schema(description = "학기", example = "1", nullable = true) + val semester: Int?, + @field:Schema(description = "기수 상태", example = "CURRENT") + val status: CardinalStatus, + @field:Schema(description = "생성 시각") + val createdAt: LocalDateTime?, + @field:Schema(description = "수정 시각") + val modifiedAt: LocalDateTime?, +) diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/response/SocialLoginResponse.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/response/SocialLoginResponse.kt new file mode 100644 index 00000000..3ad05ed4 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/response/SocialLoginResponse.kt @@ -0,0 +1,16 @@ +package com.weeth.domain.user.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class SocialLoginResponse( + @field:Schema(description = "로그인 사용자 이메일", example = "hong@example.com") + val email: String, + @field:Schema(description = "액세스 토큰") + val accessToken: String, + @field:Schema(description = "리프레시 토큰") + val refreshToken: String, + @field:Schema(description = "신규 회원 여부", example = "true") + val isNewUser: Boolean, + @field:Schema(description = "프로필 완성 여부", example = "false") + val profileCompleted: Boolean, +) diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserDetailsResponse.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserDetailsResponse.kt new file mode 100644 index 00000000..4d32406e --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserDetailsResponse.kt @@ -0,0 +1,21 @@ +package com.weeth.domain.user.application.dto.response + +import com.weeth.domain.user.domain.entity.enums.Role +import io.swagger.v3.oas.annotations.media.Schema + +data class UserDetailsResponse( + @field:Schema(description = "사용자 ID", example = "1") + val id: Long, + @field:Schema(description = "이름", example = "홍길동") + val name: String, + @field:Schema(description = "이메일", example = "hong@example.com") + val email: String, + @field:Schema(description = "학번", example = "20201234") + val studentId: String, + @field:Schema(description = "학과", example = "컴퓨터공학과") + val department: String, + @field:Schema(description = "소속 기수 목록", example = "[6, 7]") + val cardinals: List, + @field:Schema(description = "권한", example = "USER", nullable = true) + val role: Role?, +) diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserInfoResponse.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserInfoResponse.kt new file mode 100644 index 00000000..f3f2d009 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserInfoResponse.kt @@ -0,0 +1,15 @@ +package com.weeth.domain.user.application.dto.response + +import com.weeth.domain.user.domain.entity.enums.Role +import io.swagger.v3.oas.annotations.media.Schema + +data class UserInfoResponse( + @field:Schema(description = "사용자 ID", example = "1") + val id: Long, + @field:Schema(description = "이름", example = "홍길동") + val name: String, + @field:Schema(description = "소속 기수 목록", example = "[6, 7]") + val cardinals: List, + @field:Schema(description = "권한", example = "USER", nullable = true) + val role: Role?, +) diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserProfileResponse.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserProfileResponse.kt new file mode 100644 index 00000000..61599f61 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserProfileResponse.kt @@ -0,0 +1,23 @@ +package com.weeth.domain.user.application.dto.response + +import com.weeth.domain.user.domain.entity.enums.Role +import io.swagger.v3.oas.annotations.media.Schema + +data class UserProfileResponse( + @field:Schema(description = "사용자 ID", example = "1") + val id: Long, + @field:Schema(description = "이름", example = "홍길동") + val name: String, + @field:Schema(description = "이메일", example = "hong@example.com") + val email: String, + @field:Schema(description = "학번", example = "20201234") + val studentId: String, + @field:Schema(description = "전화번호", example = "01012345678") + val tel: String, + @field:Schema(description = "학과", example = "컴퓨터공학과") + val department: String, + @field:Schema(description = "소속 기수 목록", example = "[6, 7]") + val cardinals: List, + @field:Schema(description = "권한", example = "USER", nullable = true) + val role: Role?, +) diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserSummaryResponse.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserSummaryResponse.kt new file mode 100644 index 00000000..4bd1e31e --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserSummaryResponse.kt @@ -0,0 +1,15 @@ +package com.weeth.domain.user.application.dto.response + +import com.weeth.domain.user.domain.entity.enums.Role +import io.swagger.v3.oas.annotations.media.Schema + +data class UserSummaryResponse( + @field:Schema(description = "사용자 ID", example = "1") + val id: Long, + @field:Schema(description = "이름", example = "홍길동") + val name: String, + @field:Schema(description = "소속 기수 목록", example = "[6, 7]") + val cardinals: List, + @field:Schema(description = "권한", example = "USER", nullable = true) + val role: Role?, +) diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/CardinalNotFoundException.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/CardinalNotFoundException.kt new file mode 100644 index 00000000..94ea712f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/CardinalNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.user.application.exception + +import com.weeth.global.common.exception.BaseException + +class CardinalNotFoundException : BaseException(UserErrorCode.CARDINAL_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/DuplicateCardinalException.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/DuplicateCardinalException.kt new file mode 100644 index 00000000..8cacbd14 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/DuplicateCardinalException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.user.application.exception + +import com.weeth.global.common.exception.BaseException + +class DuplicateCardinalException : BaseException(UserErrorCode.DUPLICATE_CARDINAL) diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/EmailNotFoundException.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/EmailNotFoundException.kt new file mode 100644 index 00000000..0c94746c --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/EmailNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.user.application.exception + +import com.weeth.global.common.exception.BaseException + +class EmailNotFoundException : BaseException(UserErrorCode.EMAIL_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/InvalidUserOrderException.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/InvalidUserOrderException.kt new file mode 100644 index 00000000..635e0293 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/InvalidUserOrderException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.user.application.exception + +import com.weeth.global.common.exception.BaseException + +class InvalidUserOrderException : BaseException(UserErrorCode.INVALID_USER_ORDER) diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/PasswordMismatchException.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/PasswordMismatchException.kt new file mode 100644 index 00000000..3e46d078 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/PasswordMismatchException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.user.application.exception + +import com.weeth.global.common.exception.BaseException + +class PasswordMismatchException : BaseException(UserErrorCode.PASSWORD_MISMATCH) diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/RoleNotFoundException.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/RoleNotFoundException.kt new file mode 100644 index 00000000..c2608604 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/RoleNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.user.application.exception + +import com.weeth.global.common.exception.BaseException + +class RoleNotFoundException : BaseException(UserErrorCode.ROLE_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/StatusNotFoundException.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/StatusNotFoundException.kt new file mode 100644 index 00000000..d1f7c1c3 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/StatusNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.user.application.exception + +import com.weeth.global.common.exception.BaseException + +class StatusNotFoundException : BaseException(UserErrorCode.STATUS_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/StudentIdExistsException.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/StudentIdExistsException.kt new file mode 100644 index 00000000..f5a74190 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/StudentIdExistsException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.user.application.exception + +import com.weeth.global.common.exception.BaseException + +class StudentIdExistsException : BaseException(UserErrorCode.STUDENT_ID_EXISTS) diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/TelExistsException.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/TelExistsException.kt new file mode 100644 index 00000000..aaf8d445 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/TelExistsException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.user.application.exception + +import com.weeth.global.common.exception.BaseException + +class TelExistsException : BaseException(UserErrorCode.TEL_EXISTS) diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/UserCardinalNotFoundException.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/UserCardinalNotFoundException.kt new file mode 100644 index 00000000..da49afec --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/UserCardinalNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.user.application.exception + +import com.weeth.global.common.exception.BaseException + +class UserCardinalNotFoundException : BaseException(UserErrorCode.USER_CARDINAL_NOT_FOUND) diff --git a/src/main/java/com/weeth/domain/user/application/exception/UserErrorCode.java b/src/main/kotlin/com/weeth/domain/user/application/exception/UserErrorCode.kt similarity index 83% rename from src/main/java/com/weeth/domain/user/application/exception/UserErrorCode.java rename to src/main/kotlin/com/weeth/domain/user/application/exception/UserErrorCode.kt index 9fd45632..12f4af47 100644 --- a/src/main/java/com/weeth/domain/user/application/exception/UserErrorCode.java +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/UserErrorCode.kt @@ -1,15 +1,14 @@ -package com.weeth.domain.user.application.exception; - -import com.weeth.global.common.exception.ErrorCodeInterface; -import com.weeth.global.common.exception.ExplainError; -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@AllArgsConstructor -public enum UserErrorCode implements ErrorCodeInterface { - // User 관련 에러 +package com.weeth.domain.user.application.exception + +import com.weeth.global.common.exception.ErrorCodeInterface +import com.weeth.global.common.exception.ExplainError +import org.springframework.http.HttpStatus + +enum class UserErrorCode( + private val code: Int, + private val status: HttpStatus, + private val message: String, +) : ErrorCodeInterface { @ExplainError("사용자 ID로 조회했으나 해당 사용자가 존재하지 않을 때 발생합니다.") USER_NOT_FOUND(2800, HttpStatus.NOT_FOUND, "존재하지 않는 유저입니다."), @@ -25,21 +24,18 @@ public enum UserErrorCode implements ErrorCodeInterface { @ExplainError("다른 사용자의 리소스에 접근하려고 할 때 발생합니다.") USER_NOT_MATCH(2804, HttpStatus.FORBIDDEN, "해당 사용자가 아닙니다."), - // 인증 관련 에러 @ExplainError("로그인 시 비밀번호가 일치하지 않을 때 발생합니다.") PASSWORD_MISMATCH(2805, HttpStatus.BAD_REQUEST, "비밀번호가 일치하지 않습니다."), @ExplainError("입력한 이메일로 등록된 사용자가 없을 때 발생합니다.") EMAIL_NOT_FOUND(2806, HttpStatus.NOT_FOUND, "이메일을 찾을 수 없습니다."), - // 검증 에러 @ExplainError("이미 등록된 학번으로 회원가입을 시도할 때 발생합니다.") STUDENT_ID_EXISTS(2807, HttpStatus.BAD_REQUEST, "이미 존재하는 학번입니다."), @ExplainError("이미 등록된 전화번호로 회원가입을 시도할 때 발생합니다.") TEL_EXISTS(2808, HttpStatus.BAD_REQUEST, "이미 존재하는 전화번호입니다."), - // Cardinal 관련 에러 @ExplainError("존재하지 않는 기수 정보로 조회할 때 발생합니다.") CARDINAL_NOT_FOUND(2809, HttpStatus.NOT_FOUND, "기수를 찾을 수 없습니다."), @@ -49,7 +45,6 @@ public enum UserErrorCode implements ErrorCodeInterface { @ExplainError("사용자와 기수 간의 연결 정보를 찾을 수 없을 때 발생합니다.") USER_CARDINAL_NOT_FOUND(2811, HttpStatus.NOT_FOUND, "사용자 기수 정보를 찾을 수 없습니다."), - // Enum 관련 에러 @ExplainError("잘못된 학과 값이 입력되었을 때 발생합니다.") DEPARTMENT_NOT_FOUND(2812, HttpStatus.BAD_REQUEST, "학과를 찾을 수 없습니다."), @@ -60,9 +55,12 @@ public enum UserErrorCode implements ErrorCodeInterface { STATUS_NOT_FOUND(2814, HttpStatus.BAD_REQUEST, "상태를 찾을 수 없습니다."), @ExplainError("사용자 순서 지정 시 잘못된 값이 입력되었을 때 발생합니다.") - INVALID_USER_ORDER(2815, HttpStatus.BAD_REQUEST, "잘못된 사용자 순서입니다."); + INVALID_USER_ORDER(2815, HttpStatus.BAD_REQUEST, "잘못된 사용자 순서입니다."), + ; + + override fun getCode(): Int = code + + override fun getStatus(): HttpStatus = status - private final int code; - private final HttpStatus status; - private final String message; + override fun getMessage(): String = message } diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/UserExistsException.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/UserExistsException.kt new file mode 100644 index 00000000..ece5509d --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/UserExistsException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.user.application.exception + +import com.weeth.global.common.exception.BaseException + +class UserExistsException : BaseException(UserErrorCode.USER_EXISTS) diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/UserInActiveException.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/UserInActiveException.kt new file mode 100644 index 00000000..5f639651 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/UserInActiveException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.user.application.exception + +import com.weeth.global.common.exception.BaseException + +class UserInActiveException : BaseException(UserErrorCode.USER_INACTIVE) diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/UserMismatchException.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/UserMismatchException.kt new file mode 100644 index 00000000..20db334e --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/UserMismatchException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.user.application.exception + +import com.weeth.global.common.exception.BaseException + +class UserMismatchException : BaseException(UserErrorCode.USER_MISMATCH) diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/UserNotFoundException.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/UserNotFoundException.kt new file mode 100644 index 00000000..9ff4caf9 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/UserNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.user.application.exception + +import com.weeth.global.common.exception.BaseException + +class UserNotFoundException : BaseException(UserErrorCode.USER_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/UserNotMatchException.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/UserNotMatchException.kt new file mode 100644 index 00000000..d058919e --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/UserNotMatchException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.user.application.exception + +import com.weeth.global.common.exception.BaseException + +class UserNotMatchException : BaseException(UserErrorCode.USER_NOT_MATCH) diff --git a/src/main/kotlin/com/weeth/domain/user/application/mapper/CardinalMapper.kt b/src/main/kotlin/com/weeth/domain/user/application/mapper/CardinalMapper.kt new file mode 100644 index 00000000..a0fb3b74 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/mapper/CardinalMapper.kt @@ -0,0 +1,27 @@ +package com.weeth.domain.user.application.mapper + +import com.weeth.domain.user.application.dto.request.CardinalSaveRequest +import com.weeth.domain.user.application.dto.response.CardinalResponse +import com.weeth.domain.user.domain.entity.Cardinal +import org.springframework.stereotype.Component + +@Component +class CardinalMapper { + fun toEntity(request: CardinalSaveRequest): Cardinal = + Cardinal( + cardinalNumber = request.cardinalNumber, + year = request.year, + semester = request.semester, + ) + + fun toResponse(cardinal: Cardinal): CardinalResponse = + CardinalResponse( + cardinal.id, + cardinal.cardinalNumber, + cardinal.year, + cardinal.semester, + cardinal.status, + cardinal.createdAt, + cardinal.modifiedAt, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt b/src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt new file mode 100644 index 00000000..63c25ff2 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt @@ -0,0 +1,104 @@ +package com.weeth.domain.user.application.mapper + +import com.weeth.domain.user.application.dto.request.SignUpRequest +import com.weeth.domain.user.application.dto.response.AdminUserResponse +import com.weeth.domain.user.application.dto.response.UserDetailsResponse +import com.weeth.domain.user.application.dto.response.UserInfoResponse +import com.weeth.domain.user.application.dto.response.UserProfileResponse +import com.weeth.domain.user.application.dto.response.UserSummaryResponse +import com.weeth.domain.user.domain.entity.User +import com.weeth.domain.user.domain.entity.UserCardinal +import org.springframework.stereotype.Component + +@Component +class UserMapper { + fun toEntity(request: SignUpRequest): User = + User.create( + name = request.name, + email = request.email, + studentId = request.studentId, + tel = request.tel, + department = request.department, + ) + + fun toUserProfileResponse( + user: User, + userCardinals: List, + ): UserProfileResponse = + UserProfileResponse( + user.id, + user.name, + user.emailValue, + user.studentId, + user.telValue, + user.department, + toCardinalNumbers(userCardinals), + user.role, + ) + + fun toAdminUserResponse( + user: User, + userCardinals: List, + ): AdminUserResponse = + AdminUserResponse( + user.id, + user.name, + user.emailValue, + user.studentId, + user.telValue, + user.department, + toCardinalNumbers(userCardinals), + user.status, + user.role, + user.attendanceCount, + user.absenceCount, + user.attendanceRate, + user.penaltyCount, + user.warningCount, + user.createdAt, + user.modifiedAt, + ) + + fun toUserSummaryResponse( + user: User, + userCardinals: List, + ): UserSummaryResponse = + UserSummaryResponse( + user.id, + user.name, + toCardinalNumbers(userCardinals), + user.role, + ) + + fun toUserDetailsResponse( + user: User, + userCardinals: List, + ): UserDetailsResponse = + UserDetailsResponse( + user.id, + user.name, + user.emailValue, + user.studentId, + user.department, + toCardinalNumbers(userCardinals), + user.role, + ) + + fun toUserInfoResponse( + user: User, + userCardinals: List, + ): UserInfoResponse = + UserInfoResponse( + user.id, + user.name, + toCardinalNumbers(userCardinals), + user.role, + ) + + private fun toCardinalNumbers(userCardinals: List): List { + if (userCardinals.isEmpty()) { + return emptyList() + } + return userCardinals.map { it.cardinal.cardinalNumber } + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AdminUserUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AdminUserUseCase.kt new file mode 100644 index 00000000..df19f100 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AdminUserUseCase.kt @@ -0,0 +1,107 @@ +package com.weeth.domain.user.application.usecase.command + +import com.weeth.domain.attendance.domain.service.AttendanceSaveService +import com.weeth.domain.schedule.domain.entity.Meeting +import com.weeth.domain.schedule.domain.service.MeetingGetService +import com.weeth.domain.user.application.dto.request.UserApplyObRequest +import com.weeth.domain.user.application.dto.request.UserIdsRequest +import com.weeth.domain.user.application.dto.request.UserRoleUpdateRequest +import com.weeth.domain.user.application.exception.CardinalNotFoundException +import com.weeth.domain.user.domain.entity.Cardinal +import com.weeth.domain.user.domain.entity.User +import com.weeth.domain.user.domain.entity.UserCardinal +import com.weeth.domain.user.domain.repository.CardinalRepository +import com.weeth.domain.user.domain.repository.UserCardinalRepository +import com.weeth.domain.user.domain.repository.UserReader +import com.weeth.domain.user.domain.service.UserCardinalPolicy +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class AdminUserUseCase( + private val userReader: UserReader, + private val attendanceSaveService: AttendanceSaveService, + private val meetingGetService: MeetingGetService, + private val cardinalRepository: CardinalRepository, + private val userCardinalRepository: UserCardinalRepository, + private val userCardinalPolicy: UserCardinalPolicy, +) { + @Transactional + fun accept(request: UserIdsRequest) { + val users = userReader.findAllByIds(request.userId) + users.forEach { user -> + val cardinal = userCardinalPolicy.getCurrentCardinal(user).cardinalNumber + if (user.isInactive()) { + user.accept() + val meetings: List = meetingGetService.find(cardinal) + attendanceSaveService.init(user, meetings) + } + } + } + + @Transactional + fun updateRole(request: List) { + request.forEach { req -> + val user = userReader.getById(req.userId) + user.updateRole(req.role) + } + } + + @Transactional + fun ban(request: UserIdsRequest) { + val users = userReader.findAllByIds(request.userId) + users.forEach { user -> + user.ban() + } + } + + @Transactional + fun applyOb(requests: List) { // todo: 리팩토링 + if (requests.isEmpty()) return + + val distinctUserIds = requests.map { it.userId }.distinct() + val users = userReader.findAllByIds(distinctUserIds) + val userMap = users.associateBy { it.id } + distinctUserIds.firstOrNull { it !in userMap }?.let { userReader.getById(it) } + + val existingCardinalsByUser = userCardinalRepository.findAllByUsers(users).groupBy { it.user.id } + val cardinalMap = getOrCreateCardinals(requests.map { it.cardinal }.distinct()) + + val newLinks = mutableListOf>() + val initNeededByCardinal = mutableMapOf>() + + requests.forEach { req -> + val user = userMap.getValue(req.userId) + val nextCardinal = cardinalMap.getValue(req.cardinal) + val existing = existingCardinalsByUser[user.id] ?: emptyList() + + if (existing.any { it.cardinal.id == nextCardinal.id }) return@forEach + + val maxCardinalNumber = + existing.maxOfOrNull { it.cardinal.cardinalNumber } ?: throw CardinalNotFoundException() + + if (maxCardinalNumber < nextCardinal.cardinalNumber) { + user.resetAttendanceStats() + initNeededByCardinal.getOrPut(req.cardinal) { mutableListOf() }.add(user) + } + newLinks.add(user to nextCardinal) + } + + if (initNeededByCardinal.isNotEmpty()) { + val meetingsMap = meetingGetService.findByCardinals(initNeededByCardinal.keys.toList()) + initNeededByCardinal.forEach { (cardinalNumber, usersToInit) -> + val meetings = meetingsMap[cardinalNumber] ?: emptyList() + usersToInit.forEach { attendanceSaveService.init(it, meetings) } + } + } + + newLinks.forEach { (user, cardinal) -> userCardinalRepository.save(UserCardinal(user, cardinal)) } + } + + private fun getOrCreateCardinals(cardinalNumbers: List): Map { + val existing = cardinalRepository.findAllByCardinalNumberIn(cardinalNumbers).associateBy { it.cardinalNumber } + return cardinalNumbers.associateWith { num -> + existing[num] ?: cardinalRepository.save(Cardinal.create(cardinalNumber = num)) + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCase.kt new file mode 100644 index 00000000..75a139a1 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCase.kt @@ -0,0 +1,231 @@ +package com.weeth.domain.user.application.usecase.command + +import com.weeth.domain.user.application.dto.request.SignUpRequest +import com.weeth.domain.user.application.dto.request.SocialLoginRequest +import com.weeth.domain.user.application.dto.request.UpdateUserProfileRequest +import com.weeth.domain.user.application.dto.response.SocialLoginResponse +import com.weeth.domain.user.application.exception.EmailNotFoundException +import com.weeth.domain.user.application.exception.StudentIdExistsException +import com.weeth.domain.user.application.exception.TelExistsException +import com.weeth.domain.user.application.exception.UserInActiveException +import com.weeth.domain.user.application.mapper.UserMapper +import com.weeth.domain.user.domain.entity.User +import com.weeth.domain.user.domain.entity.UserCardinal +import com.weeth.domain.user.domain.entity.UserSocialAccount +import com.weeth.domain.user.domain.entity.enums.SocialProvider +import com.weeth.domain.user.domain.entity.enums.Status +import com.weeth.domain.user.domain.repository.CardinalReader +import com.weeth.domain.user.domain.repository.UserCardinalRepository +import com.weeth.domain.user.domain.repository.UserReader +import com.weeth.domain.user.domain.repository.UserRepository +import com.weeth.domain.user.domain.repository.UserSocialAccountRepository +import com.weeth.global.auth.apple.AppleAuthService +import com.weeth.global.auth.jwt.application.dto.JwtDto +import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor +import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase +import com.weeth.global.auth.kakao.KakaoAuthService +import jakarta.servlet.http.HttpServletRequest +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class AuthUserUseCase( + private val userRepository: UserRepository, + private val userReader: UserReader, + private val cardinalReader: CardinalReader, + private val userCardinalRepository: UserCardinalRepository, + private val mapper: UserMapper, + private val userSocialAccountRepository: UserSocialAccountRepository, + private val kakaoAuthService: KakaoAuthService, + private val appleAuthService: AppleAuthService, + private val jwtManageUseCase: JwtManageUseCase, + private val jwtTokenExtractor: JwtTokenExtractor, +) { + @Transactional + fun updateProfile( + request: UpdateUserProfileRequest, + userId: Long, + ) { + validate(request, userId) + val user = userReader.getById(userId) + user.update(request.name, request.email, request.studentId, request.tel, request.department) + } + + @Transactional + fun apply(request: SignUpRequest) { // todo: 리팩토링 + validate(request) + val cardinal = cardinalReader.getByCardinalNumber(request.cardinal) + val user = mapper.toEntity(request) + val userCardinal = UserCardinal(user, cardinal) + + userRepository.save(user) + userCardinalRepository.save(userCardinal) + } + + @Transactional + fun leave(userId: Long) { + val user = userReader.getById(userId) + user.leave() + } + + @Transactional + fun socialLoginByKakao(request: SocialLoginRequest): SocialLoginResponse { // todo: 리팩토링 + val kakaoToken = kakaoAuthService.getKakaoToken(request.authCode) + val userInfo = kakaoAuthService.getUserInfo(kakaoToken.accessToken) + val account = userInfo.kakaoAccount + val email = account.email?.trim()?.lowercase() + val providerName = + account.profile + ?.nickname + ?.trim() + ?.takeIf { it.isNotBlank() } + if (!account.isEmailValid || !account.isEmailVerified || email.isNullOrBlank()) { + throw EmailNotFoundException() + } + return loginOrCreate( + provider = SocialProvider.KAKAO, + providerUserId = userInfo.id.toString(), + providerEmail = email, + providerName = providerName, + request = request, + ) + } + + @Transactional + fun socialLoginByApple(request: SocialLoginRequest): SocialLoginResponse { // todo: 리팩토링 + val appleToken = appleAuthService.getAppleToken(request.authCode) + val userInfo = appleAuthService.verifyAndDecodeIdToken(appleToken.idToken) + val email = userInfo.email?.trim()?.lowercase() + val providerName = userInfo.name?.trim()?.takeIf { it.isNotBlank() } + if (!userInfo.emailVerified || email.isNullOrBlank()) { + throw EmailNotFoundException() + } + return loginOrCreate( + provider = SocialProvider.APPLE, + providerUserId = userInfo.appleId, + providerEmail = email, + providerName = providerName, + request = request, + ) + } + + fun refreshToken(httpServletRequest: HttpServletRequest): JwtDto { + val refreshToken = jwtTokenExtractor.extractRefreshToken(httpServletRequest) + return jwtManageUseCase.reIssueToken(refreshToken) + } + + private fun validate( + request: UpdateUserProfileRequest, + userId: Long, + ) { + if (userRepository.existsByStudentIdAndIdIsNot(request.studentId, userId)) { + throw StudentIdExistsException() + } + if (userRepository.existsByTelAndIdIsNotValue(request.tel, userId)) { + throw TelExistsException() + } + } + + private fun validate(request: SignUpRequest) { + if (userRepository.existsByStudentId(request.studentId)) { + throw StudentIdExistsException() + } + if (userRepository.existsByTelValue(request.tel)) { + throw TelExistsException() + } + } + + private fun loginOrCreate( + provider: SocialProvider, + providerUserId: String, + providerEmail: String, + providerName: String?, + request: SocialLoginRequest, + ): SocialLoginResponse { + val socialAccount = userSocialAccountRepository.findByProviderAndProviderUserId(provider, providerUserId).orElse(null) + val (user, isNewUser) = + if (socialAccount != null) { + socialAccount.user to false + } else { + createAndPersistSocialAccount(provider, providerUserId, providerEmail, providerName) + } + + if (user.status == Status.BANNED || user.status == Status.LEFT) { + throw UserInActiveException() + } + + val hasExplicitPayload = + request.name != null || + request.studentId != null || + request.tel != null || + request.department != null + + if (isNewUser || hasExplicitPayload) { + applyOptionalProfile(user, request, providerName) + } + + val token = jwtManageUseCase.create(user.id, user.emailValue, user.role) + return SocialLoginResponse( + email = user.emailValue, + accessToken = token.accessToken, + refreshToken = token.refreshToken, + isNewUser = isNewUser, + profileCompleted = user.isProfileCompleted(), + ) + } + + private fun createAndPersistSocialAccount( + provider: SocialProvider, + providerUserId: String, + providerEmail: String, + providerName: String?, + ): Pair { + val existingUser = userRepository.findByEmailValue(providerEmail).orElse(null) + val user = + existingUser ?: userRepository.save( + User.create( + name = providerName ?: "", + email = providerEmail, + studentId = "", + tel = "", + department = "", + ), + ) + userSocialAccountRepository.save(UserSocialAccount(provider = provider, providerUserId = providerUserId, user = user)) + return user to (existingUser == null) + } + + private fun applyOptionalProfile( + user: User, + request: SocialLoginRequest, + providerName: String?, + ) { + val hasProfilePayload = + providerName != null || + request.name != null || + request.studentId != null || + request.tel != null || + request.department != null + if (!hasProfilePayload) { + return + } + + val nextName = request.name?.trim()?.takeIf { it.isNotBlank() } ?: providerName ?: user.name + val nextStudentId = request.studentId ?: user.studentId + val nextTel = request.tel ?: user.telValue + val nextDepartment = request.department ?: user.department + + if ( + nextStudentId != user.studentId && + nextStudentId.isNotBlank() && + userRepository.existsByStudentIdAndIdIsNot(nextStudentId, user.id) + ) { + throw StudentIdExistsException() + } + if (nextTel != user.telValue && nextTel.isNotBlank() && userRepository.existsByTelAndIdIsNotValue(nextTel, user.id)) { + throw TelExistsException() + } + + user.update(nextName, user.emailValue, nextStudentId, nextTel, nextDepartment) + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/ManageCardinalUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/ManageCardinalUseCase.kt new file mode 100644 index 00000000..055b9656 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/ManageCardinalUseCase.kt @@ -0,0 +1,46 @@ +package com.weeth.domain.user.application.usecase.command + +import com.weeth.domain.user.application.dto.request.CardinalSaveRequest +import com.weeth.domain.user.application.dto.request.CardinalUpdateRequest +import com.weeth.domain.user.application.exception.CardinalNotFoundException +import com.weeth.domain.user.application.exception.DuplicateCardinalException +import com.weeth.domain.user.application.mapper.CardinalMapper +import com.weeth.domain.user.domain.entity.Cardinal +import com.weeth.domain.user.domain.entity.enums.CardinalStatus +import com.weeth.domain.user.domain.repository.CardinalRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class ManageCardinalUseCase( + private val cardinalRepository: CardinalRepository, + private val cardinalMapper: CardinalMapper, +) { + @Transactional + fun save(request: CardinalSaveRequest) { + if (cardinalRepository.findByCardinalNumber(request.cardinalNumber).isPresent) { + throw DuplicateCardinalException() + } + + val cardinal = cardinalRepository.save(cardinalMapper.toEntity(request)) + if (request.inProgress) { + updateCardinalStatus(cardinal) + } + } + + @Transactional + fun update(request: CardinalUpdateRequest) { + val cardinal = cardinalRepository.findById(request.id).orElseThrow { CardinalNotFoundException() } + cardinal.update(request.year, request.semester) + + if (request.inProgress) { + updateCardinalStatus(cardinal) + } + } + + private fun updateCardinalStatus(cardinal: Cardinal) { + val inProgressCardinals = cardinalRepository.findAllByStatus(CardinalStatus.IN_PROGRESS) + inProgressCardinals.forEach(Cardinal::done) + cardinal.inProgress() + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/query/GetCardinalQueryService.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/query/GetCardinalQueryService.kt new file mode 100644 index 00000000..91d02166 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/query/GetCardinalQueryService.kt @@ -0,0 +1,16 @@ +package com.weeth.domain.user.application.usecase.query + +import com.weeth.domain.user.application.dto.response.CardinalResponse +import com.weeth.domain.user.application.mapper.CardinalMapper +import com.weeth.domain.user.domain.repository.CardinalRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class GetCardinalQueryService( + private val cardinalRepository: CardinalRepository, + private val cardinalMapper: CardinalMapper, +) { + fun findAll(): List = cardinalRepository.findAllByOrderByCardinalNumberAsc().map(cardinalMapper::toResponse) +} diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryService.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryService.kt new file mode 100644 index 00000000..f3ab026f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryService.kt @@ -0,0 +1,115 @@ +package com.weeth.domain.user.application.usecase.query + +import com.weeth.domain.user.application.dto.response.AdminUserResponse +import com.weeth.domain.user.application.dto.response.UserDetailsResponse +import com.weeth.domain.user.application.dto.response.UserInfoResponse +import com.weeth.domain.user.application.dto.response.UserProfileResponse +import com.weeth.domain.user.application.dto.response.UserSummaryResponse +import com.weeth.domain.user.application.mapper.UserMapper +import com.weeth.domain.user.domain.entity.User +import com.weeth.domain.user.domain.entity.UserCardinal +import com.weeth.domain.user.domain.entity.enums.Status +import com.weeth.domain.user.domain.entity.enums.StatusPriority +import com.weeth.domain.user.domain.entity.enums.UsersOrderBy +import com.weeth.domain.user.domain.repository.CardinalReader +import com.weeth.domain.user.domain.repository.UserCardinalReader +import com.weeth.domain.user.domain.repository.UserCardinalRepository +import com.weeth.domain.user.domain.repository.UserReader +import com.weeth.domain.user.domain.repository.UserRepository +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Slice +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.LinkedHashMap + +@Service +@Transactional(readOnly = true) +class GetUserQueryService( + private val userRepository: UserRepository, + private val userReader: UserReader, + private val cardinalReader: CardinalReader, + private val userCardinalRepository: UserCardinalRepository, + private val userCardinalReader: UserCardinalReader, + private val mapper: UserMapper, +) { + fun existsByEmail(email: String): Boolean = userRepository.existsByEmailValue(email) + + fun findAllUser( + pageNumber: Int, + pageSize: Int, + cardinal: Int?, + ): Slice { + val pageable = PageRequest.of(pageNumber, pageSize) + val users: Slice = + if (cardinal == null) { + userRepository.findAllByStatusOrderedByCardinalAndName(Status.ACTIVE, pageable) + } else { + val inputCardinal = cardinalReader.getByCardinalNumber(cardinal) + userRepository.findAllByCardinalOrderByNameAsc(Status.ACTIVE, inputCardinal, pageable) + } + + val allUserCardinals = userCardinalReader.findAllByUsersOrderByCardinalDesc(users.content) + val userCardinalMap = allUserCardinals.groupBy { it.user.id } + return users.map { user -> + val userCardinals = userCardinalMap[user.id] ?: emptyList() + mapper.toUserSummaryResponse(user, userCardinals) + } + } + + fun searchUser(keyword: String): List { + val users = userRepository.findAllByNameContainingAndStatus(keyword, Status.ACTIVE) + val allUserCardinals = userCardinalReader.findAllByUsersOrderByCardinalDesc(users) + val userCardinalMap = allUserCardinals.groupBy { it.user.id } + return users.map { user -> + val userCardinals = userCardinalMap[user.id] ?: emptyList() + mapper.toUserSummaryResponse(user, userCardinals) + } + } + + fun findUserDetails(userId: Long): UserDetailsResponse { + val user = userReader.getById(userId) + val userCardinals = userCardinalReader.findAllByUser(user) + return mapper.toUserDetailsResponse(user, userCardinals) + } + + fun findMyProfile(userId: Long): UserProfileResponse { + val user = userReader.getById(userId) + val userCardinals = userCardinalReader.findAllByUser(user) + return mapper.toUserProfileResponse(user, userCardinals) + } + + fun findMyInfo(userId: Long): UserInfoResponse { + val user = userReader.getById(userId) + val userCardinals = userCardinalReader.findAllByUser(user) + return mapper.toUserInfoResponse(user, userCardinals) + } + + fun findAllByAdmin(orderBy: UsersOrderBy): List { + val userCardinalMap: LinkedHashMap> = + LinkedHashMap( + userCardinalRepository.findAllByOrderByUserNameAsc().groupBy { it.user }, + ) + + return when (orderBy) { + UsersOrderBy.NAME_ASCENDING -> { + userCardinalMap.entries + .sortedBy { StatusPriority.fromStatus(it.key.status).priority } + .map { entry -> + mapper.toAdminUserResponse(entry.key, entry.value) + } + } + + UsersOrderBy.CARDINAL_DESCENDING -> { + userCardinalMap.entries + .sortedWith( + compareBy>> { StatusPriority.fromStatus(it.key.status).priority } + .thenByDescending { entry -> + entry.value.maxOfOrNull { it.cardinal.cardinalNumber } ?: -1 + }, + ).map { entry -> + mapper.toAdminUserResponse(entry.key, entry.value) + } + } + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/converter/EmailConverter.kt b/src/main/kotlin/com/weeth/domain/user/domain/converter/EmailConverter.kt new file mode 100644 index 00000000..836d8745 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/converter/EmailConverter.kt @@ -0,0 +1,12 @@ +package com.weeth.domain.user.domain.converter + +import com.weeth.domain.user.domain.vo.Email +import jakarta.persistence.AttributeConverter +import jakarta.persistence.Converter + +@Converter(autoApply = false) +class EmailConverter : AttributeConverter { + override fun convertToDatabaseColumn(attribute: Email?): String = attribute?.value ?: "" + + override fun convertToEntityAttribute(dbData: String?): Email = Email.from(dbData ?: "") +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/converter/PhoneNumberConverter.kt b/src/main/kotlin/com/weeth/domain/user/domain/converter/PhoneNumberConverter.kt new file mode 100644 index 00000000..c90c8129 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/converter/PhoneNumberConverter.kt @@ -0,0 +1,12 @@ +package com.weeth.domain.user.domain.converter + +import com.weeth.domain.user.domain.vo.PhoneNumber +import jakarta.persistence.AttributeConverter +import jakarta.persistence.Converter + +@Converter(autoApply = false) +class PhoneNumberConverter : AttributeConverter { + override fun convertToDatabaseColumn(attribute: PhoneNumber?): String = attribute?.value ?: "" + + override fun convertToEntityAttribute(dbData: String?): PhoneNumber = PhoneNumber.from(dbData ?: "") +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/entity/Cardinal.kt b/src/main/kotlin/com/weeth/domain/user/domain/entity/Cardinal.kt new file mode 100644 index 00000000..d5c3cf42 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/entity/Cardinal.kt @@ -0,0 +1,56 @@ +package com.weeth.domain.user.domain.entity + +import com.weeth.domain.user.domain.entity.enums.CardinalStatus +import com.weeth.global.common.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id + +@Entity +class Cardinal( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "cardinal_id") + val id: Long = 0L, + @Column(unique = true, nullable = false) + val cardinalNumber: Int, + var year: Int? = null, + var semester: Int? = null, + @Enumerated(EnumType.STRING) + var status: CardinalStatus = CardinalStatus.DONE, +) : BaseEntity() { + fun update( + year: Int, + semester: Int, + ) { + this.year = year + this.semester = semester + } + + fun inProgress() { + status = CardinalStatus.IN_PROGRESS + } + + fun done() { + status = CardinalStatus.DONE + } + + companion object { + fun create( + cardinalNumber: Int, + year: Int? = null, + semester: Int? = null, + status: CardinalStatus = CardinalStatus.DONE, + ): Cardinal = + Cardinal( + cardinalNumber = cardinalNumber, + year = year, + semester = semester, + status = status, + ) + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt b/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt new file mode 100644 index 00000000..b2b27bab --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt @@ -0,0 +1,196 @@ +package com.weeth.domain.user.domain.entity + +import com.weeth.domain.user.domain.converter.EmailConverter +import com.weeth.domain.user.domain.converter.PhoneNumberConverter +import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.entity.enums.Status +import com.weeth.domain.user.domain.vo.AttendanceStats +import com.weeth.domain.user.domain.vo.Email +import com.weeth.domain.user.domain.vo.PhoneNumber +import com.weeth.global.common.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Convert +import jakarta.persistence.Embedded +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.PrePersist +import jakarta.persistence.Table + +@Entity +@Table(name = "users") +class User( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_id") + var id: Long = 0L, + var name: String = "", + @Convert(converter = EmailConverter::class) + @Column(name = "email") + var email: Email = Email.from(""), + var studentId: String = "", + @Convert(converter = PhoneNumberConverter::class) + @Column(name = "tel") + var tel: PhoneNumber = PhoneNumber.from(""), + var department: String = "", + @Enumerated(EnumType.STRING) + var status: Status = Status.WAITING, + @Enumerated(EnumType.STRING) + var role: Role = Role.USER, + @Embedded + var attendanceStats: AttendanceStats = AttendanceStats(), + var penaltyCount: Int = 0, + var warningCount: Int = 0, // todo: 경고시 자동 페널티 기능도 제거 +) : BaseEntity() { + constructor( + id: Long = 0L, + name: String = "", + email: String = "", + studentId: String = "", + tel: String = "", + department: String = "", + status: Status = Status.WAITING, + role: Role = Role.USER, + attendanceCount: Int = 0, + absenceCount: Int = 0, + attendanceRate: Int = 0, + penaltyCount: Int = 0, + warningCount: Int = 0, + ) : this( + id = id, + name = name, + email = Email.from(email), + studentId = studentId, + tel = PhoneNumber.from(tel), + department = department, + status = status, + role = role, + attendanceStats = AttendanceStats(attendanceCount, absenceCount, attendanceRate), + penaltyCount = penaltyCount, + warningCount = warningCount, + ) + + val emailValue: String + get() = email.value + + val telValue: String + get() = tel.value + + val attendanceCount: Int + get() = attendanceStats.attendanceCount + + val absenceCount: Int + get() = attendanceStats.absenceCount + + val attendanceRate: Int + get() = attendanceStats.attendanceRate + + @PrePersist + fun init() { + status = Status.WAITING + role = Role.USER + attendanceStats.reset() + penaltyCount = 0 + warningCount = 0 + } + + fun leave() { + status = Status.LEFT + } + + fun isInactive(): Boolean = status != Status.ACTIVE + + fun isProfileCompleted(): Boolean = + name.isNotBlank() && + studentId.isNotBlank() && + telValue.isNotBlank() && + department.isNotBlank() + + fun update( + name: String, + email: String, + studentId: String, + tel: String, + department: String, + ) { + this.name = name + this.email = Email.from(email) + this.studentId = studentId + this.tel = PhoneNumber.from(tel) + this.department = department + } + + fun accept() { + status = Status.ACTIVE + } + + fun ban() { + status = Status.BANNED + } + + fun updateRole(role: Role) { + this.role = role + } + + fun resetAttendanceStats() { + attendanceStats.reset() + } + + fun attend() { + attendanceStats.attend() + } + + fun removeAttend() { + attendanceStats.removeAttend() + } + + fun absent() { + attendanceStats.absent() + } + + fun removeAbsent() { + attendanceStats.removeAbsent() + } + + fun incrementPenaltyCount() { + penaltyCount++ + } + + fun decrementPenaltyCount() { + if (penaltyCount > 0) { + penaltyCount-- + } + } + + fun incrementWarningCount() { + warningCount++ + } + + fun decrementWarningCount() { + if (warningCount > 0) { + warningCount-- + } + } + + fun hasRole(role: Role): Boolean = this.role == role + + companion object { + fun create( + name: String, + email: String, + studentId: String, + tel: String, + department: String, + ): User = + User( + name = name, + email = Email.from(email), + studentId = studentId, + tel = PhoneNumber.from(tel), + department = department, + ) + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/entity/UserCardinal.kt b/src/main/kotlin/com/weeth/domain/user/domain/entity/UserCardinal.kt new file mode 100644 index 00000000..c33c346b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/entity/UserCardinal.kt @@ -0,0 +1,34 @@ +package com.weeth.domain.user.domain.entity + +import com.weeth.global.common.entity.BaseEntity +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 + +@Entity +class UserCardinal( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_cardinal_id") + val id: Long = 0L, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + val user: User, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "cardinal_id") + val cardinal: Cardinal, +) : BaseEntity() { + constructor( + user: User, + cardinal: Cardinal, + ) : this( + id = 0L, + user = user, + cardinal = cardinal, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/entity/UserSocialAccount.kt b/src/main/kotlin/com/weeth/domain/user/domain/entity/UserSocialAccount.kt new file mode 100644 index 00000000..29a27557 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/entity/UserSocialAccount.kt @@ -0,0 +1,41 @@ +package com.weeth.domain.user.domain.entity + +import com.weeth.domain.user.domain.entity.enums.SocialProvider +import com.weeth.global.common.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint + +@Entity +@Table( + name = "user_social_account", + uniqueConstraints = [ + UniqueConstraint( + name = "uk_provider_provider_user_id", + columnNames = ["provider", "provider_user_id"], + ), + ], +) +class UserSocialAccount( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_social_account_id") + val id: Long = 0L, + @Enumerated(EnumType.STRING) + @Column(nullable = false) + val provider: SocialProvider, + @Column(name = "provider_user_id", nullable = false) + val providerUserId: String, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + val user: User, +) : BaseEntity() diff --git a/src/main/kotlin/com/weeth/domain/user/domain/entity/enums/CardinalStatus.kt b/src/main/kotlin/com/weeth/domain/user/domain/entity/enums/CardinalStatus.kt new file mode 100644 index 00000000..8f009d0e --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/entity/enums/CardinalStatus.kt @@ -0,0 +1,6 @@ +package com.weeth.domain.user.domain.entity.enums + +enum class CardinalStatus { + IN_PROGRESS, + DONE, +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/entity/enums/Role.kt b/src/main/kotlin/com/weeth/domain/user/domain/entity/enums/Role.kt new file mode 100644 index 00000000..03fe532a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/entity/enums/Role.kt @@ -0,0 +1,6 @@ +package com.weeth.domain.user.domain.entity.enums + +enum class Role { + USER, + ADMIN, +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/entity/enums/SocialProvider.kt b/src/main/kotlin/com/weeth/domain/user/domain/entity/enums/SocialProvider.kt new file mode 100644 index 00000000..3bb93fa2 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/entity/enums/SocialProvider.kt @@ -0,0 +1,6 @@ +package com.weeth.domain.user.domain.entity.enums + +enum class SocialProvider { + KAKAO, + APPLE, +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/entity/enums/Status.kt b/src/main/kotlin/com/weeth/domain/user/domain/entity/enums/Status.kt new file mode 100644 index 00000000..655dfea6 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/entity/enums/Status.kt @@ -0,0 +1,8 @@ +package com.weeth.domain.user.domain.entity.enums + +enum class Status { + WAITING, + ACTIVE, + BANNED, + LEFT, +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/entity/enums/StatusPriority.kt b/src/main/kotlin/com/weeth/domain/user/domain/entity/enums/StatusPriority.kt new file mode 100644 index 00000000..299cf814 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/entity/enums/StatusPriority.kt @@ -0,0 +1,26 @@ +package com.weeth.domain.user.domain.entity.enums + +import com.weeth.domain.user.application.exception.StatusNotFoundException + +enum class StatusPriority( + val priority: Int, +) { + ACTIVE(1), + WAITING(2), + LEFT(3), + BANNED(4), + ; + + companion object { + @JvmStatic + fun from(status: Status?): StatusPriority { + if (status == null) { + throw StatusNotFoundException() + } + return valueOf(status.name) + } + + @JvmStatic + fun fromStatus(status: Status?): StatusPriority = from(status) + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/entity/enums/UsersOrderBy.kt b/src/main/kotlin/com/weeth/domain/user/domain/entity/enums/UsersOrderBy.kt new file mode 100644 index 00000000..c8ce2584 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/entity/enums/UsersOrderBy.kt @@ -0,0 +1,6 @@ +package com.weeth.domain.user.domain.entity.enums + +enum class UsersOrderBy { + NAME_ASCENDING, + CARDINAL_DESCENDING, +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/repository/CardinalReader.kt b/src/main/kotlin/com/weeth/domain/user/domain/repository/CardinalReader.kt new file mode 100644 index 00000000..ae02e8bf --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/repository/CardinalReader.kt @@ -0,0 +1,11 @@ +package com.weeth.domain.user.domain.repository + +import com.weeth.domain.user.domain.entity.Cardinal + +interface CardinalReader { + fun getByCardinalNumber(cardinalNumber: Int): Cardinal + + fun findByIdOrNull(cardinalId: Long): Cardinal? + + fun findAllByCardinalNumberDesc(): List +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/repository/CardinalRepository.kt b/src/main/kotlin/com/weeth/domain/user/domain/repository/CardinalRepository.kt new file mode 100644 index 00000000..0fd0d865 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/repository/CardinalRepository.kt @@ -0,0 +1,33 @@ +package com.weeth.domain.user.domain.repository + +import com.weeth.domain.user.application.exception.CardinalNotFoundException +import com.weeth.domain.user.domain.entity.Cardinal +import com.weeth.domain.user.domain.entity.enums.CardinalStatus +import org.springframework.data.jpa.repository.JpaRepository +import java.util.Optional + +interface CardinalRepository : + JpaRepository, + CardinalReader { + fun findByCardinalNumber(cardinal: Int): Optional + + fun findAllByCardinalNumberIn(cardinalNumbers: List): List + + fun findByYearAndSemester( + year: Int, + semester: Int, + ): Optional + + fun findAllByStatus(cardinalStatus: CardinalStatus): List + + fun findAllByOrderByCardinalNumberAsc(): List + + fun findAllByOrderByCardinalNumberDesc(): List + + override fun getByCardinalNumber(cardinalNumber: Int): Cardinal = + findByCardinalNumber(cardinalNumber).orElseThrow { CardinalNotFoundException() } + + override fun findByIdOrNull(cardinalId: Long): Cardinal? = findById(cardinalId).orElse(null) + + override fun findAllByCardinalNumberDesc(): List = findAllByOrderByCardinalNumberDesc() +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserCardinalReader.kt b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserCardinalReader.kt new file mode 100644 index 00000000..0e4c25b4 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserCardinalReader.kt @@ -0,0 +1,12 @@ +package com.weeth.domain.user.domain.repository + +import com.weeth.domain.user.domain.entity.User +import com.weeth.domain.user.domain.entity.UserCardinal + +interface UserCardinalReader { + fun findAllByUser(user: User): List + + fun findAllByUsersOrderByCardinalDesc(users: List): List + + fun findTopByUserOrderByCardinalNumberDesc(user: User): UserCardinal? +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserCardinalRepository.kt b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserCardinalRepository.kt new file mode 100644 index 00000000..b637eb94 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserCardinalRepository.kt @@ -0,0 +1,40 @@ +package com.weeth.domain.user.domain.repository + +import com.weeth.domain.user.domain.entity.User +import com.weeth.domain.user.domain.entity.UserCardinal +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param + +interface UserCardinalRepository : + JpaRepository, + UserCardinalReader { + fun findAllByUserOrderByCardinalCardinalNumberDesc(user: User): List + + fun findTopByUserOrderByCardinalCardinalNumberDesc(user: User): UserCardinal? + + @Query("SELECT uc FROM UserCardinal uc WHERE uc.user IN :users ORDER BY uc.user.id, uc.cardinal.cardinalNumber DESC") + fun findAllByUsers( + @Param("users") users: List, + ): List + + fun findAllByOrderByUserNameAsc(): List + + @Query( + """ + select uc.cardinal.cardinalNumber + from UserCardinal uc + where uc.user = :user + order by uc.cardinal.cardinalNumber desc + """, + ) + fun findCardinalNumbersByUser( + @Param("user") user: User, + ): List + + override fun findAllByUser(user: User): List = findAllByUserOrderByCardinalCardinalNumberDesc(user) + + override fun findAllByUsersOrderByCardinalDesc(users: List): List = findAllByUsers(users) + + override fun findTopByUserOrderByCardinalNumberDesc(user: User): UserCardinal? = findTopByUserOrderByCardinalCardinalNumberDesc(user) +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserReader.kt b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserReader.kt new file mode 100644 index 00000000..8e7c60fb --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserReader.kt @@ -0,0 +1,13 @@ +package com.weeth.domain.user.domain.repository + +import com.weeth.domain.user.domain.entity.User + +interface UserReader { + fun getById(userId: Long): User + + fun getByEmail(email: String): User + + fun findByIdOrNull(userId: Long): User? + + fun findAllByIds(userIds: List): List +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserRepository.kt b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserRepository.kt new file mode 100644 index 00000000..47ec7f96 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserRepository.kt @@ -0,0 +1,112 @@ +package com.weeth.domain.user.domain.repository + +import com.weeth.domain.user.application.exception.UserNotFoundException +import com.weeth.domain.user.domain.entity.Cardinal +import com.weeth.domain.user.domain.entity.User +import com.weeth.domain.user.domain.entity.enums.Status +import com.weeth.domain.user.domain.vo.Email +import com.weeth.domain.user.domain.vo.PhoneNumber +import jakarta.persistence.LockModeType +import jakarta.persistence.QueryHint +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Slice +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Lock +import org.springframework.data.jpa.repository.Query +import org.springframework.data.jpa.repository.QueryHints +import org.springframework.data.repository.query.Param +import java.util.Optional + +interface UserRepository : + JpaRepository, + UserReader { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query("SELECT u FROM User u WHERE u.id = :id") + fun findByIdWithLock( + @Param("id") id: Long, + ): Optional + + fun findByEmail(email: Email): Optional + + fun findAllByNameContainingAndStatus( + name: String, + status: Status, + ): List + + fun existsByEmail(email: Email): Boolean + + fun existsByStudentId(studentId: String): Boolean + + fun existsByTel(tel: PhoneNumber): Boolean + + fun existsByStudentIdAndIdIsNot( + studentId: String, + id: Long, + ): Boolean + + fun existsByTelAndIdIsNot( + tel: PhoneNumber, + id: Long, + ): Boolean + + fun findAllByStatusOrderByName(status: Status): List + + fun findAllByOrderByNameAsc(): List + + @Query("SELECT uc.user FROM UserCardinal uc WHERE uc.cardinal = :cardinal AND uc.user.status = :status") + fun findAllByCardinalAndStatus( + @Param("cardinal") cardinal: Cardinal, + @Param("status") status: Status, + ): List + + @Query( + """ + SELECT u + FROM User u + JOIN UserCardinal uc ON u.id = uc.user.id + JOIN uc.cardinal c + WHERE u.status = :status + GROUP BY u.id + ORDER BY MAX(c.cardinalNumber) DESC, u.name ASC + """, + ) + fun findAllByStatusOrderedByCardinalAndName( + @Param("status") status: Status, + pageable: Pageable, + ): Slice + + @Query( + """ + SELECT u FROM User u + JOIN UserCardinal uc ON uc.user.id = u.id + WHERE u.status = :status + AND uc.cardinal = :cardinal + ORDER BY u.name ASC + """, + ) + fun findAllByCardinalOrderByNameAsc( + @Param("status") status: Status, + @Param("cardinal") cardinal: Cardinal, + pageable: Pageable, + ): Slice + + fun findByEmailValue(email: String): Optional = findByEmail(Email.from(email)) + + fun existsByEmailValue(email: String): Boolean = existsByEmail(Email.from(email)) + + fun existsByTelValue(tel: String): Boolean = existsByTel(PhoneNumber.from(tel)) + + fun existsByTelAndIdIsNotValue( + tel: String, + id: Long, + ): Boolean = existsByTelAndIdIsNot(PhoneNumber.from(tel), id) + + override fun getById(userId: Long): User = findById(userId).orElseThrow { UserNotFoundException() } + + override fun getByEmail(email: String): User = findByEmailValue(email).orElseThrow { UserNotFoundException() } + + override fun findByIdOrNull(userId: Long): User? = findById(userId).orElse(null) + + override fun findAllByIds(userIds: List): List = findAllById(userIds) +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserSocialAccountRepository.kt b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserSocialAccountRepository.kt new file mode 100644 index 00000000..d5a4e4db --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserSocialAccountRepository.kt @@ -0,0 +1,13 @@ +package com.weeth.domain.user.domain.repository + +import com.weeth.domain.user.domain.entity.UserSocialAccount +import com.weeth.domain.user.domain.entity.enums.SocialProvider +import org.springframework.data.jpa.repository.JpaRepository +import java.util.Optional + +interface UserSocialAccountRepository : JpaRepository { + fun findByProviderAndProviderUserId( + provider: SocialProvider, + providerUserId: String, + ): Optional +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/service/UserCardinalPolicy.kt b/src/main/kotlin/com/weeth/domain/user/domain/service/UserCardinalPolicy.kt new file mode 100644 index 00000000..2b0a8e3c --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/service/UserCardinalPolicy.kt @@ -0,0 +1,28 @@ +package com.weeth.domain.user.domain.service + +import com.weeth.domain.user.application.exception.CardinalNotFoundException +import com.weeth.domain.user.domain.entity.Cardinal +import com.weeth.domain.user.domain.entity.User +import com.weeth.domain.user.domain.repository.UserCardinalReader +import org.springframework.stereotype.Service + +@Service +class UserCardinalPolicy( + private val userCardinalReader: UserCardinalReader, +) { + fun getCurrentCardinal(user: User): Cardinal = + userCardinalReader + .findTopByUserOrderByCardinalNumberDesc(user) + ?.cardinal + ?: throw CardinalNotFoundException() + + fun notContains( + user: User, + cardinal: Cardinal, + ): Boolean = userCardinalReader.findAllByUser(user).none { it.cardinal.id == cardinal.id } + + fun isCurrent( + user: User, + cardinal: Cardinal, + ): Boolean = getCurrentCardinal(user).cardinalNumber < cardinal.cardinalNumber +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/vo/AttendanceStats.kt b/src/main/kotlin/com/weeth/domain/user/domain/vo/AttendanceStats.kt new file mode 100644 index 00000000..12c357ee --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/vo/AttendanceStats.kt @@ -0,0 +1,49 @@ +package com.weeth.domain.user.domain.vo + +import jakarta.persistence.Column +import jakarta.persistence.Embeddable + +@Embeddable +class AttendanceStats( + @Column(name = "attendance_count") + var attendanceCount: Int = 0, + @Column(name = "absence_count") + var absenceCount: Int = 0, + @Column(name = "attendance_rate") + var attendanceRate: Int = 0, +) { + fun reset() { + attendanceCount = 0 + absenceCount = 0 + attendanceRate = 0 + } + + fun attend() { + attendanceCount++ + recalculateRate() + } + + fun removeAttend() { + if (attendanceCount > 0) { + attendanceCount-- + recalculateRate() + } + } + + fun absent() { + absenceCount++ + recalculateRate() + } + + fun removeAbsent() { + if (absenceCount > 0) { + absenceCount-- + recalculateRate() + } + } + + private fun recalculateRate() { + val total = attendanceCount + absenceCount + attendanceRate = if (total > 0) (attendanceCount * 100) / total else 0 + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/vo/Email.kt b/src/main/kotlin/com/weeth/domain/user/domain/vo/Email.kt new file mode 100644 index 00000000..2b840356 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/vo/Email.kt @@ -0,0 +1,18 @@ +package com.weeth.domain.user.domain.vo + +data class Email private constructor( + val value: String, +) { + companion object { + fun from(raw: String): Email { + val normalized = raw.trim().lowercase() + if (normalized.isBlank()) { + return Email("") + } + require(EMAIL_REGEX.matches(normalized)) { "Invalid email format." } + return Email(normalized) + } + + private val EMAIL_REGEX = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$") + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/vo/PhoneNumber.kt b/src/main/kotlin/com/weeth/domain/user/domain/vo/PhoneNumber.kt new file mode 100644 index 00000000..743dcf7c --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/vo/PhoneNumber.kt @@ -0,0 +1,16 @@ +package com.weeth.domain.user.domain.vo + +data class PhoneNumber private constructor( + val value: String, +) { + companion object { + fun from(raw: String): PhoneNumber { + val normalized = raw.filter { it.isDigit() } + if (normalized.isBlank()) { + return PhoneNumber("") + } + require(normalized.length in 10..11) { "Invalid phone number format." } + return PhoneNumber(normalized) + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/presentation/CardinalController.kt b/src/main/kotlin/com/weeth/domain/user/presentation/CardinalController.kt new file mode 100644 index 00000000..740b06b5 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/presentation/CardinalController.kt @@ -0,0 +1,52 @@ +package com.weeth.domain.user.presentation + +import com.weeth.domain.user.application.dto.request.CardinalSaveRequest +import com.weeth.domain.user.application.dto.request.CardinalUpdateRequest +import com.weeth.domain.user.application.dto.response.CardinalResponse +import com.weeth.domain.user.application.exception.UserErrorCode +import com.weeth.domain.user.application.usecase.command.ManageCardinalUseCase +import com.weeth.domain.user.application.usecase.query.GetCardinalQueryService +import com.weeth.global.auth.jwt.application.exception.JwtErrorCode +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "CARDINAL") +@RestController +@RequestMapping("/api/v4") +@ApiErrorCodeExample(UserErrorCode::class, JwtErrorCode::class) +class CardinalController( + private val manageCardinalUseCase: ManageCardinalUseCase, + private val getCardinalQueryService: GetCardinalQueryService, +) { + @GetMapping("/cardinals") + @Operation(summary = "현재 저장된 기수 목록 조회 API") + fun findAllCardinals(): CommonResponse> = + CommonResponse.success(UserResponseCode.CARDINAL_FIND_ALL_SUCCESS, getCardinalQueryService.findAll()) + + @PatchMapping("/admin/cardinals") // todo: 어드민 컨트롤러 분리 + @Operation(summary = "[admin] 기수 정보 수정 API") + fun updateCardinals( + @RequestBody @Valid request: CardinalUpdateRequest, + ): CommonResponse { + manageCardinalUseCase.update(request) + return CommonResponse.success(UserResponseCode.CARDINAL_UPDATE_SUCCESS) + } + + @PostMapping("/admin/cardinals") + @Operation(summary = "[admin] 새로운 기수 정보 저장 API") + fun save( + @RequestBody @Valid request: CardinalSaveRequest, + ): CommonResponse { + manageCardinalUseCase.save(request) + return CommonResponse.success(UserResponseCode.CARDINAL_SAVE_SUCCESS) + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/presentation/UserAdminController.kt b/src/main/kotlin/com/weeth/domain/user/presentation/UserAdminController.kt new file mode 100644 index 00000000..e5883150 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/presentation/UserAdminController.kt @@ -0,0 +1,75 @@ +package com.weeth.domain.user.presentation + +import com.weeth.domain.user.application.dto.request.UserApplyObRequest +import com.weeth.domain.user.application.dto.request.UserIdsRequest +import com.weeth.domain.user.application.dto.request.UserRoleUpdateRequest +import com.weeth.domain.user.application.dto.response.AdminUserResponse +import com.weeth.domain.user.application.exception.UserErrorCode +import com.weeth.domain.user.application.usecase.command.AdminUserUseCase +import com.weeth.domain.user.application.usecase.query.GetUserQueryService +import com.weeth.domain.user.domain.entity.enums.UsersOrderBy +import com.weeth.global.auth.jwt.application.exception.JwtErrorCode +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "USER ADMIN", description = "[ADMIN] 사용자 어드민 API") +@RestController +@RequestMapping("/api/v4/admin/users") +@ApiErrorCodeExample(UserErrorCode::class, JwtErrorCode::class) +class UserAdminController( + private val adminUserUseCase: AdminUserUseCase, + private val getUserQueryService: GetUserQueryService, +) { + @GetMapping("/all") + @Operation(summary = "어드민용 회원 조회") + fun findAll( + @RequestParam orderBy: UsersOrderBy, + ): CommonResponse> = + CommonResponse.success(UserResponseCode.USER_FIND_ALL_SUCCESS, getUserQueryService.findAllByAdmin(orderBy)) + + @PatchMapping + @Operation(summary = "가입 신청 승인") + fun accept( + @RequestBody @Valid request: UserIdsRequest, + ): CommonResponse { + adminUserUseCase.accept(request) + return CommonResponse.success(UserResponseCode.USER_ACCEPT_SUCCESS) + } + + @DeleteMapping + @Operation(summary = "유저 추방") + fun ban( + @RequestBody @Valid request: UserIdsRequest, + ): CommonResponse { + adminUserUseCase.ban(request) + return CommonResponse.success(UserResponseCode.USER_BAN_SUCCESS) + } + + @PatchMapping("/role") + @Operation(summary = "관리자로 승격/강등") + fun update( + @RequestBody request: List, + ): CommonResponse { + adminUserUseCase.updateRole(request) + return CommonResponse.success(UserResponseCode.USER_ROLE_UPDATE_SUCCESS) + } + + @PatchMapping("/apply") + @Operation(summary = "다음 기수도 이어서 진행") + fun applyOb( + @RequestBody request: List, + ): CommonResponse { + adminUserUseCase.applyOb(request) + return CommonResponse.success(UserResponseCode.USER_APPLY_OB_SUCCESS) + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt b/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt new file mode 100644 index 00000000..98ccb3b8 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt @@ -0,0 +1,134 @@ +package com.weeth.domain.user.presentation + +import com.weeth.domain.user.application.dto.request.SignUpRequest +import com.weeth.domain.user.application.dto.request.SocialLoginRequest +import com.weeth.domain.user.application.dto.request.UpdateUserProfileRequest +import com.weeth.domain.user.application.dto.response.SocialLoginResponse +import com.weeth.domain.user.application.dto.response.UserDetailsResponse +import com.weeth.domain.user.application.dto.response.UserInfoResponse +import com.weeth.domain.user.application.dto.response.UserProfileResponse +import com.weeth.domain.user.application.dto.response.UserSummaryResponse +import com.weeth.domain.user.application.exception.UserErrorCode +import com.weeth.domain.user.application.usecase.command.AdminUserUseCase +import com.weeth.domain.user.application.usecase.command.AuthUserUseCase +import com.weeth.domain.user.application.usecase.query.GetUserQueryService +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.auth.jwt.application.dto.JwtDto +import com.weeth.global.auth.jwt.application.exception.JwtErrorCode +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.servlet.http.HttpServletRequest +import jakarta.validation.Valid +import org.springframework.data.domain.Slice +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "USER", description = "사용자 API") +@RestController +@RequestMapping("/api/v4/users") +@ApiErrorCodeExample(UserErrorCode::class, JwtErrorCode::class) +class UserController( + private val authUserUseCase: AuthUserUseCase, + private val adminUserUseCase: AdminUserUseCase, + private val getUserQueryService: GetUserQueryService, +) { + @PostMapping("/social/kakao") + @Operation(summary = "카카오 소셜 로그인(auth code flow)") + fun socialLoginByKakao( + @RequestBody @Valid request: SocialLoginRequest, + ): CommonResponse = + CommonResponse.success(UserResponseCode.SOCIAL_LOGIN_SUCCESS, authUserUseCase.socialLoginByKakao(request)) + + @PostMapping("/social/apple") + @Operation(summary = "애플 소셜 로그인(auth code flow)") + fun socialLoginByApple( + @RequestBody @Valid request: SocialLoginRequest, + ): CommonResponse = + CommonResponse.success(UserResponseCode.SOCIAL_LOGIN_SUCCESS, authUserUseCase.socialLoginByApple(request)) + + @PostMapping("/social/refresh") + @Operation(summary = "토큰 재발급") + fun refreshToken(request: HttpServletRequest): CommonResponse = + CommonResponse.success(UserResponseCode.JWT_REFRESH_SUCCESS, authUserUseCase.refreshToken(request)) + + @PostMapping("/apply") + @Operation(summary = "동아리 지원 신청") + fun apply( + @RequestBody @Valid request: SignUpRequest, + ): CommonResponse { + authUserUseCase.apply(request) + return CommonResponse.success(UserResponseCode.USER_APPLY_SUCCESS) + } + + @GetMapping("/email") + @Operation(summary = "이메일 중복 확인") + fun checkEmail( + @RequestParam email: String, + ): CommonResponse = + CommonResponse.success(UserResponseCode.USER_EMAIL_CHECK_SUCCESS, !getUserQueryService.existsByEmail(email)) + + @GetMapping("/all") + @Operation(summary = "동아리 멤버 전체 조회(전체/기수별)") + fun findAllUser( + @RequestParam("pageNumber") pageNumber: Int, + @RequestParam("pageSize") pageSize: Int, + @RequestParam(required = false) cardinal: Int?, + ): CommonResponse> = + CommonResponse.success(UserResponseCode.USER_FIND_ALL_SUCCESS, getUserQueryService.findAllUser(pageNumber, pageSize, cardinal)) + + @GetMapping("/search") + @Operation(summary = "동아리 멤버 검색") + fun searchUser( + @RequestParam keyword: String, + ): CommonResponse> = + CommonResponse.success(UserResponseCode.USER_FIND_BY_ID_SUCCESS, getUserQueryService.searchUser(keyword)) + + @GetMapping("/details") + @Operation(summary = "특정 멤버 상세 조회") + fun findUser( + @RequestParam userId: Long, + ): CommonResponse = + CommonResponse.success(UserResponseCode.USER_DETAILS_SUCCESS, getUserQueryService.findUserDetails(userId)) + + @GetMapping + @Operation(summary = "내 정보 조회") + fun find( + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success(UserResponseCode.USER_FIND_BY_ID_SUCCESS, getUserQueryService.findMyProfile(userId)) + + @GetMapping("/info") + @Operation(summary = "전역 내 정보 조회 API") + fun findMyInfo( + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success(UserResponseCode.USER_FIND_BY_ID_SUCCESS, getUserQueryService.findMyInfo(userId)) + + @PatchMapping + @Operation(summary = "내 정보 수정") + fun update( + @RequestBody @Valid request: UpdateUserProfileRequest, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + authUserUseCase.updateProfile(request, userId) + return CommonResponse.success(UserResponseCode.USER_UPDATE_SUCCESS) + } + + @DeleteMapping + @Operation(summary = "동아리 탈퇴") + fun leave( + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + authUserUseCase.leave(userId) + return CommonResponse.success(UserResponseCode.USER_LEAVE_SUCCESS) + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt b/src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt new file mode 100644 index 00000000..b7a71b4b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt @@ -0,0 +1,27 @@ +package com.weeth.domain.user.presentation + +import com.weeth.global.common.response.ResponseCodeInterface +import org.springframework.http.HttpStatus + +enum class UserResponseCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ResponseCodeInterface { + SOCIAL_LOGIN_SUCCESS(1815, HttpStatus.OK, "소셜 로그인이 성공적으로 처리되었습니다."), + USER_FIND_ALL_SUCCESS(1800, HttpStatus.OK, "모든 회원 정보를 성공적으로 조회했습니다."), + USER_DETAILS_SUCCESS(1801, HttpStatus.OK, "특정 회원의 상세 정보를 성공적으로 조회했습니다."), + USER_ACCEPT_SUCCESS(1802, HttpStatus.OK, "회원 가입 승인이 성공적으로 처리되었습니다."), + USER_BAN_SUCCESS(1803, HttpStatus.OK, "회원이 성공적으로 차단되었습니다."), + USER_ROLE_UPDATE_SUCCESS(1804, HttpStatus.OK, "회원의 역할이 성공적으로 수정되었습니다."), + USER_APPLY_OB_SUCCESS(1805, HttpStatus.OK, "OB 신청이 성공적으로 처리되었습니다."), + USER_APPLY_SUCCESS(1806, HttpStatus.OK, "회원 가입 신청이 성공적으로 처리되었습니다."), + USER_EMAIL_CHECK_SUCCESS(1807, HttpStatus.OK, "이메일 중복 검사가 성공적으로 처리되었습니다."), + USER_FIND_BY_ID_SUCCESS(1808, HttpStatus.OK, "회원 정보가 성공적으로 조회되었습니다."), + USER_UPDATE_SUCCESS(1809, HttpStatus.OK, "회원 정보가 성공적으로 수정되었습니다."), + USER_LEAVE_SUCCESS(1810, HttpStatus.OK, "회원 탈퇴가 성공적으로 처리되었습니다."), + CARDINAL_FIND_ALL_SUCCESS(1811, HttpStatus.OK, "전체 기수 조회에 성공했습니다."), + CARDINAL_SAVE_SUCCESS(1812, HttpStatus.OK, "기수 저장에 성공했습니다."), + CARDINAL_UPDATE_SUCCESS(1813, HttpStatus.OK, "기수 수정에 성공했습니다."), + JWT_REFRESH_SUCCESS(1814, HttpStatus.OK, "토큰 재발급에 성공했습니다."), +} diff --git a/src/main/kotlin/com/weeth/global/auth/apple/AppleAuthService.kt b/src/main/kotlin/com/weeth/global/auth/apple/AppleAuthService.kt index 43e247d6..9130d90e 100644 --- a/src/main/kotlin/com/weeth/global/auth/apple/AppleAuthService.kt +++ b/src/main/kotlin/com/weeth/global/auth/apple/AppleAuthService.kt @@ -112,11 +112,13 @@ class AppleAuthService( val appleId = claims.subject val email = claims.get("email", String::class.java) val emailVerified = parseEmailVerified(claims["email_verified"]) + val name = claims.get("name", String::class.java) return AppleUserInfo( appleId = appleId, email = email, emailVerified = emailVerified, + name = name, ) } catch (e: AppleAuthenticationException) { throw e diff --git a/src/main/kotlin/com/weeth/global/auth/apple/dto/AppleUserInfo.kt b/src/main/kotlin/com/weeth/global/auth/apple/dto/AppleUserInfo.kt index 6678fb98..444de610 100644 --- a/src/main/kotlin/com/weeth/global/auth/apple/dto/AppleUserInfo.kt +++ b/src/main/kotlin/com/weeth/global/auth/apple/dto/AppleUserInfo.kt @@ -4,4 +4,5 @@ data class AppleUserInfo( val appleId: String, val email: String?, val emailVerified: Boolean, + val name: String? = null, ) diff --git a/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoAccount.kt b/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoAccount.kt index da0ca572..1faf46d2 100644 --- a/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoAccount.kt +++ b/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoAccount.kt @@ -9,4 +9,6 @@ data class KakaoAccount( val isEmailVerified: Boolean, @field:JsonProperty("email") val email: String?, + @field:JsonProperty("profile") + val profile: KakaoProfile? = null, ) diff --git a/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoProfile.kt b/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoProfile.kt new file mode 100644 index 00000000..e7ce2ef3 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoProfile.kt @@ -0,0 +1,8 @@ +package com.weeth.global.auth.kakao.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class KakaoProfile( + @field:JsonProperty("nickname") + val nickname: String?, +) diff --git a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt index 10dc755f..3db6a33e 100644 --- a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt +++ b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt @@ -13,8 +13,6 @@ import org.springframework.security.config.annotation.method.configuration.Enabl import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity import org.springframework.security.config.http.SessionCreationPolicy -import org.springframework.security.crypto.factory.PasswordEncoderFactories -import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.web.SecurityFilterChain import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter import org.springframework.web.cors.CorsConfiguration @@ -43,14 +41,13 @@ class SecurityConfig( .authorizeHttpRequests { authorize -> authorize .requestMatchers( - "/api/v1/users/kakao/login", - "/api/v1/users/kakao/register", - "/api/v1/users/kakao/link", - "/api/v1/users/apple/login", - "/api/v1/users/apple/register", + "/api/v4/users/apply", + "/api/v4/users/email", + "/api/v4/users/social/kakao", + "/api/v4/users/social/apple", + "/api/v4/users/social/refresh", "/api/v1/users/apply", "/api/v1/users/email", - "/api/v1/users/refresh", ).permitAll() .requestMatchers("/health-check") .permitAll() @@ -105,9 +102,6 @@ class SecurityConfig( } } - @Bean - fun passwordEncoder(): PasswordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder() - @Bean fun jwtAuthenticationProcessingFilter(): JwtAuthenticationProcessingFilter = JwtAuthenticationProcessingFilter(jwtTokenProvider, jwtTokenExtractor) diff --git a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCaseTest.kt index ea07505f..bbffddb8 100644 --- a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCaseTest.kt @@ -3,43 +3,47 @@ package com.weeth.domain.account.application.usecase.command import com.weeth.domain.account.application.dto.request.AccountSaveRequest import com.weeth.domain.account.application.exception.AccountExistsException import com.weeth.domain.account.domain.repository.AccountRepository -import com.weeth.domain.user.domain.service.CardinalGetService +import com.weeth.domain.user.domain.entity.Cardinal +import com.weeth.domain.user.domain.repository.CardinalRepository import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk import io.mockk.verify +import java.util.Optional class ManageAccountUseCaseTest : DescribeSpec({ val accountRepository = mockk(relaxed = true) - val cardinalGetService = mockk(relaxUnitFun = true) - val useCase = ManageAccountUseCase(accountRepository, cardinalGetService) + val cardinalRepository = mockk(relaxed = true) + val useCase = ManageAccountUseCase(accountRepository, cardinalRepository) beforeTest { - clearMocks(accountRepository, cardinalGetService) + clearMocks(accountRepository, cardinalRepository) } describe("save") { context("이미 존재하는 기수로 저장 시") { it("AccountExistsException을 던진다") { - val dto = AccountSaveRequest("설명", 100_000, 40) + val request = AccountSaveRequest("설명", 100_000, 40) every { accountRepository.existsByCardinal(40) } returns true - shouldThrow { useCase.save(dto) } + shouldThrow { useCase.save(request) } } } context("정상 저장 시") { - it("account가 저장된다") { - val dto = AccountSaveRequest("설명", 100_000, 40) + it("기수 존재를 보장하고 account를 저장한다") { + val request = AccountSaveRequest("설명", 100_000, 40) every { accountRepository.existsByCardinal(40) } returns false - every { cardinalGetService.findByAdminSide(40) } returns mockk() + every { cardinalRepository.findByCardinalNumber(40) } returns Optional.of(mockk()) every { accountRepository.save(any()) } answers { firstArg() } - useCase.save(dto) + useCase.save(request) + verify(exactly = 1) { cardinalRepository.findByCardinalNumber(40) } + verify(exactly = 0) { cardinalRepository.save(any()) } verify(exactly = 1) { accountRepository.save(any()) } } } diff --git a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCaseTest.kt index 2da320fb..986f3b3e 100644 --- a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCaseTest.kt @@ -15,7 +15,8 @@ import com.weeth.domain.file.domain.entity.File import com.weeth.domain.file.domain.entity.FileOwnerType import com.weeth.domain.file.domain.repository.FileReader import com.weeth.domain.file.domain.repository.FileRepository -import com.weeth.domain.user.domain.service.CardinalGetService +import com.weeth.domain.user.domain.entity.Cardinal +import com.weeth.domain.user.domain.repository.CardinalRepository import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.mockk.clearMocks @@ -31,7 +32,7 @@ class ManageReceiptUseCaseTest : val accountRepository = mockk() val fileReader = mockk() val fileRepository = mockk(relaxed = true) - val cardinalGetService = mockk(relaxUnitFun = true) + val cardinalRepository = mockk(relaxed = true) val fileMapper = mockk() val useCase = ManageReceiptUseCase( @@ -39,12 +40,16 @@ class ManageReceiptUseCaseTest : accountRepository, fileReader, fileRepository, - cardinalGetService, + cardinalRepository, fileMapper, ) beforeTest { - clearMocks(receiptRepository, accountRepository, fileReader, fileRepository, cardinalGetService, fileMapper) + clearMocks(receiptRepository, accountRepository, fileReader, fileRepository, cardinalRepository, fileMapper) + } + + fun stubExistingCardinal(cardinalNumber: Int) { + every { cardinalRepository.findByCardinalNumber(cardinalNumber) } returns Optional.of(mockk()) } describe("save") { @@ -53,7 +58,7 @@ class ManageReceiptUseCaseTest : val account = AccountTestFixture.createAccount(cardinal = 40) val savedReceipt = ReceiptTestFixture.createReceipt(id = 10L, amount = 5_000, account = account) val files = listOf(mockk()) - val dto = + val request = ReceiptSaveRequest( "간식비", "편의점", @@ -63,12 +68,12 @@ class ManageReceiptUseCaseTest : listOf(FileSaveRequest("receipt.png", "TEMP/2024-09/receipt.png", 200L, "image/png")), ) - every { cardinalGetService.findByAdminSide(40) } returns mockk() + stubExistingCardinal(40) every { accountRepository.findByCardinal(40) } returns account every { receiptRepository.save(any()) } returns savedReceipt - every { fileMapper.toFileList(dto.files, FileOwnerType.RECEIPT, savedReceipt.id) } returns files + every { fileMapper.toFileList(request.files, FileOwnerType.RECEIPT, savedReceipt.id) } returns files - useCase.save(dto) + useCase.save(request) verify(exactly = 1) { receiptRepository.save(any()) } verify(exactly = 1) { fileRepository.saveAll(files) } @@ -79,14 +84,14 @@ class ManageReceiptUseCaseTest : it("fileRepository.saveAll은 빈 리스트로 호출된다") { val account = AccountTestFixture.createAccount(cardinal = 40) val savedReceipt = ReceiptTestFixture.createReceipt(id = 11L, amount = 3_000, account = account) - val dto = ReceiptSaveRequest("교통비", "지하철", 3_000, LocalDate.of(2024, 9, 2), 40, emptyList()) + val request = ReceiptSaveRequest("교통비", "지하철", 3_000, LocalDate.of(2024, 9, 2), 40, emptyList()) - every { cardinalGetService.findByAdminSide(40) } returns mockk() + stubExistingCardinal(40) every { accountRepository.findByCardinal(40) } returns account every { receiptRepository.save(any()) } returns savedReceipt every { fileMapper.toFileList(emptyList(), FileOwnerType.RECEIPT, savedReceipt.id) } returns emptyList() - useCase.save(dto) + useCase.save(request) verify(exactly = 1) { receiptRepository.save(any()) } verify(exactly = 1) { fileRepository.saveAll(emptyList()) } @@ -95,12 +100,12 @@ class ManageReceiptUseCaseTest : context("존재하지 않는 기수로 저장 시") { it("AccountNotFoundException을 던진다") { - val dto = ReceiptSaveRequest("간식비", "편의점", 5_000, LocalDate.of(2024, 9, 1), 99, null) + val request = ReceiptSaveRequest("간식비", "편의점", 5_000, LocalDate.of(2024, 9, 1), 99, null) - every { cardinalGetService.findByAdminSide(99) } returns mockk() + stubExistingCardinal(99) every { accountRepository.findByCardinal(99) } returns null - shouldThrow { useCase.save(dto) } + shouldThrow { useCase.save(request) } } } } @@ -111,7 +116,7 @@ class ManageReceiptUseCaseTest : val account = AccountTestFixture.createAccount(cardinal = 40) val receipt = ReceiptTestFixture.createReceipt(id = receiptId, amount = 1_000, account = account) account.spend(Money.of(receipt.amount)) - val dto = + val request = ReceiptUpdateRequest( "desc", "source", @@ -123,14 +128,13 @@ class ManageReceiptUseCaseTest : val oldFiles = listOf(mockk()) val newFiles = listOf(mockk()) - every { cardinalGetService.findByAdminSide(dto.cardinal) } returns mockk() - every { accountRepository.findByCardinal(dto.cardinal) } returns account - + stubExistingCardinal(request.cardinal) + every { accountRepository.findByCardinal(request.cardinal) } returns account every { receiptRepository.findById(receiptId) } returns Optional.of(receipt) every { fileReader.findAll(FileOwnerType.RECEIPT, receiptId, null) } returns oldFiles - every { fileMapper.toFileList(dto.files, FileOwnerType.RECEIPT, receiptId) } returns newFiles + every { fileMapper.toFileList(request.files, FileOwnerType.RECEIPT, receiptId) } returns newFiles - useCase.update(receiptId, dto) + useCase.update(receiptId, request) verify(exactly = 1) { fileRepository.deleteAll(oldFiles) } verify(exactly = 1) { fileRepository.saveAll(newFiles) } @@ -141,13 +145,13 @@ class ManageReceiptUseCaseTest : val accountA = AccountTestFixture.createAccount(id = 1L, cardinal = 40) val accountB = AccountTestFixture.createAccount(id = 2L, cardinal = 41) val receipt = ReceiptTestFixture.createReceipt(id = receiptId, amount = 1_000, account = accountB) - val dto = ReceiptUpdateRequest("desc", "source", 2_000, LocalDate.of(2026, 1, 1), 40, null) + val request = ReceiptUpdateRequest("desc", "source", 2_000, LocalDate.of(2026, 1, 1), 40, null) - every { cardinalGetService.findByAdminSide(dto.cardinal) } returns mockk() - every { accountRepository.findByCardinal(dto.cardinal) } returns accountA + stubExistingCardinal(request.cardinal) + every { accountRepository.findByCardinal(request.cardinal) } returns accountA every { receiptRepository.findById(receiptId) } returns Optional.of(receipt) - shouldThrow { useCase.update(receiptId, dto) } + shouldThrow { useCase.update(receiptId, request) } } it("빈 리스트로 업데이트 시 기존 파일을 모두 삭제한다") { @@ -155,7 +159,7 @@ class ManageReceiptUseCaseTest : val account = AccountTestFixture.createAccount(cardinal = 40) val receipt = ReceiptTestFixture.createReceipt(id = receiptId, amount = 1_000, account = account) account.spend(Money.of(receipt.amount)) - val dto = + val request = ReceiptUpdateRequest( "desc", "source", @@ -166,13 +170,13 @@ class ManageReceiptUseCaseTest : ) val oldFiles = listOf(mockk()) - every { cardinalGetService.findByAdminSide(dto.cardinal) } returns mockk() - every { accountRepository.findByCardinal(dto.cardinal) } returns account + stubExistingCardinal(request.cardinal) + every { accountRepository.findByCardinal(request.cardinal) } returns account every { receiptRepository.findById(receiptId) } returns Optional.of(receipt) every { fileReader.findAll(FileOwnerType.RECEIPT, receiptId, null) } returns oldFiles every { fileMapper.toFileList(emptyList(), FileOwnerType.RECEIPT, receiptId) } returns emptyList() - useCase.update(receiptId, dto) + useCase.update(receiptId, request) verify(exactly = 1) { fileRepository.deleteAll(oldFiles) } verify(exactly = 1) { fileRepository.saveAll(emptyList()) } diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapperTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapperTest.kt index f737afe3..d9b6a8b6 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapperTest.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapperTest.kt @@ -1,14 +1,12 @@ package com.weeth.domain.attendance.application.mapper import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createActiveUser -import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createActiveUserWithAttendances -import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createAdminUserWithAttendances +import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createAdminUser import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createAttendance import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createOneDayMeeting import com.weeth.domain.attendance.fixture.AttendanceTestFixture.enrichUserProfile import com.weeth.domain.attendance.fixture.AttendanceTestFixture.setAttendanceId import com.weeth.domain.attendance.fixture.AttendanceTestFixture.setUserAttendanceStats -import com.weeth.domain.user.domain.entity.enums.Position import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.nulls.shouldNotBeNull @@ -17,15 +15,14 @@ import java.time.LocalDate class AttendanceMapperTest : DescribeSpec({ - val mapper = AttendanceMapper() describe("toSummaryResponse") { it("사용자 + 당일 출석 객체를 MainResponse로 매핑한다") { val today = LocalDate.now() val meeting = createOneDayMeeting(today, 1, 1111, "Today") - val user = createActiveUserWithAttendances("이지훈", listOf(meeting)) - val attendance = user.attendances[0] + val user = createActiveUser("이지훈") + val attendance = createAttendance(meeting, user) val main = mapper.toSummaryResponse(user, attendance) @@ -52,8 +49,8 @@ class AttendanceMapperTest : it("일반 유저는 출석 코드가 null로 매핑된다") { val today = LocalDate.now() val meeting = createOneDayMeeting(today, 1, 1234, "Today") - val user = createActiveUserWithAttendances("일반유저", listOf(meeting)) - val attendance = user.attendances[0] + val user = createActiveUser("일반유저") + val attendance = createAttendance(meeting, user) val main = mapper.toSummaryResponse(user, attendance) @@ -67,8 +64,8 @@ class AttendanceMapperTest : val today = LocalDate.now() val expectedCode = 1234 val meeting = createOneDayMeeting(today, 1, expectedCode, "Today") - val adminUser = createAdminUserWithAttendances("관리자", listOf(meeting)) - val attendance = adminUser.attendances[0] + val adminUser = createAdminUser("관리자") + val attendance = createAttendance(meeting, adminUser) val main = mapper.toSummaryResponse(adminUser, attendance, isAdmin = true) @@ -123,7 +120,7 @@ class AttendanceMapperTest : it("Attendance를 InfoResponse로 매핑") { val meeting = createOneDayMeeting(LocalDate.now(), 1, 3333, "Info") val user = createActiveUser("유저B") - enrichUserProfile(user, Position.BE, "컴퓨터공학과", "20201234") + enrichUserProfile(user, "컴퓨터공학과", "20201234") val attendance = createAttendance(meeting, user) setAttendanceId(attendance, 10L) @@ -134,6 +131,7 @@ class AttendanceMapperTest : info.id shouldBe attendance.id info.status shouldBe attendance.status info.name shouldBe user.name + info.department shouldBe user.department } } }) diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/CheckInAttendanceUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/CheckInAttendanceUseCaseTest.kt index 3e96b276..72fdd612 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/CheckInAttendanceUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/CheckInAttendanceUseCaseTest.kt @@ -6,7 +6,7 @@ import com.weeth.domain.attendance.domain.entity.Attendance import com.weeth.domain.attendance.domain.enums.Status import com.weeth.domain.attendance.domain.repository.AttendanceRepository import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.service.UserGetService +import com.weeth.domain.user.domain.repository.UserReader import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.mockk.every @@ -17,10 +17,10 @@ class CheckInAttendanceUseCaseTest : DescribeSpec({ val userId = 10L - val userGetService = mockk() + val userReader = mockk() val attendanceRepository = mockk() - val useCase = CheckInAttendanceUseCase(userGetService, attendanceRepository) + val useCase = CheckInAttendanceUseCase(userReader, attendanceRepository) describe("checkIn") { context("진행 중 정기모임이고 코드 일치하며 상태가 ATTEND가 아닐 때") { @@ -30,7 +30,7 @@ class CheckInAttendanceUseCaseTest : every { attendance.isWrong(1234) } returns false every { attendance.status } returns Status.PENDING - every { userGetService.find(userId) } returns user + every { userReader.getById(userId) } returns user every { attendanceRepository.findCurrentByUserId(eq(userId), any(), any()) } returns attendance every { user.attend() } returns Unit @@ -44,7 +44,7 @@ class CheckInAttendanceUseCaseTest : context("진행 중 정기모임이 없을 때") { it("AttendanceNotFoundException") { val user = mockk() - every { userGetService.find(userId) } returns user + every { userReader.getById(userId) } returns user every { attendanceRepository.findCurrentByUserId(eq(userId), any(), any()) } returns null shouldThrow { @@ -59,7 +59,7 @@ class CheckInAttendanceUseCaseTest : val attendance = mockk() every { attendance.isWrong(9999) } returns true - every { userGetService.find(userId) } returns user + every { userReader.getById(userId) } returns user every { attendanceRepository.findCurrentByUserId(eq(userId), any(), any()) } returns attendance shouldThrow { @@ -75,7 +75,7 @@ class CheckInAttendanceUseCaseTest : every { attendance.isWrong(1234) } returns false every { attendance.status } returns Status.ATTEND - every { userGetService.find(userId) } returns user + every { userReader.getById(userId) } returns user every { attendanceRepository.findCurrentByUserId(eq(userId), any(), any()) } returns attendance useCase.checkIn(userId, 1234) diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt index 6c600674..a3474d13 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt @@ -12,8 +12,8 @@ import com.weeth.domain.schedule.domain.entity.Meeting import com.weeth.domain.schedule.domain.service.MeetingGetService import com.weeth.domain.user.domain.entity.Cardinal import com.weeth.domain.user.domain.entity.enums.Status -import com.weeth.domain.user.domain.service.UserCardinalGetService -import com.weeth.domain.user.domain.service.UserGetService +import com.weeth.domain.user.domain.repository.UserReader +import com.weeth.domain.user.domain.service.UserCardinalPolicy import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe import io.mockk.every @@ -23,16 +23,16 @@ import io.mockk.verify class GetAttendanceQueryServiceTest : DescribeSpec({ - val userGetService = mockk() - val userCardinalGetService = mockk() + val userReader = mockk() + val userCardinalPolicy = mockk() val meetingGetService = mockk() val attendanceRepository = mockk() val attendanceMapper = mockk() val queryService = GetAttendanceQueryService( - userGetService, - userCardinalGetService, + userReader, + userCardinalPolicy, meetingGetService, attendanceRepository, attendanceMapper, @@ -46,7 +46,7 @@ class GetAttendanceQueryServiceTest : val todayAttendance = mockk() val mapped = mockk() - every { userGetService.find(userId) } returns user + every { userReader.getById(userId) } returns user every { attendanceRepository.findTodayByUserId(eq(userId), any(), any()) } returns todayAttendance every { attendanceMapper.toSummaryResponse(eq(user), eq(todayAttendance), eq(false)) } returns mapped @@ -60,7 +60,7 @@ class GetAttendanceQueryServiceTest : val user = createActiveUser("이지훈") val mapped = mockk() - every { userGetService.find(userId) } returns user + every { userReader.getById(userId) } returns user every { attendanceRepository.findTodayByUserId(eq(userId), any(), any()) } returns null every { attendanceMapper.toSummaryResponse(user, null, false) } returns mapped @@ -77,10 +77,10 @@ class GetAttendanceQueryServiceTest : val attendance1 = mockk() val attendance2 = mockk() - every { userGetService.find(userId) } returns user + every { userReader.getById(userId) } returns user val currentCardinal = mockk() every { currentCardinal.cardinalNumber } returns 1 - every { userCardinalGetService.getCurrentCardinal(user) } returns currentCardinal + every { userCardinalPolicy.getCurrentCardinal(user) } returns currentCardinal every { attendanceRepository.findAllByUserIdAndCardinal(userId, 1) } returns listOf(attendance1, attendance2) val response1 = mockk() diff --git a/src/test/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepositoryTest.kt b/src/test/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepositoryTest.kt index f63aaff8..6744c1e2 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepositoryTest.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepositoryTest.kt @@ -44,17 +44,15 @@ class AttendanceRepositoryTest( meetingRepository.save(meeting) activeUser1 = - User - .builder() - .name("이지훈") - .status(Status.ACTIVE) - .build() + User( + name = "이지훈", + status = Status.ACTIVE, + ) activeUser2 = - User - .builder() - .name("이강혁") - .status(Status.ACTIVE) - .build() + User( + name = "이강혁", + status = Status.ACTIVE, + ) userRepository.saveAll(listOf(activeUser1, activeUser2)) activeUser1.accept() activeUser2.accept() diff --git a/src/test/kotlin/com/weeth/domain/attendance/domain/service/AttendanceSaveServiceTest.kt b/src/test/kotlin/com/weeth/domain/attendance/domain/service/AttendanceSaveServiceTest.kt index 5d8db16a..eec1deac 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/domain/service/AttendanceSaveServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/domain/service/AttendanceSaveServiceTest.kt @@ -19,8 +19,8 @@ class AttendanceSaveServiceTest : val attendanceSaveService = AttendanceSaveService(attendanceRepository) describe("init") { - it("각 정기모임에 대한 Attendance 저장 후 user.add 호출") { - val user = mockk(relaxUnitFun = true) + it("각 정기모임에 대한 Attendance를 저장한다") { + val user = mockk() val meetingFirst = createMeeting() val meetingSecond = createMeeting() @@ -29,7 +29,6 @@ class AttendanceSaveServiceTest : attendanceSaveService.init(user, listOf(meetingFirst, meetingSecond)) verify(exactly = 2) { attendanceRepository.save(any()) } - verify(exactly = 2) { user.add(any()) } } } diff --git a/src/test/kotlin/com/weeth/domain/attendance/fixture/AttendanceTestFixture.kt b/src/test/kotlin/com/weeth/domain/attendance/fixture/AttendanceTestFixture.kt index 864321cf..53f73aab 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/fixture/AttendanceTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/fixture/AttendanceTestFixture.kt @@ -3,55 +3,26 @@ package com.weeth.domain.attendance.fixture import com.weeth.domain.attendance.domain.entity.Attendance import com.weeth.domain.schedule.domain.entity.Meeting import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.entity.enums.Department -import com.weeth.domain.user.domain.entity.enums.Position import com.weeth.domain.user.domain.entity.enums.Role import com.weeth.domain.user.domain.entity.enums.Status +import com.weeth.domain.user.domain.vo.AttendanceStats import org.springframework.test.util.ReflectionTestUtils import java.time.LocalDate import java.time.LocalDateTime object AttendanceTestFixture { fun createActiveUser(name: String): User = - User - .builder() - .name(name) - .status(Status.ACTIVE) - .build() + User( + name = name, + status = Status.ACTIVE, + ) fun createAdminUser(name: String): User = - User - .builder() - .name(name) - .status(Status.ACTIVE) - .role(Role.ADMIN) - .build() - - fun createActiveUserWithAttendances( - name: String, - meetings: List, - ): User { - val user = createActiveUser(name) - initAttendancesField(user) - meetings.forEach { meeting -> - val attendance = createAttendance(meeting, user) - user.add(attendance) - } - return user - } - - fun createAdminUserWithAttendances( - name: String, - meetings: List, - ): User { - val user = createAdminUser(name) - initAttendancesField(user) - meetings.forEach { meeting -> - val attendance = createAttendance(meeting, user) - user.add(attendance) - } - return user - } + User( + name = name, + status = Status.ACTIVE, + role = Role.ADMIN, + ) fun createAttendance( meeting: Meeting, @@ -101,36 +72,23 @@ object AttendanceTestFixture { attendanceCount: Int, absenceCount: Int, ) { - ReflectionTestUtils.setField(user, "attendanceCount", attendanceCount) - ReflectionTestUtils.setField(user, "absenceCount", absenceCount) - } - - fun enrichUserProfile( - user: User, - position: Position, - department: Department, - studentId: String, - ) { - ReflectionTestUtils.setField(user, "position", position) - ReflectionTestUtils.setField(user, "department", department) - ReflectionTestUtils.setField(user, "studentId", studentId) + ReflectionTestUtils.setField( + user, + "attendanceStats", + AttendanceStats( + attendanceCount = attendanceCount, + absenceCount = absenceCount, + attendanceRate = if (attendanceCount + absenceCount > 0) (attendanceCount * 100) / (attendanceCount + absenceCount) else 0, + ), + ) } fun enrichUserProfile( user: User, - position: Position, - departmentKoreanValue: String, + department: String, studentId: String, ) { - ReflectionTestUtils.setField(user, "position", position) - val department = Department.to(departmentKoreanValue) ReflectionTestUtils.setField(user, "department", department) ReflectionTestUtils.setField(user, "studentId", studentId) } - - private fun initAttendancesField(user: User) { - if (user.attendances == null) { - ReflectionTestUtils.setField(user, "attendances", mutableListOf()) - } - } } diff --git a/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt b/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt index dda26cba..59718b59 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt @@ -1,12 +1,10 @@ package com.weeth.domain.board.application.mapper import com.weeth.domain.board.domain.entity.Post -import com.weeth.domain.board.domain.entity.enums.BoardType import com.weeth.domain.comment.application.dto.response.CommentResponse import com.weeth.domain.file.application.dto.response.FileResponse import com.weeth.domain.file.domain.entity.FileStatus import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.entity.enums.Position import com.weeth.domain.user.domain.entity.enums.Role import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe @@ -22,7 +20,6 @@ class PostMapperTest : val post = mockk() every { user.name } returns "테스터" - every { user.position } returns Position.BE every { user.role } returns Role.USER every { post.id } returns 1L @@ -50,7 +47,6 @@ class PostMapperTest : CommentResponse( id = 10L, name = "댓글작성자", - position = Position.BE, role = Role.USER, content = "댓글", time = LocalDateTime.now(), diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt index 9374e211..a403a5e8 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt @@ -21,7 +21,7 @@ import com.weeth.domain.file.domain.repository.FileRepository import com.weeth.domain.user.domain.entity.User import com.weeth.domain.user.domain.entity.enums.Role import com.weeth.domain.user.domain.entity.enums.Status -import com.weeth.domain.user.domain.service.UserGetService +import com.weeth.domain.user.domain.repository.UserReader import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec @@ -37,7 +37,7 @@ class ManagePostUseCaseTest : DescribeSpec({ val postRepository = mockk() val boardRepository = mockk() - val userGetService = mockk() + val userReader = mockk() val fileRepository = mockk() val fileReader = mockk() val fileMapper = mockk() @@ -47,7 +47,7 @@ class ManagePostUseCaseTest : ManagePostUseCase( postRepository, boardRepository, - userGetService, + userReader, fileRepository, fileReader, fileMapper, @@ -71,17 +71,16 @@ class ManagePostUseCaseTest : id: Long = 1L, role: Role = Role.USER, ): User = - User - .builder() - .id(id) - .name("적순") - .email("test1@test.com") - .status(Status.ACTIVE) - .role(role) - .build() + User( + id = id, + name = "적순", + email = "test1@test.com", + status = Status.ACTIVE, + role = role, + ) beforeTest { - clearMocks(postRepository, boardRepository, userGetService, fileRepository, fileReader, fileMapper, postMapper) + clearMocks(postRepository, boardRepository, userReader, fileRepository, fileReader, fileMapper, postMapper) every { postRepository.save(any()) } answers { firstArg() } every { fileMapper.toFileList(any(), any(), any()) } returns emptyList() every { fileRepository.saveAll(any>()) } returns emptyList() @@ -96,7 +95,7 @@ class ManagePostUseCaseTest : val board = Board(id = 10L, name = "일반", type = BoardType.GENERAL) val request = CreatePostRequest(title = "제목", content = "내용") - every { userGetService.find(1L) } returns user + every { userReader.getById(1L) } returns user every { boardRepository.findByIdAndIsDeletedFalse(10L) } returns board val result = useCase.save(10L, request, 1L) @@ -116,7 +115,7 @@ class ManagePostUseCaseTest : ) val request = CreatePostRequest(title = "제목", content = "내용") - every { userGetService.find(1L) } returns user + every { userReader.getById(1L) } returns user every { boardRepository.findByIdAndIsDeletedFalse(20L) } returns board shouldThrow { @@ -137,7 +136,7 @@ class ManagePostUseCaseTest : ) val request = CreatePostRequest(title = "제목", content = "내용") - every { userGetService.find(1L) } returns user + every { userReader.getById(1L) } returns user every { boardRepository.findByIdAndIsDeletedFalse(21L) } returns board shouldThrow { @@ -157,7 +156,7 @@ class ManagePostUseCaseTest : cardinalNumber = 6, ) - every { userGetService.find(1L) } returns user + every { userReader.getById(1L) } returns user every { boardRepository.findByIdAndIsDeletedFalse(11L) } returns board useCase.save(11L, request, 1L) @@ -175,7 +174,7 @@ class ManagePostUseCaseTest : val user = createUser(1L, Role.USER) val request = CreatePostRequest(title = "제목", content = "내용") - every { userGetService.find(1L) } returns user + every { userReader.getById(1L) } returns user every { boardRepository.findByIdAndIsDeletedFalse(999L) } returns null shouldThrow { diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt index 7c718db8..52155f40 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt @@ -66,12 +66,11 @@ class CommentConcurrencyTest( fun createUsers(size: Int): List = (1..size).map { i -> userRepository.save( - User - .builder() - .name("user$i") - .email("user$i@test.com") - .status(Status.ACTIVE) - .build(), + User( + name = "user$i", + email = "user$i@test.com", + status = Status.ACTIVE, + ), ) } diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt index 008c6ba8..e214c1c9 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt @@ -16,7 +16,7 @@ import com.weeth.domain.file.domain.entity.FileOwnerType import com.weeth.domain.file.domain.entity.FileStatus import com.weeth.domain.file.domain.repository.FileReader import com.weeth.domain.file.domain.repository.FileRepository -import com.weeth.domain.user.domain.service.UserGetService +import com.weeth.domain.user.domain.repository.UserReader import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec @@ -32,7 +32,7 @@ class ManageCommentUseCaseTest : DescribeSpec({ val commentRepository = mockk(relaxUnitFun = true) val postRepository = mockk() - val userGetService = mockk() + val userReader = mockk() val fileReader = mockk() val fileRepository = mockk(relaxed = true) val fileMapper = mockk() @@ -41,14 +41,14 @@ class ManageCommentUseCaseTest : ManageCommentUseCase( commentRepository, postRepository, - userGetService, + userReader, fileReader, fileRepository, fileMapper, ) beforeTest { - clearMocks(commentRepository, postRepository, userGetService, fileReader, fileRepository, fileMapper) + clearMocks(commentRepository, postRepository, userReader, fileReader, fileRepository, fileMapper) every { fileMapper.toFileList(any(), FileOwnerType.COMMENT, any()) } returns emptyList() every { commentRepository.save(any()) } answers { firstArg() } every { fileReader.findAll(FileOwnerType.COMMENT, any(), any()) } returns emptyList() @@ -61,7 +61,7 @@ class ManageCommentUseCaseTest : val post = PostTestFixture.create(id = 10L, user = user) val dto = CommentSaveRequest(parentCommentId = null, content = "최상위 댓글", files = null) - every { userGetService.find(1L) } returns user + every { userReader.getById(1L) } returns user every { postRepository.findByIdWithLock(10L) } returns post useCase.savePostComment(dto, postId = 10L, userId = 1L) @@ -76,7 +76,7 @@ class ManageCommentUseCaseTest : val post = PostTestFixture.create(id = 10L, user = user) val dto = CommentSaveRequest(parentCommentId = 999L, content = "대댓글", files = null) - every { userGetService.find(1L) } returns user + every { userReader.getById(1L) } returns user every { postRepository.findByIdWithLock(10L) } returns post every { commentRepository.findByIdAndPostId(999L, 10L) } returns null diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt index fc84aa54..501d986a 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt @@ -17,7 +17,6 @@ import com.weeth.domain.file.domain.entity.FileOwnerType import com.weeth.domain.file.domain.port.FileAccessUrlPort import com.weeth.domain.file.domain.repository.FileRepository import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.entity.enums.Position import com.weeth.domain.user.domain.entity.enums.Role import com.weeth.domain.user.domain.entity.enums.Status import com.weeth.domain.user.domain.repository.UserRepository @@ -47,14 +46,13 @@ class CommentQueryPerformanceTest( fun createUser(): User = userRepository.save( - User - .builder() - .name("perf-user") - .email("perf-user@test.com") - .status(Status.ACTIVE) - .position(Position.BE) - .role(Role.USER) - .build(), + User( + name = "perf-user", + email = "perf-user@test.com", + department = "컴퓨터공학과", + status = Status.ACTIVE, + role = Role.USER, + ), ) fun createBoard(): Board = diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt index 023faf4c..7d0e3213 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt @@ -7,7 +7,6 @@ import com.weeth.domain.comment.fixture.CommentTestFixture import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.entity.FileOwnerType import com.weeth.domain.file.domain.repository.FileReader -import com.weeth.domain.user.domain.entity.enums.Position import com.weeth.domain.user.domain.entity.enums.Role import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.core.spec.style.DescribeSpec @@ -38,7 +37,6 @@ class GetCommentQueryServiceTest : ) = CommentResponse( id = id, name = "테스트유저", - position = Position.BE, role = Role.USER, content = "content", time = LocalDateTime.now(), diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/UserManageUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/UserManageUseCaseTest.kt deleted file mode 100644 index 680b0e94..00000000 --- a/src/test/kotlin/com/weeth/domain/user/application/usecase/UserManageUseCaseTest.kt +++ /dev/null @@ -1,238 +0,0 @@ -package com.weeth.domain.user.application.usecase - -import com.weeth.domain.attendance.domain.service.AttendanceSaveService -import com.weeth.domain.schedule.domain.entity.Meeting -import com.weeth.domain.schedule.domain.service.MeetingGetService -import com.weeth.domain.user.application.dto.request.UserRequestDto -import com.weeth.domain.user.application.dto.response.UserResponseDto -import com.weeth.domain.user.application.exception.InvalidUserOrderException -import com.weeth.domain.user.application.mapper.UserMapper -import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.entity.UserCardinal -import com.weeth.domain.user.domain.entity.enums.Role -import com.weeth.domain.user.domain.entity.enums.Status -import com.weeth.domain.user.domain.entity.enums.UsersOrderBy -import com.weeth.domain.user.domain.service.CardinalGetService -import com.weeth.domain.user.domain.service.UserCardinalGetService -import com.weeth.domain.user.domain.service.UserCardinalSaveService -import com.weeth.domain.user.domain.service.UserDeleteService -import com.weeth.domain.user.domain.service.UserGetService -import com.weeth.domain.user.domain.service.UserUpdateService -import com.weeth.domain.user.fixture.CardinalTestFixture -import com.weeth.domain.user.fixture.UserTestFixture -import com.weeth.global.auth.jwt.domain.port.RefreshTokenStorePort -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.core.spec.style.DescribeSpec -import io.kotest.matchers.collections.shouldHaveSize -import io.kotest.matchers.shouldBe -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import org.springframework.security.crypto.password.PasswordEncoder -import java.time.LocalDateTime -import java.util.ArrayList - -class UserManageUseCaseTest : - DescribeSpec({ - - val userGetService = mockk() - val userUpdateService = mockk(relaxUnitFun = true) - val userDeleteService = mockk(relaxUnitFun = true) - val attendanceSaveService = mockk(relaxUnitFun = true) - val meetingGetService = mockk() - val refreshTokenStorePort = mockk(relaxUnitFun = true) - val cardinalGetService = mockk() - val userCardinalSaveService = mockk(relaxUnitFun = true) - val userCardinalGetService = mockk() - val userMapper = mockk() - val passwordEncoder = mockk() - - val useCase = - UserManageUseCaseImpl( - userGetService, - userUpdateService, - userDeleteService, - attendanceSaveService, - meetingGetService, - refreshTokenStorePort, - cardinalGetService, - userCardinalSaveService, - userCardinalGetService, - userMapper, - passwordEncoder, - ) - - describe("findAllByAdmin") { - context("orderBy가 null이면") { - it("예외가 발생한다") { - shouldThrow { - useCase.findAllByAdmin(null) - } - } - } - - context("orderBy에 맞게 정렬하여 조회할 때") { - it("정렬된 결과를 반환한다") { - val user1 = UserTestFixture.createActiveUser1() - val user2 = UserTestFixture.createWaitingUser2() - val cd1 = CardinalTestFixture.createCardinal(id = 1L, cardinalNumber = 6, year = 2020, semester = 2) - val cd2 = CardinalTestFixture.createCardinal(id = 2L, cardinalNumber = 7, year = 2021, semester = 1) - val uc1 = UserCardinal(user1, cd1) - val uc2 = UserCardinal(user2, cd2) - - val adminResponse1 = - UserResponseDto.AdminResponse( - 1, - "aaa", - "a@a.com", - "202034420", - "01011112222", - "산업공학과", - listOf(6), - null, - Status.ACTIVE, - null, - 0, - 0, - 0, - 0, - 0, - LocalDateTime.now().minusDays(3), - LocalDateTime.now(), - ) - val adminResponse2 = - UserResponseDto.AdminResponse( - 2, - "bbb", - "b@b.com", - "202045678", - "01033334444", - "컴퓨터공학과", - listOf(7), - null, - Status.WAITING, - null, - 0, - 0, - 0, - 0, - 0, - LocalDateTime.now().minusDays(2), - LocalDateTime.now(), - ) - - every { userCardinalGetService.getUserCardinals(user1) } returns listOf(uc1) - every { userCardinalGetService.getUserCardinals(user2) } returns listOf(uc2) - every { userCardinalGetService.findAll() } returns listOf(uc2, uc1) - every { userMapper.toAdminResponse(user1, listOf(uc1)) } returns adminResponse1 - every { userMapper.toAdminResponse(user2, listOf(uc2)) } returns adminResponse2 - - val result = useCase.findAllByAdmin(UsersOrderBy.NAME_ASCENDING) - - result shouldHaveSize 2 - result[0].name() shouldBe "aaa" - result[1].name() shouldBe "bbb" - } - } - } - - describe("accept") { - it("비활성유저 승인시 출석초기화가 정상 호출된다") { - val user1 = UserTestFixture.createWaitingUser1(1L) - val userIds = UserRequestDto.UserId(listOf(1L)) - val cardinal = CardinalTestFixture.createCardinal(id = 1L, cardinalNumber = 8, year = 2020, semester = 2) - val meetings = listOf(mockk()) - - every { userGetService.findAll(userIds.userId()) } returns listOf(user1) - every { userCardinalGetService.getCurrentCardinal(user1) } returns cardinal - every { meetingGetService.find(8) } returns meetings - - useCase.accept(userIds) - - verify { userUpdateService.accept(user1) } - verify { attendanceSaveService.init(user1, meetings) } - } - } - - describe("update") { - it("유저권한변경시 DB와 Redis 모두 갱신된다") { - val user1 = UserTestFixture.createActiveUser1(1L) - val request = UserRequestDto.UserRoleUpdate(1L, Role.ADMIN) - - every { userGetService.find(1L) } returns user1 - - useCase.update(listOf(request)) - - verify { userUpdateService.update(user1, "ADMIN") } - verify { refreshTokenStorePort.updateRole(1L, Role.ADMIN) } - } - } - - describe("leave") { - it("회원탈퇴시 토큰무효화 및 유저상태변경된다") { - val user1 = UserTestFixture.createActiveUser1(1L) - every { userGetService.find(1L) } returns user1 - - useCase.leave(1L) - - verify { refreshTokenStorePort.delete(1L) } - verify { userDeleteService.leave(user1) } - } - } - - describe("ban") { - it("회원ban시 토큰무효화 및 유저상태변경된다") { - val user1 = UserTestFixture.createActiveUser1(1L) - val ids = UserRequestDto.UserId(listOf(1L)) - every { userGetService.findAll(ids.userId()) } returns listOf(user1) - - useCase.ban(ids) - - verify { refreshTokenStorePort.delete(1L) } - verify { userDeleteService.ban(user1) } - } - } - - describe("applyOB") { - it("현재기수 OB신청시 출석초기화 및 기수업데이트된다") { - val user = - User - .builder() - .id(1L) - .name("aaa") - .status(Status.ACTIVE) - .attendances(ArrayList()) - .build() - val nextCardinal = CardinalTestFixture.createCardinal(id = 1L, cardinalNumber = 4, year = 2020, semester = 2) - val request = UserRequestDto.UserApplyOB(1L, 4) - val meeting = listOf(mockk()) - - every { userGetService.find(1L) } returns user - every { cardinalGetService.findByAdminSide(4) } returns nextCardinal - every { userCardinalGetService.notContains(user, nextCardinal) } returns true - every { userCardinalGetService.isCurrent(user, nextCardinal) } returns true - every { meetingGetService.find(4) } returns meeting - - useCase.applyOB(listOf(request)) - - verify { attendanceSaveService.init(user, meeting) } - verify { userCardinalSaveService.save(any()) } - } - } - - describe("reset") { - it("비밀번호초기화시 모든유저에 reset이 호출된다") { - val user1 = UserTestFixture.createActiveUser1(1L) - val user2 = UserTestFixture.createActiveUser2(2L) - val ids = UserRequestDto.UserId(listOf(1L, 2L)) - - every { userGetService.findAll(ids.userId()) } returns listOf(user1, user2) - - useCase.reset(ids) - - verify { userGetService.findAll(ids.userId()) } - verify { userUpdateService.reset(user1, passwordEncoder) } - verify { userUpdateService.reset(user2, passwordEncoder) } - } - } - }) diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AdminUserUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AdminUserUseCaseTest.kt new file mode 100644 index 00000000..f4c88e7a --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AdminUserUseCaseTest.kt @@ -0,0 +1,158 @@ +package com.weeth.domain.user.application.usecase.command + +import com.weeth.domain.attendance.domain.service.AttendanceSaveService +import com.weeth.domain.schedule.domain.entity.Meeting +import com.weeth.domain.schedule.domain.service.MeetingGetService +import com.weeth.domain.user.application.dto.request.UserApplyObRequest +import com.weeth.domain.user.application.dto.request.UserIdsRequest +import com.weeth.domain.user.application.dto.request.UserRoleUpdateRequest +import com.weeth.domain.user.domain.entity.UserCardinal +import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.entity.enums.Status +import com.weeth.domain.user.domain.repository.CardinalRepository +import com.weeth.domain.user.domain.repository.UserCardinalRepository +import com.weeth.domain.user.domain.repository.UserReader +import com.weeth.domain.user.domain.service.UserCardinalPolicy +import com.weeth.domain.user.fixture.CardinalTestFixture +import com.weeth.domain.user.fixture.UserTestFixture +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify + +class AdminUserUseCaseTest : + DescribeSpec({ + val userReader = mockk() + val attendanceSaveService = mockk(relaxUnitFun = true) + val meetingGetService = mockk() + val cardinalRepository = mockk() + val userCardinalRepository = mockk(relaxUnitFun = true) + val userCardinalPolicy = mockk() + + val useCase = + AdminUserUseCase( + userReader, + attendanceSaveService, + meetingGetService, + cardinalRepository, + userCardinalRepository, + userCardinalPolicy, + ) + + beforeTest { + clearMocks( + userReader, + attendanceSaveService, + meetingGetService, + cardinalRepository, + userCardinalRepository, + userCardinalPolicy, + ) + } + + describe("accept") { + it("비활성 유저 승인 시 출석 초기화를 수행한다") { + val user = UserTestFixture.createWaitingUser1(1L) + val currentCardinal = CardinalTestFixture.createCardinal(id = 1L, cardinalNumber = 8, year = 2025, semester = 1) + val meetings = listOf(mockk()) + + every { userReader.findAllByIds(listOf(1L)) } returns listOf(user) + every { userCardinalPolicy.getCurrentCardinal(user) } returns currentCardinal + every { meetingGetService.find(8) } returns meetings + + useCase.accept(UserIdsRequest(listOf(1L))) + + verify(exactly = 1) { attendanceSaveService.init(user, meetings) } + user.status shouldBe Status.ACTIVE + } + } + + describe("updateRole") { + it("권한 변경 시 엔티티 권한을 갱신한다") { + val user = UserTestFixture.createActiveUser1(1L) + every { userReader.getById(1L) } returns user + + useCase.updateRole(listOf(UserRoleUpdateRequest(1L, Role.ADMIN))) + + user.role shouldBe Role.ADMIN + } + } + + describe("ban") { + it("회원 추방 시 상태를 BANNED로 변경한다") { + val user = UserTestFixture.createActiveUser1(1L) + every { userReader.findAllByIds(listOf(1L)) } returns listOf(user) + + useCase.ban(UserIdsRequest(listOf(1L))) + + user.status shouldBe Status.BANNED + } + } + + describe("applyOb") { + it("다음 기수로 OB 신청 시 출석을 초기화하고 user-cardinal을 저장한다") { + val user = UserTestFixture.createActiveUser1(1L) + val currentCardinal = CardinalTestFixture.createCardinal(id = 10L, cardinalNumber = 3, year = 2024, semester = 2) + val nextCardinal = CardinalTestFixture.createCardinal(id = 11L, cardinalNumber = 4, year = 2025, semester = 1) + val meetings = listOf(mockk()) + val request = listOf(UserApplyObRequest(1L, 4)) + + every { userReader.findAllByIds(listOf(1L)) } returns listOf(user) + every { userCardinalRepository.findAllByUsers(listOf(user)) } returns listOf(UserCardinal(user, currentCardinal)) + every { cardinalRepository.findAllByCardinalNumberIn(listOf(4)) } returns listOf(nextCardinal) + every { meetingGetService.findByCardinals(listOf(4)) } returns mapOf(4 to meetings) + every { userCardinalRepository.save(any()) } answers { firstArg() } + + useCase.applyOb(request) + + verify(exactly = 1) { attendanceSaveService.init(user, meetings) } + verify(exactly = 1) { userCardinalRepository.save(match { it.user == user && it.cardinal == nextCardinal }) } + } + + it("이미 해당 기수를 보유한 유저는 저장을 스킵한다") { + val user = UserTestFixture.createActiveUser1(1L) + val cardinal = CardinalTestFixture.createCardinal(id = 11L, cardinalNumber = 4, year = 2025, semester = 1) + val request = listOf(UserApplyObRequest(1L, 4)) + + every { userReader.findAllByIds(listOf(1L)) } returns listOf(user) + every { userCardinalRepository.findAllByUsers(listOf(user)) } returns listOf(UserCardinal(user, cardinal)) + every { cardinalRepository.findAllByCardinalNumberIn(listOf(4)) } returns listOf(cardinal) + + useCase.applyOb(request) + + verify(exactly = 0) { meetingGetService.findByCardinals(any()) } + verify(exactly = 0) { userCardinalRepository.save(any()) } + verify(exactly = 0) { attendanceSaveService.init(any(), any()) } + } + + it("요청 목록이 비어 있으면 아무 처리도 하지 않는다") { + useCase.applyOb(emptyList()) + + verify(exactly = 0) { userReader.findAllByIds(any()) } + verify(exactly = 0) { userCardinalRepository.save(any()) } + } + + it("존재하지 않는 기수라면 새로 생성한다") { + val user = UserTestFixture.createActiveUser1(1L) + val currentCardinal = CardinalTestFixture.createCardinal(id = 10L, cardinalNumber = 3, year = 2024, semester = 2) + val createdCardinal = CardinalTestFixture.createCardinal(id = 12L, cardinalNumber = 5, year = 2025, semester = 2) + val meetings = listOf(mockk()) + val request = listOf(UserApplyObRequest(1L, 5)) + + every { userReader.findAllByIds(listOf(1L)) } returns listOf(user) + every { userCardinalRepository.findAllByUsers(listOf(user)) } returns listOf(UserCardinal(user, currentCardinal)) + every { cardinalRepository.findAllByCardinalNumberIn(listOf(5)) } returns emptyList() + every { cardinalRepository.save(any()) } returns createdCardinal + every { meetingGetService.findByCardinals(listOf(5)) } returns mapOf(5 to meetings) + every { userCardinalRepository.save(any()) } answers { firstArg() } + + useCase.applyOb(request) + + verify(exactly = 1) { cardinalRepository.save(any()) } + verify(exactly = 1) { attendanceSaveService.init(user, meetings) } + verify(exactly = 1) { userCardinalRepository.save(match { it.user == user && it.cardinal == createdCardinal }) } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCaseTest.kt new file mode 100644 index 00000000..1d7f89af --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCaseTest.kt @@ -0,0 +1,266 @@ +package com.weeth.domain.user.application.usecase.command + +import com.weeth.domain.user.application.dto.request.SignUpRequest +import com.weeth.domain.user.application.dto.request.SocialLoginRequest +import com.weeth.domain.user.application.dto.request.UpdateUserProfileRequest +import com.weeth.domain.user.application.exception.StudentIdExistsException +import com.weeth.domain.user.application.exception.UserInActiveException +import com.weeth.domain.user.application.mapper.UserMapper +import com.weeth.domain.user.domain.entity.User +import com.weeth.domain.user.domain.entity.UserCardinal +import com.weeth.domain.user.domain.entity.enums.Status +import com.weeth.domain.user.domain.repository.CardinalReader +import com.weeth.domain.user.domain.repository.UserCardinalRepository +import com.weeth.domain.user.domain.repository.UserReader +import com.weeth.domain.user.domain.repository.UserRepository +import com.weeth.domain.user.domain.repository.UserSocialAccountRepository +import com.weeth.domain.user.fixture.CardinalTestFixture +import com.weeth.domain.user.fixture.UserTestFixture +import com.weeth.global.auth.apple.AppleAuthService +import com.weeth.global.auth.apple.dto.AppleTokenResponse +import com.weeth.global.auth.apple.dto.AppleUserInfo +import com.weeth.global.auth.jwt.application.dto.JwtDto +import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor +import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase +import com.weeth.global.auth.kakao.KakaoAuthService +import com.weeth.global.auth.kakao.dto.KakaoAccount +import com.weeth.global.auth.kakao.dto.KakaoProfile +import com.weeth.global.auth.kakao.dto.KakaoTokenResponse +import com.weeth.global.auth.kakao.dto.KakaoUserInfoResponse +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import jakarta.servlet.http.HttpServletRequest +import java.util.Optional + +class AuthUserUseCaseTest : + DescribeSpec({ + val userRepository = mockk(relaxed = true) + val userReader = mockk() + val cardinalReader = mockk() + val userCardinalRepository = mockk(relaxed = true) + val userSocialAccountRepository = mockk(relaxed = true) + val mapper = mockk() + val kakaoAuthService = mockk() + val appleAuthService = mockk() + val jwtManageUseCase = mockk() + val jwtTokenExtractor = mockk() + + val useCase = + AuthUserUseCase( + userRepository, + userReader, + cardinalReader, + userCardinalRepository, + mapper, + userSocialAccountRepository, + kakaoAuthService, + appleAuthService, + jwtManageUseCase, + jwtTokenExtractor, + ) + + describe("apply") { + it("유저와 유저-기수 연관관계를 저장한다") { + val request = SignUpRequest("홍길동", "a@test.com", "20201234", "01012345678", "컴퓨터공학과", 7) + val user = UserTestFixture.createActiveUser1(1L) + val cardinal = CardinalTestFixture.createCardinal(id = 10L, cardinalNumber = 7, year = 2025, semester = 1) + + every { userRepository.existsByStudentId(request.studentId) } returns false + every { userRepository.existsByTelValue(request.tel) } returns false + every { cardinalReader.getByCardinalNumber(request.cardinal) } returns cardinal + every { mapper.toEntity(request) } returns user + every { userRepository.save(user) } returns user + every { userCardinalRepository.save(any()) } answers { firstArg() } + + useCase.apply(request) + + verify(exactly = 1) { userRepository.save(user) } + verify(exactly = 1) { userCardinalRepository.save(any()) } + } + + it("학번 중복이면 StudentIdExistsException") { + val request = SignUpRequest("홍길동", "a@test.com", "20201234", "01012345678", "컴퓨터공학과", 7) + every { userRepository.existsByStudentId(request.studentId) } returns true + + shouldThrow { + useCase.apply(request) + } + } + } + + describe("updateProfile") { + it("내 정보를 수정한다") { + val user = UserTestFixture.createActiveUser1(1L) + val request = UpdateUserProfileRequest("변경이름", "new@test.com", "20209999", "01099998888", "경영학과") + + every { userRepository.existsByStudentIdAndIdIsNot(request.studentId, 1L) } returns false + every { userRepository.existsByTelAndIdIsNotValue(request.tel, 1L) } returns false + every { userReader.getById(1L) } returns user + + useCase.updateProfile(request, 1L) + + user.name shouldBe "변경이름" + user.department shouldBe "경영학과" + } + } + + describe("leave") { + it("회원 탈퇴 시 상태를 LEFT로 변경한다") { + val user = UserTestFixture.createActiveUser1(1L) + every { userReader.getById(1L) } returns user + + useCase.leave(1L) + + user.status shouldBe Status.LEFT + } + } + + describe("socialLoginByKakao") { + it("가입된 활성 사용자면 토큰을 발급한다") { + val request = SocialLoginRequest("auth-code") + val tokenResponse = KakaoTokenResponse("bearer", "kakao-access", 3600, "kakao-refresh", 3600) + val userInfo = + KakaoUserInfoResponse( + id = 1L, + kakaoAccount = KakaoAccount(isEmailValid = true, isEmailVerified = true, email = "a@test.com"), + ) + val user = UserTestFixture.createActiveUser1(1L) + + every { kakaoAuthService.getKakaoToken("auth-code") } returns tokenResponse + every { kakaoAuthService.getUserInfo("kakao-access") } returns userInfo + every { userSocialAccountRepository.findByProviderAndProviderUserId(any(), any()) } returns Optional.empty() + every { userRepository.findByEmailValue("a@test.com") } returns Optional.of(user) + every { userSocialAccountRepository.save(any()) } answers { firstArg() } + every { jwtManageUseCase.create(user.id, user.emailValue, user.role) } returns JwtDto("access", "refresh") + + val result = useCase.socialLoginByKakao(request) + + result.isNewUser shouldBe false + result.profileCompleted shouldBe false + result.accessToken shouldBe "access" + result.refreshToken shouldBe "refresh" + } + + it("기존 사용자가 추가 프로필 payload 없이 로그인하면 provider 이름으로 덮어쓰지 않는다") { + val request = SocialLoginRequest("auth-code") + val tokenResponse = KakaoTokenResponse("bearer", "kakao-access", 3600, "kakao-refresh", 3600) + val userInfo = + KakaoUserInfoResponse( + id = 1L, + kakaoAccount = + KakaoAccount( + isEmailValid = true, + isEmailVerified = true, + email = "a@test.com", + profile = KakaoProfile(nickname = "카카오닉네임"), + ), + ) + val user = UserTestFixture.createActiveUser1(1L).also { it.name = "내가수정한이름" } + + every { kakaoAuthService.getKakaoToken("auth-code") } returns tokenResponse + every { kakaoAuthService.getUserInfo("kakao-access") } returns userInfo + every { userSocialAccountRepository.findByProviderAndProviderUserId(any(), any()) } returns Optional.empty() + every { userRepository.findByEmailValue("a@test.com") } returns Optional.of(user) + every { userSocialAccountRepository.save(any()) } answers { firstArg() } + every { jwtManageUseCase.create(user.id, user.emailValue, user.role) } returns JwtDto("access", "refresh") + + useCase.socialLoginByKakao(request) + + user.name shouldBe "내가수정한이름" + } + + it("식별자가 없고 이메일 사용자도 없으면 사용자를 생성하고 로그인한다") { + val request = SocialLoginRequest("auth-code") + val tokenResponse = KakaoTokenResponse("bearer", "kakao-access", 3600, "kakao-refresh", 3600) + val userInfo = + KakaoUserInfoResponse( + id = 1L, + kakaoAccount = KakaoAccount(isEmailValid = true, isEmailVerified = true, email = "new@test.com"), + ) + val createdUser = User.create(name = "", email = "new@test.com", studentId = "", tel = "", department = "") + + every { kakaoAuthService.getKakaoToken("auth-code") } returns tokenResponse + every { kakaoAuthService.getUserInfo("kakao-access") } returns userInfo + every { userSocialAccountRepository.findByProviderAndProviderUserId(any(), any()) } returns Optional.empty() + every { userRepository.findByEmailValue("new@test.com") } returns Optional.empty() + every { userRepository.save(any()) } returns createdUser + every { userSocialAccountRepository.save(any()) } answers { firstArg() } + every { + jwtManageUseCase.create( + createdUser.id, + createdUser.emailValue, + createdUser.role, + ) + } returns + JwtDto( + "access", + "refresh", + ) + + val result = useCase.socialLoginByKakao(request) + + result.isNewUser shouldBe true + result.email shouldBe "new@test.com" + result.accessToken shouldBe "access" + } + + it("추방된 사용자면 예외를 던진다") { + val request = SocialLoginRequest("auth-code") + val tokenResponse = KakaoTokenResponse("bearer", "kakao-access", 3600, "kakao-refresh", 3600) + val userInfo = + KakaoUserInfoResponse( + id = 1L, + kakaoAccount = KakaoAccount(isEmailValid = true, isEmailVerified = true, email = "ban@test.com"), + ) + val bannedUser = UserTestFixture.createActiveUser1(1L).also { it.ban() } + + every { kakaoAuthService.getKakaoToken("auth-code") } returns tokenResponse + every { kakaoAuthService.getUserInfo("kakao-access") } returns userInfo + every { userSocialAccountRepository.findByProviderAndProviderUserId(any(), any()) } returns Optional.empty() + every { userRepository.findByEmailValue("ban@test.com") } returns Optional.of(bannedUser) + every { userSocialAccountRepository.save(any()) } answers { firstArg() } + + shouldThrow { + useCase.socialLoginByKakao(request) + } + } + } + + describe("socialLoginByApple") { + it("가입된 활성 사용자면 토큰을 발급한다") { + val request = SocialLoginRequest("apple-code") + val tokenResponse = AppleTokenResponse("apple-access", "bearer", 3600, "apple-refresh", "id-token") + val userInfo = AppleUserInfo(appleId = "apple-sub", email = "apple@test.com", emailVerified = true) + val user = UserTestFixture.createActiveUser1(1L) + + every { appleAuthService.getAppleToken("apple-code") } returns tokenResponse + every { appleAuthService.verifyAndDecodeIdToken("id-token") } returns userInfo + every { userSocialAccountRepository.findByProviderAndProviderUserId(any(), any()) } returns Optional.empty() + every { userRepository.findByEmailValue("apple@test.com") } returns Optional.of(user) + every { userSocialAccountRepository.save(any()) } answers { firstArg() } + every { jwtManageUseCase.create(user.id, user.emailValue, user.role) } returns JwtDto("access", "refresh") + + val result = useCase.socialLoginByApple(request) + + result.isNewUser shouldBe false + result.accessToken shouldBe "access" + } + } + + describe("refreshToken") { + it("헤더의 리프레시 토큰으로 토큰을 재발급한다") { + val servletRequest = mockk() + every { jwtTokenExtractor.extractRefreshToken(servletRequest) } returns "refresh-token" + every { jwtManageUseCase.reIssueToken("refresh-token") } returns JwtDto("new-access", "new-refresh") + + val result = useCase.refreshToken(servletRequest) + + result.accessToken shouldBe "new-access" + result.refreshToken shouldBe "new-refresh" + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/CardinalUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/CardinalUseCaseTest.kt similarity index 56% rename from src/test/kotlin/com/weeth/domain/user/application/usecase/CardinalUseCaseTest.kt rename to src/test/kotlin/com/weeth/domain/user/application/usecase/command/CardinalUseCaseTest.kt index b22de9b1..885f4261 100644 --- a/src/test/kotlin/com/weeth/domain/user/application/usecase/CardinalUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/CardinalUseCaseTest.kt @@ -1,31 +1,29 @@ -package com.weeth.domain.user.application.usecase +package com.weeth.domain.user.application.usecase.command import com.weeth.domain.user.application.dto.request.CardinalSaveRequest import com.weeth.domain.user.application.dto.request.CardinalUpdateRequest import com.weeth.domain.user.application.dto.response.CardinalResponse import com.weeth.domain.user.application.mapper.CardinalMapper +import com.weeth.domain.user.application.usecase.query.GetCardinalQueryService import com.weeth.domain.user.domain.entity.Cardinal import com.weeth.domain.user.domain.entity.enums.CardinalStatus -import com.weeth.domain.user.domain.service.CardinalGetService -import com.weeth.domain.user.domain.service.CardinalSaveService +import com.weeth.domain.user.domain.repository.CardinalRepository import com.weeth.domain.user.fixture.CardinalTestFixture import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe -import io.mockk.Runs import io.mockk.every -import io.mockk.just import io.mockk.mockk import io.mockk.verify import java.time.LocalDateTime +import java.util.Optional class CardinalUseCaseTest : DescribeSpec({ - - val cardinalGetService = mockk() - val cardinalSaveService = mockk() + val cardinalRepository = mockk() val cardinalMapper = mockk() - val useCase = CardinalUseCase(cardinalGetService, cardinalSaveService, cardinalMapper) + val manageCardinalUseCase = ManageCardinalUseCase(cardinalRepository, cardinalMapper) + val getCardinalQueryService = GetCardinalQueryService(cardinalRepository, cardinalMapper) describe("save") { context("진행중이 아닌 기수라면") { @@ -34,15 +32,15 @@ class CardinalUseCaseTest : val toSave = CardinalTestFixture.createCardinal(cardinalNumber = 7, year = 2025, semester = 1) val saved = CardinalTestFixture.createCardinal(cardinalNumber = 7, year = 2025, semester = 1) - every { cardinalGetService.validateCardinal(7) } just Runs - every { cardinalMapper.from(request) } returns toSave - every { cardinalSaveService.save(toSave) } returns saved + every { cardinalRepository.findByCardinalNumber(7) } returns Optional.empty() + every { cardinalMapper.toEntity(request) } returns toSave + every { cardinalRepository.save(toSave) } returns saved - useCase.save(request) + manageCardinalUseCase.save(request) - verify { cardinalGetService.validateCardinal(7) } - verify { cardinalSaveService.save(toSave) } - verify(exactly = 0) { cardinalGetService.findInProgress() } + verify { cardinalRepository.findByCardinalNumber(7) } + verify { cardinalRepository.save(toSave) } + verify(exactly = 0) { cardinalRepository.findAllByStatus(CardinalStatus.IN_PROGRESS) } } } @@ -56,15 +54,15 @@ class CardinalUseCaseTest : val newCardinalAfterSave = CardinalTestFixture.createCardinal(cardinalNumber = 7, year = 2025, semester = 1) - every { cardinalGetService.validateCardinal(7) } just Runs - every { cardinalGetService.findInProgress() } returns listOf(oldCardinal) - every { cardinalMapper.from(request) } returns newCardinalBeforeSave - every { cardinalSaveService.save(newCardinalBeforeSave) } returns newCardinalAfterSave + every { cardinalRepository.findByCardinalNumber(7) } returns Optional.empty() + every { cardinalRepository.findAllByStatus(CardinalStatus.IN_PROGRESS) } returns listOf(oldCardinal) + every { cardinalMapper.toEntity(request) } returns newCardinalBeforeSave + every { cardinalRepository.save(newCardinalBeforeSave) } returns newCardinalAfterSave - useCase.save(request) + manageCardinalUseCase.save(request) - verify { cardinalGetService.findInProgress() } - verify { cardinalSaveService.save(newCardinalBeforeSave) } + verify { cardinalRepository.findAllByStatus(CardinalStatus.IN_PROGRESS) } + verify { cardinalRepository.save(newCardinalBeforeSave) } oldCardinal.status shouldBe CardinalStatus.DONE newCardinalAfterSave.status shouldBe CardinalStatus.IN_PROGRESS @@ -75,9 +73,9 @@ class CardinalUseCaseTest : describe("update") { it("연도와 학기를 변경한다") { val cardinal = CardinalTestFixture.createCardinal(cardinalNumber = 6, year = 2024, semester = 2) - val dto = CardinalUpdateRequest(1L, 2025, 1, false) + every { cardinalRepository.findById(1L) } returns Optional.of(cardinal) - cardinal.update(dto) + manageCardinalUseCase.update(CardinalUpdateRequest(1L, 2025, 1, false)) cardinal.year shouldBe 2025 cardinal.semester shouldBe 1 @@ -97,18 +95,18 @@ class CardinalUseCaseTest : val response2 = CardinalResponse(2L, 7, 2025, 1, CardinalStatus.IN_PROGRESS, now.minusDays(2), now) - every { cardinalGetService.findAll() } returns cardinals - every { cardinalMapper.to(cardinal1) } returns response1 - every { cardinalMapper.to(cardinal2) } returns response2 + every { cardinalRepository.findAllByOrderByCardinalNumberAsc() } returns cardinals + every { cardinalMapper.toResponse(cardinal1) } returns response1 + every { cardinalMapper.toResponse(cardinal2) } returns response2 - val responses = useCase.findAll() + val responses = getCardinalQueryService.findAll() - verify { cardinalGetService.findAll() } - verify(exactly = 2) { cardinalMapper.to(any()) } + verify { cardinalRepository.findAllByOrderByCardinalNumberAsc() } + verify(exactly = 2) { cardinalMapper.toResponse(any()) } responses shouldHaveSize 2 - responses.map { it.cardinalNumber() } shouldBe listOf(6, 7) - responses.map { it.status() } shouldBe listOf(CardinalStatus.DONE, CardinalStatus.IN_PROGRESS) + responses.map { it.cardinalNumber } shouldBe listOf(6, 7) + responses.map { it.status } shouldBe listOf(CardinalStatus.DONE, CardinalStatus.IN_PROGRESS) } } }) diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryServiceTest.kt new file mode 100644 index 00000000..b73993c5 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryServiceTest.kt @@ -0,0 +1,94 @@ +package com.weeth.domain.user.application.usecase.query + +import com.weeth.domain.user.application.dto.response.UserDetailsResponse +import com.weeth.domain.user.application.dto.response.UserProfileResponse +import com.weeth.domain.user.application.mapper.UserMapper +import com.weeth.domain.user.domain.entity.UserCardinal +import com.weeth.domain.user.domain.repository.CardinalReader +import com.weeth.domain.user.domain.repository.UserCardinalReader +import com.weeth.domain.user.domain.repository.UserCardinalRepository +import com.weeth.domain.user.domain.repository.UserReader +import com.weeth.domain.user.domain.repository.UserRepository +import com.weeth.domain.user.fixture.CardinalTestFixture +import com.weeth.domain.user.fixture.UserTestFixture +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk + +class GetUserQueryServiceTest : + DescribeSpec({ + val userRepository = mockk() + val userReader = mockk() + val cardinalReader = mockk() + val userCardinalRepository = mockk() + val userCardinalReader = mockk() + val mapper = mockk() + + val queryService = + GetUserQueryService( + userRepository, + userReader, + cardinalReader, + userCardinalRepository, + userCardinalReader, + mapper, + ) + + describe("existsByEmail") { + it("repository exists 결과를 반환한다") { + every { userRepository.existsByEmailValue("foo@bar.com") } returns true + + queryService.existsByEmail("foo@bar.com") shouldBe true + } + } + + describe("findUserDetails") { + it("user와 cardinal 목록을 조회해 UserDetailsResponse로 매핑한다") { + val user = UserTestFixture.createActiveUser1(1L) + val cardinal = CardinalTestFixture.createCardinal(id = 10L, cardinalNumber = 6, year = 2024, semester = 2) + val userCardinals = listOf(UserCardinal(user, cardinal)) + val response = + UserDetailsResponse( + 1, + user.name, + user.emailValue, + user.studentId, + user.department, + listOf(6), + user.role, + ) + + every { userReader.getById(1L) } returns user + every { userCardinalReader.findAllByUser(user) } returns userCardinals + every { mapper.toUserDetailsResponse(user, userCardinals) } returns response + + queryService.findUserDetails(1L) shouldBe response + } + } + + describe("findMyProfile") { + it("내 프로필을 UserProfileResponse로 매핑한다") { + val user = UserTestFixture.createActiveUser1(2L) + val cardinal = CardinalTestFixture.createCardinal(id = 11L, cardinalNumber = 7, year = 2025, semester = 1) + val userCardinals = listOf(UserCardinal(user, cardinal)) + val response = + UserProfileResponse( + 2, + user.name, + user.emailValue, + user.studentId, + user.telValue, + user.department, + listOf(7), + user.role, + ) + + every { userReader.getById(2L) } returns user + every { userCardinalReader.findAllByUser(user) } returns userCardinals + every { mapper.toUserProfileResponse(user, userCardinals) } returns response + + queryService.findMyProfile(2L) shouldBe response + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/user/domain/entity/CardinalTest.kt b/src/test/kotlin/com/weeth/domain/user/domain/entity/CardinalTest.kt new file mode 100644 index 00000000..14edcf3d --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/user/domain/entity/CardinalTest.kt @@ -0,0 +1,26 @@ +package com.weeth.domain.user.domain.entity + +import com.weeth.domain.user.domain.entity.enums.CardinalStatus +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe + +class CardinalTest : + StringSpec({ + "inProgress/done 상태 전환" { + val cardinal = Cardinal(cardinalNumber = 10, year = 2026, semester = 1) + + cardinal.inProgress() + cardinal.status shouldBe CardinalStatus.IN_PROGRESS + + cardinal.done() + cardinal.status shouldBe CardinalStatus.DONE + } + + "update는 year/semester를 변경한다" { + val cardinal = Cardinal(cardinalNumber = 9, year = 2025, semester = 2) + cardinal.update(2026, 1) + + cardinal.year shouldBe 2026 + cardinal.semester shouldBe 1 + } + }) diff --git a/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt b/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt new file mode 100644 index 00000000..6d892f14 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt @@ -0,0 +1,40 @@ +package com.weeth.domain.user.domain.entity + +import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.entity.enums.Status +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe + +class UserTest : + StringSpec({ + "accept/ban/leave 상태 전환" { + val user = User(name = "test", email = "test@test.com", studentId = "20200001") + + user.accept() + user.status shouldBe Status.ACTIVE + + user.ban() + user.status shouldBe Status.BANNED + + user.leave() + user.status shouldBe Status.LEFT + } + + "attendance 카운터 및 출석률 계산" { + val user = User(name = "test", email = "test@test.com", studentId = "20200001") + user.attend() + user.attend() + user.absent() + + user.attendanceCount shouldBe 2 + user.absenceCount shouldBe 1 + user.attendanceRate shouldBe (2 * 100 / 3) + } + + "updateRole / hasRole" { + val user = User(name = "test", email = "test@test.com", studentId = "20200001") + user.updateRole(Role.ADMIN) + + user.hasRole(Role.ADMIN) shouldBe true + } + }) diff --git a/src/test/kotlin/com/weeth/domain/user/domain/service/CardinalGetServiceTest.kt b/src/test/kotlin/com/weeth/domain/user/domain/service/CardinalGetServiceTest.kt deleted file mode 100644 index b900c9a2..00000000 --- a/src/test/kotlin/com/weeth/domain/user/domain/service/CardinalGetServiceTest.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.weeth.domain.user.domain.service - -import com.weeth.domain.user.application.exception.DuplicateCardinalException -import com.weeth.domain.user.domain.entity.Cardinal -import com.weeth.domain.user.domain.repository.CardinalRepository -import io.kotest.assertions.throwables.shouldNotThrowAny -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.core.spec.style.DescribeSpec -import io.kotest.matchers.shouldBe -import io.mockk.every -import io.mockk.mockk -import java.util.Optional - -class CardinalGetServiceTest : - DescribeSpec({ - - val cardinalRepository = mockk() - val cardinalGetService = CardinalGetService(cardinalRepository) - - describe("findByAdminSide") { - context("존재하지 않는 기수를 넣었을 때") { - it("새로 저장된다") { - every { cardinalRepository.findByCardinalNumber(7) } returns Optional.empty() - every { cardinalRepository.save(any()) } returns - Cardinal.builder().cardinalNumber(7).build() - - val result = cardinalGetService.findByAdminSide(7) - - result.cardinalNumber shouldBe 7 - } - } - } - - describe("validateCardinal") { - context("중복된 기수일 때") { - it("예외를 던진다") { - every { cardinalRepository.findByCardinalNumber(7) } returns - Optional.of(Cardinal.builder().cardinalNumber(7).build()) - - shouldThrow { - cardinalGetService.validateCardinal(7) - } - } - } - - context("중복되지 않는 기수일 때") { - it("예외를 던지지 않는다") { - every { cardinalRepository.findByCardinalNumber(7) } returns Optional.empty() - - shouldNotThrowAny { - cardinalGetService.validateCardinal(7) - } - } - } - } - }) diff --git a/src/test/kotlin/com/weeth/domain/user/domain/service/UserCardinalGetServiceTest.kt b/src/test/kotlin/com/weeth/domain/user/domain/service/UserCardinalGetServiceTest.kt deleted file mode 100644 index b784670d..00000000 --- a/src/test/kotlin/com/weeth/domain/user/domain/service/UserCardinalGetServiceTest.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.weeth.domain.user.domain.service - -import com.weeth.domain.user.application.exception.CardinalNotFoundException -import com.weeth.domain.user.domain.entity.UserCardinal -import com.weeth.domain.user.domain.repository.UserCardinalRepository -import com.weeth.domain.user.fixture.CardinalTestFixture -import com.weeth.domain.user.fixture.UserTestFixture -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.core.spec.style.DescribeSpec -import io.kotest.matchers.booleans.shouldBeFalse -import io.kotest.matchers.booleans.shouldBeTrue -import io.mockk.every -import io.mockk.mockk - -class UserCardinalGetServiceTest : - DescribeSpec({ - - val userCardinalRepository = mockk() - val userCardinalGetService = UserCardinalGetService(userCardinalRepository) - - describe("notContains") { - it("유저의 기수 목록 중 특정 기수가 없으면 true를 반환한다") { - val user = UserTestFixture.createActiveUser1() - val existingCardinal = CardinalTestFixture.createCardinal(cardinalNumber = 7, year = 2025, semester = 2) - val targetCardinal = CardinalTestFixture.createCardinal(cardinalNumber = 8, year = 2026, semester = 1) - val userCardinal = UserCardinal(user, existingCardinal) - - every { - userCardinalRepository.findAllByUserOrderByCardinalCardinalNumberDesc(user) - } returns listOf(userCardinal) - - val result = userCardinalGetService.notContains(user, targetCardinal) - - result.shouldBeTrue() - } - } - - describe("isCurrent") { - context("현재 유저의 최신 기수보다 최신 기수일 때") { - it("true를 반환한다") { - val user = UserTestFixture.createActiveUser1() - val oldCardinal = CardinalTestFixture.createCardinal(cardinalNumber = 7, year = 2025, semester = 2) - val newCardinal = CardinalTestFixture.createCardinal(cardinalNumber = 8, year = 2026, semester = 1) - val userCardinal = UserCardinal(user, oldCardinal) - - every { - userCardinalRepository.findAllByUserOrderByCardinalCardinalNumberDesc(user) - } returns listOf(userCardinal) - - val result = userCardinalGetService.isCurrent(user, newCardinal) - - result.shouldBeTrue() - } - } - - context("새 기수가 기존 최대보다 작을 때") { - it("false를 반환한다") { - val user = UserTestFixture.createActiveUser1() - val oldCardinal = CardinalTestFixture.createCardinal(cardinalNumber = 7, year = 2025, semester = 1) - val newCardinal = CardinalTestFixture.createCardinal(cardinalNumber = 6, year = 2024, semester = 2) - val userCardinal = UserCardinal(user, oldCardinal) - - every { - userCardinalRepository.findAllByUserOrderByCardinalCardinalNumberDesc(user) - } returns listOf(userCardinal) - - val result = userCardinalGetService.isCurrent(user, newCardinal) - - result.shouldBeFalse() - } - } - - context("유저가 어떤 기수도 가지고 있지 않을 때") { - it("CardinalNotFoundException이 발생한다") { - val user = UserTestFixture.createActiveUser1() - val newCardinal = CardinalTestFixture.createCardinal(cardinalNumber = 8, year = 2026, semester = 1) - - every { - userCardinalRepository.findAllByUserOrderByCardinalCardinalNumberDesc(user) - } returns listOf() - - shouldThrow { - userCardinalGetService.isCurrent(user, newCardinal) - } - } - } - } - }) diff --git a/src/test/kotlin/com/weeth/domain/user/domain/service/UserCardinalPolicyTest.kt b/src/test/kotlin/com/weeth/domain/user/domain/service/UserCardinalPolicyTest.kt new file mode 100644 index 00000000..3b606765 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/user/domain/service/UserCardinalPolicyTest.kt @@ -0,0 +1,63 @@ +package com.weeth.domain.user.domain.service + +import com.weeth.domain.user.application.exception.CardinalNotFoundException +import com.weeth.domain.user.domain.repository.UserCardinalReader +import com.weeth.domain.user.fixture.CardinalTestFixture +import com.weeth.domain.user.fixture.UserCardinalTestFixture +import com.weeth.domain.user.fixture.UserTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.booleans.shouldBeFalse +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk + +class UserCardinalPolicyTest : + DescribeSpec({ + val userCardinalReader = mockk() + val policy = UserCardinalPolicy(userCardinalReader) + + describe("getCurrentCardinal") { + it("가장 큰 기수 번호를 반환한다") { + val user = UserTestFixture.createActiveUser1(1L) + val cardinal5 = CardinalTestFixture.createCardinal(id = 2L, cardinalNumber = 5, year = 2025, semester = 1) + + every { userCardinalReader.findTopByUserOrderByCardinalNumberDesc(user) } returns + UserCardinalTestFixture.linkUserCardinal(user, cardinal5) + + policy.getCurrentCardinal(user).cardinalNumber shouldBe 5 + } + + it("기수 이력이 없으면 예외를 던진다") { + val user = UserTestFixture.createActiveUser1(1L) + every { userCardinalReader.findTopByUserOrderByCardinalNumberDesc(user) } returns null + + shouldThrow { + policy.getCurrentCardinal(user) + } + } + } + + describe("notContains") { + it("이미 포함된 기수면 false를 반환한다") { + val user = UserTestFixture.createActiveUser1(1L) + val cardinal = CardinalTestFixture.createCardinal(id = 2L, cardinalNumber = 5, year = 2025, semester = 1) + every { userCardinalReader.findAllByUser(user) } returns listOf(UserCardinalTestFixture.linkUserCardinal(user, cardinal)) + + policy.notContains(user, cardinal).shouldBeFalse() + } + } + + describe("isCurrent") { + it("신규 기수가 현재 기수보다 크면 true를 반환한다") { + val user = UserTestFixture.createActiveUser1(1L) + val current = CardinalTestFixture.createCardinal(id = 1L, cardinalNumber = 4, year = 2024, semester = 2) + val next = CardinalTestFixture.createCardinal(id = 2L, cardinalNumber = 5, year = 2025, semester = 1) + every { userCardinalReader.findTopByUserOrderByCardinalNumberDesc(user) } returns + UserCardinalTestFixture.linkUserCardinal(user, current) + + policy.isCurrent(user, next).shouldBeTrue() + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/user/domain/service/UserGetServiceTest.kt b/src/test/kotlin/com/weeth/domain/user/domain/service/UserGetServiceTest.kt deleted file mode 100644 index 7b8cc38d..00000000 --- a/src/test/kotlin/com/weeth/domain/user/domain/service/UserGetServiceTest.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.weeth.domain.user.domain.service - -import com.weeth.domain.user.application.exception.UserNotFoundException -import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.repository.UserRepository -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.core.spec.style.DescribeSpec -import io.mockk.every -import io.mockk.mockk -import org.springframework.data.domain.PageRequest -import org.springframework.data.domain.SliceImpl -import java.util.Optional - -class UserGetServiceTest : - DescribeSpec({ - - val userRepository = mockk() - val userGetService = UserGetService(userRepository) - - describe("find(Long)") { - context("존재하지 않는 유저일 때") { - it("예외를 던진다") { - val userId = 1L - every { userRepository.findById(userId) } returns Optional.empty() - - shouldThrow { - userGetService.find(userId) - } - } - } - } - - describe("find(String)") { - context("존재하지 않는 유저일 때") { - it("예외를 던진다") { - val email = "test@test.com" - every { userRepository.findByEmail(email) } returns Optional.empty() - - shouldThrow { - userGetService.find(email) - } - } - } - } - - describe("findAll(Pageable)") { - context("빈 슬라이스 반환 시") { - it("유저 예외를 던진다") { - val pageable = PageRequest.of(0, 10) - val emptySlice = SliceImpl(listOf(), pageable, false) - - every { - userRepository.findAllByStatusOrderedByCardinalAndName(any(), eq(pageable)) - } returns emptySlice - - shouldThrow { - userGetService.findAll(pageable) - } - } - } - } - }) diff --git a/src/test/kotlin/com/weeth/domain/user/fixture/CardinalTestFixture.kt b/src/test/kotlin/com/weeth/domain/user/fixture/CardinalTestFixture.kt index 8097dfa8..fd715201 100644 --- a/src/test/kotlin/com/weeth/domain/user/fixture/CardinalTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/user/fixture/CardinalTestFixture.kt @@ -10,14 +10,13 @@ object CardinalTestFixture { year: Int, semester: Int, ): Cardinal = - Cardinal - .builder() - .id(id) - .cardinalNumber(cardinalNumber) - .year(year) - .semester(semester) - .status(CardinalStatus.DONE) - .build() + Cardinal( + id = id ?: 0L, + cardinalNumber = cardinalNumber, + year = year, + semester = semester, + status = CardinalStatus.DONE, + ) fun createCardinalInProgress( id: Long? = null, @@ -25,12 +24,11 @@ object CardinalTestFixture { year: Int, semester: Int, ): Cardinal = - Cardinal - .builder() - .id(id) - .cardinalNumber(cardinalNumber) - .year(year) - .semester(semester) - .status(CardinalStatus.IN_PROGRESS) - .build() + Cardinal( + id = id ?: 0L, + cardinalNumber = cardinalNumber, + year = year, + semester = semester, + status = CardinalStatus.IN_PROGRESS, + ) } diff --git a/src/test/kotlin/com/weeth/domain/user/fixture/UserTestFixture.kt b/src/test/kotlin/com/weeth/domain/user/fixture/UserTestFixture.kt index a28777c4..f77be4a2 100644 --- a/src/test/kotlin/com/weeth/domain/user/fixture/UserTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/user/fixture/UserTestFixture.kt @@ -6,48 +6,43 @@ import com.weeth.domain.user.domain.entity.enums.Status object UserTestFixture { fun createActiveUser1(id: Long? = null): User = - User - .builder() - .id(id) - .name("적순") - .email("test1@test.com") - .status(Status.ACTIVE) - .build() + User( + id = id ?: 0L, + name = "적순", + email = "test1@test.com", + status = Status.ACTIVE, + ) fun createActiveUser2(id: Long? = null): User = - User - .builder() - .id(id) - .name("적순2") - .email("test2@test.com") - .status(Status.ACTIVE) - .build() + User( + id = id ?: 0L, + name = "적순2", + email = "test2@test.com", + status = Status.ACTIVE, + ) fun createWaitingUser1(id: Long? = null): User = - User - .builder() - .id(id) - .name("순적") - .email("test2@test.com") - .status(Status.WAITING) - .build() + User( + id = id ?: 0L, + name = "순적", + email = "test2@test.com", + status = Status.WAITING, + ) fun createWaitingUser2(id: Long? = null): User = - User - .builder() - .id(id) - .name("순적2") - .email("test3@test.com") - .status(Status.WAITING) - .build() + User( + id = id ?: 0L, + name = "순적2", + email = "test3@test.com", + status = Status.WAITING, + ) fun createAdmin(id: Long? = null): User = - User - .builder() - .id(id) - .name("적순") - .email("admin@test.com") - .status(Status.ACTIVE) - .role(Role.ADMIN) - .build() + User( + id = id ?: 0L, + name = "적순", + email = "admin@test.com", + status = Status.ACTIVE, + role = Role.ADMIN, + ) } From 16183ca38756ce882fd9e9e934f4c79b7ecaefa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=84=EC=88=98=ED=98=84?= <128474444+soo0711@users.noreply.github.com> Date: Fri, 27 Feb 2026 17:22:13 +0900 Subject: [PATCH 13/73] =?UTF-8?q?[WTH-148]=20schedule=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=BD=94=ED=8B=80=EB=A6=B0=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20(#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: Schedule 도메인 안전망 테스트 추가 * refactor: Schedule entity event java -> kotlin 패키지 변경 * refactor: Schedule entity event 독립 entity로 변경 및 kotlin 문법으로 수정 * refactor: Schedule entity event과 관련된 참조 파일 수정 * refactor: Meeting → Session, Attendance kotlin 엔티티 변환 및 attendance 도메인으로 이동 * refactor: Attendance 도메인 참조 파일 Session/AttendanceStatus로 업데이트 * refactor: Schedule 도메인 참조 파일 Session으로 업데이트 * refactor: UserManageUseCase Session 참조로 업데이트 * test: 문법 변환에 맞춰 Schedule 테스트 및 픽스처 수정 * refactor: 미사용 Status enum 제거 * refactor: Schedule enum java -> kotlin 패키지 변경 * refactor: Schedule enum kotlin 문법으로 변환 * refactor: Schedule dto 분리 및 kotlin 문법으로 변환 * refactor: requiredItem 속성 삭제 및 dto 참조 파일 수정 * refactor: Schedule mapper java -> kotlin 패키지 변경 * refactor: Schedule mapper kotlin 문법으로 변환 * refactor: Schedule validator java -> kotlin 패키지 변경, java dto 삭제 * refactor: Schedule validator kotlin 문법으로 변환 * refactor: Schedule UseCase/Service Kotlin DTO 참조로 전환 * refactor: Schedule Controller Kotlin DTO 참조로 전환 * refactor: Schedule mapper/validator Kotlin DTO 참조로 전환 및 테스트 수정 * refactor: Schedule event repository kotlin 문법으로 변환 * refactor: SessionReader 인터페이스 추가 * refactor: User Attendance 연관관계 단순화 및 N+1 해결 * refactor: AttendanceRepository 배치 조회 메서드 추가 * refactor: AttendanceRepository deleteAllBySession 벌크 삭제 추가 * refactor: session, evenet, attendence usecase 생성 * refactor: schedule usecase get query 생성 * refactor: usecase command/query 통합, 분리에 맞춰 참조 파일 수정, 삭제 * refactor: usecase command/query 통합, 분리에 맞춰 test 수정, 삭제 * refactor: SessionRepository/AttendanceRepository 비관적 락 쿼리 추가 * refactor: ManageSessionUseCase/ManageAttendanceUseCase 비관적 락 적용 * refactor: schedule/attendance 컨트롤러 Kotlin 전환 및 Event/Session API 분리 * refactor: ScheduleTimeCheck Kotlin 전환 및 요청 DTO type 필드 제거 * fix: ScheduleTimeCheckValidator null 입력 시 NPE 방지 * refactor: Event 조회를 Command UseCase에서 QueryService로 분리 * style: GetScheduleQueryService Kotlin 관용 표현으로 리팩토링 * style: findById().orElseThrow() Kotlin 스타일로 통일 및 불필요한 줄 제거 * refactor: Session 조회를 Command UseCase에서 GetSessionQueryService로 분리 * style: ktlintFormat 적용 * refactor: Meeting → Session 네이밍 통일 * style: GetAttendanceQueryService mapper 필드명 attendanceMapper로 통일 * docs: QR 코드 출석 기능 예정 메서드에 TODO 주석 추가 * refactor: schedule DTO에 @Schema 어노테이션 추가 * refactor: Meeting → Session 네이밍 통일 * fix: 머지 충돌로 인한 UserManageUseCaseImpl 컴파일 오류 수정 * fix: 출석 상태 관리자 재정 시 상태 검증 및 카운터 보정 로직 수정 * test: UserManageUseCaseTest 의존성을 실제 구현체에 맞게 수정 * fix: SessionNotFoundException을 attendance 도메인으로 분리 * refactor: findSessionInfos 중복 정렬 제거 * refactor: Meeting → Session 네이밍 통일 * fix: Session.updateInfo에 시간 유효성 검증 추가 * fix: Event update에 시간 유효성 검증 추가 * refactor: ScheduleController에서 불필요한 @ApiErrorCodeExample 제거 * refactor: AttendanceRepository mock을 relaxed로 변경하여 saveAll 호출 오류 수정 * refactor: Meeting -> Session 네이밍 변경 * refactor: ResponseCode 코드 번호 정리 * refactor: SessionErrorCode, SessionNotFoundException를 attendance 패키지로 이동 * refactor: content 필드 text -> length 500으로 변경 * refactor: attendance에서 session 패키지로 분리 * refactor: SwaggerConfig 도메인 코드 범위 테이블에 Session 추가 * fix: 세션 삭제 시 출석 상태 조회에 비관적 락 추가 --- .../annotation/ScheduleTimeCheck.java | 24 --- .../schedule/application/dto/EventDTO.java | 24 --- .../schedule/application/dto/MeetingDTO.java | 41 ----- .../schedule/application/dto/ScheduleDTO.java | 45 ------ .../application/exception/EventErrorCode.java | 19 --- .../exception/EventNotFoundException.java | 9 -- .../exception/MeetingErrorCode.java | 19 --- .../exception/MeetingNotFoundException.java | 7 - .../application/mapper/EventMapper.java | 22 --- .../application/mapper/MeetingMapper.java | 49 ------ .../application/mapper/ScheduleMapper.java | 13 -- .../application/usecase/EventUseCase.java | 16 -- .../application/usecase/EventUseCaseImpl.java | 59 ------- .../application/usecase/MeetingUseCase.java | 22 --- .../usecase/MeetingUseCaseImpl.java | 147 ------------------ .../application/usecase/ScheduleUseCase.java | 15 -- .../usecase/ScheduleUseCaseImpl.java | 66 -------- .../validator/ScheduleTimeCheckValidator.java | 19 --- .../domain/schedule/domain/entity/Event.java | 20 --- .../schedule/domain/entity/Meeting.java | 40 ----- .../schedule/domain/entity/Schedule.java | 55 ------- .../domain/entity/enums/MeetingStatus.java | 6 - .../schedule/domain/entity/enums/Type.java | 5 - .../domain/repository/EventRepository.java | 14 -- .../domain/repository/MeetingRepository.java | 25 --- .../domain/service/EventDeleteService.java | 17 -- .../domain/service/EventGetService.java | 37 ----- .../domain/service/EventSaveService.java | 17 -- .../domain/service/EventUpdateService.java | 18 --- .../domain/service/MeetingDeleteService.java | 17 -- .../domain/service/MeetingGetService.java | 65 -------- .../domain/service/MeetingSaveService.java | 17 -- .../domain/service/MeetingUpdateService.java | 14 -- .../presentation/EventAdminController.java | 63 -------- .../presentation/EventController.java | 34 ---- .../presentation/MeetingAdminController.java | 32 ---- .../presentation/MeetingController.java | 35 ----- .../presentation/ScheduleController.java | 47 ------ .../presentation/ScheduleResponseCode.java | 36 ----- .../dto/response/AttendanceInfoResponse.kt | 4 +- .../dto/response/AttendanceResponse.kt | 4 +- .../dto/response/AttendanceSummaryResponse.kt | 4 +- .../application/mapper/AttendanceMapper.kt | 18 +-- .../command/CheckInAttendanceUseCase.kt | 38 ----- .../usecase/command/CloseAttendanceUseCase.kt | 53 ------- .../command/ManageAttendanceUseCase.kt | 102 ++++++++++++ .../command/UpdateAttendanceStatusUseCase.kt | 34 ---- .../query/GetAttendanceQueryService.kt | 20 +-- .../attendance/domain/entity/Attendance.kt | 83 +++++----- .../domain/entity/enums/AttendanceStatus.kt | 7 + .../domain/attendance/domain/enums/Status.kt | 7 - .../domain/repository/AttendanceRepository.kt | 57 +++++-- .../domain/service/AttendanceDeleteService.kt | 14 -- .../domain/service/AttendanceGetService.kt | 14 -- .../domain/service/AttendanceSaveService.kt | 29 ---- .../domain/service/AttendanceUpdateService.kt | 33 ---- .../infrastructure/AttendanceScheduler.kt | 6 +- .../presentation/AttendanceAdminController.kt | 32 ++-- .../presentation/AttendanceController.kt | 12 +- .../presentation/AttendanceResponseCode.kt | 7 +- .../annotation/ScheduleTimeCheck.kt | 15 ++ .../dto/request/ScheduleSaveRequest.kt | 32 ++++ .../dto/request/ScheduleTimeRequest.kt | 17 ++ .../dto/request/ScheduleUpdateRequest.kt | 29 ++++ .../application/dto/response/EventResponse.kt | 30 ++++ .../dto/response/ScheduleResponse.kt | 17 ++ .../dto/response/SessionInfoResponse.kt | 15 ++ .../dto/response/SessionInfosResponse.kt | 10 ++ .../dto/response/SessionResponse.kt | 34 ++++ .../application/exception/EventErrorCode.kt | 21 +++ .../exception/EventNotFoundException.kt | 5 + .../application/mapper/EventMapper.kt | 40 +++++ .../application/mapper/ScheduleMapper.kt | 33 ++++ .../application/mapper/SessionMapper.kt | 76 +++++++++ .../usecase/command/ManageEventUseCase.kt | 47 ++++++ .../usecase/query/GetScheduleQueryService.kt | 66 ++++++++ .../validator/ScheduleTimeCheckValidator.kt | 13 ++ .../domain/schedule/domain/entity/Event.kt | 73 +++++++++ .../schedule/domain/entity/enums/Type.kt | 6 + .../domain/repository/EventRepository.kt | 14 ++ .../presentation/EventAdminController.kt | 58 +++++++ .../schedule/presentation/EventController.kt | 28 ++++ .../presentation/ScheduleController.kt | 36 +++++ .../presentation/ScheduleResponseCode.kt | 17 ++ .../application/exception/SessionErrorCode.kt | 21 +++ .../exception/SessionNotFoundException.kt | 5 + .../usecase/command/ManageSessionUseCase.kt | 63 ++++++++ .../usecase/query/GetSessionQueryService.kt | 58 +++++++ .../domain/session/domain/entity/Session.kt | 93 +++++++++++ .../domain/entity/enums/SessionStatus.kt | 6 + .../domain/repository/SessionReader.kt | 31 ++++ .../domain/repository/SessionRepository.kt | 47 ++++++ .../presentation/SessionAdminController.kt | 73 +++++++++ .../session/presentation/SessionController.kt | 31 ++++ .../presentation/SessionResponseCode.kt | 19 +++ .../usecase/command/AdminUserUseCase.kt | 23 +-- .../user/domain/repository/CardinalReader.kt | 5 + .../domain/repository/CardinalRepository.kt | 5 + .../user/domain/repository/UserReader.kt | 7 + .../user/domain/repository/UserRepository.kt | 2 +- .../controller/ExceptionDocController.kt | 6 +- .../com/weeth/global/config/SwaggerConfig.kt | 2 +- .../mapper/AttendanceMapperTest.kt | 52 +++---- .../command/CheckInAttendanceUseCaseTest.kt | 88 ----------- .../command/CloseAttendanceUseCaseTest.kt | 62 -------- .../UpdateAttendanceStatusUseCaseTest.kt | 68 -------- .../query/GetAttendanceQueryServiceTest.kt | 20 +-- .../domain/entity/AttendanceTest.kt | 49 ++---- .../repository/AttendanceRepositoryTest.kt | 47 +++--- .../service/AttendanceSaveServiceTest.kt | 52 ------- .../fixture/AttendanceTestFixture.kt | 39 +---- .../schedule/fixture/ScheduleTestFixture.kt | 44 +++--- .../session/domain/entity/SessionTest.kt | 26 ++++ .../session/fixture/SessionTestFixture.kt | 64 ++++++++ .../usecase/command/AdminUserUseCaseTest.kt | 43 ++--- 115 files changed, 1671 insertions(+), 2020 deletions(-) delete mode 100644 src/main/java/com/weeth/domain/schedule/application/annotation/ScheduleTimeCheck.java delete mode 100644 src/main/java/com/weeth/domain/schedule/application/dto/EventDTO.java delete mode 100644 src/main/java/com/weeth/domain/schedule/application/dto/MeetingDTO.java delete mode 100644 src/main/java/com/weeth/domain/schedule/application/dto/ScheduleDTO.java delete mode 100644 src/main/java/com/weeth/domain/schedule/application/exception/EventErrorCode.java delete mode 100644 src/main/java/com/weeth/domain/schedule/application/exception/EventNotFoundException.java delete mode 100644 src/main/java/com/weeth/domain/schedule/application/exception/MeetingErrorCode.java delete mode 100644 src/main/java/com/weeth/domain/schedule/application/exception/MeetingNotFoundException.java delete mode 100644 src/main/java/com/weeth/domain/schedule/application/mapper/EventMapper.java delete mode 100644 src/main/java/com/weeth/domain/schedule/application/mapper/MeetingMapper.java delete mode 100644 src/main/java/com/weeth/domain/schedule/application/mapper/ScheduleMapper.java delete mode 100644 src/main/java/com/weeth/domain/schedule/application/usecase/EventUseCase.java delete mode 100644 src/main/java/com/weeth/domain/schedule/application/usecase/EventUseCaseImpl.java delete mode 100644 src/main/java/com/weeth/domain/schedule/application/usecase/MeetingUseCase.java delete mode 100644 src/main/java/com/weeth/domain/schedule/application/usecase/MeetingUseCaseImpl.java delete mode 100644 src/main/java/com/weeth/domain/schedule/application/usecase/ScheduleUseCase.java delete mode 100644 src/main/java/com/weeth/domain/schedule/application/usecase/ScheduleUseCaseImpl.java delete mode 100644 src/main/java/com/weeth/domain/schedule/application/validator/ScheduleTimeCheckValidator.java delete mode 100644 src/main/java/com/weeth/domain/schedule/domain/entity/Event.java delete mode 100644 src/main/java/com/weeth/domain/schedule/domain/entity/Meeting.java delete mode 100644 src/main/java/com/weeth/domain/schedule/domain/entity/Schedule.java delete mode 100644 src/main/java/com/weeth/domain/schedule/domain/entity/enums/MeetingStatus.java delete mode 100644 src/main/java/com/weeth/domain/schedule/domain/entity/enums/Type.java delete mode 100644 src/main/java/com/weeth/domain/schedule/domain/repository/EventRepository.java delete mode 100644 src/main/java/com/weeth/domain/schedule/domain/repository/MeetingRepository.java delete mode 100644 src/main/java/com/weeth/domain/schedule/domain/service/EventDeleteService.java delete mode 100644 src/main/java/com/weeth/domain/schedule/domain/service/EventGetService.java delete mode 100644 src/main/java/com/weeth/domain/schedule/domain/service/EventSaveService.java delete mode 100644 src/main/java/com/weeth/domain/schedule/domain/service/EventUpdateService.java delete mode 100644 src/main/java/com/weeth/domain/schedule/domain/service/MeetingDeleteService.java delete mode 100644 src/main/java/com/weeth/domain/schedule/domain/service/MeetingGetService.java delete mode 100644 src/main/java/com/weeth/domain/schedule/domain/service/MeetingSaveService.java delete mode 100644 src/main/java/com/weeth/domain/schedule/domain/service/MeetingUpdateService.java delete mode 100644 src/main/java/com/weeth/domain/schedule/presentation/EventAdminController.java delete mode 100644 src/main/java/com/weeth/domain/schedule/presentation/EventController.java delete mode 100644 src/main/java/com/weeth/domain/schedule/presentation/MeetingAdminController.java delete mode 100644 src/main/java/com/weeth/domain/schedule/presentation/MeetingController.java delete mode 100644 src/main/java/com/weeth/domain/schedule/presentation/ScheduleController.java delete mode 100644 src/main/java/com/weeth/domain/schedule/presentation/ScheduleResponseCode.java delete mode 100644 src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/CheckInAttendanceUseCase.kt delete mode 100644 src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/CloseAttendanceUseCase.kt create mode 100644 src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCase.kt delete mode 100644 src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/UpdateAttendanceStatusUseCase.kt create mode 100644 src/main/kotlin/com/weeth/domain/attendance/domain/entity/enums/AttendanceStatus.kt delete mode 100644 src/main/kotlin/com/weeth/domain/attendance/domain/enums/Status.kt delete mode 100644 src/main/kotlin/com/weeth/domain/attendance/domain/service/AttendanceDeleteService.kt delete mode 100644 src/main/kotlin/com/weeth/domain/attendance/domain/service/AttendanceGetService.kt delete mode 100644 src/main/kotlin/com/weeth/domain/attendance/domain/service/AttendanceSaveService.kt delete mode 100644 src/main/kotlin/com/weeth/domain/attendance/domain/service/AttendanceUpdateService.kt create mode 100644 src/main/kotlin/com/weeth/domain/schedule/application/annotation/ScheduleTimeCheck.kt create mode 100644 src/main/kotlin/com/weeth/domain/schedule/application/dto/request/ScheduleSaveRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/schedule/application/dto/request/ScheduleTimeRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/schedule/application/dto/request/ScheduleUpdateRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/schedule/application/dto/response/EventResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/schedule/application/dto/response/ScheduleResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/schedule/application/dto/response/SessionInfoResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/schedule/application/dto/response/SessionInfosResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/schedule/application/dto/response/SessionResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/schedule/application/exception/EventErrorCode.kt create mode 100644 src/main/kotlin/com/weeth/domain/schedule/application/exception/EventNotFoundException.kt create mode 100644 src/main/kotlin/com/weeth/domain/schedule/application/mapper/EventMapper.kt create mode 100644 src/main/kotlin/com/weeth/domain/schedule/application/mapper/ScheduleMapper.kt create mode 100644 src/main/kotlin/com/weeth/domain/schedule/application/mapper/SessionMapper.kt create mode 100644 src/main/kotlin/com/weeth/domain/schedule/application/usecase/command/ManageEventUseCase.kt create mode 100644 src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt create mode 100644 src/main/kotlin/com/weeth/domain/schedule/application/validator/ScheduleTimeCheckValidator.kt create mode 100644 src/main/kotlin/com/weeth/domain/schedule/domain/entity/Event.kt create mode 100644 src/main/kotlin/com/weeth/domain/schedule/domain/entity/enums/Type.kt create mode 100644 src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventRepository.kt create mode 100644 src/main/kotlin/com/weeth/domain/schedule/presentation/EventAdminController.kt create mode 100644 src/main/kotlin/com/weeth/domain/schedule/presentation/EventController.kt create mode 100644 src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt create mode 100644 src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleResponseCode.kt create mode 100644 src/main/kotlin/com/weeth/domain/session/application/exception/SessionErrorCode.kt create mode 100644 src/main/kotlin/com/weeth/domain/session/application/exception/SessionNotFoundException.kt create mode 100644 src/main/kotlin/com/weeth/domain/session/application/usecase/command/ManageSessionUseCase.kt create mode 100644 src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt create mode 100644 src/main/kotlin/com/weeth/domain/session/domain/entity/Session.kt create mode 100644 src/main/kotlin/com/weeth/domain/session/domain/entity/enums/SessionStatus.kt create mode 100644 src/main/kotlin/com/weeth/domain/session/domain/repository/SessionReader.kt create mode 100644 src/main/kotlin/com/weeth/domain/session/domain/repository/SessionRepository.kt create mode 100644 src/main/kotlin/com/weeth/domain/session/presentation/SessionAdminController.kt create mode 100644 src/main/kotlin/com/weeth/domain/session/presentation/SessionController.kt create mode 100644 src/main/kotlin/com/weeth/domain/session/presentation/SessionResponseCode.kt delete mode 100644 src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/CheckInAttendanceUseCaseTest.kt delete mode 100644 src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/CloseAttendanceUseCaseTest.kt delete mode 100644 src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/UpdateAttendanceStatusUseCaseTest.kt delete mode 100644 src/test/kotlin/com/weeth/domain/attendance/domain/service/AttendanceSaveServiceTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/session/domain/entity/SessionTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/session/fixture/SessionTestFixture.kt diff --git a/src/main/java/com/weeth/domain/schedule/application/annotation/ScheduleTimeCheck.java b/src/main/java/com/weeth/domain/schedule/application/annotation/ScheduleTimeCheck.java deleted file mode 100644 index 7e8ec584..00000000 --- a/src/main/java/com/weeth/domain/schedule/application/annotation/ScheduleTimeCheck.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.weeth.domain.schedule.application.annotation; - -import jakarta.validation.Constraint; -import jakarta.validation.Payload; -import com.weeth.domain.schedule.application.validator.ScheduleTimeCheckValidator; - -import java.lang.annotation.Retention; -import java.lang.annotation.Target; - -import static java.lang.annotation.ElementType.FIELD; -import static java.lang.annotation.RetentionPolicy.RUNTIME; - -@Target({FIELD}) -@Retention(RUNTIME) -@Constraint(validatedBy = ScheduleTimeCheckValidator.class) -public @interface ScheduleTimeCheck { - - String message() default "마감 시간이 시작 시간보다 빠를 수 없습니다."; - - Class[] groups() default {}; - - Class[] payload() default {}; - -} diff --git a/src/main/java/com/weeth/domain/schedule/application/dto/EventDTO.java b/src/main/java/com/weeth/domain/schedule/application/dto/EventDTO.java deleted file mode 100644 index ee949d3a..00000000 --- a/src/main/java/com/weeth/domain/schedule/application/dto/EventDTO.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.weeth.domain.schedule.application.dto; - -import com.weeth.domain.schedule.domain.entity.enums.Type; - -import java.time.LocalDateTime; - -public class EventDTO { - - public record Response( - Long id, - String title, - String content, - String location, - String requiredItem, - String name, - Integer cardinal, - Type type, - LocalDateTime start, - LocalDateTime end, - LocalDateTime createdAt, - LocalDateTime modifiedAt - ) {} -} - diff --git a/src/main/java/com/weeth/domain/schedule/application/dto/MeetingDTO.java b/src/main/java/com/weeth/domain/schedule/application/dto/MeetingDTO.java deleted file mode 100644 index 09b89017..00000000 --- a/src/main/java/com/weeth/domain/schedule/application/dto/MeetingDTO.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.weeth.domain.schedule.application.dto; - -import com.fasterxml.jackson.annotation.JsonInclude; -import com.weeth.domain.schedule.domain.entity.enums.Type; - -import java.time.LocalDateTime; -import java.util.List; - -public class MeetingDTO { - - @JsonInclude(JsonInclude.Include.NON_NULL) - public record Response( - Long id, - String title, - String content, - String location, - String requiredItem, - String name, - Integer cardinal, - Type type, - Integer code, - LocalDateTime start, - LocalDateTime end, - LocalDateTime createdAt, - LocalDateTime modifiedAt - ) {} - - public record Info( - Long id, - Integer cardinal, - String title, - LocalDateTime start - ) {} - - public record Infos( - Info thisWeek, - List meetings - ) {} - - -} diff --git a/src/main/java/com/weeth/domain/schedule/application/dto/ScheduleDTO.java b/src/main/java/com/weeth/domain/schedule/application/dto/ScheduleDTO.java deleted file mode 100644 index 0aecfa05..00000000 --- a/src/main/java/com/weeth/domain/schedule/application/dto/ScheduleDTO.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.weeth.domain.schedule.application.dto; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import com.weeth.domain.schedule.domain.entity.enums.Type; -import org.springframework.format.annotation.DateTimeFormat; - -import java.time.LocalDateTime; - -public class ScheduleDTO { - - public record Response( - Long id, - String title, - LocalDateTime start, - LocalDateTime end, - Boolean isMeeting - ) {} - - public record Time( - @NotNull @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime start, - @NotNull @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime end - ) {} - - public record Save( - @NotBlank String title, - @NotBlank String content, - @NotBlank String location, - String requiredItem, - @NotNull Type type, - @NotNull Integer cardinal, - @NotNull @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime start, - @NotNull @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime end - ) {} - - public record Update( - @NotBlank String title, - @NotBlank String content, - @NotBlank String location, - String requiredItem, - @NotNull Type type, - @NotNull @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime start, - @NotNull @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime end - ) {} -} diff --git a/src/main/java/com/weeth/domain/schedule/application/exception/EventErrorCode.java b/src/main/java/com/weeth/domain/schedule/application/exception/EventErrorCode.java deleted file mode 100644 index 591dbaca..00000000 --- a/src/main/java/com/weeth/domain/schedule/application/exception/EventErrorCode.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.weeth.domain.schedule.application.exception; - -import com.weeth.global.common.exception.ErrorCodeInterface; -import com.weeth.global.common.exception.ExplainError; -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@AllArgsConstructor -public enum EventErrorCode implements ErrorCodeInterface { - - @ExplainError("요청한 일정 ID에 해당하는 일정이 존재하지 않을 때 발생합니다.") - EVENT_NOT_FOUND(2700, HttpStatus.NOT_FOUND, "존재하지 않는 일정입니다."); - - private final int code; - private final HttpStatus status; - private final String message; -} diff --git a/src/main/java/com/weeth/domain/schedule/application/exception/EventNotFoundException.java b/src/main/java/com/weeth/domain/schedule/application/exception/EventNotFoundException.java deleted file mode 100644 index 48856ea5..00000000 --- a/src/main/java/com/weeth/domain/schedule/application/exception/EventNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.schedule.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class EventNotFoundException extends BaseException { - public EventNotFoundException() { - super(EventErrorCode.EVENT_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/domain/schedule/application/exception/MeetingErrorCode.java b/src/main/java/com/weeth/domain/schedule/application/exception/MeetingErrorCode.java deleted file mode 100644 index ad96ff71..00000000 --- a/src/main/java/com/weeth/domain/schedule/application/exception/MeetingErrorCode.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.weeth.domain.schedule.application.exception; - -import com.weeth.global.common.exception.ErrorCodeInterface; -import com.weeth.global.common.exception.ExplainError; -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@AllArgsConstructor -public enum MeetingErrorCode implements ErrorCodeInterface { - - @ExplainError("요청한 정기모임 ID에 해당하는 정기모임이 존재하지 않을 때 발생합니다.") - MEETING_NOT_FOUND(2701, HttpStatus.NOT_FOUND, "존재하지 않는 정기모임입니다."); - - private final int code; - private final HttpStatus status; - private final String message; -} diff --git a/src/main/java/com/weeth/domain/schedule/application/exception/MeetingNotFoundException.java b/src/main/java/com/weeth/domain/schedule/application/exception/MeetingNotFoundException.java deleted file mode 100644 index d9b4d4d7..00000000 --- a/src/main/java/com/weeth/domain/schedule/application/exception/MeetingNotFoundException.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.weeth.domain.schedule.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class MeetingNotFoundException extends BaseException { - public MeetingNotFoundException() {super(MeetingErrorCode.MEETING_NOT_FOUND);} -} diff --git a/src/main/java/com/weeth/domain/schedule/application/mapper/EventMapper.java b/src/main/java/com/weeth/domain/schedule/application/mapper/EventMapper.java deleted file mode 100644 index c297891c..00000000 --- a/src/main/java/com/weeth/domain/schedule/application/mapper/EventMapper.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.weeth.domain.schedule.application.mapper; - -import com.weeth.domain.schedule.application.dto.ScheduleDTO; -import com.weeth.domain.schedule.domain.entity.Event; -import com.weeth.domain.user.domain.entity.User; -import org.mapstruct.*; - -import static com.weeth.domain.schedule.application.dto.EventDTO.Response; - -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, unmappedTargetPolicy = ReportingPolicy.IGNORE) -public interface EventMapper { - - @Mapping(target = "name", source = "event.user.name") - @Mapping(target = "type", expression = "java(Type.EVENT)") - Response to(Event event); - - @Mappings({ - @Mapping(target = "id", ignore = true), - @Mapping(target = "user", source = "user") - }) - Event from(ScheduleDTO.Save dto, User user); -} diff --git a/src/main/java/com/weeth/domain/schedule/application/mapper/MeetingMapper.java b/src/main/java/com/weeth/domain/schedule/application/mapper/MeetingMapper.java deleted file mode 100644 index c81c72bc..00000000 --- a/src/main/java/com/weeth/domain/schedule/application/mapper/MeetingMapper.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.weeth.domain.schedule.application.mapper; - -import com.weeth.domain.schedule.application.dto.ScheduleDTO; -import com.weeth.domain.schedule.domain.entity.Meeting; -import com.weeth.domain.user.domain.entity.User; -import org.mapstruct.*; - -import java.util.Random; - -import static com.weeth.domain.schedule.application.dto.MeetingDTO.Info; -import static com.weeth.domain.schedule.application.dto.MeetingDTO.Response; - -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, unmappedTargetPolicy = ReportingPolicy.IGNORE) -public interface MeetingMapper { - - @Mapping(target = "name", source = "user.name") - @Mapping(target = "code", ignore = true) - @Mapping(target = "type", expression = "java(Type.MEETING)") - Response to(Meeting meeting); - - Info toInfo(Meeting meeting); - - @Mapping(target = "name", source = "user.name") - @Mapping(target = "type", expression = "java(Type.MEETING)") - Response toAdminResponse(Meeting meeting); - - @Mappings({ - @Mapping(target = "id", ignore = true), - @Mapping(target = "code", expression = "java( generateCode() )"), - @Mapping(target = "user", source = "user") - }) - Meeting from(ScheduleDTO.Save dto, User user); - - default Integer generateCode() { - return new Random().nextInt(9000) + 1000; - } - - /* - 차후 필히 리팩토링 할 것 - -> 정기 모임의 참여하는 인원의 멤버수를 어떻게 관리할지. - 해당 코드는 일시적인 대안책임 - */ -// default Integer getMemberCount(Meeting meeting) { -// return (int)meeting.getAttendances().stream() -// .filter(attendance -> !attendance.getUser().getStatus().equals(Status.BANNED)) -// .filter(attendance -> !attendance.getUser().getStatus().equals(Status.LEFT)) -// .count(); -// } -} diff --git a/src/main/java/com/weeth/domain/schedule/application/mapper/ScheduleMapper.java b/src/main/java/com/weeth/domain/schedule/application/mapper/ScheduleMapper.java deleted file mode 100644 index c61299bd..00000000 --- a/src/main/java/com/weeth/domain/schedule/application/mapper/ScheduleMapper.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.weeth.domain.schedule.application.mapper; - -import com.weeth.domain.schedule.application.dto.ScheduleDTO; -import com.weeth.domain.schedule.domain.entity.Schedule; -import org.mapstruct.Mapper; -import org.mapstruct.MappingConstants; -import org.mapstruct.ReportingPolicy; - -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, unmappedTargetPolicy = ReportingPolicy.IGNORE) -public interface ScheduleMapper { - - ScheduleDTO.Response toScheduleDTO(Schedule schedule, Boolean isMeeting); -} diff --git a/src/main/java/com/weeth/domain/schedule/application/usecase/EventUseCase.java b/src/main/java/com/weeth/domain/schedule/application/usecase/EventUseCase.java deleted file mode 100644 index e0227af7..00000000 --- a/src/main/java/com/weeth/domain/schedule/application/usecase/EventUseCase.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.weeth.domain.schedule.application.usecase; - -import com.weeth.domain.schedule.application.dto.ScheduleDTO; - -import static com.weeth.domain.schedule.application.dto.EventDTO.*; - -public interface EventUseCase { - - Response find(Long eventId); - - void save(ScheduleDTO.Save dto, Long userId); - - void update(Long eventId, ScheduleDTO.Update dto, Long userId); - - void delete(Long eventId); -} diff --git a/src/main/java/com/weeth/domain/schedule/application/usecase/EventUseCaseImpl.java b/src/main/java/com/weeth/domain/schedule/application/usecase/EventUseCaseImpl.java deleted file mode 100644 index 21a8ae35..00000000 --- a/src/main/java/com/weeth/domain/schedule/application/usecase/EventUseCaseImpl.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.weeth.domain.schedule.application.usecase; - -import com.weeth.domain.schedule.application.dto.ScheduleDTO; -import com.weeth.domain.schedule.application.mapper.EventMapper; -import com.weeth.domain.schedule.domain.entity.Event; -import com.weeth.domain.schedule.domain.service.EventDeleteService; -import com.weeth.domain.schedule.domain.service.EventGetService; -import com.weeth.domain.schedule.domain.service.EventSaveService; -import com.weeth.domain.schedule.domain.service.EventUpdateService; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.repository.CardinalReader; -import com.weeth.domain.user.domain.repository.UserReader; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import static com.weeth.domain.schedule.application.dto.EventDTO.Response; - -@Service -@RequiredArgsConstructor -public class EventUseCaseImpl implements EventUseCase { - - private final UserReader userReader; - private final EventGetService eventGetService; - private final EventSaveService eventSaveService; - private final EventUpdateService eventUpdateService; - private final EventDeleteService eventDeleteService; - private final CardinalReader cardinalReader; - private final EventMapper mapper; - - @Override - public Response find(Long eventId) { - return mapper.to(eventGetService.find(eventId)); - } - - @Override - @Transactional - public void save(ScheduleDTO.Save dto, Long userId) { - User user = userReader.getById(userId); - cardinalReader.getByCardinalNumber(dto.cardinal()); - - eventSaveService.save(mapper.from(dto, user)); - } - - @Override - @Transactional - public void update(Long eventId, ScheduleDTO.Update dto, Long userId) { - User user = userReader.getById(userId); - Event event = eventGetService.find(eventId); - eventUpdateService.update(event, dto, user); - } - - @Override - @Transactional - public void delete(Long eventId) { - Event event = eventGetService.find(eventId); - eventDeleteService.delete(event); - } -} diff --git a/src/main/java/com/weeth/domain/schedule/application/usecase/MeetingUseCase.java b/src/main/java/com/weeth/domain/schedule/application/usecase/MeetingUseCase.java deleted file mode 100644 index 857de980..00000000 --- a/src/main/java/com/weeth/domain/schedule/application/usecase/MeetingUseCase.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.weeth.domain.schedule.application.usecase; - -import com.weeth.domain.schedule.application.dto.MeetingDTO; -import com.weeth.domain.schedule.application.dto.ScheduleDTO; - -import java.util.List; - -import static com.weeth.domain.schedule.application.dto.MeetingDTO.Info; -import static com.weeth.domain.schedule.application.dto.MeetingDTO.Response; - -public interface MeetingUseCase { - - Response find(Long userId, Long eventId); - - MeetingDTO.Infos find(Integer cardinal); - - void save(ScheduleDTO.Save dto, Long userId); - - void update(ScheduleDTO.Update dto, Long userId, Long meetingId); - - void delete(Long meetingId); -} diff --git a/src/main/java/com/weeth/domain/schedule/application/usecase/MeetingUseCaseImpl.java b/src/main/java/com/weeth/domain/schedule/application/usecase/MeetingUseCaseImpl.java deleted file mode 100644 index de95fef2..00000000 --- a/src/main/java/com/weeth/domain/schedule/application/usecase/MeetingUseCaseImpl.java +++ /dev/null @@ -1,147 +0,0 @@ -package com.weeth.domain.schedule.application.usecase; - -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import com.weeth.domain.attendance.domain.entity.Attendance; -import com.weeth.domain.attendance.domain.service.AttendanceDeleteService; -import com.weeth.domain.attendance.domain.service.AttendanceGetService; -import com.weeth.domain.attendance.domain.service.AttendanceSaveService; -import com.weeth.domain.attendance.domain.service.AttendanceUpdateService; -import com.weeth.domain.schedule.application.dto.MeetingDTO; -import com.weeth.domain.schedule.application.dto.ScheduleDTO; -import com.weeth.domain.schedule.application.mapper.MeetingMapper; -import com.weeth.domain.schedule.domain.entity.Meeting; -import com.weeth.domain.schedule.domain.service.MeetingDeleteService; -import com.weeth.domain.schedule.domain.service.MeetingGetService; -import com.weeth.domain.schedule.domain.service.MeetingSaveService; -import com.weeth.domain.schedule.domain.service.MeetingUpdateService; -import com.weeth.domain.user.domain.entity.Cardinal; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.entity.enums.Role; -import com.weeth.domain.user.domain.entity.enums.Status; -import com.weeth.domain.user.domain.repository.CardinalReader; -import com.weeth.domain.user.domain.repository.UserReader; -import com.weeth.domain.user.domain.repository.UserRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.DayOfWeek; -import java.time.LocalDate; -import java.time.temporal.TemporalAdjusters; -import java.util.Comparator; -import java.util.List; - -import static com.weeth.domain.schedule.application.dto.MeetingDTO.Response; - -@Slf4j -@Service -@RequiredArgsConstructor -public class MeetingUseCaseImpl implements MeetingUseCase { - - private final MeetingGetService meetingGetService; - private final MeetingMapper mapper; - private final MeetingSaveService meetingSaveService; - private final UserReader userReader; - private final UserRepository userRepository; - private final MeetingUpdateService meetingUpdateService; - private final MeetingDeleteService meetingDeleteService; - private final AttendanceGetService attendanceGetService; - private final AttendanceSaveService attendanceSaveService; - private final AttendanceDeleteService attendanceDeleteService; - private final AttendanceUpdateService attendanceUpdateService; - private final CardinalReader cardinalReader; - - @PersistenceContext - private EntityManager em; - - @Override - public Response find(Long userId, Long meetingId) { - User user = userReader.getById(userId); - Meeting meeting = meetingGetService.find(meetingId); - - if (Role.ADMIN == user.getRole()) { - return mapper.toAdminResponse(meeting) ; - } - - return mapper.to(meeting); - } - - @Override - public MeetingDTO.Infos find(Integer cardinal) { - List meetings; - - if (cardinal == null) { - meetings = meetingGetService.findAll(); - } else { - meetings = meetingGetService.findMeetingByCardinal(cardinal); - } - - Meeting thisWeek = findThisWeek(meetings); - List sorted = sortMeetings(meetings); - - return new MeetingDTO.Infos( - thisWeek != null ? mapper.toInfo(thisWeek) : null, - sorted.stream().map(mapper::toInfo).toList()); - } - - @Override - @Transactional - public void save(ScheduleDTO.Save dto, Long userId) { - User user = userReader.getById(userId); - Cardinal cardinal = cardinalReader.getByCardinalNumber(dto.cardinal()); - - List userList = userRepository.findAllByCardinalAndStatus(cardinal, Status.ACTIVE); - - Meeting meeting = mapper.from(dto, user); - meetingSaveService.save(meeting); - - attendanceSaveService.saveAll(userList, meeting); - } - - @Override - @Transactional - public void update(ScheduleDTO.Update dto, Long userId, Long meetingId) { - Meeting meeting = meetingGetService.find(meetingId); - User user = userReader.getById(userId); - meetingUpdateService.update(dto, user, meeting); - } - - @Override - @Transactional - public void delete(Long meetingId) { - Meeting meeting = meetingGetService.find(meetingId); - List attendances = attendanceGetService.findAllByMeeting(meeting); - - attendanceUpdateService.updateUserAttendanceByStatus(attendances); - - em.flush(); - em.clear(); - - attendanceDeleteService.deleteAll(meeting); - meetingDeleteService.delete(meeting); - } - - private List sortMeetings(List meetings) { - return meetings.stream() - .sorted(Comparator.comparing(Meeting::getStart).reversed()) - .toList(); - } - - - private Meeting findThisWeek(List meetings) { - LocalDate today = LocalDate.now(); - LocalDate startOfWeek = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); - LocalDate endOfWeek = today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)); - - return meetings.stream() - .filter(m -> { - LocalDate d = m.getStart().toLocalDate(); - return !d.isBefore(startOfWeek) && !d.isAfter(endOfWeek); - }) - .findFirst() - .orElse(null); - } - -} diff --git a/src/main/java/com/weeth/domain/schedule/application/usecase/ScheduleUseCase.java b/src/main/java/com/weeth/domain/schedule/application/usecase/ScheduleUseCase.java deleted file mode 100644 index 4676a026..00000000 --- a/src/main/java/com/weeth/domain/schedule/application/usecase/ScheduleUseCase.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.weeth.domain.schedule.application.usecase; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Map; - -import static com.weeth.domain.schedule.application.dto.ScheduleDTO.Response; - -public interface ScheduleUseCase { - - List findByMonthly(LocalDateTime start, LocalDateTime end); - - Map> findByYearly(Integer year, Integer semester); - -} diff --git a/src/main/java/com/weeth/domain/schedule/application/usecase/ScheduleUseCaseImpl.java b/src/main/java/com/weeth/domain/schedule/application/usecase/ScheduleUseCaseImpl.java deleted file mode 100644 index 79fdcb36..00000000 --- a/src/main/java/com/weeth/domain/schedule/application/usecase/ScheduleUseCaseImpl.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.weeth.domain.schedule.application.usecase; - -import com.weeth.domain.schedule.domain.service.EventGetService; -import com.weeth.domain.schedule.domain.service.MeetingGetService; -import com.weeth.domain.user.domain.entity.Cardinal; -import com.weeth.domain.user.application.exception.CardinalNotFoundException; -import com.weeth.domain.user.domain.repository.CardinalRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.time.LocalDateTime; -import java.util.*; -import java.util.stream.Collectors; -import java.util.stream.IntStream; -import java.util.stream.Stream; - -import static com.weeth.domain.schedule.application.dto.ScheduleDTO.Response; - -@Service -@RequiredArgsConstructor -public class ScheduleUseCaseImpl implements ScheduleUseCase { - - private final EventGetService eventGetService; - private final MeetingGetService meetingGetService; - private final CardinalRepository cardinalRepository; - - @Override - public List findByMonthly(LocalDateTime start, LocalDateTime end) { - List events = eventGetService.find(start, end); - List meetings = meetingGetService.find(start, end); - - return Stream.of(events, meetings) - .flatMap(Collection::stream) - .sorted(Comparator.comparing(Response::start)) - .toList(); - } - - @Override - public Map> findByYearly(Integer year, Integer semester) { - Cardinal cardinal = cardinalRepository.findByYearAndSemester(year, semester) - .orElseThrow(CardinalNotFoundException::new); - - List events = eventGetService.find(cardinal.getCardinalNumber()); - List meetings = meetingGetService.findByCardinal(cardinal.getCardinalNumber()); - - return Stream.of(events, meetings) - .flatMap(Collection::stream) // 병합 - .sorted(Comparator.comparing(Response::start)) // 스케줄 시작 시간으로 정렬 - .flatMap(schedule -> { - List> monthEventPairs = new ArrayList<>(); - - int left = schedule.start().getMonthValue(); - int right = schedule.end().getMonthValue() + 1; - IntStream.range(left, right) // 기간 내 포함된 달 계산 - .forEach(month -> monthEventPairs.add( - new AbstractMap.SimpleEntry<>(month, schedule)) - ); - - return monthEventPairs.stream(); - }) - .collect(Collectors.groupingBy( - Map.Entry::getKey, - Collectors.mapping(Map.Entry::getValue, Collectors.toList()) - )); - } -} diff --git a/src/main/java/com/weeth/domain/schedule/application/validator/ScheduleTimeCheckValidator.java b/src/main/java/com/weeth/domain/schedule/application/validator/ScheduleTimeCheckValidator.java deleted file mode 100644 index 62668e4b..00000000 --- a/src/main/java/com/weeth/domain/schedule/application/validator/ScheduleTimeCheckValidator.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.weeth.domain.schedule.application.validator; - -import jakarta.validation.ConstraintValidator; -import jakarta.validation.ConstraintValidatorContext; -import com.weeth.domain.schedule.application.annotation.ScheduleTimeCheck; -import com.weeth.domain.schedule.application.dto.ScheduleDTO.Time; - -public class ScheduleTimeCheckValidator implements ConstraintValidator { - - @Override - public void initialize(ScheduleTimeCheck constraintAnnotation) { - ConstraintValidator.super.initialize(constraintAnnotation); - } - - @Override - public boolean isValid(Time time, ConstraintValidatorContext context) { - return time.start().isBefore(time.end().plusMinutes(1)); - } -} \ No newline at end of file diff --git a/src/main/java/com/weeth/domain/schedule/domain/entity/Event.java b/src/main/java/com/weeth/domain/schedule/domain/entity/Event.java deleted file mode 100644 index b9b5253f..00000000 --- a/src/main/java/com/weeth/domain/schedule/domain/entity/Event.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.weeth.domain.schedule.domain.entity; - -import jakarta.persistence.Entity; -import com.weeth.domain.schedule.application.dto.ScheduleDTO; -import com.weeth.domain.user.domain.entity.User; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.experimental.SuperBuilder; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@SuperBuilder -public class Event extends Schedule { - - public void update(ScheduleDTO.Update dto, User user) { - this.updateUpperClass(dto, user); - } -} diff --git a/src/main/java/com/weeth/domain/schedule/domain/entity/Meeting.java b/src/main/java/com/weeth/domain/schedule/domain/entity/Meeting.java deleted file mode 100644 index 30ad37fe..00000000 --- a/src/main/java/com/weeth/domain/schedule/domain/entity/Meeting.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.weeth.domain.schedule.domain.entity; - -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.PrePersist; -import com.weeth.domain.schedule.application.dto.ScheduleDTO; -import com.weeth.domain.schedule.domain.entity.enums.MeetingStatus; -import com.weeth.domain.user.domain.entity.User; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.experimental.SuperBuilder; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@SuperBuilder -public class Meeting extends Schedule { - - private Integer code; - - @Enumerated(EnumType.STRING) - private MeetingStatus meetingStatus; - - public void update(ScheduleDTO.Update dto, User user) { - this.updateUpperClass(dto, user); - } - - @PrePersist - public void init() { - this.meetingStatus = MeetingStatus.OPEN; - } - - public void close() { - this.meetingStatus = MeetingStatus.CLOSE; - } -} diff --git a/src/main/java/com/weeth/domain/schedule/domain/entity/Schedule.java b/src/main/java/com/weeth/domain/schedule/domain/entity/Schedule.java deleted file mode 100644 index 3c232518..00000000 --- a/src/main/java/com/weeth/domain/schedule/domain/entity/Schedule.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.weeth.domain.schedule.domain.entity; - -import jakarta.persistence.*; -import com.weeth.domain.schedule.application.dto.ScheduleDTO; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.global.common.entity.BaseEntity; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.experimental.SuperBuilder; -import org.springframework.data.jpa.domain.support.AuditingEntityListener; - -import java.time.LocalDateTime; - - -@Getter -@MappedSuperclass -@EntityListeners(AuditingEntityListener.class) -@SuperBuilder -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class Schedule extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - private String title; - - @Column(columnDefinition = "TEXT") - private String content; - - private String location; - - private Integer cardinal; - - private String requiredItem; - - private LocalDateTime start; - - private LocalDateTime end; - - @ManyToOne - @JoinColumn(name = "user_id") - private User user; - - public void updateUpperClass(ScheduleDTO.Update dto, User user) { - this.title = dto.title(); - this.content = dto.content(); - this.location = dto.location(); - this.requiredItem = dto.requiredItem(); - this.start = dto.start(); - this.end = dto.end(); - this.user = user; - } -} diff --git a/src/main/java/com/weeth/domain/schedule/domain/entity/enums/MeetingStatus.java b/src/main/java/com/weeth/domain/schedule/domain/entity/enums/MeetingStatus.java deleted file mode 100644 index 7ca34280..00000000 --- a/src/main/java/com/weeth/domain/schedule/domain/entity/enums/MeetingStatus.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.weeth.domain.schedule.domain.entity.enums; - -public enum MeetingStatus { - OPEN, - CLOSE -} diff --git a/src/main/java/com/weeth/domain/schedule/domain/entity/enums/Type.java b/src/main/java/com/weeth/domain/schedule/domain/entity/enums/Type.java deleted file mode 100644 index ca0c721d..00000000 --- a/src/main/java/com/weeth/domain/schedule/domain/entity/enums/Type.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.weeth.domain.schedule.domain.entity.enums; - -public enum Type { - EVENT, MEETING -} diff --git a/src/main/java/com/weeth/domain/schedule/domain/repository/EventRepository.java b/src/main/java/com/weeth/domain/schedule/domain/repository/EventRepository.java deleted file mode 100644 index a281ed08..00000000 --- a/src/main/java/com/weeth/domain/schedule/domain/repository/EventRepository.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.weeth.domain.schedule.domain.repository; - -import com.weeth.domain.schedule.domain.entity.Event; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.time.LocalDateTime; -import java.util.List; - -public interface EventRepository extends JpaRepository { - - List findByStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc(LocalDateTime end, LocalDateTime start); - - List findAllByCardinal(int cardinal); -} diff --git a/src/main/java/com/weeth/domain/schedule/domain/repository/MeetingRepository.java b/src/main/java/com/weeth/domain/schedule/domain/repository/MeetingRepository.java deleted file mode 100644 index 8b3c1128..00000000 --- a/src/main/java/com/weeth/domain/schedule/domain/repository/MeetingRepository.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.weeth.domain.schedule.domain.repository; - -import com.weeth.domain.schedule.domain.entity.Meeting; -import com.weeth.domain.schedule.domain.entity.enums.MeetingStatus; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.time.LocalDateTime; -import java.util.List; - -public interface MeetingRepository extends JpaRepository { - - List findByStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc(LocalDateTime start, LocalDateTime end); - - List findAllByCardinalOrderByStartAsc(int cardinal); - - List findAllByCardinalOrderByStartDesc(int cardinal); - - List findAllByCardinal(int cardinal); - - List findAllByCardinalInOrderByCardinalAscStartAsc(List cardinals); - - List findAllByMeetingStatusAndEndBeforeOrderByEndAsc(MeetingStatus status, LocalDateTime end); - - List findAllByOrderByStartDesc(); -} diff --git a/src/main/java/com/weeth/domain/schedule/domain/service/EventDeleteService.java b/src/main/java/com/weeth/domain/schedule/domain/service/EventDeleteService.java deleted file mode 100644 index 1bcef851..00000000 --- a/src/main/java/com/weeth/domain/schedule/domain/service/EventDeleteService.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.weeth.domain.schedule.domain.service; - -import com.weeth.domain.schedule.domain.entity.Event; -import com.weeth.domain.schedule.domain.repository.EventRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class EventDeleteService { - - private final EventRepository eventRepository; - - public void delete(Event event) { - eventRepository.delete(event); - } -} diff --git a/src/main/java/com/weeth/domain/schedule/domain/service/EventGetService.java b/src/main/java/com/weeth/domain/schedule/domain/service/EventGetService.java deleted file mode 100644 index 71546635..00000000 --- a/src/main/java/com/weeth/domain/schedule/domain/service/EventGetService.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.weeth.domain.schedule.domain.service; - -import com.weeth.domain.schedule.application.dto.ScheduleDTO; -import com.weeth.domain.schedule.application.mapper.ScheduleMapper; -import com.weeth.domain.schedule.domain.entity.Event; -import com.weeth.domain.schedule.domain.repository.EventRepository; -import com.weeth.domain.schedule.application.exception.EventNotFoundException; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.time.LocalDateTime; -import java.util.List; - -@Service -@RequiredArgsConstructor -public class EventGetService { - - private final EventRepository eventRepository; - private final ScheduleMapper mapper; - - public Event find(Long eventId) { - return eventRepository.findById(eventId) - .orElseThrow(EventNotFoundException::new); - } - - public List find(LocalDateTime start, LocalDateTime end) { - return eventRepository.findByStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc(end, start).stream() - .map(event -> mapper.toScheduleDTO(event, false)) - .toList(); - } - - public List find(Integer cardinal) { - return eventRepository.findAllByCardinal(cardinal).stream() - .map(event -> mapper.toScheduleDTO(event, false)) - .toList(); - } -} diff --git a/src/main/java/com/weeth/domain/schedule/domain/service/EventSaveService.java b/src/main/java/com/weeth/domain/schedule/domain/service/EventSaveService.java deleted file mode 100644 index b2c82831..00000000 --- a/src/main/java/com/weeth/domain/schedule/domain/service/EventSaveService.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.weeth.domain.schedule.domain.service; - -import com.weeth.domain.schedule.domain.entity.Event; -import com.weeth.domain.schedule.domain.repository.EventRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class EventSaveService { - - private final EventRepository eventRepository; - - public void save(Event event) { - eventRepository.save(event); - } -} diff --git a/src/main/java/com/weeth/domain/schedule/domain/service/EventUpdateService.java b/src/main/java/com/weeth/domain/schedule/domain/service/EventUpdateService.java deleted file mode 100644 index 905af593..00000000 --- a/src/main/java/com/weeth/domain/schedule/domain/service/EventUpdateService.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.weeth.domain.schedule.domain.service; - -import jakarta.transaction.Transactional; -import com.weeth.domain.schedule.application.dto.ScheduleDTO; -import com.weeth.domain.schedule.domain.entity.Event; -import com.weeth.domain.user.domain.entity.User; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@Transactional -@RequiredArgsConstructor -public class EventUpdateService { - - public void update(Event event, ScheduleDTO.Update dto, User user) { - event.update(dto, user); - } -} diff --git a/src/main/java/com/weeth/domain/schedule/domain/service/MeetingDeleteService.java b/src/main/java/com/weeth/domain/schedule/domain/service/MeetingDeleteService.java deleted file mode 100644 index 39fecb02..00000000 --- a/src/main/java/com/weeth/domain/schedule/domain/service/MeetingDeleteService.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.weeth.domain.schedule.domain.service; - -import com.weeth.domain.schedule.domain.entity.Meeting; -import com.weeth.domain.schedule.domain.repository.MeetingRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class MeetingDeleteService { - - private final MeetingRepository meetingRepository; - - public void delete(Meeting meeting) { - meetingRepository.delete(meeting); - } -} diff --git a/src/main/java/com/weeth/domain/schedule/domain/service/MeetingGetService.java b/src/main/java/com/weeth/domain/schedule/domain/service/MeetingGetService.java deleted file mode 100644 index 6eebc011..00000000 --- a/src/main/java/com/weeth/domain/schedule/domain/service/MeetingGetService.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.weeth.domain.schedule.domain.service; - -import com.weeth.domain.schedule.application.dto.ScheduleDTO; -import com.weeth.domain.schedule.application.mapper.ScheduleMapper; -import com.weeth.domain.schedule.domain.entity.Meeting; -import com.weeth.domain.schedule.domain.entity.enums.MeetingStatus; -import com.weeth.domain.schedule.domain.repository.MeetingRepository; -import com.weeth.domain.schedule.application.exception.MeetingNotFoundException; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.time.LocalDateTime; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -@Service -@RequiredArgsConstructor -public class MeetingGetService { - - private final MeetingRepository meetingRepository; - private final ScheduleMapper mapper; - - public Meeting find(Long meetingId) { - return meetingRepository.findById(meetingId) - .orElseThrow(MeetingNotFoundException::new); - } - - public List find(LocalDateTime start, LocalDateTime end) { - return meetingRepository.findByStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc(end, start).stream() - .map(meeting -> mapper.toScheduleDTO(meeting, true)) - .toList(); - } - - public List find(Integer cardinal) { - return meetingRepository.findAllByCardinalOrderByStartAsc(cardinal); - } - - public Map> findByCardinals(List cardinals) { - if (cardinals == null || cardinals.isEmpty()) { - return Map.of(); - } - return meetingRepository.findAllByCardinalInOrderByCardinalAscStartAsc(cardinals).stream() - .collect(Collectors.groupingBy(Meeting::getCardinal, LinkedHashMap::new, Collectors.toList())); - } - - public List findMeetingByCardinal(Integer cardinal) { - return meetingRepository.findAllByCardinalOrderByStartDesc(cardinal); - } - - public List findAll() { - return meetingRepository.findAllByOrderByStartDesc(); - } - - public List findByCardinal(Integer cardinal) { - return meetingRepository.findAllByCardinal(cardinal).stream() - .map(meeting -> mapper.toScheduleDTO(meeting, true)) - .toList(); - } - - public List findAllOpenMeetingsBeforeNow() { - return meetingRepository.findAllByMeetingStatusAndEndBeforeOrderByEndAsc(MeetingStatus.OPEN, LocalDateTime.now()); - } -} diff --git a/src/main/java/com/weeth/domain/schedule/domain/service/MeetingSaveService.java b/src/main/java/com/weeth/domain/schedule/domain/service/MeetingSaveService.java deleted file mode 100644 index ba671f62..00000000 --- a/src/main/java/com/weeth/domain/schedule/domain/service/MeetingSaveService.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.weeth.domain.schedule.domain.service; - -import com.weeth.domain.schedule.domain.entity.Meeting; -import com.weeth.domain.schedule.domain.repository.MeetingRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class MeetingSaveService { - - private final MeetingRepository meetingRepository; - - public void save(Meeting meeting) { - meetingRepository.save(meeting); - } -} diff --git a/src/main/java/com/weeth/domain/schedule/domain/service/MeetingUpdateService.java b/src/main/java/com/weeth/domain/schedule/domain/service/MeetingUpdateService.java deleted file mode 100644 index e89301c7..00000000 --- a/src/main/java/com/weeth/domain/schedule/domain/service/MeetingUpdateService.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.weeth.domain.schedule.domain.service; - -import com.weeth.domain.schedule.application.dto.ScheduleDTO; -import com.weeth.domain.schedule.domain.entity.Meeting; -import com.weeth.domain.user.domain.entity.User; -import org.springframework.stereotype.Service; - -@Service -public class MeetingUpdateService { - - public void update(ScheduleDTO.Update dto, User user, Meeting meeting) { - meeting.update(dto, user); - } -} diff --git a/src/main/java/com/weeth/domain/schedule/presentation/EventAdminController.java b/src/main/java/com/weeth/domain/schedule/presentation/EventAdminController.java deleted file mode 100644 index 2a8f2a99..00000000 --- a/src/main/java/com/weeth/domain/schedule/presentation/EventAdminController.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.weeth.domain.schedule.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import com.weeth.domain.schedule.application.dto.ScheduleDTO; -import com.weeth.domain.schedule.application.exception.EventErrorCode; -import com.weeth.domain.schedule.application.usecase.EventUseCase; -import com.weeth.domain.schedule.application.usecase.MeetingUseCase; -import com.weeth.domain.schedule.domain.entity.enums.Type; -import com.weeth.global.auth.annotation.CurrentUser; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import static com.weeth.domain.schedule.presentation.ScheduleResponseCode.*; - -@Tag(name = "EVENT ADMIN", description = "[ADMIN] 일정 어드민 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/admin/events") -@ApiErrorCodeExample(EventErrorCode.class) -public class EventAdminController { - - private final EventUseCase eventUseCase; - private final MeetingUseCase meetingUseCase; - - @PostMapping - @Operation(summary = "일정/정기모임 생성") - public CommonResponse save(@Valid @RequestBody ScheduleDTO.Save dto, - @Parameter(hidden = true) @CurrentUser Long userId) { - if (dto.type() == Type.EVENT) { - eventUseCase.save(dto, userId); - } else { - meetingUseCase.save(dto, userId); - } - - return CommonResponse.success(EVENT_SAVE_SUCCESS); - } - - @PatchMapping("/{eventId}") - @Operation(summary = "일정 수정 (type은 변경할 수 없게 해주세요.)") - public CommonResponse update(@PathVariable Long eventId, @Valid @RequestBody ScheduleDTO.Update dto, - @Parameter(hidden = true) @CurrentUser Long userId) { - if (dto.type() == Type.EVENT) { - eventUseCase.update(eventId, dto, userId); - } else { - meetingUseCase.update(dto, userId, eventId); - } - - return CommonResponse.success(EVENT_UPDATE_SUCCESS); - } - - @DeleteMapping("/{eventId}") - @Operation(summary = "일정 삭제") - public CommonResponse delete(@PathVariable Long eventId) { - eventUseCase.delete(eventId); - - return CommonResponse.success(EVENT_DELETE_SUCCESS); - } -} diff --git a/src/main/java/com/weeth/domain/schedule/presentation/EventController.java b/src/main/java/com/weeth/domain/schedule/presentation/EventController.java deleted file mode 100644 index d94165e9..00000000 --- a/src/main/java/com/weeth/domain/schedule/presentation/EventController.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.weeth.domain.schedule.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import com.weeth.domain.schedule.application.exception.EventErrorCode; -import com.weeth.domain.schedule.application.usecase.EventUseCase; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import static com.weeth.domain.schedule.application.dto.EventDTO.Response; -import static com.weeth.domain.schedule.presentation.ScheduleResponseCode.EVENT_FIND_SUCCESS; - -@Tag(name = "EVENT", description = "일정 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/events") -@ApiErrorCodeExample(EventErrorCode.class) -public class EventController { - - private final EventUseCase eventUseCase; - - @GetMapping("/{eventId}") - @Operation(summary="일정 상세 조회") - public CommonResponse find(@PathVariable Long eventId) { - return CommonResponse.success(EVENT_FIND_SUCCESS, - eventUseCase.find(eventId)); - } - -} diff --git a/src/main/java/com/weeth/domain/schedule/presentation/MeetingAdminController.java b/src/main/java/com/weeth/domain/schedule/presentation/MeetingAdminController.java deleted file mode 100644 index dcbc3e19..00000000 --- a/src/main/java/com/weeth/domain/schedule/presentation/MeetingAdminController.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.weeth.domain.schedule.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import com.weeth.domain.schedule.application.exception.MeetingErrorCode; -import com.weeth.domain.schedule.application.usecase.MeetingUseCase; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import static com.weeth.domain.schedule.presentation.ScheduleResponseCode.MEETING_DELETE_SUCCESS; - -@Tag(name = "MEETING ADMIN", description = "[ADMIN] 정기모임 어드민 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/admin/meetings") -@ApiErrorCodeExample(MeetingErrorCode.class) -public class MeetingAdminController { - - private final MeetingUseCase meetingUseCase; - - @DeleteMapping("/{meetingId}") - @Operation(summary = "정기모임 삭제") - public CommonResponse delete(@PathVariable Long meetingId) { - meetingUseCase.delete(meetingId); - return CommonResponse.success(MEETING_DELETE_SUCCESS); - } -} diff --git a/src/main/java/com/weeth/domain/schedule/presentation/MeetingController.java b/src/main/java/com/weeth/domain/schedule/presentation/MeetingController.java deleted file mode 100644 index 3af43758..00000000 --- a/src/main/java/com/weeth/domain/schedule/presentation/MeetingController.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.weeth.domain.schedule.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import com.weeth.domain.schedule.application.dto.MeetingDTO; -import com.weeth.domain.schedule.application.exception.MeetingErrorCode; -import com.weeth.domain.schedule.application.usecase.MeetingUseCase; -import com.weeth.global.auth.annotation.CurrentUser; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import static com.weeth.domain.schedule.presentation.ScheduleResponseCode.MEETING_FIND_SUCCESS; - -@Tag(name = "MEETING", description = "정기모임 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/meetings") -@ApiErrorCodeExample(MeetingErrorCode.class) -public class MeetingController { - - private final MeetingUseCase meetingUseCase; - - @GetMapping("/{meetingId}") - @Operation(summary="정기모임 상세 조회") - public CommonResponse find(@Parameter(hidden = true) @CurrentUser Long userId, - @PathVariable Long meetingId) { - return CommonResponse.success(MEETING_FIND_SUCCESS, meetingUseCase.find(userId, meetingId)); - } -} diff --git a/src/main/java/com/weeth/domain/schedule/presentation/ScheduleController.java b/src/main/java/com/weeth/domain/schedule/presentation/ScheduleController.java deleted file mode 100644 index 7e3203c5..00000000 --- a/src/main/java/com/weeth/domain/schedule/presentation/ScheduleController.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.weeth.domain.schedule.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import com.weeth.domain.schedule.application.exception.EventErrorCode; -import com.weeth.domain.schedule.application.exception.MeetingErrorCode; -import com.weeth.domain.schedule.application.usecase.ScheduleUseCase; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.format.annotation.DateTimeFormat; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Map; - -import static com.weeth.domain.schedule.application.dto.ScheduleDTO.Response; -import static com.weeth.domain.schedule.presentation.ScheduleResponseCode.SCHEDULE_MONTHLY_FIND_SUCCESS; -import static com.weeth.domain.schedule.presentation.ScheduleResponseCode.SCHEDULE_YEARLY_FIND_SUCCESS; - -@Tag(name = "SCHEDULE", description = "캘린더 조회 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/schedules") -@ApiErrorCodeExample({EventErrorCode.class, MeetingErrorCode.class}) -public class ScheduleController { - - private final ScheduleUseCase scheduleUseCase; - - @GetMapping("/monthly") - @Operation(summary="월별 일정 조회") - public CommonResponse> findByMonthly(@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime start, - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime end) { - return CommonResponse.success(SCHEDULE_MONTHLY_FIND_SUCCESS,scheduleUseCase.findByMonthly(start, end)); - } - - @GetMapping("/yearly") - @Operation(summary="연도별 일정 조회") - public CommonResponse>> findByYearly(@RequestParam Integer year, - @RequestParam Integer semester) { - return CommonResponse.success(SCHEDULE_YEARLY_FIND_SUCCESS,scheduleUseCase.findByYearly(year, semester)); - } -} diff --git a/src/main/java/com/weeth/domain/schedule/presentation/ScheduleResponseCode.java b/src/main/java/com/weeth/domain/schedule/presentation/ScheduleResponseCode.java deleted file mode 100644 index 73655542..00000000 --- a/src/main/java/com/weeth/domain/schedule/presentation/ScheduleResponseCode.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.weeth.domain.schedule.presentation; - -import com.weeth.global.common.response.ResponseCodeInterface; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -public enum ScheduleResponseCode implements ResponseCodeInterface { - // EventAdminController 관련 - EVENT_SAVE_SUCCESS(1700, HttpStatus.OK, "일정/정기모임이 성공적으로 생성되었습니다."), - EVENT_UPDATE_SUCCESS(1701, HttpStatus.OK, "일정/정기모임이 성공적으로 수정되었습니다."), - EVENT_DELETE_SUCCESS(1702, HttpStatus.OK, "일정이 성공적으로 삭제되었습니다."), - // EventController 관련 - EVENT_FIND_SUCCESS(1703, HttpStatus.OK, "일정이 성공적으로 조회되었습니다."), - // MeetingAdminController 관련 - MEETING_SAVE_SUCCESS(1704, HttpStatus.OK, "정기모임 일정이 성공적으로 생성되었습니다."), - MEETING_UPDATE_SUCCESS(1705, HttpStatus.OK, "정기모임 일정이 성공적으로 수정되었습니다."), - MEETING_DELETE_SUCCESS(1706, HttpStatus.OK, "정기모임 일정이 성공적으로 삭제되었습니다."), - MEETING_CARDINAL_FIND_SUCCESS(1707, HttpStatus.OK, "특정 기수 정기모임이 성공적으로 조회되었습니다."), - MEETING_ALL_FIND_SUCCESS(1708, HttpStatus.OK, "정기모임 전체일정이 성공적으로 조회되었습니다."), - // MeetingController 관련 - MEETING_FIND_SUCCESS(1709, HttpStatus.OK, "정기모임이 성공적으로 조회되었습니다."), - // ScheduleController 관련 - SCHEDULE_MONTHLY_FIND_SUCCESS(1710, HttpStatus.OK, "월별 일정이 성공적으로 조회되었습니다."), - SCHEDULE_YEARLY_FIND_SUCCESS(1711, HttpStatus.OK, "연도별 일정이 성공적으로 조회되었습니다."); - - private final int code; - private final HttpStatus status; - private final String message; - - ScheduleResponseCode(int code, HttpStatus status, String message) { - this.code = code; - this.status = status; - this.message = message; - } -} diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceInfoResponse.kt b/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceInfoResponse.kt index 1b72be7a..e6f36bbb 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceInfoResponse.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceInfoResponse.kt @@ -1,13 +1,13 @@ package com.weeth.domain.attendance.application.dto.response -import com.weeth.domain.attendance.domain.enums.Status +import com.weeth.domain.attendance.domain.entity.enums.AttendanceStatus import io.swagger.v3.oas.annotations.media.Schema data class AttendanceInfoResponse( @field:Schema(description = "출석 ID", example = "1") val id: Long, @field:Schema(description = "출석 상태", example = "ATTEND") - val status: Status?, + val status: AttendanceStatus?, @field:Schema(description = "사용자 이름", example = "이지훈") val name: String?, @field:Schema(description = "소속 학과", example = "컴퓨터공학과") diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceResponse.kt b/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceResponse.kt index e99559af..7ef78c9a 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceResponse.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceResponse.kt @@ -1,6 +1,6 @@ package com.weeth.domain.attendance.application.dto.response -import com.weeth.domain.attendance.domain.enums.Status +import com.weeth.domain.attendance.domain.entity.enums.AttendanceStatus import io.swagger.v3.oas.annotations.media.Schema import java.time.LocalDateTime @@ -8,7 +8,7 @@ data class AttendanceResponse( @field:Schema(description = "출석 ID", example = "1") val id: Long, @field:Schema(description = "출석 상태", example = "ATTEND") - val status: Status?, + val status: AttendanceStatus?, @field:Schema(description = "정기모임 제목", example = "1주차 정기모임") val title: String?, @field:Schema(description = "정기모임 시작 시간") diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceSummaryResponse.kt b/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceSummaryResponse.kt index f53b633f..7a445550 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceSummaryResponse.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceSummaryResponse.kt @@ -1,6 +1,6 @@ package com.weeth.domain.attendance.application.dto.response -import com.weeth.domain.attendance.domain.enums.Status +import com.weeth.domain.attendance.domain.entity.enums.AttendanceStatus import io.swagger.v3.oas.annotations.media.Schema import java.time.LocalDateTime @@ -10,7 +10,7 @@ data class AttendanceSummaryResponse( @field:Schema(description = "정기모임 제목", example = "1주차 정기모임") val title: String?, @field:Schema(description = "출석 상태", example = "ATTEND") - val status: Status?, + val status: AttendanceStatus?, @field:Schema(description = "어드민인 경우 출석 코드 노출", example = "1234") val code: Int?, @field:Schema(description = "정기모임 시작 시간") diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapper.kt b/src/main/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapper.kt index 8ccf206b..fffc3d3a 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapper.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapper.kt @@ -17,12 +17,12 @@ class AttendanceMapper { ): AttendanceSummaryResponse = AttendanceSummaryResponse( attendanceRate = user.attendanceRate, - title = attendance?.meeting?.title, + title = attendance?.session?.title, status = attendance?.status, - code = if (isAdmin) attendance?.meeting?.code else null, - start = attendance?.meeting?.start, - end = attendance?.meeting?.end, - location = attendance?.meeting?.location, + code = if (isAdmin) attendance?.session?.code else null, + start = attendance?.session?.start, + end = attendance?.session?.end, + location = attendance?.session?.location, ) fun toDetailResponse( @@ -40,10 +40,10 @@ class AttendanceMapper { AttendanceResponse( id = attendance.id, status = attendance.status, - title = attendance.meeting.title, - start = attendance.meeting.start, - end = attendance.meeting.end, - location = attendance.meeting.location, + title = attendance.session.title, + start = attendance.session.start, + end = attendance.session.end, + location = attendance.session.location, ) fun toInfoResponse(attendance: Attendance): AttendanceInfoResponse = diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/CheckInAttendanceUseCase.kt b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/CheckInAttendanceUseCase.kt deleted file mode 100644 index 7d0efcd6..00000000 --- a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/CheckInAttendanceUseCase.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.weeth.domain.attendance.application.usecase.command - -import com.weeth.domain.attendance.application.exception.AttendanceCodeMismatchException -import com.weeth.domain.attendance.application.exception.AttendanceNotFoundException -import com.weeth.domain.attendance.domain.enums.Status -import com.weeth.domain.attendance.domain.repository.AttendanceRepository -import com.weeth.domain.user.domain.repository.UserReader -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional -import java.time.LocalDateTime - -@Service -class CheckInAttendanceUseCase( - private val userReader: UserReader, - private val attendanceRepository: AttendanceRepository, -) { - @Transactional - fun checkIn( - userId: Long, - code: Int, - ) { - val user = userReader.getById(userId) - val now = LocalDateTime.now() - - val todayAttendance = - attendanceRepository.findCurrentByUserId(userId, now, now.plusMinutes(10)) - ?: throw AttendanceNotFoundException() - - if (todayAttendance.isWrong(code)) { - throw AttendanceCodeMismatchException() - } - - if (todayAttendance.status != Status.ATTEND) { - todayAttendance.attend() - user.attend() - } - } -} diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/CloseAttendanceUseCase.kt b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/CloseAttendanceUseCase.kt deleted file mode 100644 index 7d860987..00000000 --- a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/CloseAttendanceUseCase.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.weeth.domain.attendance.application.usecase.command - -import com.weeth.domain.attendance.domain.entity.Attendance -import com.weeth.domain.attendance.domain.repository.AttendanceRepository -import com.weeth.domain.schedule.application.exception.MeetingNotFoundException -import com.weeth.domain.schedule.domain.service.MeetingGetService -import com.weeth.domain.user.domain.entity.enums.Status -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional -import java.time.LocalDate - -@Service -class CloseAttendanceUseCase( - private val meetingGetService: MeetingGetService, - private val attendanceRepository: AttendanceRepository, -) { - @Transactional - fun close( - now: LocalDate, - cardinal: Int, - ) { - val meetings = meetingGetService.find(cardinal) - - val targetMeeting = - meetings.firstOrNull { meeting -> - meeting.start.toLocalDate().isEqual(now) && - meeting.end.toLocalDate().isEqual(now) - } ?: throw MeetingNotFoundException() - - val attendanceList = attendanceRepository.findAllByMeetingAndUserStatus(targetMeeting, Status.ACTIVE) - closePendingAttendances(attendanceList) - } - - @Transactional - fun autoClose() { - val meetings = meetingGetService.findAllOpenMeetingsBeforeNow() - - meetings.forEach { meeting -> - meeting.close() - val attendanceList = attendanceRepository.findAllByMeetingAndUserStatus(meeting, Status.ACTIVE) - closePendingAttendances(attendanceList) - } - } - - private fun closePendingAttendances(attendances: List) { - attendances - .filter { it.isPending } - .forEach { attendance -> - attendance.close() - attendance.user.absent() - } - } -} diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCase.kt b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCase.kt new file mode 100644 index 00000000..fcdfee1d --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCase.kt @@ -0,0 +1,102 @@ +package com.weeth.domain.attendance.application.usecase.command + +import com.weeth.domain.attendance.application.dto.request.UpdateAttendanceStatusRequest +import com.weeth.domain.attendance.application.exception.AttendanceCodeMismatchException +import com.weeth.domain.attendance.application.exception.AttendanceNotFoundException +import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.attendance.domain.entity.enums.AttendanceStatus +import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.session.application.exception.SessionNotFoundException +import com.weeth.domain.session.domain.entity.enums.SessionStatus +import com.weeth.domain.session.domain.repository.SessionReader +import com.weeth.domain.user.domain.entity.enums.Status +import com.weeth.domain.user.domain.repository.UserReader +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate +import java.time.LocalDateTime + +@Service +class ManageAttendanceUseCase( + private val userReader: UserReader, + private val sessionReader: SessionReader, + private val attendanceRepository: AttendanceRepository, +) { + @Transactional + fun checkIn( + userId: Long, + code: Int, + ) { + val user = userReader.getById(userId) + val now = LocalDateTime.now() + val todayAttendance = + attendanceRepository.findCurrentByUserId(userId, now, now.plusMinutes(10)) + ?: throw AttendanceNotFoundException() + if (todayAttendance.isWrong(code)) { + throw AttendanceCodeMismatchException() + } + val lockedAttendance = + attendanceRepository.findBySessionAndUserWithLock(todayAttendance.session, user) + ?: throw AttendanceNotFoundException() + if (lockedAttendance.status != AttendanceStatus.ATTEND) { + lockedAttendance.attend() + user.attend() + } + } + + @Transactional + fun close( + now: LocalDate, + cardinal: Int, + ) { + val targetSession = + sessionReader + .findAllByCardinalOrderByStartAsc(cardinal) + .firstOrNull { session -> session.start.toLocalDate().isEqual(now) && session.end.toLocalDate().isEqual(now) } + ?: throw SessionNotFoundException() + val attendances = attendanceRepository.findAllBySessionAndUserStatus(targetSession, Status.ACTIVE) + closePendingAttendances(attendances) + } + + @Transactional + fun autoClose() { + val sessions = sessionReader.findAllByStatusAndEndBeforeOrderByEndAsc(SessionStatus.OPEN, LocalDateTime.now()) + sessions.forEach { session -> + session.close() + val attendances = attendanceRepository.findAllBySessionAndUserStatus(session, Status.ACTIVE) + closePendingAttendances(attendances) + } + } + + @Transactional + fun updateStatus(attendanceUpdates: List) { + attendanceUpdates.forEach { update -> + val attendance = + attendanceRepository.findByIdWithUser(update.attendanceId) + ?: throw AttendanceNotFoundException() + val user = attendance.user + val newStatus = AttendanceStatus.valueOf(update.status) + + if (attendance.status == newStatus) return@forEach + + val prevStatus = attendance.status + attendance.adminOverride(newStatus) + if (newStatus == AttendanceStatus.ABSENT) { + if (prevStatus == AttendanceStatus.ATTEND) user.removeAttend() + user.absent() + } else { + if (prevStatus == AttendanceStatus.ABSENT) user.removeAbsent() + user.attend() + } + } + } + + private fun closePendingAttendances(attendances: List) { + attendances + .filter { it.isPending() } + .forEach { attendance -> + attendance.close() + attendance.user.absent() + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/UpdateAttendanceStatusUseCase.kt b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/UpdateAttendanceStatusUseCase.kt deleted file mode 100644 index 14d984bf..00000000 --- a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/UpdateAttendanceStatusUseCase.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.weeth.domain.attendance.application.usecase.command - -import com.weeth.domain.attendance.application.dto.request.UpdateAttendanceStatusRequest -import com.weeth.domain.attendance.application.exception.AttendanceNotFoundException -import com.weeth.domain.attendance.domain.enums.Status -import com.weeth.domain.attendance.domain.repository.AttendanceRepository -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional - -@Service -class UpdateAttendanceStatusUseCase( - private val attendanceRepository: AttendanceRepository, -) { - @Transactional - fun updateStatus(attendanceUpdates: List) { - attendanceUpdates.forEach { update -> - val attendance = - attendanceRepository.findByIdWithUser(update.attendanceId) - ?: throw AttendanceNotFoundException() - val user = attendance.user - val newStatus = Status.valueOf(update.status) - - if (newStatus == Status.ABSENT) { - attendance.close() - user.removeAttend() - user.absent() - } else { - attendance.attend() - user.removeAbsent() - user.attend() - } - } - } -} diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt index 59a39143..9da8d254 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt @@ -5,7 +5,7 @@ import com.weeth.domain.attendance.application.dto.response.AttendanceInfoRespon import com.weeth.domain.attendance.application.dto.response.AttendanceSummaryResponse import com.weeth.domain.attendance.application.mapper.AttendanceMapper import com.weeth.domain.attendance.domain.repository.AttendanceRepository -import com.weeth.domain.schedule.domain.service.MeetingGetService +import com.weeth.domain.session.domain.repository.SessionReader import com.weeth.domain.user.domain.entity.enums.Role import com.weeth.domain.user.domain.entity.enums.Status import com.weeth.domain.user.domain.repository.UserReader @@ -19,9 +19,9 @@ import java.time.LocalDate class GetAttendanceQueryService( private val userReader: UserReader, private val userCardinalPolicy: UserCardinalPolicy, - private val meetingGetService: MeetingGetService, + private val sessionReader: SessionReader, private val attendanceRepository: AttendanceRepository, - private val mapper: AttendanceMapper, + private val attendanceMapper: AttendanceMapper, ) { fun findAttendance(userId: Long): AttendanceSummaryResponse { val user = userReader.getById(userId) @@ -34,7 +34,7 @@ class GetAttendanceQueryService( today.plusDays(1).atStartOfDay(), ) - return mapper.toSummaryResponse(user, todayAttendance, isAdmin = user.role == Role.ADMIN) + return attendanceMapper.toSummaryResponse(user, todayAttendance, isAdmin = user.role == Role.ADMIN) } fun findAllDetailsByCurrentCardinal(userId: Long): AttendanceDetailResponse { @@ -44,14 +44,14 @@ class GetAttendanceQueryService( val responses = attendanceRepository .findAllByUserIdAndCardinal(userId, currentCardinal.cardinalNumber) - .map(mapper::toResponse) + .map(attendanceMapper::toResponse) - return mapper.toDetailResponse(user, responses) + return attendanceMapper.toDetailResponse(user, responses) } - fun findAllAttendanceByMeeting(meetingId: Long): List { - val meeting = meetingGetService.find(meetingId) - val attendances = attendanceRepository.findAllByMeetingAndUserStatus(meeting, Status.ACTIVE) - return attendances.map(mapper::toInfoResponse) + fun findAllAttendanceBySession(sessionId: Long): List { + val session = sessionReader.getById(sessionId) + val attendances = attendanceRepository.findAllBySessionAndUserStatus(session, Status.ACTIVE) + return attendances.map(attendanceMapper::toInfoResponse) } } diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/entity/Attendance.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/entity/Attendance.kt index f40184ba..be54e167 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/domain/entity/Attendance.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/entity/Attendance.kt @@ -1,7 +1,7 @@ package com.weeth.domain.attendance.domain.entity -import com.weeth.domain.attendance.domain.enums.Status -import com.weeth.domain.schedule.domain.entity.Meeting +import com.weeth.domain.attendance.domain.entity.enums.AttendanceStatus +import com.weeth.domain.session.domain.entity.Session import com.weeth.domain.user.domain.entity.User import com.weeth.global.common.entity.BaseEntity import jakarta.persistence.Column @@ -14,40 +14,51 @@ import jakarta.persistence.GenerationType import jakarta.persistence.Id import jakarta.persistence.JoinColumn import jakarta.persistence.ManyToOne -import jakarta.persistence.PrePersist +import org.hibernate.annotations.OnDelete +import org.hibernate.annotations.OnDeleteAction @Entity -class Attendance - @JvmOverloads - constructor( - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "meeting_id") - val meeting: Meeting, - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id") - val user: User, - @Enumerated(EnumType.STRING) - var status: Status? = null, - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "attendance_id") - val id: Long = 0, - ) : BaseEntity() { - @PrePersist - fun init() { - status = Status.PENDING - } - - fun attend() { - status = Status.ATTEND - } - - fun close() { - status = Status.ABSENT - } - - val isPending: Boolean - get() = status == Status.PENDING - - fun isWrong(code: Int): Boolean = meeting.getCode() != code +class Attendance( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "meeting_id") + val session: Session, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + @OnDelete(action = OnDeleteAction.CASCADE) + val user: User, + @Enumerated(EnumType.STRING) + var status: AttendanceStatus = AttendanceStatus.PENDING, +) : BaseEntity() { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "attendance_id") + val id: Long = 0 + + fun attend() { + check(status == AttendanceStatus.PENDING) { "이미 처리된 출석입니다" } + status = AttendanceStatus.ATTEND + } + + fun absent() { + check(status == AttendanceStatus.PENDING) { "이미 처리된 출석입니다" } + status = AttendanceStatus.ABSENT + } + + // 기존 close() 는 absent() 로 대체 (AttendanceUpdateService 호환 유지) + fun close() = absent() + + fun adminOverride(newStatus: AttendanceStatus) { + status = newStatus + } + + fun isPending(): Boolean = status == AttendanceStatus.PENDING + + fun isWrong(code: Int): Boolean = !session.isCodeMatch(code) + + companion object { + fun create( + session: Session, + user: User, + ): Attendance = Attendance(session = session, user = user) } +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/entity/enums/AttendanceStatus.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/entity/enums/AttendanceStatus.kt new file mode 100644 index 00000000..f54fc9f0 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/entity/enums/AttendanceStatus.kt @@ -0,0 +1,7 @@ +package com.weeth.domain.attendance.domain.entity.enums + +enum class AttendanceStatus { + ATTEND, + PENDING, + ABSENT, +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/enums/Status.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/enums/Status.kt deleted file mode 100644 index 6184bf45..00000000 --- a/src/main/kotlin/com/weeth/domain/attendance/domain/enums/Status.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.weeth.domain.attendance.domain.enums - -enum class Status { - ATTEND, - PENDING, - ABSENT, -} diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt index cd0d82a0..96c162a8 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt @@ -1,32 +1,53 @@ package com.weeth.domain.attendance.domain.repository import com.weeth.domain.attendance.domain.entity.Attendance -import com.weeth.domain.schedule.domain.entity.Meeting +import com.weeth.domain.session.domain.entity.Session +import com.weeth.domain.user.domain.entity.User import com.weeth.domain.user.domain.entity.enums.Status +import jakarta.persistence.LockModeType +import jakarta.persistence.QueryHint import org.springframework.data.jpa.repository.EntityGraph import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Lock import org.springframework.data.jpa.repository.Modifying import org.springframework.data.jpa.repository.Query +import org.springframework.data.jpa.repository.QueryHints import org.springframework.data.repository.query.Param import java.time.LocalDateTime interface AttendanceRepository : JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query("SELECT a FROM Attendance a JOIN FETCH a.user WHERE a.session = :session AND a.user = :user") + fun findBySessionAndUserWithLock( + @Param("session") session: Session, + @Param("user") user: User, + ): Attendance? + @EntityGraph(attributePaths = ["user"]) - fun findAllByMeetingAndUserStatus( - meeting: Meeting, + fun findAllBySessionAndUserStatus( + session: Session, status: Status, ): List + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query("SELECT a FROM Attendance a JOIN FETCH a.user WHERE a.session = :session AND a.user.status = :status") + fun findAllBySessionAndUserStatusWithLock( + @Param("session") session: Session, + @Param("status") status: Status, + ): List + @Query("SELECT a FROM Attendance a JOIN FETCH a.user WHERE a.id = :id") fun findByIdWithUser(id: Long): Attendance? @Query( """ SELECT a FROM Attendance a - JOIN FETCH a.meeting m + JOIN FETCH a.session s WHERE a.user.id = :userId - AND m.start <= :checkInEnd - AND m.end > :now + AND s.start <= :checkInEnd + AND s.end > :now """, ) fun findCurrentByUserId( @@ -38,10 +59,10 @@ interface AttendanceRepository : JpaRepository { @Query( """ SELECT a FROM Attendance a - JOIN FETCH a.meeting m + JOIN FETCH a.session s WHERE a.user.id = :userId - AND m.start >= :dayStart - AND m.end < :dayEnd + AND s.start >= :dayStart + AND s.end < :dayEnd """, ) fun findTodayByUserId( @@ -53,10 +74,10 @@ interface AttendanceRepository : JpaRepository { @Query( """ SELECT a FROM Attendance a - JOIN FETCH a.meeting m + JOIN FETCH a.session s WHERE a.user.id = :userId - AND m.cardinal = :cardinal - ORDER BY m.start + AND s.cardinal = :cardinal + ORDER BY s.start """, ) fun findAllByUserIdAndCardinal( @@ -64,7 +85,13 @@ interface AttendanceRepository : JpaRepository { @Param("cardinal") cardinal: Int, ): List - @Modifying - @Query("DELETE FROM Attendance a WHERE a.meeting = :meeting") - fun deleteAllByMeeting(meeting: Meeting) + // TODO: QR 코드 출석 기능 구현 시 사용 예정 (여러 세션의 출석자 배치 조회) + @Query("SELECT a FROM Attendance a JOIN FETCH a.user WHERE a.session IN :sessions") + fun findAllBySessionIn( + @Param("sessions") sessions: List, + ): List + + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("DELETE FROM Attendance a WHERE a.session = :session") + fun deleteAllBySession(session: Session) } diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/service/AttendanceDeleteService.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/service/AttendanceDeleteService.kt deleted file mode 100644 index d4a05bb7..00000000 --- a/src/main/kotlin/com/weeth/domain/attendance/domain/service/AttendanceDeleteService.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.weeth.domain.attendance.domain.service - -import com.weeth.domain.attendance.domain.repository.AttendanceRepository -import com.weeth.domain.schedule.domain.entity.Meeting -import org.springframework.stereotype.Service - -@Service -class AttendanceDeleteService( - private val attendanceRepository: AttendanceRepository, -) { - fun deleteAll(meeting: Meeting) { - attendanceRepository.deleteAllByMeeting(meeting) - } -} diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/service/AttendanceGetService.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/service/AttendanceGetService.kt deleted file mode 100644 index 647910b7..00000000 --- a/src/main/kotlin/com/weeth/domain/attendance/domain/service/AttendanceGetService.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.weeth.domain.attendance.domain.service - -import com.weeth.domain.attendance.domain.entity.Attendance -import com.weeth.domain.attendance.domain.repository.AttendanceRepository -import com.weeth.domain.schedule.domain.entity.Meeting -import com.weeth.domain.user.domain.entity.enums.Status -import org.springframework.stereotype.Service - -@Service -class AttendanceGetService( - private val attendanceRepository: AttendanceRepository, -) { - fun findAllByMeeting(meeting: Meeting): List = attendanceRepository.findAllByMeetingAndUserStatus(meeting, Status.ACTIVE) -} diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/service/AttendanceSaveService.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/service/AttendanceSaveService.kt deleted file mode 100644 index f44045c4..00000000 --- a/src/main/kotlin/com/weeth/domain/attendance/domain/service/AttendanceSaveService.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.weeth.domain.attendance.domain.service - -import com.weeth.domain.attendance.domain.entity.Attendance -import com.weeth.domain.attendance.domain.repository.AttendanceRepository -import com.weeth.domain.schedule.domain.entity.Meeting -import com.weeth.domain.user.domain.entity.User -import org.springframework.stereotype.Service - -@Service -class AttendanceSaveService( - private val attendanceRepository: AttendanceRepository, -) { - fun init( - user: User, - meetings: List?, - ) { - meetings?.forEach { meeting -> - attendanceRepository.save(Attendance(meeting, user)) - } - } - - fun saveAll( - userList: List, - meeting: Meeting, - ) { - val attendances = userList.map { user -> Attendance(meeting, user) } - attendanceRepository.saveAll(attendances) - } -} diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/service/AttendanceUpdateService.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/service/AttendanceUpdateService.kt deleted file mode 100644 index cc8f98ac..00000000 --- a/src/main/kotlin/com/weeth/domain/attendance/domain/service/AttendanceUpdateService.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.weeth.domain.attendance.domain.service - -import com.weeth.domain.attendance.domain.entity.Attendance -import com.weeth.domain.attendance.domain.enums.Status -import org.springframework.stereotype.Service - -@Service -class AttendanceUpdateService { - fun attend(attendance: Attendance) { - attendance.attend() - attendance.user.attend() - } - - fun close(attendances: List) { - attendances - .filter { it.isPending } - .forEach { attendance -> - attendance.close() - attendance.user.absent() - } - } - - fun updateUserAttendanceByStatus(attendances: List) { - attendances.forEach { attendance -> - val user = attendance.user - if (attendance.status == Status.ATTEND) { - user.removeAttend() - } else { - user.removeAbsent() - } - } - } -} diff --git a/src/main/kotlin/com/weeth/domain/attendance/infrastructure/AttendanceScheduler.kt b/src/main/kotlin/com/weeth/domain/attendance/infrastructure/AttendanceScheduler.kt index a41113e6..9890be6a 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/infrastructure/AttendanceScheduler.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/infrastructure/AttendanceScheduler.kt @@ -1,15 +1,15 @@ package com.weeth.domain.attendance.infrastructure -import com.weeth.domain.attendance.application.usecase.command.CloseAttendanceUseCase +import com.weeth.domain.attendance.application.usecase.command.ManageAttendanceUseCase import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Component @Component class AttendanceScheduler( - private val closeAttendanceUseCase: CloseAttendanceUseCase, + private val manageAttendanceUseCase: ManageAttendanceUseCase, ) { @Scheduled(cron = "0 0 22 * * THU", zone = "Asia/Seoul") fun autoCloseAttendance() { - closeAttendanceUseCase.autoClose() + manageAttendanceUseCase.autoClose() } } diff --git a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceAdminController.kt b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceAdminController.kt index 4e8ca2a2..86b82904 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceAdminController.kt @@ -3,11 +3,8 @@ package com.weeth.domain.attendance.presentation import com.weeth.domain.attendance.application.dto.request.UpdateAttendanceStatusRequest import com.weeth.domain.attendance.application.dto.response.AttendanceInfoResponse import com.weeth.domain.attendance.application.exception.AttendanceErrorCode -import com.weeth.domain.attendance.application.usecase.command.CloseAttendanceUseCase -import com.weeth.domain.attendance.application.usecase.command.UpdateAttendanceStatusUseCase +import com.weeth.domain.attendance.application.usecase.command.ManageAttendanceUseCase import com.weeth.domain.attendance.application.usecase.query.GetAttendanceQueryService -import com.weeth.domain.schedule.application.dto.MeetingDTO -import com.weeth.domain.schedule.application.usecase.MeetingUseCase import com.weeth.global.common.exception.ApiErrorCodeExample import com.weeth.global.common.response.CommonResponse import io.swagger.v3.oas.annotations.Operation @@ -24,41 +21,30 @@ import java.time.LocalDate @Tag(name = "ATTENDANCE ADMIN", description = "[ADMIN] 출석 어드민 API") @RestController -@RequestMapping("/api/v1/admin/attendances") +@RequestMapping("/api/v4/admin/attendances") @ApiErrorCodeExample(AttendanceErrorCode::class) class AttendanceAdminController( - private val closeAttendanceUseCase: CloseAttendanceUseCase, - private val updateAttendanceStatusUseCase: UpdateAttendanceStatusUseCase, + private val manageAttendanceUseCase: ManageAttendanceUseCase, private val getAttendanceQueryService: GetAttendanceQueryService, - private val meetingUseCase: MeetingUseCase, ) { - @PatchMapping + @PatchMapping("/close") @Operation(summary = "출석 마감") fun close( @RequestParam now: LocalDate, @RequestParam cardinal: Int, ): CommonResponse { - closeAttendanceUseCase.close(now, cardinal) + manageAttendanceUseCase.close(now, cardinal) return CommonResponse.success(AttendanceResponseCode.ATTENDANCE_CLOSE_SUCCESS) } - @GetMapping("/meetings") - @Operation(summary = "정기모임 조회") - fun getMeetings( - @RequestParam(required = false) cardinal: Int?, - ): CommonResponse { - val response = meetingUseCase.find(cardinal) - return CommonResponse.success(AttendanceResponseCode.MEETING_FIND_SUCCESS, response) - } - - @GetMapping("/{meetingId}") + @GetMapping("/{sessionId}") @Operation(summary = "모든 인원 정기모임 출석 정보 조회") fun getAllAttendance( - @PathVariable meetingId: Long, + @PathVariable sessionId: Long, ): CommonResponse> = CommonResponse.success( AttendanceResponseCode.ATTENDANCE_FIND_DETAIL_SUCCESS, - getAttendanceQueryService.findAllAttendanceByMeeting(meetingId), + getAttendanceQueryService.findAllAttendanceBySession(sessionId), ) @PatchMapping("/status") @@ -66,7 +52,7 @@ class AttendanceAdminController( fun updateAttendanceStatus( @RequestBody @Valid attendanceUpdates: List, ): CommonResponse { - updateAttendanceStatusUseCase.updateStatus(attendanceUpdates) + manageAttendanceUseCase.updateStatus(attendanceUpdates) return CommonResponse.success(AttendanceResponseCode.ATTENDANCE_UPDATED_SUCCESS) } } diff --git a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt index 8a1256b7..37aa0ec9 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt @@ -4,7 +4,7 @@ import com.weeth.domain.attendance.application.dto.request.CheckInRequest import com.weeth.domain.attendance.application.dto.response.AttendanceDetailResponse import com.weeth.domain.attendance.application.dto.response.AttendanceSummaryResponse import com.weeth.domain.attendance.application.exception.AttendanceErrorCode -import com.weeth.domain.attendance.application.usecase.command.CheckInAttendanceUseCase +import com.weeth.domain.attendance.application.usecase.command.ManageAttendanceUseCase import com.weeth.domain.attendance.application.usecase.query.GetAttendanceQueryService import com.weeth.global.auth.annotation.CurrentUser import com.weeth.global.common.exception.ApiErrorCodeExample @@ -13,26 +13,26 @@ import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @Tag(name = "ATTENDANCE", description = "출석 API") @RestController -@RequestMapping("/api/v1/attendances") +@RequestMapping("/api/v4/attendances") @ApiErrorCodeExample(AttendanceErrorCode::class) class AttendanceController( - private val checkInAttendanceUseCase: CheckInAttendanceUseCase, + private val manageAttendanceUseCase: ManageAttendanceUseCase, private val getAttendanceQueryService: GetAttendanceQueryService, ) { - @PatchMapping + @PostMapping("/check-in") @Operation(summary = "출석체크") fun checkIn( @Parameter(hidden = true) @CurrentUser userId: Long, @RequestBody checkIn: CheckInRequest, ): CommonResponse { - checkInAttendanceUseCase.checkIn(userId, checkIn.code) + manageAttendanceUseCase.checkIn(userId, checkIn.code) return CommonResponse.success(AttendanceResponseCode.ATTENDANCE_CHECKIN_SUCCESS) } diff --git a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceResponseCode.kt b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceResponseCode.kt index 54ad6348..e67ef08a 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceResponseCode.kt @@ -12,10 +12,9 @@ enum class AttendanceResponseCode( ATTENDANCE_CLOSE_SUCCESS(1200, HttpStatus.OK, "출석이 성공적으로 마감되었습니다."), ATTENDANCE_UPDATED_SUCCESS(1201, HttpStatus.OK, "개별 출석 상태가 성공적으로 수정되었습니다."), ATTENDANCE_FIND_DETAIL_SUCCESS(1202, HttpStatus.OK, "모든 인원의 정기모임 출석 정보가 성공적으로 조회되었습니다."), - MEETING_FIND_SUCCESS(1203, HttpStatus.OK, "기수별 정기모임 리스트를 성공적으로 조회했습니다."), // AttendanceController 관련 - ATTENDANCE_CHECKIN_SUCCESS(1204, HttpStatus.OK, "출석이 성공적으로 처리되었습니다."), - ATTENDANCE_FIND_SUCCESS(1205, HttpStatus.OK, "사용자의 출석 정보가 성공적으로 조회되었습니다."), - ATTENDANCE_FIND_ALL_SUCCESS(1206, HttpStatus.OK, "사용자의 상세 출석 정보가 성공적으로 조회되었습니다."), + ATTENDANCE_CHECKIN_SUCCESS(1203, HttpStatus.OK, "출석이 성공적으로 처리되었습니다."), + ATTENDANCE_FIND_SUCCESS(1204, HttpStatus.OK, "사용자의 출석 정보가 성공적으로 조회되었습니다."), + ATTENDANCE_FIND_ALL_SUCCESS(1205, HttpStatus.OK, "사용자의 상세 출석 정보가 성공적으로 조회되었습니다."), } diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/annotation/ScheduleTimeCheck.kt b/src/main/kotlin/com/weeth/domain/schedule/application/annotation/ScheduleTimeCheck.kt new file mode 100644 index 00000000..46202a1d --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/application/annotation/ScheduleTimeCheck.kt @@ -0,0 +1,15 @@ +package com.weeth.domain.schedule.application.annotation + +import com.weeth.domain.schedule.application.validator.ScheduleTimeCheckValidator +import jakarta.validation.Constraint +import jakarta.validation.Payload +import kotlin.reflect.KClass + +@Target(AnnotationTarget.FIELD) +@Retention(AnnotationRetention.RUNTIME) +@Constraint(validatedBy = [ScheduleTimeCheckValidator::class]) +annotation class ScheduleTimeCheck( + val message: String = "마감 시간이 시작 시간보다 빠를 수 없습니다.", + val groups: Array> = [], + val payload: Array> = [], +) diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/dto/request/ScheduleSaveRequest.kt b/src/main/kotlin/com/weeth/domain/schedule/application/dto/request/ScheduleSaveRequest.kt new file mode 100644 index 00000000..942a6608 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/application/dto/request/ScheduleSaveRequest.kt @@ -0,0 +1,32 @@ +package com.weeth.domain.schedule.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size +import org.springframework.format.annotation.DateTimeFormat +import java.time.LocalDateTime + +data class ScheduleSaveRequest( + @field:Schema(description = "일정 제목", example = "MT") + @field:NotBlank + val title: String, + @field:Schema(description = "일정 내용", example = "1박 2일 MT입니다.") + @field:NotBlank + @field:Size(max = 500) + val content: String, + @field:Schema(description = "장소", example = "가평") + @field:NotBlank + val location: String, + @field:Schema(description = "기수", example = "4") + @field:NotNull + val cardinal: Int, + @field:Schema(description = "시작 시간", example = "2024-03-01T10:00:00") + @field:NotNull + @field:DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + val start: LocalDateTime, + @field:Schema(description = "종료 시간", example = "2024-03-01T12:00:00") + @field:NotNull + @field:DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + val end: LocalDateTime, +) diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/dto/request/ScheduleTimeRequest.kt b/src/main/kotlin/com/weeth/domain/schedule/application/dto/request/ScheduleTimeRequest.kt new file mode 100644 index 00000000..debc0e90 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/application/dto/request/ScheduleTimeRequest.kt @@ -0,0 +1,17 @@ +package com.weeth.domain.schedule.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotNull +import org.springframework.format.annotation.DateTimeFormat +import java.time.LocalDateTime + +data class ScheduleTimeRequest( + @field:Schema(description = "시작 시간", example = "2024-03-01T10:00:00") + @field:NotNull + @field:DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + val start: LocalDateTime, + @field:Schema(description = "종료 시간", example = "2024-03-01T12:00:00") + @field:NotNull + @field:DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + val end: LocalDateTime, +) diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/dto/request/ScheduleUpdateRequest.kt b/src/main/kotlin/com/weeth/domain/schedule/application/dto/request/ScheduleUpdateRequest.kt new file mode 100644 index 00000000..e9694e21 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/application/dto/request/ScheduleUpdateRequest.kt @@ -0,0 +1,29 @@ +package com.weeth.domain.schedule.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size +import org.springframework.format.annotation.DateTimeFormat +import java.time.LocalDateTime + +data class ScheduleUpdateRequest( + @field:Schema(description = "일정 제목", example = "MT") + @field:NotBlank + val title: String, + @field:Schema(description = "일정 내용", example = "1박 2일 MT입니다.") + @field:NotBlank + @field:Size(max = 500) + val content: String, + @field:Schema(description = "장소", example = "가평") + @field:NotBlank + val location: String, + @field:Schema(description = "시작 시간", example = "2024-03-01T10:00:00") + @field:NotNull + @field:DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + val start: LocalDateTime, + @field:Schema(description = "종료 시간", example = "2024-03-01T12:00:00") + @field:NotNull + @field:DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + val end: LocalDateTime, +) diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/EventResponse.kt b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/EventResponse.kt new file mode 100644 index 00000000..f6d476a4 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/EventResponse.kt @@ -0,0 +1,30 @@ +package com.weeth.domain.schedule.application.dto.response + +import com.weeth.domain.schedule.domain.entity.enums.Type +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class EventResponse( + @field:Schema(description = "일정 ID", example = "1") + val id: Long, + @field:Schema(description = "일정 제목", example = "MT") + val title: String, + @field:Schema(description = "일정 내용") + val content: String, + @field:Schema(description = "장소", example = "가평") + val location: String, + @field:Schema(description = "작성자 이름", example = "이지훈") + val name: String?, + @field:Schema(description = "기수", example = "4") + val cardinal: Int, + @field:Schema(description = "일정 타입", example = "EVENT") + val type: Type, + @field:Schema(description = "시작 시간") + val start: LocalDateTime, + @field:Schema(description = "종료 시간") + val end: LocalDateTime, + @field:Schema(description = "생성 시간") + val createdAt: LocalDateTime?, + @field:Schema(description = "수정 시간") + val modifiedAt: LocalDateTime?, +) diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/ScheduleResponse.kt b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/ScheduleResponse.kt new file mode 100644 index 00000000..1a0d883f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/ScheduleResponse.kt @@ -0,0 +1,17 @@ +package com.weeth.domain.schedule.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class ScheduleResponse( + @field:Schema(description = "일정 ID", example = "1") + val id: Long, + @field:Schema(description = "제목", example = "1차 정기모임") + val title: String, + @field:Schema(description = "시작 시간") + val start: LocalDateTime, + @field:Schema(description = "종료 시간") + val end: LocalDateTime, + @field:Schema(description = "정기모임 여부") + val isSession: Boolean, +) diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/SessionInfoResponse.kt b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/SessionInfoResponse.kt new file mode 100644 index 00000000..2a93789e --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/SessionInfoResponse.kt @@ -0,0 +1,15 @@ +package com.weeth.domain.schedule.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class SessionInfoResponse( + @field:Schema(description = "정기모임 ID", example = "1") + val id: Long, + @field:Schema(description = "기수", example = "4") + val cardinal: Int, + @field:Schema(description = "제목", example = "1차 정기모임") + val title: String, + @field:Schema(description = "시작 시간") + val start: LocalDateTime, +) diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/SessionInfosResponse.kt b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/SessionInfosResponse.kt new file mode 100644 index 00000000..88409aa5 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/SessionInfosResponse.kt @@ -0,0 +1,10 @@ +package com.weeth.domain.schedule.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class SessionInfosResponse( + @field:Schema(description = "이번 주 정기모임") + val thisWeek: SessionInfoResponse?, + @field:Schema(description = "정기모임 목록") + val sessions: List, +) diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/SessionResponse.kt b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/SessionResponse.kt new file mode 100644 index 00000000..7c06371f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/SessionResponse.kt @@ -0,0 +1,34 @@ +package com.weeth.domain.schedule.application.dto.response + +import com.fasterxml.jackson.annotation.JsonInclude +import com.weeth.domain.schedule.domain.entity.enums.Type +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class SessionResponse( + @field:Schema(description = "정기모임 ID", example = "1") + val id: Long, + @field:Schema(description = "제목", example = "1차 정기모임") + val title: String, + @field:Schema(description = "내용") + val content: String?, + @field:Schema(description = "장소", example = "공학관 401호") + val location: String?, + @field:Schema(description = "작성자 이름", example = "이지훈") + val name: String?, + @field:Schema(description = "기수", example = "4") + val cardinal: Int, + @field:Schema(description = "일정 타입", example = "MEETING") + val type: Type, + @field:Schema(description = "출석 코드", example = "1234") + val code: Int?, + @field:Schema(description = "시작 시간") + val start: LocalDateTime, + @field:Schema(description = "종료 시간") + val end: LocalDateTime, + @field:Schema(description = "생성 시간") + val createdAt: LocalDateTime?, + @field:Schema(description = "수정 시간") + val modifiedAt: LocalDateTime?, +) diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/exception/EventErrorCode.kt b/src/main/kotlin/com/weeth/domain/schedule/application/exception/EventErrorCode.kt new file mode 100644 index 00000000..2e266bb0 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/application/exception/EventErrorCode.kt @@ -0,0 +1,21 @@ +package com.weeth.domain.schedule.application.exception + +import com.weeth.global.common.exception.ErrorCodeInterface +import com.weeth.global.common.exception.ExplainError +import org.springframework.http.HttpStatus + +enum class EventErrorCode( + private val code: Int, + private val status: HttpStatus, + private val message: String, +) : ErrorCodeInterface { + @ExplainError("요청한 일정 ID에 해당하는 일정이 존재하지 않을 때 발생합니다.") + EVENT_NOT_FOUND(2700, HttpStatus.NOT_FOUND, "존재하지 않는 일정입니다."), + ; + + override fun getCode(): Int = code + + override fun getStatus(): HttpStatus = status + + override fun getMessage(): String = message +} diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/exception/EventNotFoundException.kt b/src/main/kotlin/com/weeth/domain/schedule/application/exception/EventNotFoundException.kt new file mode 100644 index 00000000..56968357 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/application/exception/EventNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.schedule.application.exception + +import com.weeth.global.common.exception.BaseException + +class EventNotFoundException : BaseException(EventErrorCode.EVENT_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/mapper/EventMapper.kt b/src/main/kotlin/com/weeth/domain/schedule/application/mapper/EventMapper.kt new file mode 100644 index 00000000..a1caa781 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/application/mapper/EventMapper.kt @@ -0,0 +1,40 @@ +package com.weeth.domain.schedule.application.mapper + +import com.weeth.domain.schedule.application.dto.request.ScheduleSaveRequest +import com.weeth.domain.schedule.application.dto.response.EventResponse +import com.weeth.domain.schedule.domain.entity.Event +import com.weeth.domain.schedule.domain.entity.enums.Type +import com.weeth.domain.user.domain.entity.User +import org.springframework.stereotype.Component + +@Component +class EventMapper { + fun toResponse(event: Event): EventResponse = + EventResponse( + id = event.id, + title = event.title, + content = event.content, + location = event.location, + name = event.user?.name, + cardinal = event.cardinal, + type = Type.EVENT, + start = event.start, + end = event.end, + createdAt = event.createdAt, + modifiedAt = event.modifiedAt, + ) + + fun toEntity( + request: ScheduleSaveRequest, + user: User, + ): Event = + Event.create( + title = request.title, + content = request.content, + location = request.location, + cardinal = request.cardinal, + start = request.start, + end = request.end, + user = user, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/mapper/ScheduleMapper.kt b/src/main/kotlin/com/weeth/domain/schedule/application/mapper/ScheduleMapper.kt new file mode 100644 index 00000000..efe5ac27 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/application/mapper/ScheduleMapper.kt @@ -0,0 +1,33 @@ +package com.weeth.domain.schedule.application.mapper + +import com.weeth.domain.schedule.application.dto.response.ScheduleResponse +import com.weeth.domain.schedule.domain.entity.Event +import com.weeth.domain.session.domain.entity.Session +import org.springframework.stereotype.Component + +@Component +class ScheduleMapper { + fun toResponse( + event: Event, + isSession: Boolean, + ): ScheduleResponse = + ScheduleResponse( + id = event.id, + title = event.title, + start = event.start, + end = event.end, + isSession = isSession, + ) + + fun toResponse( + session: Session, + isSession: Boolean, + ): ScheduleResponse = + ScheduleResponse( + id = session.id, + title = session.title, + start = session.start, + end = session.end, + isSession = isSession, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/mapper/SessionMapper.kt b/src/main/kotlin/com/weeth/domain/schedule/application/mapper/SessionMapper.kt new file mode 100644 index 00000000..8d587724 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/application/mapper/SessionMapper.kt @@ -0,0 +1,76 @@ +package com.weeth.domain.schedule.application.mapper + +import com.weeth.domain.schedule.application.dto.request.ScheduleSaveRequest +import com.weeth.domain.schedule.application.dto.response.SessionInfoResponse +import com.weeth.domain.schedule.application.dto.response.SessionInfosResponse +import com.weeth.domain.schedule.application.dto.response.SessionResponse +import com.weeth.domain.schedule.domain.entity.enums.Type +import com.weeth.domain.session.domain.entity.Session +import com.weeth.domain.user.domain.entity.User +import org.springframework.stereotype.Component + +@Component +class SessionMapper { + fun toResponse(session: Session): SessionResponse = + SessionResponse( + id = session.id, + title = session.title, + content = session.content, + location = session.location, + name = session.user?.name, + cardinal = session.cardinal, + type = Type.SESSION, + code = null, + start = session.start, + end = session.end, + createdAt = session.createdAt, + modifiedAt = session.modifiedAt, + ) + + fun toAdminResponse(session: Session): SessionResponse = + SessionResponse( + id = session.id, + title = session.title, + content = session.content, + location = session.location, + name = session.user?.name, + cardinal = session.cardinal, + type = Type.SESSION, + code = session.code, + start = session.start, + end = session.end, + createdAt = session.createdAt, + modifiedAt = session.modifiedAt, + ) + + fun toInfo(session: Session): SessionInfoResponse = + SessionInfoResponse( + id = session.id, + cardinal = session.cardinal, + title = session.title, + start = session.start, + ) + + fun toInfos( + thisWeek: Session?, + sessions: List, + ): SessionInfosResponse = + SessionInfosResponse( + thisWeek = thisWeek?.let { toInfo(it) }, + sessions = sessions.map { toInfo(it) }, + ) + + fun toEntity( + request: ScheduleSaveRequest, + user: User, + ): Session = + Session.create( + title = request.title, + content = request.content, + location = request.location, + cardinal = request.cardinal, + start = request.start, + end = request.end, + user = user, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/usecase/command/ManageEventUseCase.kt b/src/main/kotlin/com/weeth/domain/schedule/application/usecase/command/ManageEventUseCase.kt new file mode 100644 index 00000000..af641a8a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/application/usecase/command/ManageEventUseCase.kt @@ -0,0 +1,47 @@ +package com.weeth.domain.schedule.application.usecase.command + +import com.weeth.domain.schedule.application.dto.request.ScheduleSaveRequest +import com.weeth.domain.schedule.application.dto.request.ScheduleUpdateRequest +import com.weeth.domain.schedule.application.exception.EventNotFoundException +import com.weeth.domain.schedule.application.mapper.EventMapper +import com.weeth.domain.schedule.domain.repository.EventRepository +import com.weeth.domain.user.domain.repository.CardinalReader +import com.weeth.domain.user.domain.repository.UserReader +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class ManageEventUseCase( + private val eventRepository: EventRepository, + private val userReader: UserReader, + private val cardinalReader: CardinalReader, + private val eventMapper: EventMapper, +) { + @Transactional + fun create( + request: ScheduleSaveRequest, + userId: Long, + ) { + val user = userReader.getById(userId) + cardinalReader.getByCardinalNumber(request.cardinal) + eventRepository.save(eventMapper.toEntity(request, user)) + } + + @Transactional + fun update( + eventId: Long, + request: ScheduleUpdateRequest, + userId: Long, + ) { + val user = userReader.getById(userId) + val event = eventRepository.findByIdOrNull(eventId) ?: throw EventNotFoundException() + event.update(request.title, request.content, request.location, request.start, request.end, user) + } + + @Transactional + fun delete(eventId: Long) { + val event = eventRepository.findByIdOrNull(eventId) ?: throw EventNotFoundException() + eventRepository.delete(event) + } +} diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt b/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt new file mode 100644 index 00000000..e07112b8 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt @@ -0,0 +1,66 @@ +package com.weeth.domain.schedule.application.usecase.query + +import com.weeth.domain.schedule.application.dto.response.EventResponse +import com.weeth.domain.schedule.application.dto.response.ScheduleResponse +import com.weeth.domain.schedule.application.exception.EventNotFoundException +import com.weeth.domain.schedule.application.mapper.EventMapper +import com.weeth.domain.schedule.application.mapper.ScheduleMapper +import com.weeth.domain.schedule.domain.repository.EventRepository +import com.weeth.domain.session.domain.repository.SessionReader +import com.weeth.domain.user.domain.repository.CardinalReader +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +@Transactional(readOnly = true) +class GetScheduleQueryService( + private val eventRepository: EventRepository, + private val sessionReader: SessionReader, + private val cardinalReader: CardinalReader, + private val scheduleMapper: ScheduleMapper, + private val eventMapper: EventMapper, +) { + fun findEvent(eventId: Long): EventResponse = + eventRepository + .findByIdOrNull(eventId) + ?.let { eventMapper.toResponse(it) } + ?: throw EventNotFoundException() + + fun findMonthly( + start: LocalDateTime, + end: LocalDateTime, + ): List { + val events = + eventRepository + .findByStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc(end, start) + .map { scheduleMapper.toResponse(it, false) } + val sessions = + sessionReader + .findByStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc(end, start) + .map { scheduleMapper.toResponse(it, true) } + return (events + sessions).sortedBy { it.start } + } + + fun findYearly( + year: Int, + semester: Int, + ): Map> { + val cardinal = cardinalReader.getByYearAndSemester(year, semester) + val events = + eventRepository + .findAllByCardinal(cardinal.cardinalNumber) + .map { scheduleMapper.toResponse(it, false) } + val sessions = + sessionReader + .findAllByCardinal(cardinal.cardinalNumber) + .map { scheduleMapper.toResponse(it, true) } + + return (events + sessions) + .sortedBy { it.start } + .flatMap { schedule -> + (schedule.start.monthValue..schedule.end.monthValue).map { month -> month to schedule } + }.groupBy({ it.first }, { it.second }) + } +} diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/validator/ScheduleTimeCheckValidator.kt b/src/main/kotlin/com/weeth/domain/schedule/application/validator/ScheduleTimeCheckValidator.kt new file mode 100644 index 00000000..84c8dc76 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/application/validator/ScheduleTimeCheckValidator.kt @@ -0,0 +1,13 @@ +package com.weeth.domain.schedule.application.validator + +import com.weeth.domain.schedule.application.annotation.ScheduleTimeCheck +import com.weeth.domain.schedule.application.dto.request.ScheduleTimeRequest +import jakarta.validation.ConstraintValidator +import jakarta.validation.ConstraintValidatorContext + +class ScheduleTimeCheckValidator : ConstraintValidator { + override fun isValid( + time: ScheduleTimeRequest?, + context: ConstraintValidatorContext, + ): Boolean = time == null || time.start.isBefore(time.end.plusMinutes(1)) +} diff --git a/src/main/kotlin/com/weeth/domain/schedule/domain/entity/Event.kt b/src/main/kotlin/com/weeth/domain/schedule/domain/entity/Event.kt new file mode 100644 index 00000000..3bfdd4e6 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/domain/entity/Event.kt @@ -0,0 +1,73 @@ +package com.weeth.domain.schedule.domain.entity + +import com.weeth.domain.user.domain.entity.User +import com.weeth.global.common.entity.BaseEntity +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.time.LocalDateTime + +@Entity +class Event( + var title: String, + @Column(length = 500) + var content: String, + var location: String, + var cardinal: Int, + var start: LocalDateTime, + var end: LocalDateTime, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + var user: User? = null, +) : BaseEntity() { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0 + + fun update( + title: String, + content: String, + location: String, + start: LocalDateTime, + end: LocalDateTime, + user: User?, + ) { + require(title.isNotBlank()) { "제목은 필수입니다" } + require(!end.isBefore(start)) { "종료 시간은 시작 시간 이후여야 합니다" } + this.title = title + this.content = content + this.location = location + this.start = start + this.end = end + this.user = user + } + + companion object { + fun create( + title: String, + content: String, + location: String, + cardinal: Int, + start: LocalDateTime, + end: LocalDateTime, + user: User?, + ): Event { + require(title.isNotBlank()) { "제목은 필수입니다" } + require(!end.isBefore(start)) { "종료 시간은 시작 시간 이후여야 합니다" } + return Event( + title = title, + content = content, + location = location, + cardinal = cardinal, + start = start, + end = end, + user = user, + ) + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/schedule/domain/entity/enums/Type.kt b/src/main/kotlin/com/weeth/domain/schedule/domain/entity/enums/Type.kt new file mode 100644 index 00000000..bbb663b4 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/domain/entity/enums/Type.kt @@ -0,0 +1,6 @@ +package com.weeth.domain.schedule.domain.entity.enums + +enum class Type { + EVENT, + SESSION, +} diff --git a/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventRepository.kt b/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventRepository.kt new file mode 100644 index 00000000..a24b1804 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventRepository.kt @@ -0,0 +1,14 @@ +package com.weeth.domain.schedule.domain.repository + +import com.weeth.domain.schedule.domain.entity.Event +import org.springframework.data.jpa.repository.JpaRepository +import java.time.LocalDateTime + +interface EventRepository : JpaRepository { + fun findByStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc( + end: LocalDateTime, + start: LocalDateTime, + ): List + + fun findAllByCardinal(cardinal: Int): List +} diff --git a/src/main/kotlin/com/weeth/domain/schedule/presentation/EventAdminController.kt b/src/main/kotlin/com/weeth/domain/schedule/presentation/EventAdminController.kt new file mode 100644 index 00000000..bc3c6434 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/presentation/EventAdminController.kt @@ -0,0 +1,58 @@ +package com.weeth.domain.schedule.presentation + +import com.weeth.domain.schedule.application.dto.request.ScheduleSaveRequest +import com.weeth.domain.schedule.application.dto.request.ScheduleUpdateRequest +import com.weeth.domain.schedule.application.exception.EventErrorCode +import com.weeth.domain.schedule.application.usecase.command.ManageEventUseCase +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "EVENT ADMIN", description = "[ADMIN] 일정 어드민 API") +@RestController +@RequestMapping("/api/v4/admin/events") +@ApiErrorCodeExample(EventErrorCode::class) +class EventAdminController( + private val manageEventUseCase: ManageEventUseCase, +) { + @PostMapping + @Operation(summary = "일정 생성") + fun create( + @Valid @RequestBody dto: ScheduleSaveRequest, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + manageEventUseCase.create(dto, userId) + return CommonResponse.success(ScheduleResponseCode.EVENT_SAVE_SUCCESS) + } + + @PatchMapping("/{eventId}") + @Operation(summary = "일정 수정") + fun update( + @PathVariable eventId: Long, + @Valid @RequestBody dto: ScheduleUpdateRequest, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + manageEventUseCase.update(eventId, dto, userId) + return CommonResponse.success(ScheduleResponseCode.EVENT_UPDATE_SUCCESS) + } + + @DeleteMapping("/{eventId}") + @Operation(summary = "일정 삭제") + fun delete( + @PathVariable eventId: Long, + ): CommonResponse { + manageEventUseCase.delete(eventId) + return CommonResponse.success(ScheduleResponseCode.EVENT_DELETE_SUCCESS) + } +} diff --git a/src/main/kotlin/com/weeth/domain/schedule/presentation/EventController.kt b/src/main/kotlin/com/weeth/domain/schedule/presentation/EventController.kt new file mode 100644 index 00000000..ca6d17c1 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/presentation/EventController.kt @@ -0,0 +1,28 @@ +package com.weeth.domain.schedule.presentation + +import com.weeth.domain.schedule.application.dto.response.EventResponse +import com.weeth.domain.schedule.application.exception.EventErrorCode +import com.weeth.domain.schedule.application.usecase.query.GetScheduleQueryService +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "EVENT", description = "일정 API") +@RestController +@RequestMapping("/api/v4/events") +@ApiErrorCodeExample(EventErrorCode::class) +class EventController( + private val getScheduleQueryService: GetScheduleQueryService, +) { + @GetMapping("/{eventId}") + @Operation(summary = "일정 상세 조회") + fun getEvent( + @PathVariable eventId: Long, + ): CommonResponse = + CommonResponse.success(ScheduleResponseCode.EVENT_FIND_SUCCESS, getScheduleQueryService.findEvent(eventId)) +} diff --git a/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt b/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt new file mode 100644 index 00000000..3e2224b6 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt @@ -0,0 +1,36 @@ +package com.weeth.domain.schedule.presentation + +import com.weeth.domain.schedule.application.dto.response.ScheduleResponse +import com.weeth.domain.schedule.application.usecase.query.GetScheduleQueryService +import com.weeth.global.common.response.CommonResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.format.annotation.DateTimeFormat +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import java.time.LocalDateTime + +@Tag(name = "SCHEDULE", description = "캘린더 조회 API") +@RestController +@RequestMapping("/api/v4/schedules") +class ScheduleController( + private val getScheduleQueryService: GetScheduleQueryService, +) { + @GetMapping("/monthly") + @Operation(summary = "월별 일정 조회") + fun findByMonthly( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) start: LocalDateTime, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) end: LocalDateTime, + ): CommonResponse> = + CommonResponse.success(ScheduleResponseCode.SCHEDULE_MONTHLY_FIND_SUCCESS, getScheduleQueryService.findMonthly(start, end)) + + @GetMapping("/yearly") + @Operation(summary = "연도별 일정 조회") + fun findByYearly( + @RequestParam year: Int, + @RequestParam semester: Int, + ): CommonResponse>> = + CommonResponse.success(ScheduleResponseCode.SCHEDULE_YEARLY_FIND_SUCCESS, getScheduleQueryService.findYearly(year, semester)) +} diff --git a/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleResponseCode.kt b/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleResponseCode.kt new file mode 100644 index 00000000..92230c61 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleResponseCode.kt @@ -0,0 +1,17 @@ +package com.weeth.domain.schedule.presentation + +import com.weeth.global.common.response.ResponseCodeInterface +import org.springframework.http.HttpStatus + +enum class ScheduleResponseCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ResponseCodeInterface { + EVENT_SAVE_SUCCESS(1700, HttpStatus.OK, "일정이 성공적으로 생성되었습니다."), + EVENT_UPDATE_SUCCESS(1701, HttpStatus.OK, "일정이 성공적으로 수정되었습니다."), + EVENT_DELETE_SUCCESS(1702, HttpStatus.OK, "일정이 성공적으로 삭제되었습니다."), + EVENT_FIND_SUCCESS(1703, HttpStatus.OK, "일정이 성공적으로 조회되었습니다."), + SCHEDULE_MONTHLY_FIND_SUCCESS(1704, HttpStatus.OK, "월별 일정이 성공적으로 조회되었습니다."), + SCHEDULE_YEARLY_FIND_SUCCESS(1705, HttpStatus.OK, "연도별 일정이 성공적으로 조회되었습니다."), +} diff --git a/src/main/kotlin/com/weeth/domain/session/application/exception/SessionErrorCode.kt b/src/main/kotlin/com/weeth/domain/session/application/exception/SessionErrorCode.kt new file mode 100644 index 00000000..a965bf4a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/exception/SessionErrorCode.kt @@ -0,0 +1,21 @@ +package com.weeth.domain.session.application.exception + +import com.weeth.global.common.exception.ErrorCodeInterface +import com.weeth.global.common.exception.ExplainError +import org.springframework.http.HttpStatus + +enum class SessionErrorCode( + private val code: Int, + private val status: HttpStatus, + private val message: String, +) : ErrorCodeInterface { + @ExplainError("요청한 정기모임 ID에 해당하는 정기모임이 존재하지 않을 때 발생합니다.") + SESSION_NOT_FOUND(2203, HttpStatus.NOT_FOUND, "존재하지 않는 정기모임입니다."), + ; + + override fun getCode(): Int = code + + override fun getStatus(): HttpStatus = status + + override fun getMessage(): String = message +} diff --git a/src/main/kotlin/com/weeth/domain/session/application/exception/SessionNotFoundException.kt b/src/main/kotlin/com/weeth/domain/session/application/exception/SessionNotFoundException.kt new file mode 100644 index 00000000..8e866880 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/exception/SessionNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.session.application.exception + +import com.weeth.global.common.exception.BaseException + +class SessionNotFoundException : BaseException(SessionErrorCode.SESSION_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/session/application/usecase/command/ManageSessionUseCase.kt b/src/main/kotlin/com/weeth/domain/session/application/usecase/command/ManageSessionUseCase.kt new file mode 100644 index 00000000..965df991 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/usecase/command/ManageSessionUseCase.kt @@ -0,0 +1,63 @@ +package com.weeth.domain.session.application.usecase.command + +import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.attendance.domain.entity.enums.AttendanceStatus +import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.schedule.application.dto.request.ScheduleSaveRequest +import com.weeth.domain.schedule.application.dto.request.ScheduleUpdateRequest +import com.weeth.domain.schedule.application.mapper.SessionMapper +import com.weeth.domain.session.application.exception.SessionNotFoundException +import com.weeth.domain.session.domain.repository.SessionRepository +import com.weeth.domain.user.domain.entity.enums.Status +import com.weeth.domain.user.domain.repository.CardinalReader +import com.weeth.domain.user.domain.repository.UserReader +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class ManageSessionUseCase( + private val sessionRepository: SessionRepository, + private val attendanceRepository: AttendanceRepository, + private val userReader: UserReader, + private val cardinalReader: CardinalReader, + private val sessionMapper: SessionMapper, +) { + @Transactional + fun create( + request: ScheduleSaveRequest, + userId: Long, + ) { + val user = userReader.getById(userId) + val cardinal = cardinalReader.getByCardinalNumber(request.cardinal) + val users = userReader.findAllByCardinalAndStatus(cardinal, Status.ACTIVE) + val session = sessionMapper.toEntity(request, user) + sessionRepository.save(session) + attendanceRepository.saveAll(users.map { Attendance.Companion.create(session, it) }) + } + + @Transactional + fun update( + sessionId: Long, + request: ScheduleUpdateRequest, + userId: Long, + ) { + val session = sessionRepository.findByIdWithLock(sessionId) ?: throw SessionNotFoundException() + val user = userReader.getById(userId) + session.updateInfo(request.title, request.content, request.location, request.start, request.end, user) + } + + @Transactional + fun delete(sessionId: Long) { + val session = sessionRepository.findByIdWithLock(sessionId) ?: throw SessionNotFoundException() + val attendances = attendanceRepository.findAllBySessionAndUserStatusWithLock(session, Status.ACTIVE) + attendances.forEach { a -> + when (a.status) { + AttendanceStatus.ATTEND -> a.user.removeAttend() + AttendanceStatus.ABSENT -> a.user.removeAbsent() + else -> Unit + } + } + attendanceRepository.deleteAllBySession(session) + sessionRepository.delete(session) + } +} diff --git a/src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt b/src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt new file mode 100644 index 00000000..45a37895 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt @@ -0,0 +1,58 @@ +package com.weeth.domain.session.application.usecase.query + +import com.weeth.domain.schedule.application.dto.response.SessionInfosResponse +import com.weeth.domain.schedule.application.dto.response.SessionResponse +import com.weeth.domain.schedule.application.mapper.SessionMapper +import com.weeth.domain.session.application.exception.SessionNotFoundException +import com.weeth.domain.session.domain.entity.Session +import com.weeth.domain.session.domain.repository.SessionRepository +import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.repository.UserReader +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.temporal.TemporalAdjusters + +@Service +@Transactional(readOnly = true) +class GetSessionQueryService( + private val sessionRepository: SessionRepository, + private val userReader: UserReader, + private val sessionMapper: SessionMapper, +) { + fun findSession( + userId: Long, + sessionId: Long, + ): SessionResponse { + val user = userReader.getById(userId) + val session = sessionRepository.findByIdOrNull(sessionId) ?: throw SessionNotFoundException() + return if (user.role == Role.ADMIN) { + sessionMapper.toAdminResponse(session) + } else { + sessionMapper.toResponse(session) + } + } + + fun findSessionInfos(cardinal: Int?): SessionInfosResponse { + val sessions = + if (cardinal == null) { + sessionRepository.findAllByOrderByStartDesc() + } else { + sessionRepository.findAllByCardinalOrderByStartDesc(cardinal) + } + val thisWeek = findThisWeek(sessions) + return sessionMapper.toInfos(thisWeek, sessions) + } + + private fun findThisWeek(sessions: List): Session? { + val today = LocalDate.now() + val startOfWeek = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + val endOfWeek = today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)) + return sessions.firstOrNull { s -> + val d = s.start.toLocalDate() + !d.isBefore(startOfWeek) && !d.isAfter(endOfWeek) + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/session/domain/entity/Session.kt b/src/main/kotlin/com/weeth/domain/session/domain/entity/Session.kt new file mode 100644 index 00000000..09fd0f03 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/domain/entity/Session.kt @@ -0,0 +1,93 @@ +package com.weeth.domain.session.domain.entity + +import com.weeth.domain.session.domain.entity.enums.SessionStatus +import com.weeth.domain.user.domain.entity.User +import com.weeth.global.common.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import java.time.LocalDateTime + +@Entity +@Table(name = "meeting") +class Session( + var title: String, + @Column(length = 500) + var content: String? = null, + var location: String? = null, + var cardinal: Int, + var start: LocalDateTime, + var end: LocalDateTime, + var code: Int, + @Enumerated(EnumType.STRING) + var status: SessionStatus = SessionStatus.OPEN, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + var user: User? = null, +) : BaseEntity() { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0 + + fun close() { + check(status == SessionStatus.OPEN) { "이미 종료된 세션입니다" } + status = SessionStatus.CLOSED + } + + fun updateInfo( + title: String, + content: String?, + location: String?, + start: LocalDateTime, + end: LocalDateTime, + user: User?, + ) { + require(title.isNotBlank()) { "제목은 필수입니다" } + require(!end.isBefore(start)) { "종료 시간은 시작 시간 이후여야 합니다" } + this.title = title + this.content = content + this.location = location + this.start = start + this.end = end + this.user = user + } + + fun isCodeMatch(code: Int): Boolean = this.code == code + + fun isInProgress(now: LocalDateTime): Boolean = !now.isBefore(start) && !now.isAfter(end) + + companion object { + fun create( + title: String, + content: String?, + location: String?, + cardinal: Int, + start: LocalDateTime, + end: LocalDateTime, + user: User?, + ): Session { + require(title.isNotBlank()) { "제목은 필수입니다" } + require(!end.isBefore(start)) { "종료 시간은 시작 시간 이후여야 합니다" } + return Session( + title = title, + content = content, + location = location, + cardinal = cardinal, + start = start, + end = end, + code = generateCode(), + user = user, + ) + } + + private fun generateCode(): Int = (1000..9999).random() + } +} diff --git a/src/main/kotlin/com/weeth/domain/session/domain/entity/enums/SessionStatus.kt b/src/main/kotlin/com/weeth/domain/session/domain/entity/enums/SessionStatus.kt new file mode 100644 index 00000000..6f148579 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/domain/entity/enums/SessionStatus.kt @@ -0,0 +1,6 @@ +package com.weeth.domain.session.domain.entity.enums + +enum class SessionStatus { + OPEN, + CLOSED, +} diff --git a/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionReader.kt b/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionReader.kt new file mode 100644 index 00000000..430a3138 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionReader.kt @@ -0,0 +1,31 @@ +package com.weeth.domain.session.domain.repository + +import com.weeth.domain.session.domain.entity.Session +import com.weeth.domain.session.domain.entity.enums.SessionStatus +import java.time.LocalDateTime + +interface SessionReader { + fun getById(sessionId: Long): Session + + // TODO: QR 코드 출석 기능 구현 시 사용 예정 (현재 시간 기준 진행 중인 세션 조회) + fun findAllByStartBetween( + start: LocalDateTime, + end: LocalDateTime, + ): List + + fun findByStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc( + end: LocalDateTime, + start: LocalDateTime, + ): List + + fun findAllByCardinal(cardinal: Int): List + + fun findAllByCardinalIn(cardinals: List): List + + fun findAllByCardinalOrderByStartAsc(cardinal: Int): List + + fun findAllByStatusAndEndBeforeOrderByEndAsc( + status: SessionStatus, + end: LocalDateTime, + ): List +} diff --git a/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionRepository.kt b/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionRepository.kt new file mode 100644 index 00000000..66c4c864 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionRepository.kt @@ -0,0 +1,47 @@ +package com.weeth.domain.session.domain.repository + +import com.weeth.domain.session.application.exception.SessionNotFoundException +import com.weeth.domain.session.domain.entity.Session +import com.weeth.domain.session.domain.entity.enums.SessionStatus +import jakarta.persistence.LockModeType +import jakarta.persistence.QueryHint +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Lock +import org.springframework.data.jpa.repository.Query +import org.springframework.data.jpa.repository.QueryHints +import org.springframework.data.repository.query.Param +import java.time.LocalDateTime + +interface SessionRepository : + JpaRepository, + SessionReader { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query("SELECT s FROM Session s WHERE s.id = :id") + fun findByIdWithLock(id: Long): Session? + + override fun findByStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc( + end: LocalDateTime, + start: LocalDateTime, + ): List + + override fun findAllByCardinalOrderByStartAsc(cardinal: Int): List + + fun findAllByCardinalOrderByStartDesc(cardinal: Int): List + + override fun findAllByCardinal(cardinal: Int): List + + override fun findAllByStatusAndEndBeforeOrderByEndAsc( + status: SessionStatus, + end: LocalDateTime, + ): List + + fun findAllByOrderByStartDesc(): List + + @Query("SELECT s FROM Session s WHERE s.cardinal IN :cardinals") + override fun findAllByCardinalIn( + @Param("cardinals") cardinals: List, + ): List + + override fun getById(sessionId: Long): Session = findById(sessionId).orElseThrow { SessionNotFoundException() } +} diff --git a/src/main/kotlin/com/weeth/domain/session/presentation/SessionAdminController.kt b/src/main/kotlin/com/weeth/domain/session/presentation/SessionAdminController.kt new file mode 100644 index 00000000..06e2a3ee --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/presentation/SessionAdminController.kt @@ -0,0 +1,73 @@ +package com.weeth.domain.session.presentation + +import com.weeth.domain.schedule.application.dto.request.ScheduleSaveRequest +import com.weeth.domain.schedule.application.dto.request.ScheduleUpdateRequest +import com.weeth.domain.schedule.application.dto.response.SessionInfosResponse +import com.weeth.domain.session.application.exception.SessionErrorCode +import com.weeth.domain.session.application.usecase.command.ManageSessionUseCase +import com.weeth.domain.session.application.usecase.query.GetSessionQueryService +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "SESSION ADMIN", description = "[ADMIN] 정기모임 어드민 API") +@RestController +@RequestMapping("/api/v4/admin/sessions") +@ApiErrorCodeExample(SessionErrorCode::class) +class SessionAdminController( + private val manageSessionUseCase: ManageSessionUseCase, + private val getSessionQueryService: GetSessionQueryService, +) { + @PostMapping + @Operation(summary = "정기모임 생성") + fun create( + @Valid @RequestBody dto: ScheduleSaveRequest, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + manageSessionUseCase.create(dto, userId) + return CommonResponse.success(SessionResponseCode.SESSION_SAVE_SUCCESS) + } + + @PatchMapping("/{sessionId}") + @Operation(summary = "정기모임 수정") + fun update( + @PathVariable sessionId: Long, + @Valid @RequestBody dto: ScheduleUpdateRequest, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + manageSessionUseCase.update(sessionId, dto, userId) + return CommonResponse.success(SessionResponseCode.SESSION_UPDATE_SUCCESS) + } + + @DeleteMapping("/{sessionId}") + @Operation(summary = "정기모임 삭제") + fun delete( + @PathVariable sessionId: Long, + ): CommonResponse { + manageSessionUseCase.delete(sessionId) + return CommonResponse.success(SessionResponseCode.SESSION_DELETE_SUCCESS) + } + + @GetMapping + @Operation(summary = "정기모임 목록 조회") + fun getSessionInfos( + @RequestParam(required = false) cardinal: Int?, + ): CommonResponse = + CommonResponse.success( + SessionResponseCode.SESSION_INFOS_FIND_SUCCESS, + getSessionQueryService.findSessionInfos(cardinal), + ) +} diff --git a/src/main/kotlin/com/weeth/domain/session/presentation/SessionController.kt b/src/main/kotlin/com/weeth/domain/session/presentation/SessionController.kt new file mode 100644 index 00000000..310282b1 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/presentation/SessionController.kt @@ -0,0 +1,31 @@ +package com.weeth.domain.session.presentation + +import com.weeth.domain.schedule.application.dto.response.SessionResponse +import com.weeth.domain.session.application.exception.SessionErrorCode +import com.weeth.domain.session.application.usecase.query.GetSessionQueryService +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "SESSION", description = "정기모임 API") +@RestController +@RequestMapping("/api/v4/sessions") +@ApiErrorCodeExample(SessionErrorCode::class) +class SessionController( + private val getSessionQueryService: GetSessionQueryService, +) { + @GetMapping("/{sessionId}") + @Operation(summary = "정기모임 상세 조회") + fun getSession( + @Parameter(hidden = true) @CurrentUser userId: Long, + @PathVariable sessionId: Long, + ): CommonResponse = + CommonResponse.success(SessionResponseCode.SESSION_FIND_SUCCESS, getSessionQueryService.findSession(userId, sessionId)) +} diff --git a/src/main/kotlin/com/weeth/domain/session/presentation/SessionResponseCode.kt b/src/main/kotlin/com/weeth/domain/session/presentation/SessionResponseCode.kt new file mode 100644 index 00000000..78f20ead --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/presentation/SessionResponseCode.kt @@ -0,0 +1,19 @@ +package com.weeth.domain.session.presentation + +import com.weeth.global.common.response.ResponseCodeInterface +import org.springframework.http.HttpStatus + +enum class SessionResponseCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ResponseCodeInterface { + // SessionAdminController 관련 + SESSION_INFOS_FIND_SUCCESS(1206, HttpStatus.OK, "기수별 정기모임 리스트를 성공적으로 조회했습니다."), + SESSION_SAVE_SUCCESS(1207, HttpStatus.OK, "정기모임이 성공적으로 생성되었습니다."), + SESSION_UPDATE_SUCCESS(1208, HttpStatus.OK, "정기모임이 성공적으로 수정되었습니다."), + SESSION_DELETE_SUCCESS(1209, HttpStatus.OK, "정기모임이 성공적으로 삭제되었습니다."), + + // SessionController 관련 + SESSION_FIND_SUCCESS(1210, HttpStatus.OK, "정기모임이 성공적으로 조회되었습니다."), +} diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AdminUserUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AdminUserUseCase.kt index df19f100..af9fccfb 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AdminUserUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AdminUserUseCase.kt @@ -1,8 +1,8 @@ package com.weeth.domain.user.application.usecase.command -import com.weeth.domain.attendance.domain.service.AttendanceSaveService -import com.weeth.domain.schedule.domain.entity.Meeting -import com.weeth.domain.schedule.domain.service.MeetingGetService +import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.session.domain.repository.SessionReader import com.weeth.domain.user.application.dto.request.UserApplyObRequest import com.weeth.domain.user.application.dto.request.UserIdsRequest import com.weeth.domain.user.application.dto.request.UserRoleUpdateRequest @@ -20,8 +20,8 @@ import org.springframework.transaction.annotation.Transactional @Service class AdminUserUseCase( private val userReader: UserReader, - private val attendanceSaveService: AttendanceSaveService, - private val meetingGetService: MeetingGetService, + private val sessionReader: SessionReader, + private val attendanceRepository: AttendanceRepository, private val cardinalRepository: CardinalRepository, private val userCardinalRepository: UserCardinalRepository, private val userCardinalPolicy: UserCardinalPolicy, @@ -33,8 +33,8 @@ class AdminUserUseCase( val cardinal = userCardinalPolicy.getCurrentCardinal(user).cardinalNumber if (user.isInactive()) { user.accept() - val meetings: List = meetingGetService.find(cardinal) - attendanceSaveService.init(user, meetings) + val sessions = sessionReader.findAllByCardinal(cardinal) + attendanceRepository.saveAll(sessions.map { Attendance.create(it, user) }) } } } @@ -88,10 +88,13 @@ class AdminUserUseCase( } if (initNeededByCardinal.isNotEmpty()) { - val meetingsMap = meetingGetService.findByCardinals(initNeededByCardinal.keys.toList()) + val sessionsByCardinal = + sessionReader.findAllByCardinalIn(initNeededByCardinal.keys.toList()).groupBy { it.cardinal } initNeededByCardinal.forEach { (cardinalNumber, usersToInit) -> - val meetings = meetingsMap[cardinalNumber] ?: emptyList() - usersToInit.forEach { attendanceSaveService.init(it, meetings) } + val sessions = sessionsByCardinal[cardinalNumber] ?: emptyList() + usersToInit.forEach { user -> + attendanceRepository.saveAll(sessions.map { Attendance.create(it, user) }) + } } } diff --git a/src/main/kotlin/com/weeth/domain/user/domain/repository/CardinalReader.kt b/src/main/kotlin/com/weeth/domain/user/domain/repository/CardinalReader.kt index ae02e8bf..9b2a50cb 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/repository/CardinalReader.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/repository/CardinalReader.kt @@ -5,6 +5,11 @@ import com.weeth.domain.user.domain.entity.Cardinal interface CardinalReader { fun getByCardinalNumber(cardinalNumber: Int): Cardinal + fun getByYearAndSemester( + year: Int, + semester: Int, + ): Cardinal + fun findByIdOrNull(cardinalId: Long): Cardinal? fun findAllByCardinalNumberDesc(): List diff --git a/src/main/kotlin/com/weeth/domain/user/domain/repository/CardinalRepository.kt b/src/main/kotlin/com/weeth/domain/user/domain/repository/CardinalRepository.kt index 0fd0d865..e10a284d 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/repository/CardinalRepository.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/repository/CardinalRepository.kt @@ -27,6 +27,11 @@ interface CardinalRepository : override fun getByCardinalNumber(cardinalNumber: Int): Cardinal = findByCardinalNumber(cardinalNumber).orElseThrow { CardinalNotFoundException() } + override fun getByYearAndSemester( + year: Int, + semester: Int, + ): Cardinal = findByYearAndSemester(year, semester).orElseThrow { CardinalNotFoundException() } + override fun findByIdOrNull(cardinalId: Long): Cardinal? = findById(cardinalId).orElse(null) override fun findAllByCardinalNumberDesc(): List = findAllByOrderByCardinalNumberDesc() diff --git a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserReader.kt b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserReader.kt index 8e7c60fb..6092e9b4 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserReader.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserReader.kt @@ -1,6 +1,8 @@ package com.weeth.domain.user.domain.repository +import com.weeth.domain.user.domain.entity.Cardinal import com.weeth.domain.user.domain.entity.User +import com.weeth.domain.user.domain.entity.enums.Status interface UserReader { fun getById(userId: Long): User @@ -10,4 +12,9 @@ interface UserReader { fun findByIdOrNull(userId: Long): User? fun findAllByIds(userIds: List): List + + fun findAllByCardinalAndStatus( + cardinal: Cardinal, + status: Status, + ): List } diff --git a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserRepository.kt b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserRepository.kt index 47ec7f96..c11fefcf 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserRepository.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserRepository.kt @@ -55,7 +55,7 @@ interface UserRepository : fun findAllByOrderByNameAsc(): List @Query("SELECT uc.user FROM UserCardinal uc WHERE uc.cardinal = :cardinal AND uc.user.status = :status") - fun findAllByCardinalAndStatus( + override fun findAllByCardinalAndStatus( @Param("cardinal") cardinal: Cardinal, @Param("status") status: Status, ): List diff --git a/src/main/kotlin/com/weeth/global/common/controller/ExceptionDocController.kt b/src/main/kotlin/com/weeth/global/common/controller/ExceptionDocController.kt index 1d67f2c0..1bc82ac0 100644 --- a/src/main/kotlin/com/weeth/global/common/controller/ExceptionDocController.kt +++ b/src/main/kotlin/com/weeth/global/common/controller/ExceptionDocController.kt @@ -6,7 +6,7 @@ import com.weeth.domain.board.application.exception.BoardErrorCode import com.weeth.domain.comment.application.exception.CommentErrorCode import com.weeth.domain.penalty.application.exception.PenaltyErrorCode import com.weeth.domain.schedule.application.exception.EventErrorCode -import com.weeth.domain.schedule.application.exception.MeetingErrorCode +import com.weeth.domain.session.application.exception.SessionErrorCode import com.weeth.domain.user.application.exception.UserErrorCode import com.weeth.global.auth.jwt.application.exception.JwtErrorCode import com.weeth.global.common.exception.ApiErrorCodeExample @@ -28,7 +28,7 @@ class ExceptionDocController { @GetMapping("/attendance") @Operation(summary = "Attendance 도메인 에러 코드 목록") - @ApiErrorCodeExample(AttendanceErrorCode::class) + @ApiErrorCodeExample(AttendanceErrorCode::class, SessionErrorCode::class) fun attendanceErrorCodes() { } @@ -46,7 +46,7 @@ class ExceptionDocController { @GetMapping("/schedule") @Operation(summary = "Schedule 도메인 에러 코드 목록") - @ApiErrorCodeExample(EventErrorCode::class, MeetingErrorCode::class) + @ApiErrorCodeExample(EventErrorCode::class) fun scheduleErrorCodes() { } diff --git a/src/main/kotlin/com/weeth/global/config/SwaggerConfig.kt b/src/main/kotlin/com/weeth/global/config/SwaggerConfig.kt index a6763ffe..d174e817 100644 --- a/src/main/kotlin/com/weeth/global/config/SwaggerConfig.kt +++ b/src/main/kotlin/com/weeth/global/config/SwaggerConfig.kt @@ -33,7 +33,7 @@ private const val SWAGGER_DESCRIPTION = "| Domain | Success | Error |\n" + "|--------|---------|------|\n" + "| Account | 11xx | 21xx |\n" + - "| Attendance | 12xx | 22xx |\n" + + "| Attendance/Session | 12xx | 22xx |\n" + "| Board | 13xx | 23xx |\n" + "| Comment | 14xx | 24xx |\n" + "| File | 15xx | 25xx |\n" + diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapperTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapperTest.kt index d9b6a8b6..700475d2 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapperTest.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapperTest.kt @@ -3,10 +3,10 @@ package com.weeth.domain.attendance.application.mapper import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createActiveUser import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createAdminUser import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createAttendance -import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createOneDayMeeting import com.weeth.domain.attendance.fixture.AttendanceTestFixture.enrichUserProfile import com.weeth.domain.attendance.fixture.AttendanceTestFixture.setAttendanceId import com.weeth.domain.attendance.fixture.AttendanceTestFixture.setUserAttendanceStats +import com.weeth.domain.session.fixture.SessionTestFixture.createOneDaySession import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.nulls.shouldNotBeNull @@ -20,18 +20,18 @@ class AttendanceMapperTest : describe("toSummaryResponse") { it("사용자 + 당일 출석 객체를 MainResponse로 매핑한다") { val today = LocalDate.now() - val meeting = createOneDayMeeting(today, 1, 1111, "Today") + val session = createOneDaySession(today, 1, 1111, "Today") val user = createActiveUser("이지훈") - val attendance = createAttendance(meeting, user) + val attendance = createAttendance(session, user) val main = mapper.toSummaryResponse(user, attendance) main.shouldNotBeNull() - main.title shouldBe meeting.title + main.title shouldBe session.title main.status shouldBe attendance.status - main.start shouldBe meeting.start - main.end shouldBe meeting.end - main.location shouldBe meeting.location + main.start shouldBe session.start + main.end shouldBe session.end + main.location shouldBe session.location } it("attendance가 null이면 필드는 null로 매핑") { @@ -48,57 +48,57 @@ class AttendanceMapperTest : it("일반 유저는 출석 코드가 null로 매핑된다") { val today = LocalDate.now() - val meeting = createOneDayMeeting(today, 1, 1234, "Today") + val session = createOneDaySession(today, 1, 1234, "Today") val user = createActiveUser("일반유저") - val attendance = createAttendance(meeting, user) + val attendance = createAttendance(session, user) val main = mapper.toSummaryResponse(user, attendance) main.shouldNotBeNull() main.code.shouldBeNull() - main.title shouldBe meeting.title + main.title shouldBe session.title main.status shouldBe attendance.status } it("ADMIN 유저는 출석 코드가 포함된다") { val today = LocalDate.now() val expectedCode = 1234 - val meeting = createOneDayMeeting(today, 1, expectedCode, "Today") + val session = createOneDaySession(today, 1, expectedCode, "Today") val adminUser = createAdminUser("관리자") - val attendance = createAttendance(meeting, adminUser) + val attendance = createAttendance(session, adminUser) val main = mapper.toSummaryResponse(adminUser, attendance, isAdmin = true) main.shouldNotBeNull() main.code shouldBe expectedCode - main.title shouldBe meeting.title - main.start shouldBe meeting.start - main.end shouldBe meeting.end - main.location shouldBe meeting.location + main.title shouldBe session.title + main.start shouldBe session.start + main.end shouldBe session.end + main.location shouldBe session.location } } describe("toResponse") { it("단일 출석을 AttendanceResponse로 매핑한다") { - val meeting = createOneDayMeeting(LocalDate.now().minusDays(1), 1, 2222, "D-1") + val session = createOneDaySession(LocalDate.now().minusDays(1), 1, 2222, "D-1") val user = createActiveUser("사용자A") - val attendance = createAttendance(meeting, user) + val attendance = createAttendance(session, user) val response = mapper.toResponse(attendance) response.shouldNotBeNull() - response.title shouldBe meeting.title - response.start shouldBe meeting.start - response.end shouldBe meeting.end - response.location shouldBe meeting.location + response.title shouldBe session.title + response.start shouldBe session.start + response.end shouldBe session.end + response.location shouldBe session.location } } describe("toDetailResponse") { it("사용자 + Response 리스트를 DetailResponse로 매핑(total = attend + absence)") { val base = LocalDate.now() - val m1 = createOneDayMeeting(base.minusDays(2), 1, 1000, "D-2") - val m2 = createOneDayMeeting(base.minusDays(1), 1, 1001, "D-1") + val m1 = createOneDaySession(base.minusDays(2), 1, 1000, "D-2") + val m2 = createOneDaySession(base.minusDays(1), 1, 1001, "D-1") val user = createActiveUser("이지훈") setUserAttendanceStats(user, 3, 2) @@ -118,11 +118,11 @@ class AttendanceMapperTest : describe("toInfoResponse") { it("Attendance를 InfoResponse로 매핑") { - val meeting = createOneDayMeeting(LocalDate.now(), 1, 3333, "Info") + val session = createOneDaySession(LocalDate.now(), 1, 3333, "Info") val user = createActiveUser("유저B") enrichUserProfile(user, "컴퓨터공학과", "20201234") - val attendance = createAttendance(meeting, user) + val attendance = createAttendance(session, user) setAttendanceId(attendance, 10L) val info = mapper.toInfoResponse(attendance) diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/CheckInAttendanceUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/CheckInAttendanceUseCaseTest.kt deleted file mode 100644 index 72fdd612..00000000 --- a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/CheckInAttendanceUseCaseTest.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.weeth.domain.attendance.application.usecase.command - -import com.weeth.domain.attendance.application.exception.AttendanceCodeMismatchException -import com.weeth.domain.attendance.application.exception.AttendanceNotFoundException -import com.weeth.domain.attendance.domain.entity.Attendance -import com.weeth.domain.attendance.domain.enums.Status -import com.weeth.domain.attendance.domain.repository.AttendanceRepository -import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.repository.UserReader -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.core.spec.style.DescribeSpec -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify - -class CheckInAttendanceUseCaseTest : - DescribeSpec({ - - val userId = 10L - val userReader = mockk() - val attendanceRepository = mockk() - - val useCase = CheckInAttendanceUseCase(userReader, attendanceRepository) - - describe("checkIn") { - context("진행 중 정기모임이고 코드 일치하며 상태가 ATTEND가 아닐 때") { - it("출석 처리된다") { - val user = mockk() - val attendance = mockk(relaxUnitFun = true) - every { attendance.isWrong(1234) } returns false - every { attendance.status } returns Status.PENDING - - every { userReader.getById(userId) } returns user - every { attendanceRepository.findCurrentByUserId(eq(userId), any(), any()) } returns attendance - every { user.attend() } returns Unit - - useCase.checkIn(userId, 1234) - - verify { attendance.attend() } - verify { user.attend() } - } - } - - context("진행 중 정기모임이 없을 때") { - it("AttendanceNotFoundException") { - val user = mockk() - every { userReader.getById(userId) } returns user - every { attendanceRepository.findCurrentByUserId(eq(userId), any(), any()) } returns null - - shouldThrow { - useCase.checkIn(userId, 1234) - } - } - } - - context("코드 불일치 시") { - it("AttendanceCodeMismatchException") { - val user = mockk() - val attendance = mockk() - every { attendance.isWrong(9999) } returns true - - every { userReader.getById(userId) } returns user - every { attendanceRepository.findCurrentByUserId(eq(userId), any(), any()) } returns attendance - - shouldThrow { - useCase.checkIn(userId, 9999) - } - } - } - - context("이미 ATTEND일 때") { - it("추가 처리 없이 종료") { - val user = mockk() - val attendance = mockk() - every { attendance.isWrong(1234) } returns false - every { attendance.status } returns Status.ATTEND - - every { userReader.getById(userId) } returns user - every { attendanceRepository.findCurrentByUserId(eq(userId), any(), any()) } returns attendance - - useCase.checkIn(userId, 1234) - - verify(exactly = 0) { attendance.attend() } - verify(exactly = 0) { user.attend() } - } - } - } - }) diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/CloseAttendanceUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/CloseAttendanceUseCaseTest.kt deleted file mode 100644 index 63c3dd68..00000000 --- a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/CloseAttendanceUseCaseTest.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.weeth.domain.attendance.application.usecase.command - -import com.weeth.domain.attendance.domain.entity.Attendance -import com.weeth.domain.attendance.domain.repository.AttendanceRepository -import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createOneDayMeeting -import com.weeth.domain.schedule.application.exception.MeetingNotFoundException -import com.weeth.domain.schedule.domain.service.MeetingGetService -import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.entity.enums.Status -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.core.spec.style.DescribeSpec -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import java.time.LocalDate - -class CloseAttendanceUseCaseTest : - DescribeSpec({ - - val meetingGetService = mockk() - val attendanceRepository = mockk() - - val useCase = CloseAttendanceUseCase(meetingGetService, attendanceRepository) - - describe("close") { - it("당일 정기모임을 찾아 pending 출석을 close") { - val now = LocalDate.now() - val targetMeeting = createOneDayMeeting(now, 1, 1111, "Today") - val otherMeeting = createOneDayMeeting(now.minusDays(1), 1, 9999, "Yesterday") - - val pendingAttendance = mockk(relaxUnitFun = true) - val attendedAttendance = mockk(relaxUnitFun = true) - val pendingUser = mockk(relaxUnitFun = true) - - every { pendingAttendance.isPending } returns true - every { pendingAttendance.user } returns pendingUser - every { attendedAttendance.isPending } returns false - - every { meetingGetService.find(1) } returns listOf(targetMeeting, otherMeeting) - every { - attendanceRepository.findAllByMeetingAndUserStatus(targetMeeting, Status.ACTIVE) - } returns listOf(pendingAttendance, attendedAttendance) - - useCase.close(now, 1) - - verify { pendingAttendance.close() } - verify { pendingUser.absent() } - verify(exactly = 0) { attendedAttendance.close() } - } - - it("당일 정기모임이 없으면 MeetingNotFoundException") { - val now = LocalDate.now() - val otherDayMeeting = createOneDayMeeting(now.minusDays(1), 1, 9999, "Yesterday") - - every { meetingGetService.find(1) } returns listOf(otherDayMeeting) - - shouldThrow { - useCase.close(now, 1) - } - } - } - }) diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/UpdateAttendanceStatusUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/UpdateAttendanceStatusUseCaseTest.kt deleted file mode 100644 index ebbecb4e..00000000 --- a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/UpdateAttendanceStatusUseCaseTest.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.weeth.domain.attendance.application.usecase.command - -import com.weeth.domain.attendance.application.dto.request.UpdateAttendanceStatusRequest -import com.weeth.domain.attendance.application.exception.AttendanceNotFoundException -import com.weeth.domain.attendance.domain.entity.Attendance -import com.weeth.domain.attendance.domain.repository.AttendanceRepository -import com.weeth.domain.user.domain.entity.User -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.core.spec.style.DescribeSpec -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify - -class UpdateAttendanceStatusUseCaseTest : - DescribeSpec({ - - val attendanceRepository = mockk() - - val useCase = UpdateAttendanceStatusUseCase(attendanceRepository) - - describe("updateStatus") { - context("ABSENT로 변경 시") { - it("close + removeAttend + absent 호출") { - val user = mockk(relaxUnitFun = true) - val attendance = mockk(relaxUnitFun = true) - every { attendance.user } returns user - - every { attendanceRepository.findByIdWithUser(1L) } returns attendance - - val request = UpdateAttendanceStatusRequest(attendanceId = 1L, status = "ABSENT") - useCase.updateStatus(listOf(request)) - - verify { attendance.close() } - verify { user.removeAttend() } - verify { user.absent() } - } - } - - context("ATTEND로 변경 시") { - it("attend + removeAbsent + attend 호출") { - val user = mockk(relaxUnitFun = true) - val attendance = mockk(relaxUnitFun = true) - every { attendance.user } returns user - - every { attendanceRepository.findByIdWithUser(1L) } returns attendance - - val request = UpdateAttendanceStatusRequest(attendanceId = 1L, status = "ATTEND") - useCase.updateStatus(listOf(request)) - - verify { attendance.attend() } - verify { user.removeAbsent() } - verify { user.attend() } - } - } - - context("출석 정보가 없을 때") { - it("AttendanceNotFoundException") { - every { attendanceRepository.findByIdWithUser(999L) } returns null - - val request = UpdateAttendanceStatusRequest(attendanceId = 999L, status = "ABSENT") - - shouldThrow { - useCase.updateStatus(listOf(request)) - } - } - } - } - }) diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt index a3474d13..d098b66d 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt @@ -8,8 +8,8 @@ import com.weeth.domain.attendance.application.mapper.AttendanceMapper import com.weeth.domain.attendance.domain.entity.Attendance import com.weeth.domain.attendance.domain.repository.AttendanceRepository import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createActiveUser -import com.weeth.domain.schedule.domain.entity.Meeting -import com.weeth.domain.schedule.domain.service.MeetingGetService +import com.weeth.domain.session.domain.entity.Session +import com.weeth.domain.session.domain.repository.SessionReader import com.weeth.domain.user.domain.entity.Cardinal import com.weeth.domain.user.domain.entity.enums.Status import com.weeth.domain.user.domain.repository.UserReader @@ -25,7 +25,7 @@ class GetAttendanceQueryServiceTest : val userReader = mockk() val userCardinalPolicy = mockk() - val meetingGetService = mockk() + val sessionReader = mockk() val attendanceRepository = mockk() val attendanceMapper = mockk() @@ -33,7 +33,7 @@ class GetAttendanceQueryServiceTest : GetAttendanceQueryService( userReader, userCardinalPolicy, - meetingGetService, + sessionReader, attendanceRepository, attendanceMapper, ) @@ -103,23 +103,23 @@ class GetAttendanceQueryServiceTest : } } - describe("findAllAttendanceByMeeting") { + describe("findAllAttendanceBySession") { it("해당 정기모임의 출석 정보를 조회") { - val meetingId = 1L - val meeting = mockk() + val sessionId = 1L + val session = mockk() val attendance1 = mockk() val attendance2 = mockk() val response1 = mockk() val response2 = mockk() - every { meetingGetService.find(meetingId) } returns meeting + every { sessionReader.getById(sessionId) } returns session every { - attendanceRepository.findAllByMeetingAndUserStatus(meeting, Status.ACTIVE) + attendanceRepository.findAllBySessionAndUserStatus(session, Status.ACTIVE) } returns listOf(attendance1, attendance2) every { attendanceMapper.toInfoResponse(attendance1) } returns response1 every { attendanceMapper.toInfoResponse(attendance2) } returns response2 - val result = queryService.findAllAttendanceByMeeting(meetingId) + val result = queryService.findAllAttendanceBySession(sessionId) result shouldBe listOf(response1, response2) } diff --git a/src/test/kotlin/com/weeth/domain/attendance/domain/entity/AttendanceTest.kt b/src/test/kotlin/com/weeth/domain/attendance/domain/entity/AttendanceTest.kt index bc61965e..f58eb13a 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/domain/entity/AttendanceTest.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/domain/entity/AttendanceTest.kt @@ -1,89 +1,70 @@ package com.weeth.domain.attendance.domain.entity -import com.weeth.domain.attendance.domain.enums.Status +import com.weeth.domain.attendance.domain.entity.enums.AttendanceStatus import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createActiveUser import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createAttendance -import com.weeth.domain.schedule.fixture.ScheduleTestFixture.createMeeting +import com.weeth.domain.session.fixture.SessionTestFixture.createOneDaySession import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe +import java.time.LocalDate class AttendanceTest : DescribeSpec({ + val session = createOneDaySession(LocalDate.now(), 1, 1234, "테스트") + describe("attend") { it("상태를 ATTEND로 변경한다") { - val meeting = createMeeting() val user = createActiveUser("테스트유저") - val attendance = createAttendance(meeting, user) - attendance.init() + val attendance = createAttendance(session, user) attendance.attend() - attendance.status shouldBe Status.ATTEND + attendance.status shouldBe AttendanceStatus.ATTEND } } describe("close") { it("상태를 ABSENT로 변경한다") { - val meeting = createMeeting() val user = createActiveUser("테스트유저") - val attendance = createAttendance(meeting, user) - attendance.init() + val attendance = createAttendance(session, user) attendance.close() - attendance.status shouldBe Status.ABSENT + attendance.status shouldBe AttendanceStatus.ABSENT } } describe("isPending") { it("상태가 PENDING이면 true를 반환한다") { - val meeting = createMeeting() val user = createActiveUser("테스트유저") - val attendance = createAttendance(meeting, user) - attendance.init() + val attendance = createAttendance(session, user) - attendance.isPending shouldBe true + attendance.isPending() shouldBe true } it("상태가 PENDING이 아니면 false를 반환한다") { - val meeting = createMeeting() val user = createActiveUser("테스트유저") - val attendance = createAttendance(meeting, user) - attendance.init() + val attendance = createAttendance(session, user) attendance.attend() - attendance.isPending shouldBe false + attendance.isPending() shouldBe false } } describe("isWrong") { it("코드가 일치하지 않으면 true를 반환한다") { - val meeting = createMeeting() val user = createActiveUser("테스트유저") - val attendance = createAttendance(meeting, user) + val attendance = createAttendance(session, user) attendance.isWrong(9999) shouldBe true } it("코드가 일치하면 false를 반환한다") { - val meeting = createMeeting() val user = createActiveUser("테스트유저") - val attendance = createAttendance(meeting, user) + val attendance = createAttendance(session, user) attendance.isWrong(1234) shouldBe false } } - - describe("init") { - it("상태를 PENDING으로 초기화한다") { - val meeting = createMeeting() - val user = createActiveUser("테스트유저") - val attendance = createAttendance(meeting, user) - - attendance.init() - - attendance.status shouldBe Status.PENDING - } - } }) diff --git a/src/test/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepositoryTest.kt b/src/test/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepositoryTest.kt index 6744c1e2..1b4fd3c8 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepositoryTest.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepositoryTest.kt @@ -2,9 +2,9 @@ package com.weeth.domain.attendance.domain.repository import com.weeth.config.TestContainersConfig import com.weeth.domain.attendance.domain.entity.Attendance -import com.weeth.domain.schedule.domain.entity.Meeting -import com.weeth.domain.schedule.domain.entity.enums.MeetingStatus -import com.weeth.domain.schedule.domain.repository.MeetingRepository +import com.weeth.domain.session.domain.entity.Session +import com.weeth.domain.session.domain.entity.enums.SessionStatus +import com.weeth.domain.session.domain.repository.SessionRepository import com.weeth.domain.user.domain.entity.User import com.weeth.domain.user.domain.entity.enums.Status import com.weeth.domain.user.domain.repository.UserRepository @@ -22,26 +22,25 @@ import java.time.LocalDateTime @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) class AttendanceRepositoryTest( private val attendanceRepository: AttendanceRepository, - private val meetingRepository: MeetingRepository, + private val sessionRepository: SessionRepository, private val userRepository: UserRepository, ) : DescribeSpec({ - lateinit var meeting: Meeting + lateinit var session: Session lateinit var activeUser1: User lateinit var activeUser2: User beforeEach { - meeting = - Meeting - .builder() - .title("1차 정기모임") - .start(LocalDateTime.now().minusHours(1)) - .end(LocalDateTime.now().plusHours(1)) - .code(1234) - .cardinal(1) - .meetingStatus(MeetingStatus.OPEN) - .build() - meetingRepository.save(meeting) + session = + Session( + title = "1차 정기모임", + start = LocalDateTime.now().minusHours(1), + end = LocalDateTime.now().plusHours(1), + code = 1234, + cardinal = 1, + status = SessionStatus.OPEN, + ) + sessionRepository.save(session) activeUser1 = User( @@ -58,22 +57,22 @@ class AttendanceRepositoryTest( activeUser2.accept() userRepository.saveAll(listOf(activeUser1, activeUser2)) - attendanceRepository.save(Attendance(meeting, activeUser1)) - attendanceRepository.save(Attendance(meeting, activeUser2)) + attendanceRepository.save(Attendance.create(session, activeUser1)) + attendanceRepository.save(Attendance.create(session, activeUser2)) } - describe("findAllByMeetingAndUserStatus") { - it("특정 정기모임 + 사용자 상태로 출석 목록 조회") { - val attendances = attendanceRepository.findAllByMeetingAndUserStatus(meeting, Status.ACTIVE) + describe("findAllBySessionAndUserStatus") { + it("특정 세션 + 사용자 상태로 출석 목록 조회") { + val attendances = attendanceRepository.findAllBySessionAndUserStatus(session, Status.ACTIVE) attendances shouldHaveSize 2 attendances.map { it.user.name } shouldContainExactlyInAnyOrder listOf("이지훈", "이강혁") } } - describe("deleteAllByMeeting") { - it("특정 정기모임의 모든 출석 레코드 삭제") { - attendanceRepository.deleteAllByMeeting(meeting) + describe("deleteAllBySession") { + it("특정 세션의 모든 출석 레코드 삭제") { + attendanceRepository.deleteAllBySession(session) attendanceRepository.findAll().shouldBeEmpty() } diff --git a/src/test/kotlin/com/weeth/domain/attendance/domain/service/AttendanceSaveServiceTest.kt b/src/test/kotlin/com/weeth/domain/attendance/domain/service/AttendanceSaveServiceTest.kt deleted file mode 100644 index eec1deac..00000000 --- a/src/test/kotlin/com/weeth/domain/attendance/domain/service/AttendanceSaveServiceTest.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.weeth.domain.attendance.domain.service - -import com.weeth.domain.attendance.domain.entity.Attendance -import com.weeth.domain.attendance.domain.repository.AttendanceRepository -import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createActiveUser -import com.weeth.domain.schedule.fixture.ScheduleTestFixture.createMeeting -import io.kotest.core.spec.style.DescribeSpec -import io.kotest.matchers.collections.shouldHaveSize -import io.kotest.matchers.shouldBe -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify - -class AttendanceSaveServiceTest : - DescribeSpec({ - - val attendanceRepository = mockk() - val attendanceSaveService = AttendanceSaveService(attendanceRepository) - - describe("init") { - it("각 정기모임에 대한 Attendance를 저장한다") { - val user = mockk() - val meetingFirst = createMeeting() - val meetingSecond = createMeeting() - - every { attendanceRepository.save(any()) } answers { firstArg() } - - attendanceSaveService.init(user, listOf(meetingFirst, meetingSecond)) - - verify(exactly = 2) { attendanceRepository.save(any()) } - } - } - - describe("saveAll") { - it("사용자 수만큼 Attendance 생성 후 saveAll 호출") { - val meeting = createMeeting() - val userFirst = createActiveUser("이지훈") - val userSecond = createActiveUser("이강혁") - - val listSlot = slot>() - every { attendanceRepository.saveAll(capture(listSlot)) } answers { firstArg() } - - attendanceSaveService.saveAll(listOf(userFirst, userSecond), meeting) - - val savedAttendances = listSlot.captured - savedAttendances shouldHaveSize 2 - savedAttendances.forEach { it.meeting shouldBe meeting } - savedAttendances.map { it.user } shouldBe listOf(userFirst, userSecond) - } - } - }) diff --git a/src/test/kotlin/com/weeth/domain/attendance/fixture/AttendanceTestFixture.kt b/src/test/kotlin/com/weeth/domain/attendance/fixture/AttendanceTestFixture.kt index 53f73aab..f20c8f7f 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/fixture/AttendanceTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/fixture/AttendanceTestFixture.kt @@ -1,14 +1,12 @@ package com.weeth.domain.attendance.fixture import com.weeth.domain.attendance.domain.entity.Attendance -import com.weeth.domain.schedule.domain.entity.Meeting +import com.weeth.domain.session.domain.entity.Session import com.weeth.domain.user.domain.entity.User import com.weeth.domain.user.domain.entity.enums.Role import com.weeth.domain.user.domain.entity.enums.Status import com.weeth.domain.user.domain.vo.AttendanceStats import org.springframework.test.util.ReflectionTestUtils -import java.time.LocalDate -import java.time.LocalDateTime object AttendanceTestFixture { fun createActiveUser(name: String): User = @@ -25,40 +23,9 @@ object AttendanceTestFixture { ) fun createAttendance( - meeting: Meeting, + session: Session, user: User, - ): Attendance = Attendance(meeting, user) - - fun createOneDayMeeting( - date: LocalDate, - cardinal: Int, - code: Int, - title: String, - ): Meeting = - Meeting - .builder() - .title(title) - .location("Test Location") - .start(date.atTime(10, 0)) - .end(date.atTime(12, 0)) - .code(code) - .cardinal(cardinal) - .build() - - fun createInProgressMeeting( - cardinal: Int, - code: Int, - title: String, - ): Meeting = - Meeting - .builder() - .title(title) - .location("Test Location") - .start(LocalDateTime.now().minusMinutes(5)) - .end(LocalDateTime.now().plusMinutes(5)) - .code(code) - .cardinal(cardinal) - .build() + ): Attendance = Attendance.create(session, user) fun setAttendanceId( attendance: Attendance, diff --git a/src/test/kotlin/com/weeth/domain/schedule/fixture/ScheduleTestFixture.kt b/src/test/kotlin/com/weeth/domain/schedule/fixture/ScheduleTestFixture.kt index 8c1b03d0..39fb7995 100644 --- a/src/test/kotlin/com/weeth/domain/schedule/fixture/ScheduleTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/schedule/fixture/ScheduleTestFixture.kt @@ -1,28 +1,30 @@ package com.weeth.domain.schedule.fixture import com.weeth.domain.schedule.domain.entity.Event -import com.weeth.domain.schedule.domain.entity.Meeting +import org.springframework.test.util.ReflectionTestUtils import java.time.LocalDateTime object ScheduleTestFixture { - fun createEvent(): Event = - Event - .builder() - .title("Test Meeting") - .location("Test Location") - .start(LocalDateTime.now()) - .end(LocalDateTime.now().plusDays(2)) - .cardinal(1) - .build() - - fun createMeeting(): Meeting = - Meeting - .builder() - .title("Test Meeting") - .location("Test Location") - .start(LocalDateTime.now()) - .end(LocalDateTime.now().plusDays(2)) - .code(1234) - .cardinal(1) - .build() + fun createEvent( + id: Long = 0L, + title: String = "Test Event", + content: String = "Test Content", + location: String = "Test Location", + cardinal: Int = 1, + start: LocalDateTime = LocalDateTime.of(2026, 3, 1, 10, 0), + end: LocalDateTime = LocalDateTime.of(2026, 3, 1, 12, 0), + ): Event { + val event = + Event.create( + title = title, + content = content, + location = location, + cardinal = cardinal, + start = start, + end = end, + user = null, + ) + if (id != 0L) ReflectionTestUtils.setField(event, "id", id) + return event + } } diff --git a/src/test/kotlin/com/weeth/domain/session/domain/entity/SessionTest.kt b/src/test/kotlin/com/weeth/domain/session/domain/entity/SessionTest.kt new file mode 100644 index 00000000..22aa40b7 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/session/domain/entity/SessionTest.kt @@ -0,0 +1,26 @@ +package com.weeth.domain.session.domain.entity + +import com.weeth.domain.session.domain.entity.enums.SessionStatus +import com.weeth.domain.session.fixture.SessionTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe + +class SessionTest : + StringSpec({ + "close는 status를 CLOSED로 변경한다" { + val session = SessionTestFixture.createSession(status = SessionStatus.OPEN) + + session.close() + + session.status shouldBe SessionStatus.CLOSED + } + + "이미 CLOSED 상태에서 close 호출 시 예외가 발생한다" { + val session = SessionTestFixture.createSession(status = SessionStatus.CLOSED) + + shouldThrow { + session.close() + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/session/fixture/SessionTestFixture.kt b/src/test/kotlin/com/weeth/domain/session/fixture/SessionTestFixture.kt new file mode 100644 index 00000000..584c6b4e --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/session/fixture/SessionTestFixture.kt @@ -0,0 +1,64 @@ +package com.weeth.domain.session.fixture + +import com.weeth.domain.session.domain.entity.Session +import com.weeth.domain.session.domain.entity.enums.SessionStatus +import org.springframework.test.util.ReflectionTestUtils +import java.time.LocalDate +import java.time.LocalDateTime + +object SessionTestFixture { + fun createSession( + id: Long = 0L, + title: String = "Test Session", + content: String = "Test Content", + location: String = "Test Location", + cardinal: Int = 1, + code: Int = 1234, + status: SessionStatus = SessionStatus.OPEN, + start: LocalDateTime = LocalDateTime.of(2026, 3, 1, 10, 0), + end: LocalDateTime = LocalDateTime.of(2026, 3, 1, 12, 0), + ): Session { + val session = + Session( + title = title, + content = content, + location = location, + cardinal = cardinal, + code = code, + status = status, + start = start, + end = end, + ) + if (id != 0L) ReflectionTestUtils.setField(session, "id", id) + return session + } + + fun createOneDaySession( + date: LocalDate, + cardinal: Int, + code: Int, + title: String, + ): Session = + Session( + title = title, + location = "Test Location", + start = date.atTime(10, 0), + end = date.atTime(12, 0), + code = code, + cardinal = cardinal, + ) + + fun createInProgressSession( + cardinal: Int, + code: Int, + title: String, + ): Session = + Session( + title = title, + location = "Test Location", + start = LocalDateTime.now().minusMinutes(5), + end = LocalDateTime.now().plusMinutes(5), + code = code, + cardinal = cardinal, + ) +} diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AdminUserUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AdminUserUseCaseTest.kt index f4c88e7a..06c279cf 100644 --- a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AdminUserUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AdminUserUseCaseTest.kt @@ -1,8 +1,9 @@ package com.weeth.domain.user.application.usecase.command -import com.weeth.domain.attendance.domain.service.AttendanceSaveService -import com.weeth.domain.schedule.domain.entity.Meeting -import com.weeth.domain.schedule.domain.service.MeetingGetService +import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.session.domain.entity.Session +import com.weeth.domain.session.domain.repository.SessionReader import com.weeth.domain.user.application.dto.request.UserApplyObRequest import com.weeth.domain.user.application.dto.request.UserIdsRequest import com.weeth.domain.user.application.dto.request.UserRoleUpdateRequest @@ -25,8 +26,8 @@ import io.mockk.verify class AdminUserUseCaseTest : DescribeSpec({ val userReader = mockk() - val attendanceSaveService = mockk(relaxUnitFun = true) - val meetingGetService = mockk() + val sessionReader = mockk() + val attendanceRepository = mockk(relaxed = true) val cardinalRepository = mockk() val userCardinalRepository = mockk(relaxUnitFun = true) val userCardinalPolicy = mockk() @@ -34,8 +35,8 @@ class AdminUserUseCaseTest : val useCase = AdminUserUseCase( userReader, - attendanceSaveService, - meetingGetService, + sessionReader, + attendanceRepository, cardinalRepository, userCardinalRepository, userCardinalPolicy, @@ -44,8 +45,8 @@ class AdminUserUseCaseTest : beforeTest { clearMocks( userReader, - attendanceSaveService, - meetingGetService, + sessionReader, + attendanceRepository, cardinalRepository, userCardinalRepository, userCardinalPolicy, @@ -56,15 +57,15 @@ class AdminUserUseCaseTest : it("비활성 유저 승인 시 출석 초기화를 수행한다") { val user = UserTestFixture.createWaitingUser1(1L) val currentCardinal = CardinalTestFixture.createCardinal(id = 1L, cardinalNumber = 8, year = 2025, semester = 1) - val meetings = listOf(mockk()) + val sessions = listOf(mockk()) every { userReader.findAllByIds(listOf(1L)) } returns listOf(user) every { userCardinalPolicy.getCurrentCardinal(user) } returns currentCardinal - every { meetingGetService.find(8) } returns meetings + every { sessionReader.findAllByCardinal(8) } returns sessions useCase.accept(UserIdsRequest(listOf(1L))) - verify(exactly = 1) { attendanceSaveService.init(user, meetings) } + verify(exactly = 1) { attendanceRepository.saveAll(any>()) } user.status shouldBe Status.ACTIVE } } @@ -96,18 +97,19 @@ class AdminUserUseCaseTest : val user = UserTestFixture.createActiveUser1(1L) val currentCardinal = CardinalTestFixture.createCardinal(id = 10L, cardinalNumber = 3, year = 2024, semester = 2) val nextCardinal = CardinalTestFixture.createCardinal(id = 11L, cardinalNumber = 4, year = 2025, semester = 1) - val meetings = listOf(mockk()) + val session = mockk() + every { session.cardinal } returns 4 val request = listOf(UserApplyObRequest(1L, 4)) every { userReader.findAllByIds(listOf(1L)) } returns listOf(user) every { userCardinalRepository.findAllByUsers(listOf(user)) } returns listOf(UserCardinal(user, currentCardinal)) every { cardinalRepository.findAllByCardinalNumberIn(listOf(4)) } returns listOf(nextCardinal) - every { meetingGetService.findByCardinals(listOf(4)) } returns mapOf(4 to meetings) + every { sessionReader.findAllByCardinalIn(listOf(4)) } returns listOf(session) every { userCardinalRepository.save(any()) } answers { firstArg() } useCase.applyOb(request) - verify(exactly = 1) { attendanceSaveService.init(user, meetings) } + verify(exactly = 1) { attendanceRepository.saveAll(any>()) } verify(exactly = 1) { userCardinalRepository.save(match { it.user == user && it.cardinal == nextCardinal }) } } @@ -122,9 +124,9 @@ class AdminUserUseCaseTest : useCase.applyOb(request) - verify(exactly = 0) { meetingGetService.findByCardinals(any()) } + verify(exactly = 0) { sessionReader.findAllByCardinalIn(any()) } verify(exactly = 0) { userCardinalRepository.save(any()) } - verify(exactly = 0) { attendanceSaveService.init(any(), any()) } + verify(exactly = 0) { attendanceRepository.saveAll(any>()) } } it("요청 목록이 비어 있으면 아무 처리도 하지 않는다") { @@ -138,20 +140,21 @@ class AdminUserUseCaseTest : val user = UserTestFixture.createActiveUser1(1L) val currentCardinal = CardinalTestFixture.createCardinal(id = 10L, cardinalNumber = 3, year = 2024, semester = 2) val createdCardinal = CardinalTestFixture.createCardinal(id = 12L, cardinalNumber = 5, year = 2025, semester = 2) - val meetings = listOf(mockk()) + val session = mockk() + every { session.cardinal } returns 5 val request = listOf(UserApplyObRequest(1L, 5)) every { userReader.findAllByIds(listOf(1L)) } returns listOf(user) every { userCardinalRepository.findAllByUsers(listOf(user)) } returns listOf(UserCardinal(user, currentCardinal)) every { cardinalRepository.findAllByCardinalNumberIn(listOf(5)) } returns emptyList() every { cardinalRepository.save(any()) } returns createdCardinal - every { meetingGetService.findByCardinals(listOf(5)) } returns mapOf(5 to meetings) + every { sessionReader.findAllByCardinalIn(listOf(5)) } returns listOf(session) every { userCardinalRepository.save(any()) } answers { firstArg() } useCase.applyOb(request) verify(exactly = 1) { cardinalRepository.save(any()) } - verify(exactly = 1) { attendanceSaveService.init(user, meetings) } + verify(exactly = 1) { attendanceRepository.saveAll(any>()) } verify(exactly = 1) { userCardinalRepository.save(match { it.user == user && it.cardinal == createdCardinal }) } } } From b164e9b3100a71de295b9d64438fc4db034e3923 Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:23:36 +0900 Subject: [PATCH 14/73] =?UTF-8?q?[WTH-161]=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=ED=9B=84=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?(#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.json | 43 +++++++++++ .dockerignore | 2 +- .editorconfig | 19 +++++ CLAUDE.md | 32 ++++++-- build.gradle.kts | 15 ---- .../global/common/entity/BaseEntity.java | 30 -------- .../com/weeth/WeethApplication.kt} | 22 +++--- .../application/exception/AccountErrorCode.kt | 13 +--- .../usecase/command/ManageAccountUseCase.kt | 2 + .../usecase/command/ManageReceiptUseCase.kt | 6 +- .../usecase/query/GetAccountQueryService.kt | 5 +- .../account/presentation/AccountController.kt | 3 +- .../dto/response/AttendanceInfoResponse.kt | 2 +- .../dto/response/AttendanceResponse.kt | 2 +- .../dto/response/AttendanceSummaryResponse.kt | 2 +- .../exception/AttendanceErrorCode.kt | 13 +--- .../command/ManageAttendanceUseCase.kt | 15 +++- .../query/GetAttendanceQueryService.kt | 4 +- .../attendance/domain/entity/Attendance.kt | 2 +- .../{entity => }/enums/AttendanceStatus.kt | 2 +- .../domain/repository/AttendanceRepository.kt | 2 +- .../presentation/AttendanceController.kt | 5 +- .../dto/request/CreateBoardRequest.kt | 4 +- .../dto/request/UpdateBoardRequest.kt | 2 +- .../dto/response/BoardDetailResponse.kt | 4 +- .../dto/response/BoardListResponse.kt | 2 +- .../dto/response/PostDetailResponse.kt | 2 +- .../dto/response/PostListResponse.kt | 2 +- .../application/exception/BoardErrorCode.kt | 13 +--- .../usecase/command/ManageBoardUseCase.kt | 3 +- .../usecase/command/ManagePostUseCase.kt | 10 ++- .../usecase/query/GetBoardQueryService.kt | 2 +- .../usecase/query/GetPostQueryService.kt | 4 +- .../weeth/domain/board/domain/entity/Board.kt | 4 +- .../domain/{entity => }/enums/BoardType.kt | 2 +- .../board/domain/{entity => }/enums/Part.kt | 2 +- .../domain/board/domain/vo/BoardConfig.kt | 2 +- .../board/presentation/BoardController.kt | 2 +- .../board/presentation/PostController.kt | 2 +- .../dto/response/CommentResponse.kt | 2 +- .../application/exception/CommentErrorCode.kt | 13 +--- .../usecase/command/ManageCommentUseCase.kt | 7 +- .../usecase/command/PostCommentUsecase.kt | 3 + .../usecase/query/GetCommentQueryService.kt | 2 +- .../dto/request/FileSaveRequest.kt | 5 +- .../application/dto/response/FileResponse.kt | 2 +- .../application/dto/response/UrlResponse.kt | 5 +- .../application/exception/FileErrorCode.kt | 13 +--- .../PresignedUrlGenerationException.kt | 6 +- .../file/application/mapper/FileMapper.kt | 2 +- .../usecase/command/GenerateFileUrlUsecase.kt | 2 +- .../weeth/domain/file/domain/entity/File.kt | 2 + .../domain/{entity => enums}/FileOwnerType.kt | 2 +- .../domain/{entity => enums}/FileStatus.kt | 2 +- .../file/domain/port/FileUploadUrlPort.kt | 2 +- .../file/domain/repository/FileReader.kt | 4 +- .../file/domain/repository/FileRepository.kt | 4 +- .../weeth/domain/file/domain/vo/StorageKey.kt | 2 +- .../infrastructure/S3FileUploadUrlAdapter.kt | 2 +- .../file/presentation/FileController.kt | 2 +- .../application/exception/PenaltyErrorCode.kt | 13 +--- .../usecase/command/DeletePenaltyUseCase.kt | 5 +- .../usecase/command/SavePenaltyUseCase.kt | 2 +- .../domain/repository/PenaltyRepository.kt | 8 +- .../presentation/PenaltyAdminController.kt | 5 +- .../annotation/ScheduleTimeCheck.kt | 3 + .../application/dto/response/EventResponse.kt | 2 +- .../dto/response/SessionResponse.kt | 2 +- .../application/exception/EventErrorCode.kt | 13 +--- .../application/mapper/EventMapper.kt | 2 +- .../application/mapper/SessionMapper.kt | 2 +- .../validator/ScheduleTimeCheckValidator.kt | 3 + .../schedule/domain/entity/enums/Type.kt | 6 -- .../domain/schedule/domain/enums/Type.kt | 6 ++ .../presentation/ScheduleController.kt | 10 ++- .../application/exception/SessionErrorCode.kt | 13 +--- .../usecase/command/ManageSessionUseCase.kt | 7 +- .../usecase/query/GetSessionQueryService.kt | 5 +- .../domain/session/domain/entity/Session.kt | 4 +- .../domain/entity/enums/SessionStatus.kt | 6 -- .../session/domain/enums/SessionStatus.kt | 6 ++ .../domain/repository/SessionReader.kt | 2 +- .../domain/repository/SessionRepository.kt | 2 +- .../session/presentation/SessionController.kt | 5 +- .../dto/request/UserRoleUpdateRequest.kt | 2 +- .../dto/response/AdminUserResponse.kt | 4 +- .../dto/response/CardinalResponse.kt | 2 +- .../dto/response/UserDetailsResponse.kt | 2 +- .../dto/response/UserInfoResponse.kt | 2 +- .../dto/response/UserProfileResponse.kt | 2 +- .../dto/response/UserSummaryResponse.kt | 2 +- .../application/exception/UserErrorCode.kt | 13 +--- .../usecase/command/AuthUserUseCase.kt | 19 +++-- .../usecase/command/ManageCardinalUseCase.kt | 2 +- .../usecase/query/GetCardinalQueryService.kt | 3 +- .../usecase/query/GetUserQueryService.kt | 20 +++-- .../domain/user/domain/entity/Cardinal.kt | 2 +- .../weeth/domain/user/domain/entity/User.kt | 8 +- .../user/domain/entity/UserSocialAccount.kt | 2 +- .../domain/user/domain/entity/enums/Role.kt | 6 -- .../{entity => }/enums/CardinalStatus.kt | 2 +- .../weeth/domain/user/domain/enums/Role.kt | 6 ++ .../{entity => }/enums/SocialProvider.kt | 2 +- .../user/domain/{entity => }/enums/Status.kt | 2 +- .../{entity => }/enums/StatusPriority.kt | 2 +- .../domain/{entity => }/enums/UsersOrderBy.kt | 2 +- .../domain/repository/CardinalRepository.kt | 2 +- .../repository/UserCardinalRepository.kt | 7 +- .../user/domain/repository/UserReader.kt | 2 +- .../user/domain/repository/UserRepository.kt | 2 +- .../repository/UserSocialAccountRepository.kt | 2 +- .../user/presentation/UserAdminController.kt | 2 +- .../user/presentation/UserController.kt | 5 +- .../global/auth/apple/AppleAuthService.kt | 3 +- .../jwt/application/exception/JwtErrorCode.kt | 13 +--- .../application/service/JwtTokenExtractor.kt | 5 +- .../application/usecase/JwtManageUseCase.kt | 2 +- .../jwt/domain/port/RefreshTokenStorePort.kt | 2 +- .../jwt/domain/service/JwtTokenProvider.kt | 2 +- .../RedisRefreshTokenStoreAdapter.kt | 2 +- .../global/auth/model/AuthenticatedUser.kt | 2 +- .../CurrentUserRoleArgumentResolver.kt | 2 +- .../global/common/converter/JsonConverter.kt | 3 +- .../weeth/global/common/entity/BaseEntity.kt | 22 ++++++ .../global/common/exception/BaseException.kt | 28 ++----- .../exception/CommonExceptionHandler.kt | 8 +- .../common/exception/ErrorCodeInterface.kt | 10 +-- .../global/common/response/CommonResponse.kt | 10 +-- .../com/weeth/global/config/SwaggerConfig.kt | 8 +- src/main/resources/application.yml | 2 + .../kotlin/com/weeth/config/QueryCountUtil.kt | 7 +- .../command/ManageReceiptUseCaseTest.kt | 5 +- .../query/GetAccountQueryServiceTest.kt | 2 +- .../query/GetAttendanceQueryServiceTest.kt | 5 +- .../domain/entity/AttendanceTest.kt | 2 +- .../repository/AttendanceRepositoryTest.kt | 4 +- .../fixture/AttendanceTestFixture.kt | 13 +++- .../application/mapper/PostMapperTest.kt | 4 +- .../usecase/command/ManageBoardUseCaseTest.kt | 4 +- .../usecase/command/ManagePostUseCaseTest.kt | 8 +- .../usecase/query/GetBoardQueryServiceTest.kt | 10 ++- .../usecase/query/GetPostQueryServiceTest.kt | 17 +++-- .../converter/BoardConfigConverterTest.kt | 2 +- .../board/domain/entity/BoardEntityTest.kt | 7 +- .../domain/board/fixture/BoardTestFixture.kt | 4 +- .../usecase/command/CommentConcurrencyTest.kt | 16 +++- .../command/ManageCommentUseCaseTest.kt | 4 +- .../query/CommentQueryPerformanceTest.kt | 8 +- .../query/GetCommentQueryServiceTest.kt | 4 +- .../file/application/mapper/FileMapperTest.kt | 16 +++- .../command/GenerateFileUrlUsecaseTest.kt | 8 +- .../domain/file/domain/entity/FileTest.kt | 4 +- .../domain/repository/FileRepositoryTest.kt | 11 ++- .../domain/file/fixture/FileTestFixture.kt | 2 +- .../S3FileUploadUrlAdapterTest.kt | 5 +- .../session/domain/entity/SessionTest.kt | 2 +- .../session/fixture/SessionTestFixture.kt | 2 +- .../usecase/command/AdminUserUseCaseTest.kt | 74 +++++++++++++++---- .../usecase/command/AuthUserUseCaseTest.kt | 57 ++++++++++---- .../usecase/command/CardinalUseCaseTest.kt | 5 +- .../usecase/query/GetUserQueryServiceTest.kt | 16 +++- .../domain/user/domain/entity/CardinalTest.kt | 2 +- .../domain/user/domain/entity/UserTest.kt | 4 +- .../repository/UserCardinalRepositoryTest.kt | 35 +++++++-- .../domain/repository/UserRepositoryTest.kt | 12 ++- .../domain/service/UserCardinalPolicyTest.kt | 19 ++++- .../user/fixture/CardinalTestFixture.kt | 2 +- .../domain/user/fixture/UserTestFixture.kt | 4 +- .../service/JwtTokenExtractorTest.kt | 2 +- .../usecase/JwtManageUseCaseTest.kt | 2 +- .../domain/service/JwtTokenProviderTest.kt | 2 +- .../JwtAuthenticationProcessingFilterTest.kt | 5 +- .../RedisRefreshTokenStoreAdapterTest.kt | 2 +- .../CurrentUserArgumentResolverTest.kt | 2 +- 174 files changed, 713 insertions(+), 480 deletions(-) create mode 100644 .claude/settings.json create mode 100644 .editorconfig delete mode 100644 src/main/java/com/weeth/global/common/entity/BaseEntity.java rename src/main/{java/com/weeth/WeethApplication.java => kotlin/com/weeth/WeethApplication.kt} (61%) rename src/main/kotlin/com/weeth/domain/attendance/domain/{entity => }/enums/AttendanceStatus.kt (55%) rename src/main/kotlin/com/weeth/domain/board/domain/{entity => }/enums/BoardType.kt (61%) rename src/main/kotlin/com/weeth/domain/board/domain/{entity => }/enums/Part.kt (54%) rename src/main/kotlin/com/weeth/domain/file/domain/{entity => enums}/FileOwnerType.kt (60%) rename src/main/kotlin/com/weeth/domain/file/domain/{entity => enums}/FileStatus.kt (55%) delete mode 100644 src/main/kotlin/com/weeth/domain/schedule/domain/entity/enums/Type.kt create mode 100644 src/main/kotlin/com/weeth/domain/schedule/domain/enums/Type.kt delete mode 100644 src/main/kotlin/com/weeth/domain/session/domain/entity/enums/SessionStatus.kt create mode 100644 src/main/kotlin/com/weeth/domain/session/domain/enums/SessionStatus.kt delete mode 100644 src/main/kotlin/com/weeth/domain/user/domain/entity/enums/Role.kt rename src/main/kotlin/com/weeth/domain/user/domain/{entity => }/enums/CardinalStatus.kt (53%) create mode 100644 src/main/kotlin/com/weeth/domain/user/domain/enums/Role.kt rename src/main/kotlin/com/weeth/domain/user/domain/{entity => }/enums/SocialProvider.kt (51%) rename src/main/kotlin/com/weeth/domain/user/domain/{entity => }/enums/Status.kt (58%) rename src/main/kotlin/com/weeth/domain/user/domain/{entity => }/enums/StatusPriority.kt (91%) rename src/main/kotlin/com/weeth/domain/user/domain/{entity => }/enums/UsersOrderBy.kt (59%) create mode 100644 src/main/kotlin/com/weeth/global/common/entity/BaseEntity.kt diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..63881359 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,43 @@ +{ + "permissions": { + "deny": [ + "Edit(**/.env*)", + "Edit(**/*.pem)", + "Edit(**/*.key)", + "Edit(**/*secret*)", + "Edit(**/*credential*)", + "Write(**/.env*)", + "Write(**/*.pem)", + "Write(**/*.key)", + "Write(**/*secret*)", + "Write(**/*credential*)" + ], + "allow": [ + "Read", + "Glob", + "Grep", + "NotebookEdit", + "mcp__ide__getDiagnostics", + "Edit(src/main/**)", + "Edit(src/test/**)", + "Edit(docs/**)", + "Edit(build.gradle.kts)", + "Edit(settings.gradle.kts)", + "Edit(CLAUDE.md)", + "Edit(.claude/**)", + "Edit(.editorconfig)", + "Write(src/main/**)", + "Write(src/test/**)", + "Write(docs/**)", + "Write(.claude/**)" + ], + "ask": [ + "Bash(git *)", + "Bash(gh *)", + "Edit(src/main/resources/application-prod*)", + "Edit(src/main/resources/application-dev*)", + "Write(src/main/resources/application-prod*)", + "Write(src/main/resources/application-dev*)" + ] + } +} diff --git a/.dockerignore b/.dockerignore index a90b37f0..5efde322 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,4 +9,4 @@ infra **/Dockerfile* **/*.iml -**/.DS_Store \ No newline at end of file +**/.DS_Store diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..be2dacec --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +root = true + +[*.{kt,kts}] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +insert_final_newline = true +trim_trailing_whitespace = true +max_line_length = 120 + +# wildcard import 방지 +ktlint_standard_no-wildcard-imports = enabled +ij_kotlin_name_count_to_use_star_import = 999 +ij_kotlin_name_count_to_use_star_import_for_members = 999 + +# trailing comma 설정 +ktlint_standard_trailing-comma-on-call-site = enabled +ktlint_standard_trailing-comma-on-declaration-site = enabled diff --git a/CLAUDE.md b/CLAUDE.md index 1b029baa..4aa4c418 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -Weeth Server is a community platform backend built with Spring Boot 3.5.10. The codebase is in active **Java → Kotlin migration** — new code should be written in Kotlin, while ~271 Java files remain. Lombok and MapStruct are temporary dependencies being phased out in favor of Kotlin idioms and manual mappers. +Weeth Server is a community platform backend built with Spring Boot 3.5.10. The codebase has completed **Java → Kotlin migration** — all code is Kotlin. ## Build & Development Commands @@ -52,8 +52,8 @@ domain/{name}/ │ └── service/ # Multi-entity logic only (no thin wrappers) ├── infrastructure/ # Port implementations └── presentation/ - ├── {Domain}Controller.java - └── {Domain}ResponseCode.java + ├── {Domain}Controller.kt + └── {Domain}ResponseCode.kt ``` ### Key Patterns @@ -91,13 +91,31 @@ JWT with symmetric key (JJWT 0.13.0), OAuth2 via Kakao and Apple. `@CurrentUser` - **Fixture pattern**: `{Entity}TestFixture` objects with factory methods in `fixture/` directories - Test architecture mirrors source: mock Repository/Reader/Port in UseCase tests, mock Port (not adapter) in application tests -## Kotlin Migration Notes +## Kotlin Migration Status + +**✅ Complete** — 294 Kotlin files (100%) + +- Java → Kotlin migration fully complete +- Lombok and MapStruct dependencies removed +- All 13 mappers migrated to manual `@Component` Mapper classes (see `.claude/rules/mapper-dto.md`) +- Entity fields use `private set` for Rich Domain Model pattern (see architecture.md) +- OSIV disabled: `spring.jpa.open-in-view: false` in `application.yml` + +## Kotlin Conventions -- New code: Kotlin. Existing Java code migrated incrementally. -- Replace Lombok with Kotlin data classes/properties -- Replace MapStruct with manual `@Component` Mapper classes (see `.claude/rules/mapper-dto.md`) - Use `?.`, `?:`, `requireNotNull` — avoid `!!` - Entities: regular `class` (not `data class`); DTOs: `data class` +- Entity setters: `private set` to enforce business logic via named methods +- Example: + ```kotlin + var name: String + private set + + fun updateName(newName: String) { + require(newName.isNotBlank()) { "Name cannot be empty" } + this.name = newName + } + ``` ## Detailed Rules diff --git a/build.gradle.kts b/build.gradle.kts index 638e9945..ff51eb14 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,7 +8,6 @@ plugins { kotlin("jvm") version "2.1.0" kotlin("plugin.spring") version "2.1.0" kotlin("plugin.jpa") version "2.1.0" - kotlin("plugin.lombok") version "2.1.0" id("org.jlleitschuh.gradle.ktlint") version "12.1.2" } @@ -38,21 +37,7 @@ val testcontainersBomVersion = "2.0.3" val kotestVersion = "5.9.1" val mockkVersion = "1.13.14" val springmockkVersion = "4.0.2" -val lombokVersion = "1.18.36" -val mapstructVersion = "1.6.3" - dependencies { - // --- Lombok (temporary, will be removed during Kotlin migration) --- - compileOnly("org.projectlombok:lombok:$lombokVersion") - annotationProcessor("org.projectlombok:lombok:$lombokVersion") - testCompileOnly("org.projectlombok:lombok:$lombokVersion") - testAnnotationProcessor("org.projectlombok:lombok:$lombokVersion") - - // --- MapStruct (temporary, will be removed during Kotlin migration) --- - implementation("org.mapstruct:mapstruct:$mapstructVersion") - annotationProcessor("org.mapstruct:mapstruct-processor:$mapstructVersion") - testAnnotationProcessor("org.mapstruct:mapstruct-processor:$mapstructVersion") - // --- Kotlin --- implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") diff --git a/src/main/java/com/weeth/global/common/entity/BaseEntity.java b/src/main/java/com/weeth/global/common/entity/BaseEntity.java deleted file mode 100644 index fa970c29..00000000 --- a/src/main/java/com/weeth/global/common/entity/BaseEntity.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.weeth.global.common.entity; - -import jakarta.persistence.Column; -import jakarta.persistence.EntityListeners; -import jakarta.persistence.MappedSuperclass; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.experimental.SuperBuilder; -import org.springframework.data.annotation.CreatedDate; -import org.springframework.data.annotation.LastModifiedDate; -import org.springframework.data.jpa.domain.support.AuditingEntityListener; - -import java.time.LocalDateTime; - -@Getter -@MappedSuperclass -@EntityListeners(AuditingEntityListener.class) -@SuperBuilder -@NoArgsConstructor(access = AccessLevel.PROTECTED) -// NOTE: Java 엔티티들의 Lombok @SuperBuilder 체인(BaseEntityBuilder) 호환을 위해 현재는 Java로 유지한다. -public class BaseEntity { - - @CreatedDate - @Column(updatable = false) - private LocalDateTime createdAt; - - @LastModifiedDate - private LocalDateTime modifiedAt; -} diff --git a/src/main/java/com/weeth/WeethApplication.java b/src/main/kotlin/com/weeth/WeethApplication.kt similarity index 61% rename from src/main/java/com/weeth/WeethApplication.java rename to src/main/kotlin/com/weeth/WeethApplication.kt index 4f5c810f..8664ed91 100644 --- a/src/main/java/com/weeth/WeethApplication.java +++ b/src/main/kotlin/com/weeth/WeethApplication.kt @@ -1,21 +1,19 @@ -package com.weeth; +package com.weeth -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.context.properties.ConfigurationPropertiesScan; -import org.springframework.data.jpa.repository.config.EnableJpaAuditing; -import org.springframework.scheduling.annotation.EnableScheduling; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.ConfigurationPropertiesScan +import org.springframework.boot.runApplication +import org.springframework.data.jpa.repository.config.EnableJpaAuditing +import org.springframework.scheduling.annotation.EnableScheduling +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity @EnableScheduling @EnableJpaAuditing @EnableWebSecurity @SpringBootApplication @ConfigurationPropertiesScan -public class WeethApplication { - - public static void main(String[] args) { - SpringApplication.run(WeethApplication.class, args); - } +class WeethApplication +fun main(args: Array) { + runApplication(*args) } diff --git a/src/main/kotlin/com/weeth/domain/account/application/exception/AccountErrorCode.kt b/src/main/kotlin/com/weeth/domain/account/application/exception/AccountErrorCode.kt index 06ecb74d..9bc75b76 100644 --- a/src/main/kotlin/com/weeth/domain/account/application/exception/AccountErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/account/application/exception/AccountErrorCode.kt @@ -5,9 +5,9 @@ import com.weeth.global.common.exception.ExplainError import org.springframework.http.HttpStatus enum class AccountErrorCode( - private val code: Int, - private val status: HttpStatus, - private val message: String, + override val code: Int, + override val status: HttpStatus, + override val message: String, ) : ErrorCodeInterface { @ExplainError("요청한 회비 장부 ID가 존재하지 않을 때 발생합니다.") ACCOUNT_NOT_FOUND(2100, HttpStatus.NOT_FOUND, "존재하지 않는 장부입니다."), @@ -20,11 +20,4 @@ enum class AccountErrorCode( @ExplainError("영수증이 요청한 기수의 장부에 속하지 않을 때 발생합니다.") RECEIPT_ACCOUNT_MISMATCH(2103, HttpStatus.BAD_REQUEST, "영수증이 해당 기수의 장부에 속하지 않습니다."), - ; - - override fun getCode(): Int = code - - override fun getStatus(): HttpStatus = status - - override fun getMessage(): String = message } diff --git a/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCase.kt b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCase.kt index e8a79b3e..bb627dbb 100644 --- a/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCase.kt @@ -17,6 +17,8 @@ class ManageAccountUseCase( @Transactional fun save(request: AccountSaveRequest) { if (accountRepository.existsByCardinal(request.cardinal)) throw AccountExistsException() + + // 기수가 없는 경우 생성 cardinalRepository.findByCardinalNumber(request.cardinal).orElseGet { cardinalRepository.save(Cardinal.create(cardinalNumber = request.cardinal)) } diff --git a/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCase.kt b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCase.kt index 34c06373..7f9ef5fa 100644 --- a/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCase.kt @@ -10,7 +10,7 @@ import com.weeth.domain.account.domain.repository.AccountRepository import com.weeth.domain.account.domain.repository.ReceiptRepository import com.weeth.domain.account.domain.vo.Money import com.weeth.domain.file.application.mapper.FileMapper -import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.repository.FileReader import com.weeth.domain.file.domain.repository.FileRepository import com.weeth.domain.user.domain.entity.Cardinal @@ -19,6 +19,9 @@ import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +/** + * Todo: 개행을 추가해 가독성 개선 + */ @Service class ManageReceiptUseCase( private val receiptRepository: ReceiptRepository, @@ -28,6 +31,7 @@ class ManageReceiptUseCase( private val cardinalRepository: CardinalRepository, private val fileMapper: FileMapper, ) { + // 기수가 없는 경우 생성 private fun ensureCardinalExists(cardinalNumber: Int) { cardinalRepository.findByCardinalNumber(cardinalNumber).orElseGet { cardinalRepository.save(Cardinal.create(cardinalNumber = cardinalNumber)) diff --git a/src/main/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountQueryService.kt b/src/main/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountQueryService.kt index 8d10b081..d67060ad 100644 --- a/src/main/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountQueryService.kt @@ -7,11 +7,14 @@ import com.weeth.domain.account.application.mapper.ReceiptMapper import com.weeth.domain.account.domain.repository.AccountRepository import com.weeth.domain.account.domain.repository.ReceiptRepository import com.weeth.domain.file.application.mapper.FileMapper -import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.repository.FileReader import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +/** + * Todo: 개행을 추가해 가독성 개선 + */ @Service @Transactional(readOnly = true) class GetAccountQueryService( diff --git a/src/main/kotlin/com/weeth/domain/account/presentation/AccountController.kt b/src/main/kotlin/com/weeth/domain/account/presentation/AccountController.kt index 96d9e4c6..e4bd6f61 100644 --- a/src/main/kotlin/com/weeth/domain/account/presentation/AccountController.kt +++ b/src/main/kotlin/com/weeth/domain/account/presentation/AccountController.kt @@ -24,5 +24,6 @@ class AccountController( @Operation(summary = "회비 내역 조회") fun find( @PathVariable cardinal: Int, - ): CommonResponse = CommonResponse.success(ACCOUNT_FIND_SUCCESS, getAccountQueryService.findByCardinal(cardinal)) + ): CommonResponse = + CommonResponse.success(ACCOUNT_FIND_SUCCESS, getAccountQueryService.findByCardinal(cardinal)) } diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceInfoResponse.kt b/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceInfoResponse.kt index e6f36bbb..7574e8e7 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceInfoResponse.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceInfoResponse.kt @@ -1,6 +1,6 @@ package com.weeth.domain.attendance.application.dto.response -import com.weeth.domain.attendance.domain.entity.enums.AttendanceStatus +import com.weeth.domain.attendance.domain.enums.AttendanceStatus import io.swagger.v3.oas.annotations.media.Schema data class AttendanceInfoResponse( diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceResponse.kt b/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceResponse.kt index 7ef78c9a..625ddbde 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceResponse.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceResponse.kt @@ -1,6 +1,6 @@ package com.weeth.domain.attendance.application.dto.response -import com.weeth.domain.attendance.domain.entity.enums.AttendanceStatus +import com.weeth.domain.attendance.domain.enums.AttendanceStatus import io.swagger.v3.oas.annotations.media.Schema import java.time.LocalDateTime diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceSummaryResponse.kt b/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceSummaryResponse.kt index 7a445550..cbe2d42d 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceSummaryResponse.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceSummaryResponse.kt @@ -1,6 +1,6 @@ package com.weeth.domain.attendance.application.dto.response -import com.weeth.domain.attendance.domain.entity.enums.AttendanceStatus +import com.weeth.domain.attendance.domain.enums.AttendanceStatus import io.swagger.v3.oas.annotations.media.Schema import java.time.LocalDateTime diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceErrorCode.kt b/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceErrorCode.kt index bba918e5..8682af4e 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceErrorCode.kt @@ -5,9 +5,9 @@ import com.weeth.global.common.exception.ExplainError import org.springframework.http.HttpStatus enum class AttendanceErrorCode( - private val code: Int, - private val status: HttpStatus, - private val message: String, + override val code: Int, + override val status: HttpStatus, + override val message: String, ) : ErrorCodeInterface { @ExplainError("출석 정보를 찾을 수 없을 때 발생합니다.") ATTENDANCE_NOT_FOUND(2200, HttpStatus.NOT_FOUND, "출석 정보가 존재하지 않습니다."), @@ -17,11 +17,4 @@ enum class AttendanceErrorCode( @ExplainError("사용자가 출석 일정을 직접 수정하려고 시도할 때 발생합니다. (출석 로직 위반)") ATTENDANCE_EVENT_TYPE_NOT_MATCH(2202, HttpStatus.BAD_REQUEST, "출석일정은 직접 수정할 수 없습니다."), - ; - - override fun getCode(): Int = code - - override fun getStatus(): HttpStatus = status - - override fun getMessage(): String = message } diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCase.kt b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCase.kt index fcdfee1d..785170fd 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCase.kt @@ -4,18 +4,22 @@ import com.weeth.domain.attendance.application.dto.request.UpdateAttendanceStatu import com.weeth.domain.attendance.application.exception.AttendanceCodeMismatchException import com.weeth.domain.attendance.application.exception.AttendanceNotFoundException import com.weeth.domain.attendance.domain.entity.Attendance -import com.weeth.domain.attendance.domain.entity.enums.AttendanceStatus +import com.weeth.domain.attendance.domain.enums.AttendanceStatus import com.weeth.domain.attendance.domain.repository.AttendanceRepository import com.weeth.domain.session.application.exception.SessionNotFoundException -import com.weeth.domain.session.domain.entity.enums.SessionStatus +import com.weeth.domain.session.domain.enums.SessionStatus import com.weeth.domain.session.domain.repository.SessionReader -import com.weeth.domain.user.domain.entity.enums.Status +import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.repository.UserReader import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.LocalDate import java.time.LocalDateTime +/** + * Todo: 개행을 추가해 가독성 개선 + * Todo: if 문 가독성 개선 + */ @Service class ManageAttendanceUseCase( private val userReader: UserReader, @@ -52,7 +56,10 @@ class ManageAttendanceUseCase( val targetSession = sessionReader .findAllByCardinalOrderByStartAsc(cardinal) - .firstOrNull { session -> session.start.toLocalDate().isEqual(now) && session.end.toLocalDate().isEqual(now) } + .firstOrNull { session -> + session.start.toLocalDate().isEqual(now) && + session.end.toLocalDate().isEqual(now) + } ?: throw SessionNotFoundException() val attendances = attendanceRepository.findAllBySessionAndUserStatus(targetSession, Status.ACTIVE) closePendingAttendances(attendances) diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt index 9da8d254..4bfca946 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt @@ -6,8 +6,8 @@ import com.weeth.domain.attendance.application.dto.response.AttendanceSummaryRes import com.weeth.domain.attendance.application.mapper.AttendanceMapper import com.weeth.domain.attendance.domain.repository.AttendanceRepository import com.weeth.domain.session.domain.repository.SessionReader -import com.weeth.domain.user.domain.entity.enums.Role -import com.weeth.domain.user.domain.entity.enums.Status +import com.weeth.domain.user.domain.enums.Role +import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.repository.UserReader import com.weeth.domain.user.domain.service.UserCardinalPolicy import org.springframework.stereotype.Service diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/entity/Attendance.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/entity/Attendance.kt index be54e167..1aab45c9 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/domain/entity/Attendance.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/entity/Attendance.kt @@ -1,6 +1,6 @@ package com.weeth.domain.attendance.domain.entity -import com.weeth.domain.attendance.domain.entity.enums.AttendanceStatus +import com.weeth.domain.attendance.domain.enums.AttendanceStatus import com.weeth.domain.session.domain.entity.Session import com.weeth.domain.user.domain.entity.User import com.weeth.global.common.entity.BaseEntity diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/entity/enums/AttendanceStatus.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/enums/AttendanceStatus.kt similarity index 55% rename from src/main/kotlin/com/weeth/domain/attendance/domain/entity/enums/AttendanceStatus.kt rename to src/main/kotlin/com/weeth/domain/attendance/domain/enums/AttendanceStatus.kt index f54fc9f0..9bdf0f1a 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/domain/entity/enums/AttendanceStatus.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/enums/AttendanceStatus.kt @@ -1,4 +1,4 @@ -package com.weeth.domain.attendance.domain.entity.enums +package com.weeth.domain.attendance.domain.enums enum class AttendanceStatus { ATTEND, diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt index 96c162a8..307d7b4b 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt @@ -3,7 +3,7 @@ package com.weeth.domain.attendance.domain.repository import com.weeth.domain.attendance.domain.entity.Attendance import com.weeth.domain.session.domain.entity.Session import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.entity.enums.Status +import com.weeth.domain.user.domain.enums.Status import jakarta.persistence.LockModeType import jakarta.persistence.QueryHint import org.springframework.data.jpa.repository.EntityGraph diff --git a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt index 37aa0ec9..83abc93d 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt @@ -41,7 +41,10 @@ class AttendanceController( fun find( @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse = - CommonResponse.success(AttendanceResponseCode.ATTENDANCE_FIND_SUCCESS, getAttendanceQueryService.findAttendance(userId)) + CommonResponse.success( + AttendanceResponseCode.ATTENDANCE_FIND_SUCCESS, + getAttendanceQueryService.findAttendance(userId), + ) @GetMapping("/detail") @Operation(summary = "출석 내역 상세조회") diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/request/CreateBoardRequest.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/request/CreateBoardRequest.kt index 905ac2d4..9a556341 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/dto/request/CreateBoardRequest.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/request/CreateBoardRequest.kt @@ -1,7 +1,7 @@ package com.weeth.domain.board.application.dto.request -import com.weeth.domain.board.domain.entity.enums.BoardType -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.board.domain.enums.BoardType +import com.weeth.domain.user.domain.enums.Role import io.swagger.v3.oas.annotations.media.Schema import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.NotNull diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdateBoardRequest.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdateBoardRequest.kt index 0712551b..644fa546 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdateBoardRequest.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdateBoardRequest.kt @@ -1,6 +1,6 @@ package com.weeth.domain.board.application.dto.request -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.enums.Role import io.swagger.v3.oas.annotations.media.Schema import jakarta.validation.constraints.Size diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardDetailResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardDetailResponse.kt index d42d623b..6a4e08bb 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardDetailResponse.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardDetailResponse.kt @@ -1,8 +1,8 @@ package com.weeth.domain.board.application.dto.response import com.fasterxml.jackson.annotation.JsonInclude -import com.weeth.domain.board.domain.entity.enums.BoardType -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.board.domain.enums.BoardType +import com.weeth.domain.user.domain.enums.Role import io.swagger.v3.oas.annotations.media.Schema @JsonInclude(JsonInclude.Include.NON_NULL) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardListResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardListResponse.kt index 0024a619..f5f6ec40 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardListResponse.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardListResponse.kt @@ -1,6 +1,6 @@ package com.weeth.domain.board.application.dto.response -import com.weeth.domain.board.domain.entity.enums.BoardType +import com.weeth.domain.board.domain.enums.BoardType import io.swagger.v3.oas.annotations.media.Schema data class BoardListResponse( diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt index 62963ee6..d28aca24 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt @@ -2,7 +2,7 @@ package com.weeth.domain.board.application.dto.response import com.weeth.domain.comment.application.dto.response.CommentResponse import com.weeth.domain.file.application.dto.response.FileResponse -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.enums.Role import io.swagger.v3.oas.annotations.media.Schema import java.time.LocalDateTime diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt index 10729f2f..628a1592 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt @@ -1,6 +1,6 @@ package com.weeth.domain.board.application.dto.response -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.enums.Role import io.swagger.v3.oas.annotations.media.Schema import java.time.LocalDateTime diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt index 2328b874..7e1c38b3 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt @@ -5,9 +5,9 @@ import com.weeth.global.common.exception.ExplainError import org.springframework.http.HttpStatus enum class BoardErrorCode( - private val code: Int, - private val status: HttpStatus, - private val message: String, + override val code: Int, + override val status: HttpStatus, + override val message: String, ) : ErrorCodeInterface { @ExplainError("검색 결과가 없을 때 발생합니다.") NO_SEARCH_RESULT(2300, HttpStatus.NOT_FOUND, "검색 결과가 없습니다."), @@ -26,11 +26,4 @@ enum class BoardErrorCode( @ExplainError("게시글 작성자가 아닌 사용자가 수정/삭제를 시도할 때 발생합니다.") POST_NOT_OWNED(2305, HttpStatus.FORBIDDEN, "게시글 작성자만 수정/삭제할 수 있습니다."), - ; - - override fun getCode(): Int = code - - override fun getStatus(): HttpStatus = status - - override fun getMessage(): String = message } diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt index e6a559c5..dc1443a3 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt @@ -63,5 +63,6 @@ class ManageBoardUseCase( board.markDeleted() } - private fun findBoard(boardId: Long): Board = boardRepository.findByIdAndIsDeletedFalse(boardId) ?: throw BoardNotFoundException() + private fun findBoard(boardId: Long): Board = + boardRepository.findByIdAndIsDeletedFalse(boardId) ?: throw BoardNotFoundException() } diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt index d74c52a6..6636fb97 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt @@ -14,7 +14,7 @@ import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.board.domain.repository.PostRepository import com.weeth.domain.file.application.dto.request.FileSaveRequest import com.weeth.domain.file.application.mapper.FileMapper -import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.repository.FileReader import com.weeth.domain.file.domain.repository.FileRepository import com.weeth.domain.user.domain.entity.User @@ -25,7 +25,7 @@ import org.springframework.transaction.annotation.Transactional @Service class ManagePostUseCase( private val postRepository: PostRepository, - private val boardRepository: BoardRepository, // 동일 도메인 + private val boardRepository: BoardRepository, private val userReader: UserReader, private val fileRepository: FileRepository, private val fileReader: FileReader, @@ -88,9 +88,11 @@ class ManagePostUseCase( post.markDeleted() } - private fun findBoard(boardId: Long): Board = boardRepository.findByIdAndIsDeletedFalse(boardId) ?: throw BoardNotFoundException() + private fun findBoard(boardId: Long): Board = + boardRepository.findByIdAndIsDeletedFalse(boardId) ?: throw BoardNotFoundException() - private fun findPost(postId: Long): Post = postRepository.findByIdAndIsDeletedFalse(postId) ?: throw PostNotFoundException() + private fun findPost(postId: Long): Post = + postRepository.findByIdAndIsDeletedFalse(postId) ?: throw PostNotFoundException() private fun validateOwner( post: Post, diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt index e63bf21d..d4f56f9e 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt @@ -5,7 +5,7 @@ import com.weeth.domain.board.application.dto.response.BoardListResponse import com.weeth.domain.board.application.exception.BoardNotFoundException import com.weeth.domain.board.application.mapper.BoardMapper import com.weeth.domain.board.domain.repository.BoardRepository -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.enums.Role import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt index 25d8d747..c42f4628 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt @@ -12,9 +12,9 @@ import com.weeth.domain.board.domain.repository.PostRepository import com.weeth.domain.comment.application.usecase.query.GetCommentQueryService import com.weeth.domain.comment.domain.repository.CommentReader import com.weeth.domain.file.application.mapper.FileMapper -import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.repository.FileReader -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.enums.Role import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Slice import org.springframework.data.domain.Sort diff --git a/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt b/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt index 34894e3f..341f3434 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt @@ -1,9 +1,9 @@ package com.weeth.domain.board.domain.entity import com.weeth.domain.board.domain.converter.BoardConfigConverter -import com.weeth.domain.board.domain.entity.enums.BoardType +import com.weeth.domain.board.domain.enums.BoardType import com.weeth.domain.board.domain.vo.BoardConfig -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.enums.Role import com.weeth.global.common.entity.BaseEntity import jakarta.persistence.Column import jakarta.persistence.Convert diff --git a/src/main/kotlin/com/weeth/domain/board/domain/entity/enums/BoardType.kt b/src/main/kotlin/com/weeth/domain/board/domain/enums/BoardType.kt similarity index 61% rename from src/main/kotlin/com/weeth/domain/board/domain/entity/enums/BoardType.kt rename to src/main/kotlin/com/weeth/domain/board/domain/enums/BoardType.kt index f992c924..c27ebe15 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/entity/enums/BoardType.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/enums/BoardType.kt @@ -1,4 +1,4 @@ -package com.weeth.domain.board.domain.entity.enums +package com.weeth.domain.board.domain.enums enum class BoardType { NOTICE, diff --git a/src/main/kotlin/com/weeth/domain/board/domain/entity/enums/Part.kt b/src/main/kotlin/com/weeth/domain/board/domain/enums/Part.kt similarity index 54% rename from src/main/kotlin/com/weeth/domain/board/domain/entity/enums/Part.kt rename to src/main/kotlin/com/weeth/domain/board/domain/enums/Part.kt index e6287a3e..dc6ed0da 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/entity/enums/Part.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/enums/Part.kt @@ -1,4 +1,4 @@ -package com.weeth.domain.board.domain.entity.enums +package com.weeth.domain.board.domain.enums enum class Part { D, diff --git a/src/main/kotlin/com/weeth/domain/board/domain/vo/BoardConfig.kt b/src/main/kotlin/com/weeth/domain/board/domain/vo/BoardConfig.kt index 6926c827..a3dbee1d 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/vo/BoardConfig.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/vo/BoardConfig.kt @@ -1,6 +1,6 @@ package com.weeth.domain.board.domain.vo -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.enums.Role data class BoardConfig( val commentEnabled: Boolean = true, diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt b/src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt index 7fa127e0..a7e41807 100644 --- a/src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt +++ b/src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt @@ -4,7 +4,7 @@ import com.weeth.domain.board.application.dto.response.BoardDetailResponse import com.weeth.domain.board.application.dto.response.BoardListResponse import com.weeth.domain.board.application.exception.BoardErrorCode import com.weeth.domain.board.application.usecase.query.GetBoardQueryService -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.enums.Role import com.weeth.global.auth.annotation.CurrentUserRole import com.weeth.global.common.exception.ApiErrorCodeExample import com.weeth.global.common.response.CommonResponse diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt b/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt index 6209d1a0..bf4b4f6c 100644 --- a/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt +++ b/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt @@ -8,7 +8,7 @@ import com.weeth.domain.board.application.dto.response.PostSaveResponse import com.weeth.domain.board.application.exception.BoardErrorCode import com.weeth.domain.board.application.usecase.command.ManagePostUseCase import com.weeth.domain.board.application.usecase.query.GetPostQueryService -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.enums.Role import com.weeth.global.auth.annotation.CurrentUser import com.weeth.global.auth.annotation.CurrentUserRole import com.weeth.global.common.exception.ApiErrorCodeExample diff --git a/src/main/kotlin/com/weeth/domain/comment/application/dto/response/CommentResponse.kt b/src/main/kotlin/com/weeth/domain/comment/application/dto/response/CommentResponse.kt index b668405a..c3df92f9 100644 --- a/src/main/kotlin/com/weeth/domain/comment/application/dto/response/CommentResponse.kt +++ b/src/main/kotlin/com/weeth/domain/comment/application/dto/response/CommentResponse.kt @@ -1,7 +1,7 @@ package com.weeth.domain.comment.application.dto.response import com.weeth.domain.file.application.dto.response.FileResponse -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.enums.Role import io.swagger.v3.oas.annotations.media.Schema import java.time.LocalDateTime diff --git a/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentErrorCode.kt b/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentErrorCode.kt index 253a6155..7f2af7aa 100644 --- a/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentErrorCode.kt @@ -5,9 +5,9 @@ import com.weeth.global.common.exception.ExplainError import org.springframework.http.HttpStatus enum class CommentErrorCode( - private val code: Int, - private val status: HttpStatus, - private val message: String, + override val code: Int, + override val status: HttpStatus, + override val message: String, ) : ErrorCodeInterface { @ExplainError("요청한 댓글 ID에 해당하는 댓글이 존재하지 않을 때 발생합니다.") COMMENT_NOT_FOUND(2400, HttpStatus.NOT_FOUND, "존재하지 않는 댓글입니다."), @@ -17,11 +17,4 @@ enum class CommentErrorCode( @ExplainError("이미 삭제된 댓글에 대해 삭제를 재시도할 때 발생합니다.") COMMENT_ALREADY_DELETED(2402, HttpStatus.BAD_REQUEST, "이미 삭제된 댓글입니다."), - ; - - override fun getCode(): Int = code - - override fun getStatus(): HttpStatus = status - - override fun getMessage(): String = message } diff --git a/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt b/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt index da0409fe..cb6e2444 100644 --- a/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt @@ -12,7 +12,7 @@ import com.weeth.domain.comment.domain.entity.Comment import com.weeth.domain.comment.domain.repository.CommentRepository import com.weeth.domain.file.application.dto.request.FileSaveRequest import com.weeth.domain.file.application.mapper.FileMapper -import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.repository.FileReader import com.weeth.domain.file.domain.repository.FileRepository import com.weeth.domain.user.domain.repository.UserReader @@ -22,7 +22,7 @@ import org.springframework.transaction.annotation.Transactional @Service class ManageCommentUseCase( private val commentRepository: CommentRepository, - private val postRepository: PostRepository, + private val postRepository: PostRepository, // 타 도메인 이므로 Reader 사용 검토 private val userReader: UserReader, private val fileReader: FileReader, private val fileRepository: FileRepository, @@ -153,5 +153,6 @@ class ManageCommentUseCase( } } - private fun findPostWithLock(postId: Long): Post = postRepository.findByIdWithLock(postId) ?: throw PostNotFoundException() + private fun findPostWithLock(postId: Long): Post = + postRepository.findByIdWithLock(postId) ?: throw PostNotFoundException() } diff --git a/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/PostCommentUsecase.kt b/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/PostCommentUsecase.kt index fbbeda4a..22e495ec 100644 --- a/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/PostCommentUsecase.kt +++ b/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/PostCommentUsecase.kt @@ -3,6 +3,9 @@ package com.weeth.domain.comment.application.usecase.command import com.weeth.domain.comment.application.dto.request.CommentSaveRequest import com.weeth.domain.comment.application.dto.request.CommentUpdateRequest +/** + * Todo: Notice가 제거됨에 따라 인터페이스 분리가 필요 없음. 제거 검토 + */ interface PostCommentUsecase { fun savePostComment( dto: CommentSaveRequest, diff --git a/src/main/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryService.kt b/src/main/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryService.kt index 443a04ac..1c698b91 100644 --- a/src/main/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryService.kt @@ -5,7 +5,7 @@ import com.weeth.domain.comment.application.mapper.CommentMapper import com.weeth.domain.comment.domain.entity.Comment import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.entity.File -import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.repository.FileReader import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional diff --git a/src/main/kotlin/com/weeth/domain/file/application/dto/request/FileSaveRequest.kt b/src/main/kotlin/com/weeth/domain/file/application/dto/request/FileSaveRequest.kt index 92041cd8..23220589 100644 --- a/src/main/kotlin/com/weeth/domain/file/application/dto/request/FileSaveRequest.kt +++ b/src/main/kotlin/com/weeth/domain/file/application/dto/request/FileSaveRequest.kt @@ -8,7 +8,10 @@ data class FileSaveRequest( @field:Schema(description = "원본 파일명", example = "profile-image.png") @field:NotBlank val fileName: String, - @field:Schema(description = "저장소 키. `Type/YY-MM/UUID_원본파일명` 형식", example = "POST/2026-02/58400-e29b-44-a716-44665000_profile-image.png") + @field:Schema( + description = "저장소 키. `Type/YY-MM/UUID_원본파일명` 형식", + example = "POST/2026-02/58400-e29b-44-a716-44665000_profile-image.png", + ) @field:NotBlank val storageKey: String, @field:Schema(description = "파일 크기(bytes)", example = "102400") diff --git a/src/main/kotlin/com/weeth/domain/file/application/dto/response/FileResponse.kt b/src/main/kotlin/com/weeth/domain/file/application/dto/response/FileResponse.kt index 7a4d286b..eecc14e3 100644 --- a/src/main/kotlin/com/weeth/domain/file/application/dto/response/FileResponse.kt +++ b/src/main/kotlin/com/weeth/domain/file/application/dto/response/FileResponse.kt @@ -1,6 +1,6 @@ package com.weeth.domain.file.application.dto.response -import com.weeth.domain.file.domain.entity.FileStatus +import com.weeth.domain.file.domain.enums.FileStatus import io.swagger.v3.oas.annotations.media.Schema data class FileResponse( diff --git a/src/main/kotlin/com/weeth/domain/file/application/dto/response/UrlResponse.kt b/src/main/kotlin/com/weeth/domain/file/application/dto/response/UrlResponse.kt index 3653cacc..cf4fcd53 100644 --- a/src/main/kotlin/com/weeth/domain/file/application/dto/response/UrlResponse.kt +++ b/src/main/kotlin/com/weeth/domain/file/application/dto/response/UrlResponse.kt @@ -5,7 +5,10 @@ import io.swagger.v3.oas.annotations.media.Schema data class UrlResponse( @field:Schema(description = "원본 파일명", example = "profile-image.png") val fileName: String, - @field:Schema(description = "Presigned PUT URL", example = "https://bucket.s3.amazonaws.com/TEMP/2026-02/uuid_profile-image.png") + @field:Schema( + description = "Presigned PUT URL", + example = "https://bucket.s3.amazonaws.com/TEMP/2026-02/uuid_profile-image.png", + ) val putUrl: String, @field:Schema(description = "저장소 키", example = "TEMP/2026-02/uuid_profile-image.png") val storageKey: String, diff --git a/src/main/kotlin/com/weeth/domain/file/application/exception/FileErrorCode.kt b/src/main/kotlin/com/weeth/domain/file/application/exception/FileErrorCode.kt index b7483b7d..fe12e06a 100644 --- a/src/main/kotlin/com/weeth/domain/file/application/exception/FileErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/file/application/exception/FileErrorCode.kt @@ -5,9 +5,9 @@ import com.weeth.global.common.exception.ExplainError import org.springframework.http.HttpStatus enum class FileErrorCode( - private val code: Int, - private val status: HttpStatus, - private val message: String, + override val code: Int, + override val status: HttpStatus, + override val message: String, ) : ErrorCodeInterface { @ExplainError("파일 ID로 조회했으나 해당 파일이 존재하지 않을 때 발생합니다.") FILE_NOT_FOUND(2500, HttpStatus.NOT_FOUND, "존재하지 않는 파일입니다."), @@ -20,11 +20,4 @@ enum class FileErrorCode( @ExplainError("허용되지 않은 확장자로 파일 업로드를 시도했을 때 발생합니다.") UNSUPPORTED_FILE_EXTENSION(2503, HttpStatus.BAD_REQUEST, "지원하지 않는 파일 확장자입니다."), - ; - - override fun getCode(): Int = code - - override fun getStatus(): HttpStatus = status - - override fun getMessage(): String = message } diff --git a/src/main/kotlin/com/weeth/domain/file/application/exception/PresignedUrlGenerationException.kt b/src/main/kotlin/com/weeth/domain/file/application/exception/PresignedUrlGenerationException.kt index a8056516..db32ce85 100644 --- a/src/main/kotlin/com/weeth/domain/file/application/exception/PresignedUrlGenerationException.kt +++ b/src/main/kotlin/com/weeth/domain/file/application/exception/PresignedUrlGenerationException.kt @@ -4,4 +4,8 @@ import com.weeth.global.common.exception.BaseException class PresignedUrlGenerationException( cause: Throwable? = null, -) : BaseException(FileErrorCode.PRESIGNED_URL_GENERATION_FAILED, cause) +) : BaseException(FileErrorCode.PRESIGNED_URL_GENERATION_FAILED) { + init { + initCause(cause) + } +} diff --git a/src/main/kotlin/com/weeth/domain/file/application/mapper/FileMapper.kt b/src/main/kotlin/com/weeth/domain/file/application/mapper/FileMapper.kt index 12a38ba1..4f962597 100644 --- a/src/main/kotlin/com/weeth/domain/file/application/mapper/FileMapper.kt +++ b/src/main/kotlin/com/weeth/domain/file/application/mapper/FileMapper.kt @@ -4,7 +4,7 @@ import com.weeth.domain.file.application.dto.request.FileSaveRequest import com.weeth.domain.file.application.dto.response.FileResponse import com.weeth.domain.file.application.dto.response.UrlResponse import com.weeth.domain.file.domain.entity.File -import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.port.FileAccessUrlPort import org.springframework.stereotype.Component diff --git a/src/main/kotlin/com/weeth/domain/file/application/usecase/command/GenerateFileUrlUsecase.kt b/src/main/kotlin/com/weeth/domain/file/application/usecase/command/GenerateFileUrlUsecase.kt index 207df82b..f4a7ab06 100644 --- a/src/main/kotlin/com/weeth/domain/file/application/usecase/command/GenerateFileUrlUsecase.kt +++ b/src/main/kotlin/com/weeth/domain/file/application/usecase/command/GenerateFileUrlUsecase.kt @@ -2,7 +2,7 @@ package com.weeth.domain.file.application.usecase.command import com.weeth.domain.file.application.dto.response.UrlResponse import com.weeth.domain.file.application.mapper.FileMapper -import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.port.FileUploadUrlPort import org.springframework.stereotype.Service diff --git a/src/main/kotlin/com/weeth/domain/file/domain/entity/File.kt b/src/main/kotlin/com/weeth/domain/file/domain/entity/File.kt index 1d6089cb..a2f7471f 100644 --- a/src/main/kotlin/com/weeth/domain/file/domain/entity/File.kt +++ b/src/main/kotlin/com/weeth/domain/file/domain/entity/File.kt @@ -1,5 +1,7 @@ package com.weeth.domain.file.domain.entity +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.enums.FileStatus import com.weeth.domain.file.domain.vo.FileContentType import com.weeth.domain.file.domain.vo.StorageKey import com.weeth.global.common.entity.BaseEntity diff --git a/src/main/kotlin/com/weeth/domain/file/domain/entity/FileOwnerType.kt b/src/main/kotlin/com/weeth/domain/file/domain/enums/FileOwnerType.kt similarity index 60% rename from src/main/kotlin/com/weeth/domain/file/domain/entity/FileOwnerType.kt rename to src/main/kotlin/com/weeth/domain/file/domain/enums/FileOwnerType.kt index da114464..ea495c6a 100644 --- a/src/main/kotlin/com/weeth/domain/file/domain/entity/FileOwnerType.kt +++ b/src/main/kotlin/com/weeth/domain/file/domain/enums/FileOwnerType.kt @@ -1,4 +1,4 @@ -package com.weeth.domain.file.domain.entity +package com.weeth.domain.file.domain.enums enum class FileOwnerType { POST, diff --git a/src/main/kotlin/com/weeth/domain/file/domain/entity/FileStatus.kt b/src/main/kotlin/com/weeth/domain/file/domain/enums/FileStatus.kt similarity index 55% rename from src/main/kotlin/com/weeth/domain/file/domain/entity/FileStatus.kt rename to src/main/kotlin/com/weeth/domain/file/domain/enums/FileStatus.kt index 6d1287d3..80652edd 100644 --- a/src/main/kotlin/com/weeth/domain/file/domain/entity/FileStatus.kt +++ b/src/main/kotlin/com/weeth/domain/file/domain/enums/FileStatus.kt @@ -1,4 +1,4 @@ -package com.weeth.domain.file.domain.entity +package com.weeth.domain.file.domain.enums enum class FileStatus { UPLOADED, diff --git a/src/main/kotlin/com/weeth/domain/file/domain/port/FileUploadUrlPort.kt b/src/main/kotlin/com/weeth/domain/file/domain/port/FileUploadUrlPort.kt index fe92d777..d3f93d9b 100644 --- a/src/main/kotlin/com/weeth/domain/file/domain/port/FileUploadUrlPort.kt +++ b/src/main/kotlin/com/weeth/domain/file/domain/port/FileUploadUrlPort.kt @@ -1,6 +1,6 @@ package com.weeth.domain.file.domain.port -import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.enums.FileOwnerType /** [FileUploadUrlPort.generateUploadUrl] 반환 타입 */ data class FileUploadUrl( diff --git a/src/main/kotlin/com/weeth/domain/file/domain/repository/FileReader.kt b/src/main/kotlin/com/weeth/domain/file/domain/repository/FileReader.kt index 3c0969d6..d08599ef 100644 --- a/src/main/kotlin/com/weeth/domain/file/domain/repository/FileReader.kt +++ b/src/main/kotlin/com/weeth/domain/file/domain/repository/FileReader.kt @@ -1,8 +1,8 @@ package com.weeth.domain.file.domain.repository import com.weeth.domain.file.domain.entity.File -import com.weeth.domain.file.domain.entity.FileOwnerType -import com.weeth.domain.file.domain.entity.FileStatus +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.enums.FileStatus interface FileReader { fun findAll( diff --git a/src/main/kotlin/com/weeth/domain/file/domain/repository/FileRepository.kt b/src/main/kotlin/com/weeth/domain/file/domain/repository/FileRepository.kt index fab6d0dc..e7b1cadf 100644 --- a/src/main/kotlin/com/weeth/domain/file/domain/repository/FileRepository.kt +++ b/src/main/kotlin/com/weeth/domain/file/domain/repository/FileRepository.kt @@ -1,8 +1,8 @@ package com.weeth.domain.file.domain.repository import com.weeth.domain.file.domain.entity.File -import com.weeth.domain.file.domain.entity.FileOwnerType -import com.weeth.domain.file.domain.entity.FileStatus +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.enums.FileStatus import org.springframework.data.jpa.repository.JpaRepository interface FileRepository : diff --git a/src/main/kotlin/com/weeth/domain/file/domain/vo/StorageKey.kt b/src/main/kotlin/com/weeth/domain/file/domain/vo/StorageKey.kt index 79a2d708..26bd7e4c 100644 --- a/src/main/kotlin/com/weeth/domain/file/domain/vo/StorageKey.kt +++ b/src/main/kotlin/com/weeth/domain/file/domain/vo/StorageKey.kt @@ -1,6 +1,6 @@ package com.weeth.domain.file.domain.vo -import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.enums.FileOwnerType @JvmInline value class StorageKey( diff --git a/src/main/kotlin/com/weeth/domain/file/infrastructure/S3FileUploadUrlAdapter.kt b/src/main/kotlin/com/weeth/domain/file/infrastructure/S3FileUploadUrlAdapter.kt index 1db243d3..65208b2e 100644 --- a/src/main/kotlin/com/weeth/domain/file/infrastructure/S3FileUploadUrlAdapter.kt +++ b/src/main/kotlin/com/weeth/domain/file/infrastructure/S3FileUploadUrlAdapter.kt @@ -1,7 +1,7 @@ package com.weeth.domain.file.infrastructure import com.weeth.domain.file.application.exception.PresignedUrlGenerationException -import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.port.FileUploadUrl import com.weeth.domain.file.domain.port.FileUploadUrlPort import com.weeth.domain.file.domain.vo.FileName diff --git a/src/main/kotlin/com/weeth/domain/file/presentation/FileController.kt b/src/main/kotlin/com/weeth/domain/file/presentation/FileController.kt index 5e2d26a7..e8f4ec71 100644 --- a/src/main/kotlin/com/weeth/domain/file/presentation/FileController.kt +++ b/src/main/kotlin/com/weeth/domain/file/presentation/FileController.kt @@ -3,7 +3,7 @@ package com.weeth.domain.file.presentation import com.weeth.domain.file.application.dto.response.UrlResponse import com.weeth.domain.file.application.exception.FileErrorCode import com.weeth.domain.file.application.usecase.command.GenerateFileUrlUsecase -import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.global.common.exception.ApiErrorCodeExample import com.weeth.global.common.response.CommonResponse import io.swagger.v3.oas.annotations.Operation diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/exception/PenaltyErrorCode.kt b/src/main/kotlin/com/weeth/domain/penalty/application/exception/PenaltyErrorCode.kt index d5218937..34de9120 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/application/exception/PenaltyErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/application/exception/PenaltyErrorCode.kt @@ -5,20 +5,13 @@ import com.weeth.global.common.exception.ExplainError import org.springframework.http.HttpStatus enum class PenaltyErrorCode( - private val code: Int, - private val status: HttpStatus, - private val message: String, + override val code: Int, + override val status: HttpStatus, + override val message: String, ) : ErrorCodeInterface { @ExplainError("요청한 패널티 ID가 존재하지 않을 때 발생합니다.") PENALTY_NOT_FOUND(2600, HttpStatus.NOT_FOUND, "존재하지 않는 패널티입니다."), @ExplainError("시스템에 의해 자동 부여된 패널티를 수동으로 삭제하려 할 때 발생합니다.") AUTO_PENALTY_DELETE_NOT_ALLOWED(2601, HttpStatus.BAD_REQUEST, "자동 생성된 패널티는 삭제할 수 없습니다"), - ; - - override fun getCode(): Int = code - - override fun getStatus(): HttpStatus = status - - override fun getMessage(): String = message } diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/DeletePenaltyUseCase.kt b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/DeletePenaltyUseCase.kt index 1cd077aa..c26220c9 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/DeletePenaltyUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/DeletePenaltyUseCase.kt @@ -14,8 +14,11 @@ import org.springframework.transaction.annotation.Transactional @Service class DeletePenaltyUseCase( private val penaltyRepository: PenaltyRepository, - private val userRepository: UserRepository, + private val userRepository: UserRepository, // 타 도메인이므로 Reader 사용 검토 (조회 시에는 Reader, 업데이트 시에는 Repository) ) { + /** + * Todo: 코드 가독성 개선 및 트랜잭션 범위 축소 + */ @Transactional fun delete(penaltyId: Long) { val penalty = diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/SavePenaltyUseCase.kt b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/SavePenaltyUseCase.kt index b3a96c19..80729311 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/SavePenaltyUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/SavePenaltyUseCase.kt @@ -13,7 +13,7 @@ import org.springframework.transaction.annotation.Transactional @Service class SavePenaltyUseCase( private val penaltyRepository: PenaltyRepository, - private val userRepository: UserRepository, + private val userRepository: UserRepository, // 타 도메인이므로 Reader 사용 검토 private val userCardinalPolicy: UserCardinalPolicy, private val mapper: PenaltyMapper, ) { diff --git a/src/main/kotlin/com/weeth/domain/penalty/domain/repository/PenaltyRepository.kt b/src/main/kotlin/com/weeth/domain/penalty/domain/repository/PenaltyRepository.kt index 0fb0a166..ab79eaa1 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/domain/repository/PenaltyRepository.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/domain/repository/PenaltyRepository.kt @@ -8,7 +8,9 @@ import org.springframework.data.jpa.repository.Query import java.time.LocalDateTime interface PenaltyRepository : JpaRepository { - @Query("SELECT p FROM Penalty p JOIN FETCH p.cardinal WHERE p.user.id = :userId AND p.cardinal.id = :cardinalId ORDER BY p.id DESC") + @Query( + "SELECT p FROM Penalty p JOIN FETCH p.cardinal WHERE p.user.id = :userId AND p.cardinal.id = :cardinalId ORDER BY p.id DESC", + ) fun findByUserIdAndCardinalIdOrderByIdDesc( userId: Long, cardinalId: Long, @@ -30,6 +32,8 @@ interface PenaltyRepository : JpaRepository { pageable: Pageable, ): List - @Query("SELECT p FROM Penalty p JOIN FETCH p.user JOIN FETCH p.cardinal WHERE p.cardinal.id = :cardinalId ORDER BY p.id DESC") + @Query( + "SELECT p FROM Penalty p JOIN FETCH p.user JOIN FETCH p.cardinal WHERE p.cardinal.id = :cardinalId ORDER BY p.id DESC", + ) fun findByCardinalIdOrderByIdDesc(cardinalId: Long): List } diff --git a/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyAdminController.kt b/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyAdminController.kt index 061dd1d6..a60784e0 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyAdminController.kt @@ -55,7 +55,10 @@ class PenaltyAdminController( fun findAll( @RequestParam(required = false) cardinal: Int?, ): CommonResponse> = - CommonResponse.success(PenaltyResponseCode.PENALTY_FIND_ALL_SUCCESS, getPenaltyQueryService.findAllByCardinal(cardinal)) + CommonResponse.success( + PenaltyResponseCode.PENALTY_FIND_ALL_SUCCESS, + getPenaltyQueryService.findAllByCardinal(cardinal), + ) @DeleteMapping @Operation(summary = "패널티 삭제") diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/annotation/ScheduleTimeCheck.kt b/src/main/kotlin/com/weeth/domain/schedule/application/annotation/ScheduleTimeCheck.kt index 46202a1d..ee4c546f 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/application/annotation/ScheduleTimeCheck.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/application/annotation/ScheduleTimeCheck.kt @@ -5,6 +5,9 @@ import jakarta.validation.Constraint import jakarta.validation.Payload import kotlin.reflect.KClass +/** + * Todo: 사용처 있는지 확인하고 없으면 제거 + */ @Target(AnnotationTarget.FIELD) @Retention(AnnotationRetention.RUNTIME) @Constraint(validatedBy = [ScheduleTimeCheckValidator::class]) diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/EventResponse.kt b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/EventResponse.kt index f6d476a4..71c6ad14 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/EventResponse.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/EventResponse.kt @@ -1,6 +1,6 @@ package com.weeth.domain.schedule.application.dto.response -import com.weeth.domain.schedule.domain.entity.enums.Type +import com.weeth.domain.schedule.domain.enums.Type import io.swagger.v3.oas.annotations.media.Schema import java.time.LocalDateTime diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/SessionResponse.kt b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/SessionResponse.kt index 7c06371f..c8960f11 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/SessionResponse.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/SessionResponse.kt @@ -1,7 +1,7 @@ package com.weeth.domain.schedule.application.dto.response import com.fasterxml.jackson.annotation.JsonInclude -import com.weeth.domain.schedule.domain.entity.enums.Type +import com.weeth.domain.schedule.domain.enums.Type import io.swagger.v3.oas.annotations.media.Schema import java.time.LocalDateTime diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/exception/EventErrorCode.kt b/src/main/kotlin/com/weeth/domain/schedule/application/exception/EventErrorCode.kt index 2e266bb0..8df47f00 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/application/exception/EventErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/application/exception/EventErrorCode.kt @@ -5,17 +5,10 @@ import com.weeth.global.common.exception.ExplainError import org.springframework.http.HttpStatus enum class EventErrorCode( - private val code: Int, - private val status: HttpStatus, - private val message: String, + override val code: Int, + override val status: HttpStatus, + override val message: String, ) : ErrorCodeInterface { @ExplainError("요청한 일정 ID에 해당하는 일정이 존재하지 않을 때 발생합니다.") EVENT_NOT_FOUND(2700, HttpStatus.NOT_FOUND, "존재하지 않는 일정입니다."), - ; - - override fun getCode(): Int = code - - override fun getStatus(): HttpStatus = status - - override fun getMessage(): String = message } diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/mapper/EventMapper.kt b/src/main/kotlin/com/weeth/domain/schedule/application/mapper/EventMapper.kt index a1caa781..805fa01f 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/application/mapper/EventMapper.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/application/mapper/EventMapper.kt @@ -3,7 +3,7 @@ package com.weeth.domain.schedule.application.mapper import com.weeth.domain.schedule.application.dto.request.ScheduleSaveRequest import com.weeth.domain.schedule.application.dto.response.EventResponse import com.weeth.domain.schedule.domain.entity.Event -import com.weeth.domain.schedule.domain.entity.enums.Type +import com.weeth.domain.schedule.domain.enums.Type import com.weeth.domain.user.domain.entity.User import org.springframework.stereotype.Component diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/mapper/SessionMapper.kt b/src/main/kotlin/com/weeth/domain/schedule/application/mapper/SessionMapper.kt index 8d587724..eab3fc73 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/application/mapper/SessionMapper.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/application/mapper/SessionMapper.kt @@ -4,7 +4,7 @@ import com.weeth.domain.schedule.application.dto.request.ScheduleSaveRequest import com.weeth.domain.schedule.application.dto.response.SessionInfoResponse import com.weeth.domain.schedule.application.dto.response.SessionInfosResponse import com.weeth.domain.schedule.application.dto.response.SessionResponse -import com.weeth.domain.schedule.domain.entity.enums.Type +import com.weeth.domain.schedule.domain.enums.Type import com.weeth.domain.session.domain.entity.Session import com.weeth.domain.user.domain.entity.User import org.springframework.stereotype.Component diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/validator/ScheduleTimeCheckValidator.kt b/src/main/kotlin/com/weeth/domain/schedule/application/validator/ScheduleTimeCheckValidator.kt index 84c8dc76..6970af94 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/application/validator/ScheduleTimeCheckValidator.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/application/validator/ScheduleTimeCheckValidator.kt @@ -5,6 +5,9 @@ import com.weeth.domain.schedule.application.dto.request.ScheduleTimeRequest import jakarta.validation.ConstraintValidator import jakarta.validation.ConstraintValidatorContext +/** + * Todo: 사용처 있는지 확인하고 없으면 제거 + */ class ScheduleTimeCheckValidator : ConstraintValidator { override fun isValid( time: ScheduleTimeRequest?, diff --git a/src/main/kotlin/com/weeth/domain/schedule/domain/entity/enums/Type.kt b/src/main/kotlin/com/weeth/domain/schedule/domain/entity/enums/Type.kt deleted file mode 100644 index bbb663b4..00000000 --- a/src/main/kotlin/com/weeth/domain/schedule/domain/entity/enums/Type.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.weeth.domain.schedule.domain.entity.enums - -enum class Type { - EVENT, - SESSION, -} diff --git a/src/main/kotlin/com/weeth/domain/schedule/domain/enums/Type.kt b/src/main/kotlin/com/weeth/domain/schedule/domain/enums/Type.kt new file mode 100644 index 00000000..054b9422 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/domain/enums/Type.kt @@ -0,0 +1,6 @@ +package com.weeth.domain.schedule.domain.enums + +enum class Type { + EVENT, + SESSION, +} diff --git a/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt b/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt index 3e2224b6..12e6c130 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt @@ -24,7 +24,10 @@ class ScheduleController( @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) start: LocalDateTime, @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) end: LocalDateTime, ): CommonResponse> = - CommonResponse.success(ScheduleResponseCode.SCHEDULE_MONTHLY_FIND_SUCCESS, getScheduleQueryService.findMonthly(start, end)) + CommonResponse.success( + ScheduleResponseCode.SCHEDULE_MONTHLY_FIND_SUCCESS, + getScheduleQueryService.findMonthly(start, end), + ) @GetMapping("/yearly") @Operation(summary = "연도별 일정 조회") @@ -32,5 +35,8 @@ class ScheduleController( @RequestParam year: Int, @RequestParam semester: Int, ): CommonResponse>> = - CommonResponse.success(ScheduleResponseCode.SCHEDULE_YEARLY_FIND_SUCCESS, getScheduleQueryService.findYearly(year, semester)) + CommonResponse.success( + ScheduleResponseCode.SCHEDULE_YEARLY_FIND_SUCCESS, + getScheduleQueryService.findYearly(year, semester), + ) } diff --git a/src/main/kotlin/com/weeth/domain/session/application/exception/SessionErrorCode.kt b/src/main/kotlin/com/weeth/domain/session/application/exception/SessionErrorCode.kt index a965bf4a..13563c4f 100644 --- a/src/main/kotlin/com/weeth/domain/session/application/exception/SessionErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/session/application/exception/SessionErrorCode.kt @@ -5,17 +5,10 @@ import com.weeth.global.common.exception.ExplainError import org.springframework.http.HttpStatus enum class SessionErrorCode( - private val code: Int, - private val status: HttpStatus, - private val message: String, + override val code: Int, + override val status: HttpStatus, + override val message: String, ) : ErrorCodeInterface { @ExplainError("요청한 정기모임 ID에 해당하는 정기모임이 존재하지 않을 때 발생합니다.") SESSION_NOT_FOUND(2203, HttpStatus.NOT_FOUND, "존재하지 않는 정기모임입니다."), - ; - - override fun getCode(): Int = code - - override fun getStatus(): HttpStatus = status - - override fun getMessage(): String = message } diff --git a/src/main/kotlin/com/weeth/domain/session/application/usecase/command/ManageSessionUseCase.kt b/src/main/kotlin/com/weeth/domain/session/application/usecase/command/ManageSessionUseCase.kt index 965df991..077d245c 100644 --- a/src/main/kotlin/com/weeth/domain/session/application/usecase/command/ManageSessionUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/session/application/usecase/command/ManageSessionUseCase.kt @@ -1,19 +1,22 @@ package com.weeth.domain.session.application.usecase.command import com.weeth.domain.attendance.domain.entity.Attendance -import com.weeth.domain.attendance.domain.entity.enums.AttendanceStatus +import com.weeth.domain.attendance.domain.enums.AttendanceStatus import com.weeth.domain.attendance.domain.repository.AttendanceRepository import com.weeth.domain.schedule.application.dto.request.ScheduleSaveRequest import com.weeth.domain.schedule.application.dto.request.ScheduleUpdateRequest import com.weeth.domain.schedule.application.mapper.SessionMapper import com.weeth.domain.session.application.exception.SessionNotFoundException import com.weeth.domain.session.domain.repository.SessionRepository -import com.weeth.domain.user.domain.entity.enums.Status +import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.repository.CardinalReader import com.weeth.domain.user.domain.repository.UserReader import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +/** + * Todo: 개행을 추가해 가독성 개선 + */ @Service class ManageSessionUseCase( private val sessionRepository: SessionRepository, diff --git a/src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt b/src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt index 45a37895..f6ae59b2 100644 --- a/src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt @@ -6,7 +6,7 @@ import com.weeth.domain.schedule.application.mapper.SessionMapper import com.weeth.domain.session.application.exception.SessionNotFoundException import com.weeth.domain.session.domain.entity.Session import com.weeth.domain.session.domain.repository.SessionRepository -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.enums.Role import com.weeth.domain.user.domain.repository.UserReader import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service @@ -15,6 +15,9 @@ import java.time.DayOfWeek import java.time.LocalDate import java.time.temporal.TemporalAdjusters +/** + * Todo: 개행을 추가해 가독성 개선 + */ @Service @Transactional(readOnly = true) class GetSessionQueryService( diff --git a/src/main/kotlin/com/weeth/domain/session/domain/entity/Session.kt b/src/main/kotlin/com/weeth/domain/session/domain/entity/Session.kt index 09fd0f03..387094d3 100644 --- a/src/main/kotlin/com/weeth/domain/session/domain/entity/Session.kt +++ b/src/main/kotlin/com/weeth/domain/session/domain/entity/Session.kt @@ -1,6 +1,6 @@ package com.weeth.domain.session.domain.entity -import com.weeth.domain.session.domain.entity.enums.SessionStatus +import com.weeth.domain.session.domain.enums.SessionStatus import com.weeth.domain.user.domain.entity.User import com.weeth.global.common.entity.BaseEntity import jakarta.persistence.Column @@ -17,7 +17,7 @@ import jakarta.persistence.Table import java.time.LocalDateTime @Entity -@Table(name = "meeting") +@Table(name = "meeting") // 테이블명 Session으로 수정 class Session( var title: String, @Column(length = 500) diff --git a/src/main/kotlin/com/weeth/domain/session/domain/entity/enums/SessionStatus.kt b/src/main/kotlin/com/weeth/domain/session/domain/entity/enums/SessionStatus.kt deleted file mode 100644 index 6f148579..00000000 --- a/src/main/kotlin/com/weeth/domain/session/domain/entity/enums/SessionStatus.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.weeth.domain.session.domain.entity.enums - -enum class SessionStatus { - OPEN, - CLOSED, -} diff --git a/src/main/kotlin/com/weeth/domain/session/domain/enums/SessionStatus.kt b/src/main/kotlin/com/weeth/domain/session/domain/enums/SessionStatus.kt new file mode 100644 index 00000000..3ba23699 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/domain/enums/SessionStatus.kt @@ -0,0 +1,6 @@ +package com.weeth.domain.session.domain.enums + +enum class SessionStatus { + OPEN, + CLOSED, +} diff --git a/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionReader.kt b/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionReader.kt index 430a3138..c11b75d9 100644 --- a/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionReader.kt +++ b/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionReader.kt @@ -1,7 +1,7 @@ package com.weeth.domain.session.domain.repository import com.weeth.domain.session.domain.entity.Session -import com.weeth.domain.session.domain.entity.enums.SessionStatus +import com.weeth.domain.session.domain.enums.SessionStatus import java.time.LocalDateTime interface SessionReader { diff --git a/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionRepository.kt b/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionRepository.kt index 66c4c864..bf06414d 100644 --- a/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionRepository.kt +++ b/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionRepository.kt @@ -2,7 +2,7 @@ package com.weeth.domain.session.domain.repository import com.weeth.domain.session.application.exception.SessionNotFoundException import com.weeth.domain.session.domain.entity.Session -import com.weeth.domain.session.domain.entity.enums.SessionStatus +import com.weeth.domain.session.domain.enums.SessionStatus import jakarta.persistence.LockModeType import jakarta.persistence.QueryHint import org.springframework.data.jpa.repository.JpaRepository diff --git a/src/main/kotlin/com/weeth/domain/session/presentation/SessionController.kt b/src/main/kotlin/com/weeth/domain/session/presentation/SessionController.kt index 310282b1..a9a27de3 100644 --- a/src/main/kotlin/com/weeth/domain/session/presentation/SessionController.kt +++ b/src/main/kotlin/com/weeth/domain/session/presentation/SessionController.kt @@ -27,5 +27,8 @@ class SessionController( @Parameter(hidden = true) @CurrentUser userId: Long, @PathVariable sessionId: Long, ): CommonResponse = - CommonResponse.success(SessionResponseCode.SESSION_FIND_SUCCESS, getSessionQueryService.findSession(userId, sessionId)) + CommonResponse.success( + SessionResponseCode.SESSION_FIND_SUCCESS, + getSessionQueryService.findSession(userId, sessionId), + ) } diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/request/UserRoleUpdateRequest.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/request/UserRoleUpdateRequest.kt index 9cd0c4a1..d3c10201 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/dto/request/UserRoleUpdateRequest.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/request/UserRoleUpdateRequest.kt @@ -1,6 +1,6 @@ package com.weeth.domain.user.application.dto.request -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.enums.Role import io.swagger.v3.oas.annotations.media.Schema import jakarta.validation.constraints.NotNull diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/response/AdminUserResponse.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/response/AdminUserResponse.kt index 1077baef..6c566971 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/dto/response/AdminUserResponse.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/response/AdminUserResponse.kt @@ -1,7 +1,7 @@ package com.weeth.domain.user.application.dto.response -import com.weeth.domain.user.domain.entity.enums.Role -import com.weeth.domain.user.domain.entity.enums.Status +import com.weeth.domain.user.domain.enums.Role +import com.weeth.domain.user.domain.enums.Status import io.swagger.v3.oas.annotations.media.Schema import java.time.LocalDateTime diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/response/CardinalResponse.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/response/CardinalResponse.kt index 00fd08d8..1b436053 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/dto/response/CardinalResponse.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/response/CardinalResponse.kt @@ -1,6 +1,6 @@ package com.weeth.domain.user.application.dto.response -import com.weeth.domain.user.domain.entity.enums.CardinalStatus +import com.weeth.domain.user.domain.enums.CardinalStatus import io.swagger.v3.oas.annotations.media.Schema import java.time.LocalDateTime diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserDetailsResponse.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserDetailsResponse.kt index 4d32406e..f3409bca 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserDetailsResponse.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserDetailsResponse.kt @@ -1,6 +1,6 @@ package com.weeth.domain.user.application.dto.response -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.enums.Role import io.swagger.v3.oas.annotations.media.Schema data class UserDetailsResponse( diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserInfoResponse.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserInfoResponse.kt index f3f2d009..020feb80 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserInfoResponse.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserInfoResponse.kt @@ -1,6 +1,6 @@ package com.weeth.domain.user.application.dto.response -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.enums.Role import io.swagger.v3.oas.annotations.media.Schema data class UserInfoResponse( diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserProfileResponse.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserProfileResponse.kt index 61599f61..5e011df0 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserProfileResponse.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserProfileResponse.kt @@ -1,6 +1,6 @@ package com.weeth.domain.user.application.dto.response -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.enums.Role import io.swagger.v3.oas.annotations.media.Schema data class UserProfileResponse( diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserSummaryResponse.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserSummaryResponse.kt index 4bd1e31e..e589f519 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserSummaryResponse.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserSummaryResponse.kt @@ -1,6 +1,6 @@ package com.weeth.domain.user.application.dto.response -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.enums.Role import io.swagger.v3.oas.annotations.media.Schema data class UserSummaryResponse( diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/UserErrorCode.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/UserErrorCode.kt index 12f4af47..8e930967 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/exception/UserErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/UserErrorCode.kt @@ -5,9 +5,9 @@ import com.weeth.global.common.exception.ExplainError import org.springframework.http.HttpStatus enum class UserErrorCode( - private val code: Int, - private val status: HttpStatus, - private val message: String, + override val code: Int, + override val status: HttpStatus, + override val message: String, ) : ErrorCodeInterface { @ExplainError("사용자 ID로 조회했으나 해당 사용자가 존재하지 않을 때 발생합니다.") USER_NOT_FOUND(2800, HttpStatus.NOT_FOUND, "존재하지 않는 유저입니다."), @@ -56,11 +56,4 @@ enum class UserErrorCode( @ExplainError("사용자 순서 지정 시 잘못된 값이 입력되었을 때 발생합니다.") INVALID_USER_ORDER(2815, HttpStatus.BAD_REQUEST, "잘못된 사용자 순서입니다."), - ; - - override fun getCode(): Int = code - - override fun getStatus(): HttpStatus = status - - override fun getMessage(): String = message } diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCase.kt index 75a139a1..82e8e28d 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCase.kt @@ -12,8 +12,8 @@ import com.weeth.domain.user.application.mapper.UserMapper import com.weeth.domain.user.domain.entity.User import com.weeth.domain.user.domain.entity.UserCardinal import com.weeth.domain.user.domain.entity.UserSocialAccount -import com.weeth.domain.user.domain.entity.enums.SocialProvider -import com.weeth.domain.user.domain.entity.enums.Status +import com.weeth.domain.user.domain.enums.SocialProvider +import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.repository.CardinalReader import com.weeth.domain.user.domain.repository.UserCardinalRepository import com.weeth.domain.user.domain.repository.UserReader @@ -142,7 +142,12 @@ class AuthUserUseCase( providerName: String?, request: SocialLoginRequest, ): SocialLoginResponse { - val socialAccount = userSocialAccountRepository.findByProviderAndProviderUserId(provider, providerUserId).orElse(null) + val socialAccount = + userSocialAccountRepository + .findByProviderAndProviderUserId( + provider, + providerUserId, + ).orElse(null) val (user, isNewUser) = if (socialAccount != null) { socialAccount.user to false @@ -191,7 +196,9 @@ class AuthUserUseCase( department = "", ), ) - userSocialAccountRepository.save(UserSocialAccount(provider = provider, providerUserId = providerUserId, user = user)) + userSocialAccountRepository.save( + UserSocialAccount(provider = provider, providerUserId = providerUserId, user = user), + ) return user to (existingUser == null) } @@ -222,7 +229,9 @@ class AuthUserUseCase( ) { throw StudentIdExistsException() } - if (nextTel != user.telValue && nextTel.isNotBlank() && userRepository.existsByTelAndIdIsNotValue(nextTel, user.id)) { + if (nextTel != user.telValue && nextTel.isNotBlank() && + userRepository.existsByTelAndIdIsNotValue(nextTel, user.id) + ) { throw TelExistsException() } diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/ManageCardinalUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/ManageCardinalUseCase.kt index 055b9656..908f49a6 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/ManageCardinalUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/ManageCardinalUseCase.kt @@ -6,7 +6,7 @@ import com.weeth.domain.user.application.exception.CardinalNotFoundException import com.weeth.domain.user.application.exception.DuplicateCardinalException import com.weeth.domain.user.application.mapper.CardinalMapper import com.weeth.domain.user.domain.entity.Cardinal -import com.weeth.domain.user.domain.entity.enums.CardinalStatus +import com.weeth.domain.user.domain.enums.CardinalStatus import com.weeth.domain.user.domain.repository.CardinalRepository import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/query/GetCardinalQueryService.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/query/GetCardinalQueryService.kt index 91d02166..0010fa3a 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/usecase/query/GetCardinalQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/query/GetCardinalQueryService.kt @@ -12,5 +12,6 @@ class GetCardinalQueryService( private val cardinalRepository: CardinalRepository, private val cardinalMapper: CardinalMapper, ) { - fun findAll(): List = cardinalRepository.findAllByOrderByCardinalNumberAsc().map(cardinalMapper::toResponse) + fun findAll(): List = + cardinalRepository.findAllByOrderByCardinalNumberAsc().map(cardinalMapper::toResponse) } diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryService.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryService.kt index f3ab026f..bc0ff201 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryService.kt @@ -8,9 +8,9 @@ import com.weeth.domain.user.application.dto.response.UserSummaryResponse import com.weeth.domain.user.application.mapper.UserMapper import com.weeth.domain.user.domain.entity.User import com.weeth.domain.user.domain.entity.UserCardinal -import com.weeth.domain.user.domain.entity.enums.Status -import com.weeth.domain.user.domain.entity.enums.StatusPriority -import com.weeth.domain.user.domain.entity.enums.UsersOrderBy +import com.weeth.domain.user.domain.enums.Status +import com.weeth.domain.user.domain.enums.StatusPriority +import com.weeth.domain.user.domain.enums.UsersOrderBy import com.weeth.domain.user.domain.repository.CardinalReader import com.weeth.domain.user.domain.repository.UserCardinalReader import com.weeth.domain.user.domain.repository.UserCardinalRepository @@ -26,7 +26,7 @@ import java.util.LinkedHashMap @Transactional(readOnly = true) class GetUserQueryService( private val userRepository: UserRepository, - private val userReader: UserReader, + private val userReader: UserReader, // todo: 동일 도메인이므로 UserRespository 단일 사용) private val cardinalReader: CardinalReader, private val userCardinalRepository: UserCardinalRepository, private val userCardinalReader: UserCardinalReader, @@ -102,10 +102,14 @@ class GetUserQueryService( UsersOrderBy.CARDINAL_DESCENDING -> { userCardinalMap.entries .sortedWith( - compareBy>> { StatusPriority.fromStatus(it.key.status).priority } - .thenByDescending { entry -> - entry.value.maxOfOrNull { it.cardinal.cardinalNumber } ?: -1 - }, + compareBy>> { + StatusPriority + .fromStatus( + it.key.status, + ).priority + }.thenByDescending { entry -> + entry.value.maxOfOrNull { it.cardinal.cardinalNumber } ?: -1 + }, ).map { entry -> mapper.toAdminUserResponse(entry.key, entry.value) } diff --git a/src/main/kotlin/com/weeth/domain/user/domain/entity/Cardinal.kt b/src/main/kotlin/com/weeth/domain/user/domain/entity/Cardinal.kt index d5c3cf42..946540c6 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/entity/Cardinal.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/entity/Cardinal.kt @@ -1,6 +1,6 @@ package com.weeth.domain.user.domain.entity -import com.weeth.domain.user.domain.entity.enums.CardinalStatus +import com.weeth.domain.user.domain.enums.CardinalStatus import com.weeth.global.common.entity.BaseEntity import jakarta.persistence.Column import jakarta.persistence.Entity diff --git a/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt b/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt index b2b27bab..8c99f958 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt @@ -2,8 +2,8 @@ package com.weeth.domain.user.domain.entity import com.weeth.domain.user.domain.converter.EmailConverter import com.weeth.domain.user.domain.converter.PhoneNumberConverter -import com.weeth.domain.user.domain.entity.enums.Role -import com.weeth.domain.user.domain.entity.enums.Status +import com.weeth.domain.user.domain.enums.Role +import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.vo.AttendanceStats import com.weeth.domain.user.domain.vo.Email import com.weeth.domain.user.domain.vo.PhoneNumber @@ -20,6 +20,10 @@ import jakarta.persistence.Id import jakarta.persistence.PrePersist import jakarta.persistence.Table +/** + * Todo: private set 설정 + * Todo: 생성자 리팩토링 + */ @Entity @Table(name = "users") class User( diff --git a/src/main/kotlin/com/weeth/domain/user/domain/entity/UserSocialAccount.kt b/src/main/kotlin/com/weeth/domain/user/domain/entity/UserSocialAccount.kt index 29a27557..af61353f 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/entity/UserSocialAccount.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/entity/UserSocialAccount.kt @@ -1,6 +1,6 @@ package com.weeth.domain.user.domain.entity -import com.weeth.domain.user.domain.entity.enums.SocialProvider +import com.weeth.domain.user.domain.enums.SocialProvider import com.weeth.global.common.entity.BaseEntity import jakarta.persistence.Column import jakarta.persistence.Entity diff --git a/src/main/kotlin/com/weeth/domain/user/domain/entity/enums/Role.kt b/src/main/kotlin/com/weeth/domain/user/domain/entity/enums/Role.kt deleted file mode 100644 index 03fe532a..00000000 --- a/src/main/kotlin/com/weeth/domain/user/domain/entity/enums/Role.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.weeth.domain.user.domain.entity.enums - -enum class Role { - USER, - ADMIN, -} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/entity/enums/CardinalStatus.kt b/src/main/kotlin/com/weeth/domain/user/domain/enums/CardinalStatus.kt similarity index 53% rename from src/main/kotlin/com/weeth/domain/user/domain/entity/enums/CardinalStatus.kt rename to src/main/kotlin/com/weeth/domain/user/domain/enums/CardinalStatus.kt index 8f009d0e..0d04dd07 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/entity/enums/CardinalStatus.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/enums/CardinalStatus.kt @@ -1,4 +1,4 @@ -package com.weeth.domain.user.domain.entity.enums +package com.weeth.domain.user.domain.enums enum class CardinalStatus { IN_PROGRESS, diff --git a/src/main/kotlin/com/weeth/domain/user/domain/enums/Role.kt b/src/main/kotlin/com/weeth/domain/user/domain/enums/Role.kt new file mode 100644 index 00000000..340b1eed --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/enums/Role.kt @@ -0,0 +1,6 @@ +package com.weeth.domain.user.domain.enums + +enum class Role { + USER, + ADMIN, +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/entity/enums/SocialProvider.kt b/src/main/kotlin/com/weeth/domain/user/domain/enums/SocialProvider.kt similarity index 51% rename from src/main/kotlin/com/weeth/domain/user/domain/entity/enums/SocialProvider.kt rename to src/main/kotlin/com/weeth/domain/user/domain/enums/SocialProvider.kt index 3bb93fa2..41dfc547 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/entity/enums/SocialProvider.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/enums/SocialProvider.kt @@ -1,4 +1,4 @@ -package com.weeth.domain.user.domain.entity.enums +package com.weeth.domain.user.domain.enums enum class SocialProvider { KAKAO, diff --git a/src/main/kotlin/com/weeth/domain/user/domain/entity/enums/Status.kt b/src/main/kotlin/com/weeth/domain/user/domain/enums/Status.kt similarity index 58% rename from src/main/kotlin/com/weeth/domain/user/domain/entity/enums/Status.kt rename to src/main/kotlin/com/weeth/domain/user/domain/enums/Status.kt index 655dfea6..3c28b53d 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/entity/enums/Status.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/enums/Status.kt @@ -1,4 +1,4 @@ -package com.weeth.domain.user.domain.entity.enums +package com.weeth.domain.user.domain.enums enum class Status { WAITING, diff --git a/src/main/kotlin/com/weeth/domain/user/domain/entity/enums/StatusPriority.kt b/src/main/kotlin/com/weeth/domain/user/domain/enums/StatusPriority.kt similarity index 91% rename from src/main/kotlin/com/weeth/domain/user/domain/entity/enums/StatusPriority.kt rename to src/main/kotlin/com/weeth/domain/user/domain/enums/StatusPriority.kt index 299cf814..3fb1dde3 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/entity/enums/StatusPriority.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/enums/StatusPriority.kt @@ -1,4 +1,4 @@ -package com.weeth.domain.user.domain.entity.enums +package com.weeth.domain.user.domain.enums import com.weeth.domain.user.application.exception.StatusNotFoundException diff --git a/src/main/kotlin/com/weeth/domain/user/domain/entity/enums/UsersOrderBy.kt b/src/main/kotlin/com/weeth/domain/user/domain/enums/UsersOrderBy.kt similarity index 59% rename from src/main/kotlin/com/weeth/domain/user/domain/entity/enums/UsersOrderBy.kt rename to src/main/kotlin/com/weeth/domain/user/domain/enums/UsersOrderBy.kt index c8ce2584..a3d13a29 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/entity/enums/UsersOrderBy.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/enums/UsersOrderBy.kt @@ -1,4 +1,4 @@ -package com.weeth.domain.user.domain.entity.enums +package com.weeth.domain.user.domain.enums enum class UsersOrderBy { NAME_ASCENDING, diff --git a/src/main/kotlin/com/weeth/domain/user/domain/repository/CardinalRepository.kt b/src/main/kotlin/com/weeth/domain/user/domain/repository/CardinalRepository.kt index e10a284d..8f659ba1 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/repository/CardinalRepository.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/repository/CardinalRepository.kt @@ -2,7 +2,7 @@ package com.weeth.domain.user.domain.repository import com.weeth.domain.user.application.exception.CardinalNotFoundException import com.weeth.domain.user.domain.entity.Cardinal -import com.weeth.domain.user.domain.entity.enums.CardinalStatus +import com.weeth.domain.user.domain.enums.CardinalStatus import org.springframework.data.jpa.repository.JpaRepository import java.util.Optional diff --git a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserCardinalRepository.kt b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserCardinalRepository.kt index b637eb94..a41f9c71 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserCardinalRepository.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserCardinalRepository.kt @@ -13,7 +13,9 @@ interface UserCardinalRepository : fun findTopByUserOrderByCardinalCardinalNumberDesc(user: User): UserCardinal? - @Query("SELECT uc FROM UserCardinal uc WHERE uc.user IN :users ORDER BY uc.user.id, uc.cardinal.cardinalNumber DESC") + @Query( + "SELECT uc FROM UserCardinal uc WHERE uc.user IN :users ORDER BY uc.user.id, uc.cardinal.cardinalNumber DESC", + ) fun findAllByUsers( @Param("users") users: List, ): List @@ -36,5 +38,6 @@ interface UserCardinalRepository : override fun findAllByUsersOrderByCardinalDesc(users: List): List = findAllByUsers(users) - override fun findTopByUserOrderByCardinalNumberDesc(user: User): UserCardinal? = findTopByUserOrderByCardinalCardinalNumberDesc(user) + override fun findTopByUserOrderByCardinalNumberDesc(user: User): UserCardinal? = + findTopByUserOrderByCardinalCardinalNumberDesc(user) } diff --git a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserReader.kt b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserReader.kt index 6092e9b4..f2c28ef7 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserReader.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserReader.kt @@ -2,7 +2,7 @@ package com.weeth.domain.user.domain.repository import com.weeth.domain.user.domain.entity.Cardinal import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.entity.enums.Status +import com.weeth.domain.user.domain.enums.Status interface UserReader { fun getById(userId: Long): User diff --git a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserRepository.kt b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserRepository.kt index c11fefcf..58c53040 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserRepository.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserRepository.kt @@ -3,7 +3,7 @@ package com.weeth.domain.user.domain.repository import com.weeth.domain.user.application.exception.UserNotFoundException import com.weeth.domain.user.domain.entity.Cardinal import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.entity.enums.Status +import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.vo.Email import com.weeth.domain.user.domain.vo.PhoneNumber import jakarta.persistence.LockModeType diff --git a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserSocialAccountRepository.kt b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserSocialAccountRepository.kt index d5a4e4db..05bac27e 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserSocialAccountRepository.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserSocialAccountRepository.kt @@ -1,7 +1,7 @@ package com.weeth.domain.user.domain.repository import com.weeth.domain.user.domain.entity.UserSocialAccount -import com.weeth.domain.user.domain.entity.enums.SocialProvider +import com.weeth.domain.user.domain.enums.SocialProvider import org.springframework.data.jpa.repository.JpaRepository import java.util.Optional diff --git a/src/main/kotlin/com/weeth/domain/user/presentation/UserAdminController.kt b/src/main/kotlin/com/weeth/domain/user/presentation/UserAdminController.kt index e5883150..25d4a0b9 100644 --- a/src/main/kotlin/com/weeth/domain/user/presentation/UserAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/user/presentation/UserAdminController.kt @@ -7,7 +7,7 @@ import com.weeth.domain.user.application.dto.response.AdminUserResponse import com.weeth.domain.user.application.exception.UserErrorCode import com.weeth.domain.user.application.usecase.command.AdminUserUseCase import com.weeth.domain.user.application.usecase.query.GetUserQueryService -import com.weeth.domain.user.domain.entity.enums.UsersOrderBy +import com.weeth.domain.user.domain.enums.UsersOrderBy import com.weeth.global.auth.jwt.application.exception.JwtErrorCode import com.weeth.global.common.exception.ApiErrorCodeExample import com.weeth.global.common.response.CommonResponse diff --git a/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt b/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt index 98ccb3b8..047819ca 100644 --- a/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt +++ b/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt @@ -83,7 +83,10 @@ class UserController( @RequestParam("pageSize") pageSize: Int, @RequestParam(required = false) cardinal: Int?, ): CommonResponse> = - CommonResponse.success(UserResponseCode.USER_FIND_ALL_SUCCESS, getUserQueryService.findAllUser(pageNumber, pageSize, cardinal)) + CommonResponse.success( + UserResponseCode.USER_FIND_ALL_SUCCESS, + getUserQueryService.findAllUser(pageNumber, pageSize, cardinal), + ) @GetMapping("/search") @Operation(summary = "동아리 멤버 검색") diff --git a/src/main/kotlin/com/weeth/global/auth/apple/AppleAuthService.kt b/src/main/kotlin/com/weeth/global/auth/apple/AppleAuthService.kt index 9130d90e..271197e4 100644 --- a/src/main/kotlin/com/weeth/global/auth/apple/AppleAuthService.kt +++ b/src/main/kotlin/com/weeth/global/auth/apple/AppleAuthService.kt @@ -254,7 +254,8 @@ class AppleAuthService( throw AppleAuthenticationException() } - private fun decodeBase64Url(value: String): String = String(Base64.getUrlDecoder().decode(value), StandardCharsets.UTF_8) + private fun decodeBase64Url(value: String): String = + String(Base64.getUrlDecoder().decode(value), StandardCharsets.UTF_8) private fun parseEmailVerified(raw: Any?): Boolean = when (raw) { diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/JwtErrorCode.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/JwtErrorCode.kt index 5ccded53..f3f6ff4e 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/JwtErrorCode.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/JwtErrorCode.kt @@ -5,9 +5,9 @@ import com.weeth.global.common.exception.ExplainError import org.springframework.http.HttpStatus enum class JwtErrorCode( - private val code: Int, - private val status: HttpStatus, - private val message: String, + override val code: Int, + override val status: HttpStatus, + override val message: String, ) : ErrorCodeInterface { @ExplainError("토큰의 구조가 올바르지 않거나(Malformed), 서명이 유효하지 않은 경우 발생합니다. 토큰을 재발급 받아주세요.") INVALID_TOKEN(2900, HttpStatus.BAD_REQUEST, "올바르지 않은 Token 입니다."), @@ -23,11 +23,4 @@ enum class JwtErrorCode( @ExplainError("Apple 인증 과정에서 토큰 교환 또는 검증에 실패했을 때 발생합니다.") APPLE_AUTHENTICATION_FAILED(2904, HttpStatus.UNAUTHORIZED, "애플 로그인에 실패했습니다."), - ; - - override fun getCode(): Int = code - - override fun getStatus(): HttpStatus = status - - override fun getMessage(): String = message } diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractor.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractor.kt index 02fcc525..dc2ee1c5 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractor.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractor.kt @@ -1,6 +1,6 @@ package com.weeth.global.auth.jwt.application.service -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.enums.Role import com.weeth.global.auth.jwt.application.exception.TokenNotFoundException import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider import com.weeth.global.config.properties.JwtProperties @@ -35,7 +35,8 @@ class JwtTokenExtractor( ?.takeIf { it.startsWith(BEARER) } ?.removePrefix(BEARER) - fun extractEmail(accessToken: String): String? = extractClaim(accessToken, JwtTokenProvider.EMAIL_CLAIM, String::class.java) + fun extractEmail(accessToken: String): String? = + extractClaim(accessToken, JwtTokenProvider.EMAIL_CLAIM, String::class.java) fun extractId(token: String): Long? = extractClaim(token, JwtTokenProvider.ID_CLAIM, Long::class.javaObjectType) diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt index 3fadd973..c04cf927 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt @@ -1,6 +1,6 @@ package com.weeth.global.auth.jwt.application.usecase -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.enums.Role import com.weeth.global.auth.jwt.application.dto.JwtDto import com.weeth.global.auth.jwt.application.exception.InvalidTokenException import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/domain/port/RefreshTokenStorePort.kt b/src/main/kotlin/com/weeth/global/auth/jwt/domain/port/RefreshTokenStorePort.kt index 12aeacea..9d3c3bac 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/domain/port/RefreshTokenStorePort.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/domain/port/RefreshTokenStorePort.kt @@ -1,6 +1,6 @@ package com.weeth.global.auth.jwt.domain.port -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.enums.Role interface RefreshTokenStorePort { fun save( diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProvider.kt b/src/main/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProvider.kt index 139900e4..f7d6ef15 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProvider.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProvider.kt @@ -1,6 +1,6 @@ package com.weeth.global.auth.jwt.domain.service -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.enums.Role import com.weeth.global.auth.jwt.application.exception.InvalidTokenException import com.weeth.global.config.properties.JwtProperties import io.jsonwebtoken.Claims diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisRefreshTokenStoreAdapter.kt b/src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisRefreshTokenStoreAdapter.kt index 1819978f..d90cdbcf 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisRefreshTokenStoreAdapter.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisRefreshTokenStoreAdapter.kt @@ -1,6 +1,6 @@ package com.weeth.global.auth.jwt.infrastructure -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.enums.Role import com.weeth.global.auth.jwt.application.exception.InvalidTokenException import com.weeth.global.auth.jwt.application.exception.RedisTokenNotFoundException import com.weeth.global.auth.jwt.domain.port.RefreshTokenStorePort diff --git a/src/main/kotlin/com/weeth/global/auth/model/AuthenticatedUser.kt b/src/main/kotlin/com/weeth/global/auth/model/AuthenticatedUser.kt index 11d59dc7..18ff70f9 100644 --- a/src/main/kotlin/com/weeth/global/auth/model/AuthenticatedUser.kt +++ b/src/main/kotlin/com/weeth/global/auth/model/AuthenticatedUser.kt @@ -1,6 +1,6 @@ package com.weeth.global.auth.model -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.enums.Role /** * Authentication 설정을 위한 model diff --git a/src/main/kotlin/com/weeth/global/auth/resolver/CurrentUserRoleArgumentResolver.kt b/src/main/kotlin/com/weeth/global/auth/resolver/CurrentUserRoleArgumentResolver.kt index 7e5bbaff..11623dec 100644 --- a/src/main/kotlin/com/weeth/global/auth/resolver/CurrentUserRoleArgumentResolver.kt +++ b/src/main/kotlin/com/weeth/global/auth/resolver/CurrentUserRoleArgumentResolver.kt @@ -1,6 +1,6 @@ package com.weeth.global.auth.resolver -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.enums.Role import com.weeth.global.auth.annotation.CurrentUserRole import com.weeth.global.auth.jwt.application.exception.AnonymousAuthenticationException import com.weeth.global.auth.model.AuthenticatedUser diff --git a/src/main/kotlin/com/weeth/global/common/converter/JsonConverter.kt b/src/main/kotlin/com/weeth/global/common/converter/JsonConverter.kt index 962e50a3..c92ec17c 100644 --- a/src/main/kotlin/com/weeth/global/common/converter/JsonConverter.kt +++ b/src/main/kotlin/com/weeth/global/common/converter/JsonConverter.kt @@ -17,7 +17,8 @@ abstract class JsonConverter( } } - override fun convertToDatabaseColumn(attribute: T?): String? = attribute?.let { objectMapper.writeValueAsString(it) } + override fun convertToDatabaseColumn(attribute: T?): String? = + attribute?.let { objectMapper.writeValueAsString(it) } override fun convertToEntityAttribute(dbData: String?): T? = dbData?.let { objectMapper.readValue(it, typeRef) } } diff --git a/src/main/kotlin/com/weeth/global/common/entity/BaseEntity.kt b/src/main/kotlin/com/weeth/global/common/entity/BaseEntity.kt new file mode 100644 index 00000000..fe30d166 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/entity/BaseEntity.kt @@ -0,0 +1,22 @@ +package com.weeth.global.common.entity + +import jakarta.persistence.Column +import jakarta.persistence.EntityListeners +import jakarta.persistence.MappedSuperclass +import org.springframework.data.annotation.CreatedDate +import org.springframework.data.annotation.LastModifiedDate +import org.springframework.data.jpa.domain.support.AuditingEntityListener +import java.time.LocalDateTime + +@MappedSuperclass +@EntityListeners(AuditingEntityListener::class) +abstract class BaseEntity { + @CreatedDate + @Column(updatable = false) + var createdAt: LocalDateTime = LocalDateTime.MIN + protected set + + @LastModifiedDate + var modifiedAt: LocalDateTime = LocalDateTime.MIN + protected set +} diff --git a/src/main/kotlin/com/weeth/global/common/exception/BaseException.kt b/src/main/kotlin/com/weeth/global/common/exception/BaseException.kt index 6bc0a895..300d8b64 100644 --- a/src/main/kotlin/com/weeth/global/common/exception/BaseException.kt +++ b/src/main/kotlin/com/weeth/global/common/exception/BaseException.kt @@ -1,26 +1,8 @@ package com.weeth.global.common.exception -abstract class BaseException : RuntimeException { - val statusCode: Int - val errorCode: ErrorCodeInterface? - - constructor(code: Int, message: String) : super(message) { - statusCode = code - errorCode = null - } - - constructor(code: Int, message: String, cause: Throwable) : super(message, cause) { - statusCode = code - errorCode = null - } - - constructor(errorCode: ErrorCodeInterface) : super(errorCode.getMessage()) { - statusCode = errorCode.getStatus().value() - this.errorCode = errorCode - } - - constructor(errorCode: ErrorCodeInterface, cause: Throwable?) : super(errorCode.getMessage(), cause) { - statusCode = errorCode.getStatus().value() - this.errorCode = errorCode - } +abstract class BaseException( + val errorCode: ErrorCodeInterface, + message: String? = null, +) : RuntimeException(message ?: errorCode.message) { + val statusCode: Int get() = errorCode.status.value() } diff --git a/src/main/kotlin/com/weeth/global/common/exception/CommonExceptionHandler.kt b/src/main/kotlin/com/weeth/global/common/exception/CommonExceptionHandler.kt index b26717de..0e658236 100644 --- a/src/main/kotlin/com/weeth/global/common/exception/CommonExceptionHandler.kt +++ b/src/main/kotlin/com/weeth/global/common/exception/CommonExceptionHandler.kt @@ -18,13 +18,7 @@ class CommonExceptionHandler { log.warn("예외 처리(BaseException)", ex) log.warn(LOG_FORMAT, ex::class.simpleName, ex.statusCode, ex.message) - val errorCode = ex.errorCode - val response: CommonResponse = - if (errorCode != null) { - CommonResponse.error(errorCode) - } else { - CommonResponse.createFailure(ex.statusCode, ex.message ?: "") - } + val response = CommonResponse.error(ex.errorCode) return ResponseEntity .status(ex.statusCode) diff --git a/src/main/kotlin/com/weeth/global/common/exception/ErrorCodeInterface.kt b/src/main/kotlin/com/weeth/global/common/exception/ErrorCodeInterface.kt index a137f5ef..497983f6 100644 --- a/src/main/kotlin/com/weeth/global/common/exception/ErrorCodeInterface.kt +++ b/src/main/kotlin/com/weeth/global/common/exception/ErrorCodeInterface.kt @@ -3,16 +3,14 @@ package com.weeth.global.common.exception import org.springframework.http.HttpStatus interface ErrorCodeInterface { - fun getCode(): Int - - fun getStatus(): HttpStatus - - fun getMessage(): String + val code: Int + val status: HttpStatus + val message: String @Throws(NoSuchFieldException::class) fun getExplainError(): String { val field = this::class.java.getField((this as Enum<*>).name) val annotation = field.getAnnotation(ExplainError::class.java) - return annotation?.value ?: getMessage() + return annotation?.value ?: message } } diff --git a/src/main/kotlin/com/weeth/global/common/response/CommonResponse.kt b/src/main/kotlin/com/weeth/global/common/response/CommonResponse.kt index 092d4d2f..25a272f3 100644 --- a/src/main/kotlin/com/weeth/global/common/response/CommonResponse.kt +++ b/src/main/kotlin/com/weeth/global/common/response/CommonResponse.kt @@ -51,8 +51,8 @@ data class CommonResponse( @JvmStatic fun error(errorCode: ErrorCodeInterface): CommonResponse = CommonResponse( - code = errorCode.getCode(), - message = errorCode.getMessage(), + code = errorCode.code, + message = errorCode.message, data = null, ) @@ -62,7 +62,7 @@ data class CommonResponse( message: String, ): CommonResponse = CommonResponse( - code = errorCode.getCode(), + code = errorCode.code, message = message, data = null, ) @@ -73,8 +73,8 @@ data class CommonResponse( data: T, ): CommonResponse = CommonResponse( - code = errorCode.getCode(), - message = errorCode.getMessage(), + code = errorCode.code, + message = errorCode.message, data = data, ) diff --git a/src/main/kotlin/com/weeth/global/config/SwaggerConfig.kt b/src/main/kotlin/com/weeth/global/config/SwaggerConfig.kt index d174e817..6fd24812 100644 --- a/src/main/kotlin/com/weeth/global/config/SwaggerConfig.kt +++ b/src/main/kotlin/com/weeth/global/config/SwaggerConfig.kt @@ -115,12 +115,12 @@ class SwaggerConfig( errorCodes .map { errorCode -> val enumName = (errorCode as Enum<*>).name - val description = runCatching { errorCode.getExplainError() }.getOrDefault(errorCode.getMessage()) + val description = runCatching { errorCode.getExplainError() }.getOrDefault(errorCode.message) ExampleHolder( holder = getSwaggerExample(description, errorCode), - code = errorCode.getStatus().value(), - name = "[$enumName] ${errorCode.getMessage()}", + code = errorCode.status.value(), + name = "[$enumName] ${errorCode.message}", ) }.groupBy { it.code } @@ -131,7 +131,7 @@ class SwaggerConfig( description: String, errorCode: ErrorCodeInterface, ): Example { - val errorResponse = CommonResponse.Companion.createFailure(errorCode.getCode(), errorCode.getMessage()) + val errorResponse = CommonResponse.Companion.createFailure(errorCode.code, errorCode.message) return Example() .description(description) .value(errorResponse) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fb06d574..7596a5a1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,6 +1,8 @@ spring: profiles: active: local + jpa: + open-in-view: false springdoc: use-fqn: true diff --git a/src/test/kotlin/com/weeth/config/QueryCountUtil.kt b/src/test/kotlin/com/weeth/config/QueryCountUtil.kt index 184f678f..449b50cb 100644 --- a/src/test/kotlin/com/weeth/config/QueryCountUtil.kt +++ b/src/test/kotlin/com/weeth/config/QueryCountUtil.kt @@ -23,9 +23,10 @@ object QueryCountUtil { val elapsedTimeMs: Double, ) { override fun toString(): String = - "queries=$queryCount, entityLoads=$entityLoadCount, collectionLoads=$collectionLoadCount, elapsedMs=%.3f".format( - elapsedTimeMs, - ) + "queries=$queryCount, entityLoads=$entityLoadCount, collectionLoads=$collectionLoadCount, elapsedMs=%.3f" + .format( + elapsedTimeMs, + ) } fun count( diff --git a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCaseTest.kt index 986f3b3e..06198355 100644 --- a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCaseTest.kt @@ -12,7 +12,7 @@ import com.weeth.domain.account.fixture.ReceiptTestFixture import com.weeth.domain.file.application.dto.request.FileSaveRequest import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.entity.File -import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.repository.FileReader import com.weeth.domain.file.domain.repository.FileRepository import com.weeth.domain.user.domain.entity.Cardinal @@ -89,7 +89,8 @@ class ManageReceiptUseCaseTest : stubExistingCardinal(40) every { accountRepository.findByCardinal(40) } returns account every { receiptRepository.save(any()) } returns savedReceipt - every { fileMapper.toFileList(emptyList(), FileOwnerType.RECEIPT, savedReceipt.id) } returns emptyList() + every { fileMapper.toFileList(emptyList(), FileOwnerType.RECEIPT, savedReceipt.id) } returns + emptyList() useCase.save(request) diff --git a/src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountQueryServiceTest.kt index d4a28466..2b8a1d1d 100644 --- a/src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountQueryServiceTest.kt @@ -8,7 +8,7 @@ import com.weeth.domain.account.domain.repository.ReceiptRepository import com.weeth.domain.account.fixture.AccountTestFixture import com.weeth.domain.account.fixture.ReceiptTestFixture import com.weeth.domain.file.application.mapper.FileMapper -import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.repository.FileReader import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt index d098b66d..4baa3f30 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt @@ -11,7 +11,7 @@ import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createActiveUse import com.weeth.domain.session.domain.entity.Session import com.weeth.domain.session.domain.repository.SessionReader import com.weeth.domain.user.domain.entity.Cardinal -import com.weeth.domain.user.domain.entity.enums.Status +import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.repository.UserReader import com.weeth.domain.user.domain.service.UserCardinalPolicy import io.kotest.core.spec.style.DescribeSpec @@ -81,7 +81,8 @@ class GetAttendanceQueryServiceTest : val currentCardinal = mockk() every { currentCardinal.cardinalNumber } returns 1 every { userCardinalPolicy.getCurrentCardinal(user) } returns currentCardinal - every { attendanceRepository.findAllByUserIdAndCardinal(userId, 1) } returns listOf(attendance1, attendance2) + every { attendanceRepository.findAllByUserIdAndCardinal(userId, 1) } returns + listOf(attendance1, attendance2) val response1 = mockk() val response2 = mockk() diff --git a/src/test/kotlin/com/weeth/domain/attendance/domain/entity/AttendanceTest.kt b/src/test/kotlin/com/weeth/domain/attendance/domain/entity/AttendanceTest.kt index f58eb13a..1551fe79 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/domain/entity/AttendanceTest.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/domain/entity/AttendanceTest.kt @@ -1,6 +1,6 @@ package com.weeth.domain.attendance.domain.entity -import com.weeth.domain.attendance.domain.entity.enums.AttendanceStatus +import com.weeth.domain.attendance.domain.enums.AttendanceStatus import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createActiveUser import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createAttendance import com.weeth.domain.session.fixture.SessionTestFixture.createOneDaySession diff --git a/src/test/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepositoryTest.kt b/src/test/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepositoryTest.kt index 1b4fd3c8..164199a8 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepositoryTest.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepositoryTest.kt @@ -3,10 +3,10 @@ package com.weeth.domain.attendance.domain.repository import com.weeth.config.TestContainersConfig import com.weeth.domain.attendance.domain.entity.Attendance import com.weeth.domain.session.domain.entity.Session -import com.weeth.domain.session.domain.entity.enums.SessionStatus +import com.weeth.domain.session.domain.enums.SessionStatus import com.weeth.domain.session.domain.repository.SessionRepository import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.entity.enums.Status +import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.repository.UserRepository import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.collections.shouldBeEmpty diff --git a/src/test/kotlin/com/weeth/domain/attendance/fixture/AttendanceTestFixture.kt b/src/test/kotlin/com/weeth/domain/attendance/fixture/AttendanceTestFixture.kt index f20c8f7f..6e70666d 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/fixture/AttendanceTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/fixture/AttendanceTestFixture.kt @@ -3,8 +3,8 @@ package com.weeth.domain.attendance.fixture import com.weeth.domain.attendance.domain.entity.Attendance import com.weeth.domain.session.domain.entity.Session import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.entity.enums.Role -import com.weeth.domain.user.domain.entity.enums.Status +import com.weeth.domain.user.domain.enums.Role +import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.vo.AttendanceStats import org.springframework.test.util.ReflectionTestUtils @@ -45,7 +45,14 @@ object AttendanceTestFixture { AttendanceStats( attendanceCount = attendanceCount, absenceCount = absenceCount, - attendanceRate = if (attendanceCount + absenceCount > 0) (attendanceCount * 100) / (attendanceCount + absenceCount) else 0, + attendanceRate = + if (attendanceCount + absenceCount > + 0 + ) { + (attendanceCount * 100) / (attendanceCount + absenceCount) + } else { + 0 + }, ), ) } diff --git a/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt b/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt index 59718b59..4a6eb701 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt @@ -3,9 +3,9 @@ package com.weeth.domain.board.application.mapper import com.weeth.domain.board.domain.entity.Post import com.weeth.domain.comment.application.dto.response.CommentResponse import com.weeth.domain.file.application.dto.response.FileResponse -import com.weeth.domain.file.domain.entity.FileStatus +import com.weeth.domain.file.domain.enums.FileStatus import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.enums.Role import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe import io.mockk.every diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt index 4f34b708..b1987610 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt @@ -5,9 +5,9 @@ import com.weeth.domain.board.application.dto.request.UpdateBoardRequest import com.weeth.domain.board.application.exception.BoardNotFoundException import com.weeth.domain.board.application.mapper.BoardMapper import com.weeth.domain.board.domain.entity.Board -import com.weeth.domain.board.domain.entity.enums.BoardType +import com.weeth.domain.board.domain.enums.BoardType import com.weeth.domain.board.domain.repository.BoardRepository -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.enums.Role import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt index a403a5e8..3bffc160 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt @@ -8,19 +8,19 @@ import com.weeth.domain.board.application.exception.CategoryAccessDeniedExceptio import com.weeth.domain.board.application.mapper.PostMapper import com.weeth.domain.board.domain.entity.Board import com.weeth.domain.board.domain.entity.Post -import com.weeth.domain.board.domain.entity.enums.BoardType +import com.weeth.domain.board.domain.enums.BoardType import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.board.domain.repository.PostRepository import com.weeth.domain.board.domain.vo.BoardConfig import com.weeth.domain.file.application.dto.request.FileSaveRequest import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.entity.File -import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.repository.FileReader import com.weeth.domain.file.domain.repository.FileRepository import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.entity.enums.Role -import com.weeth.domain.user.domain.entity.enums.Status +import com.weeth.domain.user.domain.enums.Role +import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.repository.UserReader import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.assertions.throwables.shouldThrow diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt index 59e4a964..29b1d291 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt @@ -3,9 +3,9 @@ package com.weeth.domain.board.application.usecase.query import com.weeth.domain.board.application.exception.BoardNotFoundException import com.weeth.domain.board.application.mapper.BoardMapper import com.weeth.domain.board.domain.entity.Board -import com.weeth.domain.board.domain.entity.enums.BoardType +import com.weeth.domain.board.domain.enums.BoardType import com.weeth.domain.board.domain.repository.BoardRepository -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.enums.Role import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.collections.shouldHaveSize @@ -27,7 +27,8 @@ class GetBoardQueryServiceTest : updateConfig(config.copy(isPrivate = true)) } - every { boardRepository.findAllByIsDeletedFalseOrderByIdAsc() } returns listOf(publicBoard, privateBoard) + every { boardRepository.findAllByIsDeletedFalseOrderByIdAsc() } returns + listOf(publicBoard, privateBoard) val result = queryService.findBoards(Role.USER) @@ -42,7 +43,8 @@ class GetBoardQueryServiceTest : updateConfig(config.copy(isPrivate = true)) } - every { boardRepository.findAllByIsDeletedFalseOrderByIdAsc() } returns listOf(publicBoard, privateBoard) + every { boardRepository.findAllByIsDeletedFalseOrderByIdAsc() } returns + listOf(publicBoard, privateBoard) val result = queryService.findBoards(Role.ADMIN) diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt index d5c7819c..e5e22418 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt @@ -7,7 +7,7 @@ import com.weeth.domain.board.application.exception.PostNotFoundException import com.weeth.domain.board.application.mapper.PostMapper import com.weeth.domain.board.domain.entity.Board import com.weeth.domain.board.domain.entity.Post -import com.weeth.domain.board.domain.entity.enums.BoardType +import com.weeth.domain.board.domain.enums.BoardType import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.board.domain.repository.PostRepository import com.weeth.domain.comment.application.dto.response.CommentResponse @@ -16,10 +16,10 @@ import com.weeth.domain.comment.domain.repository.CommentReader import com.weeth.domain.file.application.dto.response.FileResponse import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.entity.File -import com.weeth.domain.file.domain.entity.FileOwnerType -import com.weeth.domain.file.domain.entity.FileStatus +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.enums.FileStatus import com.weeth.domain.file.domain.repository.FileReader -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.enums.Role import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec @@ -133,7 +133,8 @@ class GetPostQueryServiceTest : val user = UserTestFixture.createActiveUser1(1L) val privateBoard = Board(id = 2L, name = "비공개", type = BoardType.GENERAL) privateBoard.updateConfig(privateBoard.config.copy(isPrivate = true)) - val post = Post(id = 1L, title = "제목", content = "내용", user = user, board = privateBoard, commentCount = 0) + val post = + Post(id = 1L, title = "제목", content = "내용", user = user, board = privateBoard, commentCount = 0) every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post @@ -145,7 +146,8 @@ class GetPostQueryServiceTest : it("삭제된 게시판의 게시글은 조회할 수 없다") { val user = UserTestFixture.createActiveUser1(1L) val deletedBoard = Board(id = 3L, name = "삭제", type = BoardType.GENERAL, isDeleted = true) - val post = Post(id = 1L, title = "제목", content = "내용", user = user, board = deletedBoard, commentCount = 0) + val post = + Post(id = 1L, title = "제목", content = "내용", user = user, board = deletedBoard, commentCount = 0) every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post @@ -160,7 +162,8 @@ class GetPostQueryServiceTest : val pageable = PageRequest.of(0, 10) val board = Board(id = 1L, name = "일반", type = BoardType.GENERAL) every { boardRepository.findByIdAndIsDeletedFalse(1L) } returns board - every { postRepository.searchByBoardId(1L, "키워드", any()) } returns SliceImpl(emptyList(), pageable, false) + every { postRepository.searchByBoardId(1L, "키워드", any()) } returns + SliceImpl(emptyList(), pageable, false) shouldThrow { queryService.searchPosts(1L, "키워드", 0, 10, Role.USER) diff --git a/src/test/kotlin/com/weeth/domain/board/domain/converter/BoardConfigConverterTest.kt b/src/test/kotlin/com/weeth/domain/board/domain/converter/BoardConfigConverterTest.kt index d5fc1c17..6bf835a9 100644 --- a/src/test/kotlin/com/weeth/domain/board/domain/converter/BoardConfigConverterTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/domain/converter/BoardConfigConverterTest.kt @@ -1,7 +1,7 @@ package com.weeth.domain.board.domain.converter import com.weeth.domain.board.domain.vo.BoardConfig -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.enums.Role import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.shouldBe diff --git a/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt b/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt index 4d93eb43..7ac6b386 100644 --- a/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt @@ -1,8 +1,8 @@ package com.weeth.domain.board.domain.entity -import com.weeth.domain.board.domain.entity.enums.BoardType +import com.weeth.domain.board.domain.enums.BoardType import com.weeth.domain.board.domain.vo.BoardConfig -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.enums.Role import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.shouldBe @@ -83,7 +83,8 @@ class BoardEntityTest : } "canWriteBy는 비공개/관리자 전용 설정을 모두 고려한다" { - val privateBoard = Board(id = 24L, name = "비공개", type = BoardType.GENERAL, config = BoardConfig(isPrivate = true)) + val privateBoard = + Board(id = 24L, name = "비공개", type = BoardType.GENERAL, config = BoardConfig(isPrivate = true)) val adminOnlyBoard = Board( id = 25L, diff --git a/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt b/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt index dae5c900..9060baf8 100644 --- a/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt @@ -1,9 +1,9 @@ package com.weeth.domain.board.fixture import com.weeth.domain.board.domain.entity.Board -import com.weeth.domain.board.domain.entity.enums.BoardType +import com.weeth.domain.board.domain.enums.BoardType import com.weeth.domain.board.domain.vo.BoardConfig -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.enums.Role object BoardTestFixture { fun create( diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt index 52155f40..988c7742 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt @@ -4,14 +4,14 @@ import com.weeth.config.QueryCountUtil import com.weeth.config.TestContainersConfig import com.weeth.domain.board.domain.entity.Board import com.weeth.domain.board.domain.entity.Post -import com.weeth.domain.board.domain.entity.enums.BoardType +import com.weeth.domain.board.domain.enums.BoardType import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.board.domain.repository.PostRepository import com.weeth.domain.comment.application.dto.request.CommentSaveRequest import com.weeth.domain.comment.domain.entity.Comment import com.weeth.domain.comment.domain.repository.CommentRepository import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.entity.enums.Status +import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.repository.UserRepository import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe @@ -250,7 +250,14 @@ class CommentConcurrencyTest( "atomicThroughput=${"%.2f".format(atomicSummary.medianThroughput)} ops/s, " + "pessimisticThroughput=${"%.2f".format(pessimisticSummary.medianThroughput)} ops/s", ) - val winner = if (atomicSummary.medianElapsedMs < pessimisticSummary.medianElapsedMs) "atomic" else "pessimistic" + val winner = + if (atomicSummary.medianElapsedMs < + pessimisticSummary.medianElapsedMs + ) { + "atomic" + } else { + "pessimistic" + } println("[CommentBenchmark][winner] $winner") } } @@ -276,7 +283,8 @@ class AtomicCommentCountCommand( val post = entityManager.getReference(Post::class.java, postId) val parent = dto.parentCommentId?.let { parentId -> - commentRepository.findByIdAndPostId(parentId, postId) ?: throw IllegalArgumentException("parent not found") + commentRepository.findByIdAndPostId(parentId, postId) + ?: throw IllegalArgumentException("parent not found") } commentRepository.save( diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt index e214c1c9..9060c35c 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt @@ -12,8 +12,8 @@ import com.weeth.domain.comment.domain.repository.CommentRepository import com.weeth.domain.file.application.dto.request.FileSaveRequest import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.entity.File -import com.weeth.domain.file.domain.entity.FileOwnerType -import com.weeth.domain.file.domain.entity.FileStatus +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.enums.FileStatus import com.weeth.domain.file.domain.repository.FileReader import com.weeth.domain.file.domain.repository.FileRepository import com.weeth.domain.user.domain.repository.UserReader diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt index 501d986a..116ef5fe 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt @@ -4,7 +4,7 @@ import com.weeth.config.QueryCountUtil import com.weeth.config.TestContainersConfig import com.weeth.domain.board.domain.entity.Board import com.weeth.domain.board.domain.entity.Post -import com.weeth.domain.board.domain.entity.enums.BoardType +import com.weeth.domain.board.domain.enums.BoardType import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.board.domain.repository.PostRepository import com.weeth.domain.comment.application.dto.response.CommentResponse @@ -13,12 +13,12 @@ import com.weeth.domain.comment.domain.entity.Comment import com.weeth.domain.comment.domain.repository.CommentRepository import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.entity.File -import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.port.FileAccessUrlPort import com.weeth.domain.file.domain.repository.FileRepository import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.entity.enums.Role -import com.weeth.domain.user.domain.entity.enums.Status +import com.weeth.domain.user.domain.enums.Role +import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.repository.UserRepository import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.longs.shouldBeLessThan diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt index 7d0e3213..a66f1e9f 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt @@ -5,9 +5,9 @@ import com.weeth.domain.comment.application.dto.response.CommentResponse import com.weeth.domain.comment.application.mapper.CommentMapper import com.weeth.domain.comment.fixture.CommentTestFixture import com.weeth.domain.file.application.mapper.FileMapper -import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.repository.FileReader -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.enums.Role import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe diff --git a/src/test/kotlin/com/weeth/domain/file/application/mapper/FileMapperTest.kt b/src/test/kotlin/com/weeth/domain/file/application/mapper/FileMapperTest.kt index b459d6e9..9e070961 100644 --- a/src/test/kotlin/com/weeth/domain/file/application/mapper/FileMapperTest.kt +++ b/src/test/kotlin/com/weeth/domain/file/application/mapper/FileMapperTest.kt @@ -1,7 +1,7 @@ package com.weeth.domain.file.application.mapper import com.weeth.domain.file.application.dto.request.FileSaveRequest -import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.port.FileAccessUrlPort import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.collections.shouldHaveSize @@ -29,8 +29,18 @@ class FileMapperTest : it("요청 리스트를 ownerType/ownerId를 포함한 File 리스트로 매핑한다") { val requests = listOf( - FileSaveRequest("a.png", "POST/2026-02/550e8400-e29b-41d4-a716-446655440000_a.png", 100L, "image/png"), - FileSaveRequest("b.pdf", "POST/2026-02/550e8400-e29b-41d4-a716-446655440001_b.pdf", 200L, "application/pdf"), + FileSaveRequest( + "a.png", + "POST/2026-02/550e8400-e29b-41d4-a716-446655440000_a.png", + 100L, + "image/png", + ), + FileSaveRequest( + "b.pdf", + "POST/2026-02/550e8400-e29b-41d4-a716-446655440001_b.pdf", + 200L, + "application/pdf", + ), ) val result = fileMapper.toFileList(requests, FileOwnerType.POST, 99L) diff --git a/src/test/kotlin/com/weeth/domain/file/application/usecase/command/GenerateFileUrlUsecaseTest.kt b/src/test/kotlin/com/weeth/domain/file/application/usecase/command/GenerateFileUrlUsecaseTest.kt index 3601b57d..8ec22d58 100644 --- a/src/test/kotlin/com/weeth/domain/file/application/usecase/command/GenerateFileUrlUsecaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/file/application/usecase/command/GenerateFileUrlUsecaseTest.kt @@ -2,7 +2,7 @@ package com.weeth.domain.file.application.usecase.command import com.weeth.domain.file.application.dto.response.UrlResponse import com.weeth.domain.file.application.mapper.FileMapper -import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.port.FileUploadUrl import com.weeth.domain.file.domain.port.FileUploadUrlPort import io.kotest.assertions.throwables.shouldThrow @@ -37,8 +37,10 @@ class GenerateFileUrlUsecaseTest : every { preSignedService.generateUploadUrl(ownerType, "a.png") } returns firstPresigned every { preSignedService.generateUploadUrl(ownerType, "b.pdf") } returns secondPresigned - every { fileMapper.toUrlResponse("a.png", "https://presigned/a", "POST/2026-02/a.png") } returns responses[0] - every { fileMapper.toUrlResponse("b.pdf", "https://presigned/b", "POST/2026-02/b.pdf") } returns responses[1] + every { fileMapper.toUrlResponse("a.png", "https://presigned/a", "POST/2026-02/a.png") } returns + responses[0] + every { fileMapper.toUrlResponse("b.pdf", "https://presigned/b", "POST/2026-02/b.pdf") } returns + responses[1] val result = useCase.generateFileUploadUrls(ownerType, fileNames) diff --git a/src/test/kotlin/com/weeth/domain/file/domain/entity/FileTest.kt b/src/test/kotlin/com/weeth/domain/file/domain/entity/FileTest.kt index 11c169d0..6acfe3f7 100644 --- a/src/test/kotlin/com/weeth/domain/file/domain/entity/FileTest.kt +++ b/src/test/kotlin/com/weeth/domain/file/domain/entity/FileTest.kt @@ -1,8 +1,8 @@ package com.weeth.domain.file.domain.entity import com.weeth.domain.file.application.exception.UnsupportedFileContentTypeException -import com.weeth.domain.file.domain.entity.FileOwnerType -import com.weeth.domain.file.domain.entity.FileStatus +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.enums.FileStatus import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe diff --git a/src/test/kotlin/com/weeth/domain/file/domain/repository/FileRepositoryTest.kt b/src/test/kotlin/com/weeth/domain/file/domain/repository/FileRepositoryTest.kt index ed3c34c8..38891b51 100644 --- a/src/test/kotlin/com/weeth/domain/file/domain/repository/FileRepositoryTest.kt +++ b/src/test/kotlin/com/weeth/domain/file/domain/repository/FileRepositoryTest.kt @@ -2,8 +2,8 @@ package com.weeth.domain.file.domain.repository import com.weeth.config.TestContainersConfig import com.weeth.domain.file.domain.entity.File -import com.weeth.domain.file.domain.entity.FileOwnerType -import com.weeth.domain.file.domain.entity.FileStatus +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.enums.FileStatus import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.booleans.shouldBeFalse import io.kotest.matchers.booleans.shouldBeTrue @@ -106,4 +106,9 @@ private fun createTestFile( } } -private fun Map.valueBy(key: String): String = entries.first { it.key.equals(key, ignoreCase = true) }.value.toString() +private fun Map.valueBy(key: String): String = + entries + .first { + it.key.equals(key, ignoreCase = true) + }.value + .toString() diff --git a/src/test/kotlin/com/weeth/domain/file/fixture/FileTestFixture.kt b/src/test/kotlin/com/weeth/domain/file/fixture/FileTestFixture.kt index 3510d034..9903f4af 100644 --- a/src/test/kotlin/com/weeth/domain/file/fixture/FileTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/file/fixture/FileTestFixture.kt @@ -1,7 +1,7 @@ package com.weeth.domain.file.fixture import com.weeth.domain.file.domain.entity.File -import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.vo.FileContentType import com.weeth.domain.file.domain.vo.StorageKey diff --git a/src/test/kotlin/com/weeth/domain/file/infrastructure/S3FileUploadUrlAdapterTest.kt b/src/test/kotlin/com/weeth/domain/file/infrastructure/S3FileUploadUrlAdapterTest.kt index f54d313b..bc95373e 100644 --- a/src/test/kotlin/com/weeth/domain/file/infrastructure/S3FileUploadUrlAdapterTest.kt +++ b/src/test/kotlin/com/weeth/domain/file/infrastructure/S3FileUploadUrlAdapterTest.kt @@ -2,7 +2,7 @@ package com.weeth.domain.file.infrastructure import com.weeth.domain.file.application.exception.PresignedUrlGenerationException import com.weeth.domain.file.application.exception.UnsupportedFileExtensionException -import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.global.config.properties.AwsS3Properties import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec @@ -46,7 +46,8 @@ class S3FileUploadUrlAdapterTest : val s3Presigner = mockk() val adapter = S3FileUploadUrlAdapter(s3Presigner, awsS3Properties, 5) - every { s3Presigner.presignPutObject(any()) } throws RuntimeException("s3 unavailable") + every { s3Presigner.presignPutObject(any()) } throws + RuntimeException("s3 unavailable") shouldThrow { adapter.generateUploadUrl(FileOwnerType.POST, "file.png") diff --git a/src/test/kotlin/com/weeth/domain/session/domain/entity/SessionTest.kt b/src/test/kotlin/com/weeth/domain/session/domain/entity/SessionTest.kt index 22aa40b7..c2efacc8 100644 --- a/src/test/kotlin/com/weeth/domain/session/domain/entity/SessionTest.kt +++ b/src/test/kotlin/com/weeth/domain/session/domain/entity/SessionTest.kt @@ -1,6 +1,6 @@ package com.weeth.domain.session.domain.entity -import com.weeth.domain.session.domain.entity.enums.SessionStatus +import com.weeth.domain.session.domain.enums.SessionStatus import com.weeth.domain.session.fixture.SessionTestFixture import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.StringSpec diff --git a/src/test/kotlin/com/weeth/domain/session/fixture/SessionTestFixture.kt b/src/test/kotlin/com/weeth/domain/session/fixture/SessionTestFixture.kt index 584c6b4e..ba4d36e3 100644 --- a/src/test/kotlin/com/weeth/domain/session/fixture/SessionTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/session/fixture/SessionTestFixture.kt @@ -1,7 +1,7 @@ package com.weeth.domain.session.fixture import com.weeth.domain.session.domain.entity.Session -import com.weeth.domain.session.domain.entity.enums.SessionStatus +import com.weeth.domain.session.domain.enums.SessionStatus import org.springframework.test.util.ReflectionTestUtils import java.time.LocalDate import java.time.LocalDateTime diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AdminUserUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AdminUserUseCaseTest.kt index 06c279cf..14c9a41c 100644 --- a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AdminUserUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AdminUserUseCaseTest.kt @@ -8,8 +8,8 @@ import com.weeth.domain.user.application.dto.request.UserApplyObRequest import com.weeth.domain.user.application.dto.request.UserIdsRequest import com.weeth.domain.user.application.dto.request.UserRoleUpdateRequest import com.weeth.domain.user.domain.entity.UserCardinal -import com.weeth.domain.user.domain.entity.enums.Role -import com.weeth.domain.user.domain.entity.enums.Status +import com.weeth.domain.user.domain.enums.Role +import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.repository.CardinalRepository import com.weeth.domain.user.domain.repository.UserCardinalRepository import com.weeth.domain.user.domain.repository.UserReader @@ -56,7 +56,13 @@ class AdminUserUseCaseTest : describe("accept") { it("비활성 유저 승인 시 출석 초기화를 수행한다") { val user = UserTestFixture.createWaitingUser1(1L) - val currentCardinal = CardinalTestFixture.createCardinal(id = 1L, cardinalNumber = 8, year = 2025, semester = 1) + val currentCardinal = + CardinalTestFixture.createCardinal( + id = 1L, + cardinalNumber = 8, + year = 2025, + semester = 1, + ) val sessions = listOf(mockk()) every { userReader.findAllByIds(listOf(1L)) } returns listOf(user) @@ -95,14 +101,27 @@ class AdminUserUseCaseTest : describe("applyOb") { it("다음 기수로 OB 신청 시 출석을 초기화하고 user-cardinal을 저장한다") { val user = UserTestFixture.createActiveUser1(1L) - val currentCardinal = CardinalTestFixture.createCardinal(id = 10L, cardinalNumber = 3, year = 2024, semester = 2) - val nextCardinal = CardinalTestFixture.createCardinal(id = 11L, cardinalNumber = 4, year = 2025, semester = 1) + val currentCardinal = + CardinalTestFixture.createCardinal( + id = 10L, + cardinalNumber = 3, + year = 2024, + semester = 2, + ) + val nextCardinal = + CardinalTestFixture.createCardinal( + id = 11L, + cardinalNumber = 4, + year = 2025, + semester = 1, + ) val session = mockk() every { session.cardinal } returns 4 val request = listOf(UserApplyObRequest(1L, 4)) every { userReader.findAllByIds(listOf(1L)) } returns listOf(user) - every { userCardinalRepository.findAllByUsers(listOf(user)) } returns listOf(UserCardinal(user, currentCardinal)) + every { userCardinalRepository.findAllByUsers(listOf(user)) } returns + listOf(UserCardinal(user, currentCardinal)) every { cardinalRepository.findAllByCardinalNumberIn(listOf(4)) } returns listOf(nextCardinal) every { sessionReader.findAllByCardinalIn(listOf(4)) } returns listOf(session) every { userCardinalRepository.save(any()) } answers { firstArg() } @@ -110,16 +129,25 @@ class AdminUserUseCaseTest : useCase.applyOb(request) verify(exactly = 1) { attendanceRepository.saveAll(any>()) } - verify(exactly = 1) { userCardinalRepository.save(match { it.user == user && it.cardinal == nextCardinal }) } + verify( + exactly = 1, + ) { userCardinalRepository.save(match { it.user == user && it.cardinal == nextCardinal }) } } it("이미 해당 기수를 보유한 유저는 저장을 스킵한다") { val user = UserTestFixture.createActiveUser1(1L) - val cardinal = CardinalTestFixture.createCardinal(id = 11L, cardinalNumber = 4, year = 2025, semester = 1) + val cardinal = + CardinalTestFixture.createCardinal( + id = 11L, + cardinalNumber = 4, + year = 2025, + semester = 1, + ) val request = listOf(UserApplyObRequest(1L, 4)) every { userReader.findAllByIds(listOf(1L)) } returns listOf(user) - every { userCardinalRepository.findAllByUsers(listOf(user)) } returns listOf(UserCardinal(user, cardinal)) + every { userCardinalRepository.findAllByUsers(listOf(user)) } returns + listOf(UserCardinal(user, cardinal)) every { cardinalRepository.findAllByCardinalNumberIn(listOf(4)) } returns listOf(cardinal) useCase.applyOb(request) @@ -138,14 +166,27 @@ class AdminUserUseCaseTest : it("존재하지 않는 기수라면 새로 생성한다") { val user = UserTestFixture.createActiveUser1(1L) - val currentCardinal = CardinalTestFixture.createCardinal(id = 10L, cardinalNumber = 3, year = 2024, semester = 2) - val createdCardinal = CardinalTestFixture.createCardinal(id = 12L, cardinalNumber = 5, year = 2025, semester = 2) + val currentCardinal = + CardinalTestFixture.createCardinal( + id = 10L, + cardinalNumber = 3, + year = 2024, + semester = 2, + ) + val createdCardinal = + CardinalTestFixture.createCardinal( + id = 12L, + cardinalNumber = 5, + year = 2025, + semester = 2, + ) val session = mockk() every { session.cardinal } returns 5 val request = listOf(UserApplyObRequest(1L, 5)) every { userReader.findAllByIds(listOf(1L)) } returns listOf(user) - every { userCardinalRepository.findAllByUsers(listOf(user)) } returns listOf(UserCardinal(user, currentCardinal)) + every { userCardinalRepository.findAllByUsers(listOf(user)) } returns + listOf(UserCardinal(user, currentCardinal)) every { cardinalRepository.findAllByCardinalNumberIn(listOf(5)) } returns emptyList() every { cardinalRepository.save(any()) } returns createdCardinal every { sessionReader.findAllByCardinalIn(listOf(5)) } returns listOf(session) @@ -155,7 +196,14 @@ class AdminUserUseCaseTest : verify(exactly = 1) { cardinalRepository.save(any()) } verify(exactly = 1) { attendanceRepository.saveAll(any>()) } - verify(exactly = 1) { userCardinalRepository.save(match { it.user == user && it.cardinal == createdCardinal }) } + verify(exactly = 1) { + userCardinalRepository.save( + match { + it.user == user && + it.cardinal == createdCardinal + }, + ) + } } } }) diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCaseTest.kt index 1d7f89af..f329e09e 100644 --- a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCaseTest.kt @@ -8,7 +8,7 @@ import com.weeth.domain.user.application.exception.UserInActiveException import com.weeth.domain.user.application.mapper.UserMapper import com.weeth.domain.user.domain.entity.User import com.weeth.domain.user.domain.entity.UserCardinal -import com.weeth.domain.user.domain.entity.enums.Status +import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.repository.CardinalReader import com.weeth.domain.user.domain.repository.UserCardinalRepository import com.weeth.domain.user.domain.repository.UserReader @@ -67,7 +67,13 @@ class AuthUserUseCaseTest : it("유저와 유저-기수 연관관계를 저장한다") { val request = SignUpRequest("홍길동", "a@test.com", "20201234", "01012345678", "컴퓨터공학과", 7) val user = UserTestFixture.createActiveUser1(1L) - val cardinal = CardinalTestFixture.createCardinal(id = 10L, cardinalNumber = 7, year = 2025, semester = 1) + val cardinal = + CardinalTestFixture.createCardinal( + id = 10L, + cardinalNumber = 7, + year = 2025, + semester = 1, + ) every { userRepository.existsByStudentId(request.studentId) } returns false every { userRepository.existsByTelValue(request.tel) } returns false @@ -132,10 +138,12 @@ class AuthUserUseCaseTest : every { kakaoAuthService.getKakaoToken("auth-code") } returns tokenResponse every { kakaoAuthService.getUserInfo("kakao-access") } returns userInfo - every { userSocialAccountRepository.findByProviderAndProviderUserId(any(), any()) } returns Optional.empty() + every { userSocialAccountRepository.findByProviderAndProviderUserId(any(), any()) } returns + Optional.empty() every { userRepository.findByEmailValue("a@test.com") } returns Optional.of(user) every { userSocialAccountRepository.save(any()) } answers { firstArg() } - every { jwtManageUseCase.create(user.id, user.emailValue, user.role) } returns JwtDto("access", "refresh") + every { jwtManageUseCase.create(user.id, user.emailValue, user.role) } returns + JwtDto("access", "refresh") val result = useCase.socialLoginByKakao(request) @@ -163,10 +171,12 @@ class AuthUserUseCaseTest : every { kakaoAuthService.getKakaoToken("auth-code") } returns tokenResponse every { kakaoAuthService.getUserInfo("kakao-access") } returns userInfo - every { userSocialAccountRepository.findByProviderAndProviderUserId(any(), any()) } returns Optional.empty() + every { userSocialAccountRepository.findByProviderAndProviderUserId(any(), any()) } returns + Optional.empty() every { userRepository.findByEmailValue("a@test.com") } returns Optional.of(user) every { userSocialAccountRepository.save(any()) } answers { firstArg() } - every { jwtManageUseCase.create(user.id, user.emailValue, user.role) } returns JwtDto("access", "refresh") + every { jwtManageUseCase.create(user.id, user.emailValue, user.role) } returns + JwtDto("access", "refresh") useCase.socialLoginByKakao(request) @@ -179,13 +189,26 @@ class AuthUserUseCaseTest : val userInfo = KakaoUserInfoResponse( id = 1L, - kakaoAccount = KakaoAccount(isEmailValid = true, isEmailVerified = true, email = "new@test.com"), + kakaoAccount = + KakaoAccount( + isEmailValid = true, + isEmailVerified = true, + email = "new@test.com", + ), + ) + val createdUser = + User.create( + name = "", + email = "new@test.com", + studentId = "", + tel = "", + department = "", ) - val createdUser = User.create(name = "", email = "new@test.com", studentId = "", tel = "", department = "") every { kakaoAuthService.getKakaoToken("auth-code") } returns tokenResponse every { kakaoAuthService.getUserInfo("kakao-access") } returns userInfo - every { userSocialAccountRepository.findByProviderAndProviderUserId(any(), any()) } returns Optional.empty() + every { userSocialAccountRepository.findByProviderAndProviderUserId(any(), any()) } returns + Optional.empty() every { userRepository.findByEmailValue("new@test.com") } returns Optional.empty() every { userRepository.save(any()) } returns createdUser every { userSocialAccountRepository.save(any()) } answers { firstArg() } @@ -214,13 +237,19 @@ class AuthUserUseCaseTest : val userInfo = KakaoUserInfoResponse( id = 1L, - kakaoAccount = KakaoAccount(isEmailValid = true, isEmailVerified = true, email = "ban@test.com"), + kakaoAccount = + KakaoAccount( + isEmailValid = true, + isEmailVerified = true, + email = "ban@test.com", + ), ) val bannedUser = UserTestFixture.createActiveUser1(1L).also { it.ban() } every { kakaoAuthService.getKakaoToken("auth-code") } returns tokenResponse every { kakaoAuthService.getUserInfo("kakao-access") } returns userInfo - every { userSocialAccountRepository.findByProviderAndProviderUserId(any(), any()) } returns Optional.empty() + every { userSocialAccountRepository.findByProviderAndProviderUserId(any(), any()) } returns + Optional.empty() every { userRepository.findByEmailValue("ban@test.com") } returns Optional.of(bannedUser) every { userSocialAccountRepository.save(any()) } answers { firstArg() } @@ -239,10 +268,12 @@ class AuthUserUseCaseTest : every { appleAuthService.getAppleToken("apple-code") } returns tokenResponse every { appleAuthService.verifyAndDecodeIdToken("id-token") } returns userInfo - every { userSocialAccountRepository.findByProviderAndProviderUserId(any(), any()) } returns Optional.empty() + every { userSocialAccountRepository.findByProviderAndProviderUserId(any(), any()) } returns + Optional.empty() every { userRepository.findByEmailValue("apple@test.com") } returns Optional.of(user) every { userSocialAccountRepository.save(any()) } answers { firstArg() } - every { jwtManageUseCase.create(user.id, user.emailValue, user.role) } returns JwtDto("access", "refresh") + every { jwtManageUseCase.create(user.id, user.emailValue, user.role) } returns + JwtDto("access", "refresh") val result = useCase.socialLoginByApple(request) diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/CardinalUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/CardinalUseCaseTest.kt index 885f4261..56d28f48 100644 --- a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/CardinalUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/CardinalUseCaseTest.kt @@ -6,7 +6,7 @@ import com.weeth.domain.user.application.dto.response.CardinalResponse import com.weeth.domain.user.application.mapper.CardinalMapper import com.weeth.domain.user.application.usecase.query.GetCardinalQueryService import com.weeth.domain.user.domain.entity.Cardinal -import com.weeth.domain.user.domain.entity.enums.CardinalStatus +import com.weeth.domain.user.domain.enums.CardinalStatus import com.weeth.domain.user.domain.repository.CardinalRepository import com.weeth.domain.user.fixture.CardinalTestFixture import io.kotest.core.spec.style.DescribeSpec @@ -91,7 +91,8 @@ class CardinalUseCaseTest : val cardinals = listOf(cardinal1, cardinal2) val now = LocalDateTime.now() - val response1 = CardinalResponse(1L, 6, 2024, 2, CardinalStatus.DONE, now.minusDays(5), now.minusDays(3)) + val response1 = + CardinalResponse(1L, 6, 2024, 2, CardinalStatus.DONE, now.minusDays(5), now.minusDays(3)) val response2 = CardinalResponse(2L, 7, 2025, 1, CardinalStatus.IN_PROGRESS, now.minusDays(2), now) diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryServiceTest.kt index b73993c5..05fb2180 100644 --- a/src/test/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryServiceTest.kt @@ -46,7 +46,13 @@ class GetUserQueryServiceTest : describe("findUserDetails") { it("user와 cardinal 목록을 조회해 UserDetailsResponse로 매핑한다") { val user = UserTestFixture.createActiveUser1(1L) - val cardinal = CardinalTestFixture.createCardinal(id = 10L, cardinalNumber = 6, year = 2024, semester = 2) + val cardinal = + CardinalTestFixture.createCardinal( + id = 10L, + cardinalNumber = 6, + year = 2024, + semester = 2, + ) val userCardinals = listOf(UserCardinal(user, cardinal)) val response = UserDetailsResponse( @@ -70,7 +76,13 @@ class GetUserQueryServiceTest : describe("findMyProfile") { it("내 프로필을 UserProfileResponse로 매핑한다") { val user = UserTestFixture.createActiveUser1(2L) - val cardinal = CardinalTestFixture.createCardinal(id = 11L, cardinalNumber = 7, year = 2025, semester = 1) + val cardinal = + CardinalTestFixture.createCardinal( + id = 11L, + cardinalNumber = 7, + year = 2025, + semester = 1, + ) val userCardinals = listOf(UserCardinal(user, cardinal)) val response = UserProfileResponse( diff --git a/src/test/kotlin/com/weeth/domain/user/domain/entity/CardinalTest.kt b/src/test/kotlin/com/weeth/domain/user/domain/entity/CardinalTest.kt index 14edcf3d..38677016 100644 --- a/src/test/kotlin/com/weeth/domain/user/domain/entity/CardinalTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/domain/entity/CardinalTest.kt @@ -1,6 +1,6 @@ package com.weeth.domain.user.domain.entity -import com.weeth.domain.user.domain.entity.enums.CardinalStatus +import com.weeth.domain.user.domain.enums.CardinalStatus import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.shouldBe diff --git a/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt b/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt index 6d892f14..a1285ffc 100644 --- a/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt @@ -1,7 +1,7 @@ package com.weeth.domain.user.domain.entity -import com.weeth.domain.user.domain.entity.enums.Role -import com.weeth.domain.user.domain.entity.enums.Status +import com.weeth.domain.user.domain.enums.Role +import com.weeth.domain.user.domain.enums.Status import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.shouldBe diff --git a/src/test/kotlin/com/weeth/domain/user/domain/repository/UserCardinalRepositoryTest.kt b/src/test/kotlin/com/weeth/domain/user/domain/repository/UserCardinalRepositoryTest.kt index 7060659f..029420d8 100644 --- a/src/test/kotlin/com/weeth/domain/user/domain/repository/UserCardinalRepositoryTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/domain/repository/UserCardinalRepositoryTest.kt @@ -25,9 +25,18 @@ class UserCardinalRepositoryTest( val user = UserTestFixture.createActiveUser1() userRepository.save(user) - val cardinal1 = cardinalRepository.save(CardinalTestFixture.createCardinal(cardinalNumber = 5, year = 2023, semester = 1)) - val cardinal2 = cardinalRepository.save(CardinalTestFixture.createCardinal(cardinalNumber = 6, year = 2023, semester = 2)) - val cardinal3 = cardinalRepository.save(CardinalTestFixture.createCardinal(cardinalNumber = 7, year = 2024, semester = 1)) + val cardinal1 = + cardinalRepository.save( + CardinalTestFixture.createCardinal(cardinalNumber = 5, year = 2023, semester = 1), + ) + val cardinal2 = + cardinalRepository.save( + CardinalTestFixture.createCardinal(cardinalNumber = 6, year = 2023, semester = 2), + ) + val cardinal3 = + cardinalRepository.save( + CardinalTestFixture.createCardinal(cardinalNumber = 7, year = 2024, semester = 1), + ) userCardinalRepository.saveAll( listOf( @@ -53,10 +62,22 @@ class UserCardinalRepositoryTest( userRepository.save(user1) userRepository.save(user2) - val c1 = cardinalRepository.save(CardinalTestFixture.createCardinal(cardinalNumber = 5, year = 2023, semester = 1)) - val c2 = cardinalRepository.save(CardinalTestFixture.createCardinal(cardinalNumber = 6, year = 2023, semester = 2)) - val c3 = cardinalRepository.save(CardinalTestFixture.createCardinal(cardinalNumber = 7, year = 2024, semester = 1)) - val c4 = cardinalRepository.save(CardinalTestFixture.createCardinal(cardinalNumber = 8, year = 2024, semester = 2)) + val c1 = + cardinalRepository.save( + CardinalTestFixture.createCardinal(cardinalNumber = 5, year = 2023, semester = 1), + ) + val c2 = + cardinalRepository.save( + CardinalTestFixture.createCardinal(cardinalNumber = 6, year = 2023, semester = 2), + ) + val c3 = + cardinalRepository.save( + CardinalTestFixture.createCardinal(cardinalNumber = 7, year = 2024, semester = 1), + ) + val c4 = + cardinalRepository.save( + CardinalTestFixture.createCardinal(cardinalNumber = 8, year = 2024, semester = 2), + ) userCardinalRepository.saveAll( listOf( diff --git a/src/test/kotlin/com/weeth/domain/user/domain/repository/UserRepositoryTest.kt b/src/test/kotlin/com/weeth/domain/user/domain/repository/UserRepositoryTest.kt index 5b50656c..c3415a4f 100644 --- a/src/test/kotlin/com/weeth/domain/user/domain/repository/UserRepositoryTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/domain/repository/UserRepositoryTest.kt @@ -1,7 +1,7 @@ package com.weeth.domain.user.domain.repository import com.weeth.config.TestContainersConfig -import com.weeth.domain.user.domain.entity.enums.Status +import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.fixture.CardinalTestFixture import com.weeth.domain.user.fixture.UserCardinalTestFixture import com.weeth.domain.user.fixture.UserTestFixture @@ -26,8 +26,14 @@ class UserRepositoryTest( lateinit var cardinal8: com.weeth.domain.user.domain.entity.Cardinal beforeEach { - cardinal7 = cardinalRepository.save(CardinalTestFixture.createCardinal(cardinalNumber = 7, year = 2026, semester = 1)) - cardinal8 = cardinalRepository.save(CardinalTestFixture.createCardinal(cardinalNumber = 8, year = 2026, semester = 2)) + cardinal7 = + cardinalRepository.save( + CardinalTestFixture.createCardinal(cardinalNumber = 7, year = 2026, semester = 1), + ) + cardinal8 = + cardinalRepository.save( + CardinalTestFixture.createCardinal(cardinalNumber = 8, year = 2026, semester = 2), + ) val user1 = userRepository.save(UserTestFixture.createActiveUser1()) val user2 = userRepository.save(UserTestFixture.createActiveUser2()) diff --git a/src/test/kotlin/com/weeth/domain/user/domain/service/UserCardinalPolicyTest.kt b/src/test/kotlin/com/weeth/domain/user/domain/service/UserCardinalPolicyTest.kt index 3b606765..9a50e3d1 100644 --- a/src/test/kotlin/com/weeth/domain/user/domain/service/UserCardinalPolicyTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/domain/service/UserCardinalPolicyTest.kt @@ -21,7 +21,13 @@ class UserCardinalPolicyTest : describe("getCurrentCardinal") { it("가장 큰 기수 번호를 반환한다") { val user = UserTestFixture.createActiveUser1(1L) - val cardinal5 = CardinalTestFixture.createCardinal(id = 2L, cardinalNumber = 5, year = 2025, semester = 1) + val cardinal5 = + CardinalTestFixture.createCardinal( + id = 2L, + cardinalNumber = 5, + year = 2025, + semester = 1, + ) every { userCardinalReader.findTopByUserOrderByCardinalNumberDesc(user) } returns UserCardinalTestFixture.linkUserCardinal(user, cardinal5) @@ -42,8 +48,15 @@ class UserCardinalPolicyTest : describe("notContains") { it("이미 포함된 기수면 false를 반환한다") { val user = UserTestFixture.createActiveUser1(1L) - val cardinal = CardinalTestFixture.createCardinal(id = 2L, cardinalNumber = 5, year = 2025, semester = 1) - every { userCardinalReader.findAllByUser(user) } returns listOf(UserCardinalTestFixture.linkUserCardinal(user, cardinal)) + val cardinal = + CardinalTestFixture.createCardinal( + id = 2L, + cardinalNumber = 5, + year = 2025, + semester = 1, + ) + every { userCardinalReader.findAllByUser(user) } returns + listOf(UserCardinalTestFixture.linkUserCardinal(user, cardinal)) policy.notContains(user, cardinal).shouldBeFalse() } diff --git a/src/test/kotlin/com/weeth/domain/user/fixture/CardinalTestFixture.kt b/src/test/kotlin/com/weeth/domain/user/fixture/CardinalTestFixture.kt index fd715201..72af67df 100644 --- a/src/test/kotlin/com/weeth/domain/user/fixture/CardinalTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/user/fixture/CardinalTestFixture.kt @@ -1,7 +1,7 @@ package com.weeth.domain.user.fixture import com.weeth.domain.user.domain.entity.Cardinal -import com.weeth.domain.user.domain.entity.enums.CardinalStatus +import com.weeth.domain.user.domain.enums.CardinalStatus object CardinalTestFixture { fun createCardinal( diff --git a/src/test/kotlin/com/weeth/domain/user/fixture/UserTestFixture.kt b/src/test/kotlin/com/weeth/domain/user/fixture/UserTestFixture.kt index f77be4a2..c8b13073 100644 --- a/src/test/kotlin/com/weeth/domain/user/fixture/UserTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/user/fixture/UserTestFixture.kt @@ -1,8 +1,8 @@ package com.weeth.domain.user.fixture import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.entity.enums.Role -import com.weeth.domain.user.domain.entity.enums.Status +import com.weeth.domain.user.domain.enums.Role +import com.weeth.domain.user.domain.enums.Status object UserTestFixture { fun createActiveUser1(id: Long? = null): User = diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractorTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractorTest.kt index 3a54cad5..daf1e00d 100644 --- a/src/test/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractorTest.kt +++ b/src/test/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractorTest.kt @@ -1,6 +1,6 @@ package com.weeth.global.auth.jwt.application.service -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.enums.Role import com.weeth.global.auth.jwt.application.exception.TokenNotFoundException import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider import com.weeth.global.config.properties.JwtProperties diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCaseTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCaseTest.kt index e7ec96a6..dfbd54ff 100644 --- a/src/test/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCaseTest.kt @@ -1,6 +1,6 @@ package com.weeth.global.auth.jwt.application.usecase -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.enums.Role import com.weeth.global.auth.jwt.application.dto.JwtDto import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor import com.weeth.global.auth.jwt.domain.port.RefreshTokenStorePort diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProviderTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProviderTest.kt index e77028fa..87885924 100644 --- a/src/test/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProviderTest.kt +++ b/src/test/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProviderTest.kt @@ -1,6 +1,6 @@ package com.weeth.global.auth.jwt.domain.service -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.enums.Role import com.weeth.global.auth.jwt.application.exception.InvalidTokenException import com.weeth.global.config.properties.JwtProperties import io.kotest.assertions.throwables.shouldThrow diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt index ecb9507d..e1062c09 100644 --- a/src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt +++ b/src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt @@ -1,6 +1,6 @@ package com.weeth.global.auth.jwt.filter -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.enums.Role import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider import com.weeth.global.auth.model.AuthenticatedUser @@ -40,7 +40,8 @@ class JwtAuthenticationProcessingFilterTest : every { jwtService.extractAccessToken(request) } returns "access-token" every { jwtProvider.validate("access-token") } just runs - every { jwtService.extractClaims("access-token") } returns JwtTokenExtractor.TokenClaims(1L, "admin@weeth.com", Role.ADMIN) + every { jwtService.extractClaims("access-token") } returns + JwtTokenExtractor.TokenClaims(1L, "admin@weeth.com", Role.ADMIN) filter.doFilter(request, response, chain) diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/infrastructure/store/RedisRefreshTokenStoreAdapterTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/infrastructure/store/RedisRefreshTokenStoreAdapterTest.kt index 1ba5944b..6222e907 100644 --- a/src/test/kotlin/com/weeth/global/auth/jwt/infrastructure/store/RedisRefreshTokenStoreAdapterTest.kt +++ b/src/test/kotlin/com/weeth/global/auth/jwt/infrastructure/store/RedisRefreshTokenStoreAdapterTest.kt @@ -1,7 +1,7 @@ package com.weeth.global.auth.jwt.infrastructure.store import com.weeth.config.TestContainersConfig -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.enums.Role import com.weeth.global.auth.jwt.application.exception.InvalidTokenException import com.weeth.global.auth.jwt.application.exception.RedisTokenNotFoundException import com.weeth.global.auth.jwt.infrastructure.RedisRefreshTokenStoreAdapter diff --git a/src/test/kotlin/com/weeth/global/auth/resolver/CurrentUserArgumentResolverTest.kt b/src/test/kotlin/com/weeth/global/auth/resolver/CurrentUserArgumentResolverTest.kt index c94c74bf..41668d2b 100644 --- a/src/test/kotlin/com/weeth/global/auth/resolver/CurrentUserArgumentResolverTest.kt +++ b/src/test/kotlin/com/weeth/global/auth/resolver/CurrentUserArgumentResolverTest.kt @@ -1,6 +1,6 @@ package com.weeth.global.auth.resolver -import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.enums.Role import com.weeth.global.auth.annotation.CurrentUser import com.weeth.global.auth.jwt.application.exception.AnonymousAuthenticationException import com.weeth.global.auth.model.AuthenticatedUser From bfaeb0d42bac709c915a52e5069916368ba450f1 Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:02:16 +0900 Subject: [PATCH 15/73] =?UTF-8?q?[WTH-160]=20user=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20(#18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: cardinal 도메인 분리 * refactor: cardinal 도메인 분리 * refactor: 유저 도메인 dto 개선 * refactor: 유저 관련 엔티티 개선 * refactor: 유스케이스 분리 * refactor: 소셜 로그인 개선 * refactor: 유스케이스 분리 * refactor: 테스트 변경사항 반영 * refactor: 경고 기능 제거 * feat: UserCardinal 쿼리 추가 * refactor: api 엔드포인트 제거 및 응답 코드 수정 * docs: CLAUDE.md 예외 코드 범위 현행화 * test: email 형식에 맞게 fixture 수정 * refactor: 트랜잭션 제거 * refactor: id private set 설정 --- CLAUDE.md | 23 +- .../usecase/command/ManageAccountUseCase.kt | 11 +- .../usecase/command/ManageReceiptUseCase.kt | 16 +- .../dto/request/CardinalSaveRequest.kt | 6 +- .../dto/request/CardinalUpdateRequest.kt | 6 +- .../dto/response/CardinalResponse.kt | 6 +- .../exception/CardinalErrorCode.kt | 17 ++ .../exception/CardinalNotFoundException.kt | 5 + .../exception/DuplicateCardinalException.kt | 5 + .../application/mapper/CardinalMapper.kt | 10 +- .../usecase/command/ManageCardinalUseCase.kt | 40 +++ .../usecase/query/GetCardinalQueryService.kt | 8 +- .../domain/entity/Cardinal.kt | 47 +++- .../domain/enums/CardinalStatus.kt | 2 +- .../domain/repository/CardinalReader.kt | 4 +- .../domain/repository/CardinalRepository.kt | 18 +- .../domain/service/CardinalStatusPolicy.kt | 16 ++ .../presentation/CardinalAdminController.kt | 43 +++ .../presentation/CardinalController.kt | 26 ++ .../presentation/CardinalResponseCode.kt | 14 + .../dto/response/PenaltyResponse.kt | 2 - .../application/mapper/PenaltyMapper.kt | 3 +- .../usecase/command/DeletePenaltyUseCase.kt | 20 -- .../usecase/command/SavePenaltyUseCase.kt | 18 +- .../usecase/query/GetPenaltyQueryService.kt | 2 +- .../domain/penalty/domain/entity/Penalty.kt | 2 +- .../usecase/command/ManageEventUseCase.kt | 2 +- .../usecase/query/GetScheduleQueryService.kt | 2 +- .../usecase/command/ManageSessionUseCase.kt | 2 +- .../application/dto/request/SignUpRequest.kt | 28 -- .../dto/request/SocialLoginRequest.kt | 8 - .../dto/request/UpdateUserProfileRequest.kt | 3 +- .../dto/request/UserApplyObRequest.kt | 2 - .../application/dto/request/UserIdsRequest.kt | 1 - .../dto/request/UserRoleUpdateRequest.kt | 2 - .../dto/response/AdminUserResponse.kt | 2 - .../dto/response/SocialLoginResponse.kt | 4 - .../dto/response/UserInfoResponse.kt | 15 -- .../exception/CardinalNotFoundException.kt | 5 - .../exception/DuplicateCardinalException.kt | 5 - .../application/exception/UserErrorCode.kt | 16 +- .../user/application/mapper/UserMapper.kt | 31 +-- .../usecase/command/AdminUserUseCase.kt | 97 ++++--- .../usecase/command/AuthUserUseCase.kt | 213 --------------- .../usecase/command/ManageCardinalUseCase.kt | 46 ---- .../usecase/command/SocialLoginUseCase.kt | 81 ++++++ .../command/UpdateUserProfileUseCase.kt | 43 +++ .../usecase/query/GetUserQueryService.kt | 29 +- .../weeth/domain/user/domain/entity/User.kt | 139 +++++----- .../domain/user/domain/entity/UserCardinal.kt | 46 ++-- .../user/domain/entity/UserSocialAccount.kt | 11 +- .../domain/user/domain/port/SocialAuthPort.kt | 10 + .../repository/UserCardinalRepository.kt | 19 +- .../user/domain/repository/UserReader.kt | 2 +- .../user/domain/repository/UserRepository.kt | 2 +- .../user/domain/service/UserCardinalPolicy.kt | 4 +- .../domain/user/domain/vo/AttendanceStats.kt | 17 +- .../domain/user/domain/vo/SocialAuthResult.kt | 12 + .../infrastructure/AppleSocialAuthAdapter.kt | 30 +++ .../infrastructure/KakaoSocialAuthAdapter.kt | 41 +++ .../infrastructure/SocialAuthPortRegistry.kt | 15 ++ .../user/presentation/CardinalController.kt | 52 ---- .../user/presentation/UserController.kt | 25 +- .../user/presentation/UserResponseCode.kt | 16 +- .../global/auth/kakao/dto/KakaoProfile.kt | 2 + .../com/weeth/global/config/SecurityConfig.kt | 2 - .../command/ManageAccountUseCaseTest.kt | 17 +- .../command/ManageReceiptUseCaseTest.kt | 13 +- .../query/GetAttendanceQueryServiceTest.kt | 2 +- .../repository/AttendanceRepositoryTest.kt | 14 +- .../fixture/AttendanceTestFixture.kt | 20 +- .../usecase/command/ManagePostUseCaseTest.kt | 3 +- .../usecase/command/CardinalUseCaseTest.kt | 32 +-- .../domain/entity/CardinalTest.kt | 4 +- .../repository/CardinalRepositoryTest.kt | 4 +- .../fixture/CardinalTestFixture.kt | 6 +- .../usecase/command/CommentConcurrencyTest.kt | 3 +- .../query/CommentQueryPerformanceTest.kt | 3 +- .../usecase/command/AdminUserUseCaseTest.kt | 173 ++++++------ .../usecase/command/AuthUserUseCaseTest.kt | 248 +----------------- .../usecase/command/SocialLoginUseCaseTest.kt | 106 ++++++++ .../usecase/query/GetUserQueryServiceTest.kt | 50 +++- .../domain/user/domain/entity/UserTest.kt | 85 +++++- .../repository/UserCardinalRepositoryTest.kt | 17 +- .../domain/repository/UserRepositoryTest.kt | 7 +- .../domain/service/UserCardinalPolicyTest.kt | 4 +- .../domain/user/fixture/SessionTestFixture.kt | 21 ++ .../user/fixture/UserCardinalTestFixture.kt | 4 +- .../domain/user/fixture/UserTestFixture.kt | 11 +- 89 files changed, 1143 insertions(+), 1156 deletions(-) rename src/main/kotlin/com/weeth/domain/{user => cardinal}/application/dto/request/CardinalSaveRequest.kt (78%) rename src/main/kotlin/com/weeth/domain/{user => cardinal}/application/dto/request/CardinalUpdateRequest.kt (78%) rename src/main/kotlin/com/weeth/domain/{user => cardinal}/application/dto/response/CardinalResponse.kt (77%) create mode 100644 src/main/kotlin/com/weeth/domain/cardinal/application/exception/CardinalErrorCode.kt create mode 100644 src/main/kotlin/com/weeth/domain/cardinal/application/exception/CardinalNotFoundException.kt create mode 100644 src/main/kotlin/com/weeth/domain/cardinal/application/exception/DuplicateCardinalException.kt rename src/main/kotlin/com/weeth/domain/{user => cardinal}/application/mapper/CardinalMapper.kt (67%) create mode 100644 src/main/kotlin/com/weeth/domain/cardinal/application/usecase/command/ManageCardinalUseCase.kt rename src/main/kotlin/com/weeth/domain/{user => cardinal}/application/usecase/query/GetCardinalQueryService.kt (61%) rename src/main/kotlin/com/weeth/domain/{user => cardinal}/domain/entity/Cardinal.kt (52%) rename src/main/kotlin/com/weeth/domain/{user => cardinal}/domain/enums/CardinalStatus.kt (55%) rename src/main/kotlin/com/weeth/domain/{user => cardinal}/domain/repository/CardinalReader.kt (72%) rename src/main/kotlin/com/weeth/domain/{user => cardinal}/domain/repository/CardinalRepository.kt (61%) create mode 100644 src/main/kotlin/com/weeth/domain/cardinal/domain/service/CardinalStatusPolicy.kt create mode 100644 src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalAdminController.kt create mode 100644 src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalController.kt create mode 100644 src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalResponseCode.kt delete mode 100644 src/main/kotlin/com/weeth/domain/user/application/dto/request/SignUpRequest.kt delete mode 100644 src/main/kotlin/com/weeth/domain/user/application/dto/response/UserInfoResponse.kt delete mode 100644 src/main/kotlin/com/weeth/domain/user/application/exception/CardinalNotFoundException.kt delete mode 100644 src/main/kotlin/com/weeth/domain/user/application/exception/DuplicateCardinalException.kt delete mode 100644 src/main/kotlin/com/weeth/domain/user/application/usecase/command/ManageCardinalUseCase.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCase.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/usecase/command/UpdateUserProfileUseCase.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/domain/port/SocialAuthPort.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/domain/vo/SocialAuthResult.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/infrastructure/AppleSocialAuthAdapter.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/infrastructure/KakaoSocialAuthAdapter.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/infrastructure/SocialAuthPortRegistry.kt delete mode 100644 src/main/kotlin/com/weeth/domain/user/presentation/CardinalController.kt rename src/test/kotlin/com/weeth/domain/{user => cardinal}/application/usecase/command/CardinalUseCaseTest.kt (81%) rename src/test/kotlin/com/weeth/domain/{user => cardinal}/domain/entity/CardinalTest.kt (86%) rename src/test/kotlin/com/weeth/domain/{user => cardinal}/domain/repository/CardinalRepositoryTest.kt (89%) rename src/test/kotlin/com/weeth/domain/{user => cardinal}/fixture/CardinalTestFixture.kt (82%) create mode 100644 src/test/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCaseTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/user/fixture/SessionTestFixture.kt diff --git a/CLAUDE.md b/CLAUDE.md index 4aa4c418..86300bc4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,17 +68,18 @@ All API responses wrapped in `CommonResponse` with code/message/data. Success ### Error Code Ranges -| Domain | Success | Error | -|--------|---------|-------| -| Account | 11xx | 21xx | -| Attendance | 12xx | 22xx | -| Board | 13xx | 23xx | -| Comment | 140xx | 240x | -| File | 15xx | 25xx | -| Penalty | 160xx | 260x | -| Schedule | 17xx | 27xx | -| User | 18xx | 28xx | -| JWT (Global) | — | 29xx | +| Domain | Success | Error | +|--------------|---------|-------| +| Account | 11xx | 21xx | +| Attendance | 12xx | 22xx | +| Board | 13xx | 23xx | +| Comment | 140xx | 240x | +| File | 15xx | 25xx | +| Penalty | 160xx | 260x | +| Schedule | 17xx | 27xx | +| User | 18xx | 28xx | +| Cardinal | 185x | 285x | +| JWT (Global) | — | 29xx | ### Authentication JWT with symmetric key (JJWT 0.13.0), OAuth2 via Kakao and Apple. `@CurrentUser` annotation injects authenticated user ID into controller methods. diff --git a/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCase.kt b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCase.kt index bb627dbb..5e15390d 100644 --- a/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCase.kt @@ -4,24 +4,19 @@ import com.weeth.domain.account.application.dto.request.AccountSaveRequest import com.weeth.domain.account.application.exception.AccountExistsException import com.weeth.domain.account.domain.entity.Account import com.weeth.domain.account.domain.repository.AccountRepository -import com.weeth.domain.user.domain.entity.Cardinal -import com.weeth.domain.user.domain.repository.CardinalRepository +import com.weeth.domain.cardinal.domain.repository.CardinalReader import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @Service class ManageAccountUseCase( private val accountRepository: AccountRepository, - private val cardinalRepository: CardinalRepository, + private val cardinalReader: CardinalReader, ) { @Transactional fun save(request: AccountSaveRequest) { if (accountRepository.existsByCardinal(request.cardinal)) throw AccountExistsException() - - // 기수가 없는 경우 생성 - cardinalRepository.findByCardinalNumber(request.cardinal).orElseGet { - cardinalRepository.save(Cardinal.create(cardinalNumber = request.cardinal)) - } + cardinalReader.getByCardinalNumber(request.cardinal) accountRepository.save(Account.create(request.description, request.totalAmount, request.cardinal)) } } diff --git a/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCase.kt b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCase.kt index 7f9ef5fa..9c99aa72 100644 --- a/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCase.kt @@ -9,12 +9,11 @@ import com.weeth.domain.account.domain.entity.Receipt import com.weeth.domain.account.domain.repository.AccountRepository import com.weeth.domain.account.domain.repository.ReceiptRepository import com.weeth.domain.account.domain.vo.Money +import com.weeth.domain.cardinal.domain.repository.CardinalReader import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.repository.FileReader import com.weeth.domain.file.domain.repository.FileRepository -import com.weeth.domain.user.domain.entity.Cardinal -import com.weeth.domain.user.domain.repository.CardinalRepository import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -28,19 +27,12 @@ class ManageReceiptUseCase( private val accountRepository: AccountRepository, private val fileReader: FileReader, private val fileRepository: FileRepository, - private val cardinalRepository: CardinalRepository, + private val cardinalReader: CardinalReader, private val fileMapper: FileMapper, ) { - // 기수가 없는 경우 생성 - private fun ensureCardinalExists(cardinalNumber: Int) { - cardinalRepository.findByCardinalNumber(cardinalNumber).orElseGet { - cardinalRepository.save(Cardinal.create(cardinalNumber = cardinalNumber)) - } - } - @Transactional fun save(request: ReceiptSaveRequest) { - ensureCardinalExists(request.cardinal) + cardinalReader.getByCardinalNumber(request.cardinal) val account = accountRepository.findByCardinal(request.cardinal) ?: throw AccountNotFoundException() val receipt = receiptRepository.save( @@ -55,7 +47,7 @@ class ManageReceiptUseCase( receiptId: Long, request: ReceiptUpdateRequest, ) { - ensureCardinalExists(request.cardinal) + cardinalReader.getByCardinalNumber(request.cardinal) val account = accountRepository.findByCardinal(request.cardinal) ?: throw AccountNotFoundException() val receipt = receiptRepository.findByIdOrNull(receiptId) ?: throw ReceiptNotFoundException() if (receipt.account.id != account.id) throw ReceiptAccountMismatchException() diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/request/CardinalSaveRequest.kt b/src/main/kotlin/com/weeth/domain/cardinal/application/dto/request/CardinalSaveRequest.kt similarity index 78% rename from src/main/kotlin/com/weeth/domain/user/application/dto/request/CardinalSaveRequest.kt rename to src/main/kotlin/com/weeth/domain/cardinal/application/dto/request/CardinalSaveRequest.kt index 2ebe2154..ae424b0a 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/dto/request/CardinalSaveRequest.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/application/dto/request/CardinalSaveRequest.kt @@ -1,19 +1,15 @@ -package com.weeth.domain.user.application.dto.request +package com.weeth.domain.cardinal.application.dto.request import io.swagger.v3.oas.annotations.media.Schema import jakarta.validation.constraints.NotNull data class CardinalSaveRequest( - @field:NotNull @field:Schema(description = "기수", example = "4") val cardinalNumber: Int, - @field:NotNull @field:Schema(description = "년도", example = "2024") val year: Int, - @field:NotNull @field:Schema(description = "학기", example = "2") val semester: Int, - @field:NotNull @field:Schema(description = "현재 진행중 여부", example = "false") val inProgress: Boolean, ) diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/request/CardinalUpdateRequest.kt b/src/main/kotlin/com/weeth/domain/cardinal/application/dto/request/CardinalUpdateRequest.kt similarity index 78% rename from src/main/kotlin/com/weeth/domain/user/application/dto/request/CardinalUpdateRequest.kt rename to src/main/kotlin/com/weeth/domain/cardinal/application/dto/request/CardinalUpdateRequest.kt index 2e3eda0a..37cc8f19 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/dto/request/CardinalUpdateRequest.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/application/dto/request/CardinalUpdateRequest.kt @@ -1,19 +1,15 @@ -package com.weeth.domain.user.application.dto.request +package com.weeth.domain.cardinal.application.dto.request import io.swagger.v3.oas.annotations.media.Schema import jakarta.validation.constraints.NotNull data class CardinalUpdateRequest( - @field:NotNull @field:Schema(description = "기수 ID", example = "1") val id: Long, - @field:NotNull @field:Schema(description = "년도", example = "2024") val year: Int, - @field:NotNull @field:Schema(description = "학기", example = "2") val semester: Int, - @field:NotNull @field:Schema(description = "현재 진행중 여부", example = "false") val inProgress: Boolean, ) diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/response/CardinalResponse.kt b/src/main/kotlin/com/weeth/domain/cardinal/application/dto/response/CardinalResponse.kt similarity index 77% rename from src/main/kotlin/com/weeth/domain/user/application/dto/response/CardinalResponse.kt rename to src/main/kotlin/com/weeth/domain/cardinal/application/dto/response/CardinalResponse.kt index 1b436053..68f1ced0 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/dto/response/CardinalResponse.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/application/dto/response/CardinalResponse.kt @@ -1,6 +1,6 @@ -package com.weeth.domain.user.application.dto.response +package com.weeth.domain.cardinal.application.dto.response -import com.weeth.domain.user.domain.enums.CardinalStatus +import com.weeth.domain.cardinal.domain.enums.CardinalStatus import io.swagger.v3.oas.annotations.media.Schema import java.time.LocalDateTime @@ -13,7 +13,7 @@ data class CardinalResponse( val year: Int?, @field:Schema(description = "학기", example = "1", nullable = true) val semester: Int?, - @field:Schema(description = "기수 상태", example = "CURRENT") + @field:Schema(description = "기수 상태", example = "IN_PROGRESS") val status: CardinalStatus, @field:Schema(description = "생성 시각") val createdAt: LocalDateTime?, diff --git a/src/main/kotlin/com/weeth/domain/cardinal/application/exception/CardinalErrorCode.kt b/src/main/kotlin/com/weeth/domain/cardinal/application/exception/CardinalErrorCode.kt new file mode 100644 index 00000000..7216cedc --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/cardinal/application/exception/CardinalErrorCode.kt @@ -0,0 +1,17 @@ +package com.weeth.domain.cardinal.application.exception + +import com.weeth.global.common.exception.ErrorCodeInterface +import com.weeth.global.common.exception.ExplainError +import org.springframework.http.HttpStatus + +enum class CardinalErrorCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ErrorCodeInterface { + @ExplainError("존재하지 않는 기수 ID 또는 번호로 조회했을 때 발생합니다.") + CARDINAL_NOT_FOUND(2850, HttpStatus.NOT_FOUND, "기수를 찾을 수 없습니다."), + + @ExplainError("이미 존재하는 기수를 생성하려고 할 때 발생합니다.") + DUPLICATE_CARDINAL(2851, HttpStatus.BAD_REQUEST, "이미 존재하는 기수입니다."), +} diff --git a/src/main/kotlin/com/weeth/domain/cardinal/application/exception/CardinalNotFoundException.kt b/src/main/kotlin/com/weeth/domain/cardinal/application/exception/CardinalNotFoundException.kt new file mode 100644 index 00000000..40a77f96 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/cardinal/application/exception/CardinalNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.cardinal.application.exception + +import com.weeth.global.common.exception.BaseException + +class CardinalNotFoundException : BaseException(CardinalErrorCode.CARDINAL_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/cardinal/application/exception/DuplicateCardinalException.kt b/src/main/kotlin/com/weeth/domain/cardinal/application/exception/DuplicateCardinalException.kt new file mode 100644 index 00000000..8d8a8f8a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/cardinal/application/exception/DuplicateCardinalException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.cardinal.application.exception + +import com.weeth.global.common.exception.BaseException + +class DuplicateCardinalException : BaseException(CardinalErrorCode.DUPLICATE_CARDINAL) diff --git a/src/main/kotlin/com/weeth/domain/user/application/mapper/CardinalMapper.kt b/src/main/kotlin/com/weeth/domain/cardinal/application/mapper/CardinalMapper.kt similarity index 67% rename from src/main/kotlin/com/weeth/domain/user/application/mapper/CardinalMapper.kt rename to src/main/kotlin/com/weeth/domain/cardinal/application/mapper/CardinalMapper.kt index a0fb3b74..536915e7 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/mapper/CardinalMapper.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/application/mapper/CardinalMapper.kt @@ -1,14 +1,14 @@ -package com.weeth.domain.user.application.mapper +package com.weeth.domain.cardinal.application.mapper -import com.weeth.domain.user.application.dto.request.CardinalSaveRequest -import com.weeth.domain.user.application.dto.response.CardinalResponse -import com.weeth.domain.user.domain.entity.Cardinal +import com.weeth.domain.cardinal.application.dto.request.CardinalSaveRequest +import com.weeth.domain.cardinal.application.dto.response.CardinalResponse +import com.weeth.domain.cardinal.domain.entity.Cardinal import org.springframework.stereotype.Component @Component class CardinalMapper { fun toEntity(request: CardinalSaveRequest): Cardinal = - Cardinal( + Cardinal.create( cardinalNumber = request.cardinalNumber, year = request.year, semester = request.semester, diff --git a/src/main/kotlin/com/weeth/domain/cardinal/application/usecase/command/ManageCardinalUseCase.kt b/src/main/kotlin/com/weeth/domain/cardinal/application/usecase/command/ManageCardinalUseCase.kt new file mode 100644 index 00000000..724aea07 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/cardinal/application/usecase/command/ManageCardinalUseCase.kt @@ -0,0 +1,40 @@ +package com.weeth.domain.cardinal.application.usecase.command + +import com.weeth.domain.cardinal.application.dto.request.CardinalSaveRequest +import com.weeth.domain.cardinal.application.dto.request.CardinalUpdateRequest +import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException +import com.weeth.domain.cardinal.application.exception.DuplicateCardinalException +import com.weeth.domain.cardinal.application.mapper.CardinalMapper +import com.weeth.domain.cardinal.domain.repository.CardinalRepository +import com.weeth.domain.cardinal.domain.service.CardinalStatusPolicy +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class ManageCardinalUseCase( + private val cardinalRepository: CardinalRepository, + private val cardinalMapper: CardinalMapper, + private val cardinalStatusPolicy: CardinalStatusPolicy, +) { + @Transactional + fun save(request: CardinalSaveRequest) { + if (cardinalRepository.findByCardinalNumber(request.cardinalNumber).isPresent) { + throw DuplicateCardinalException() + } + + val cardinal = cardinalRepository.save(cardinalMapper.toEntity(request)) + if (request.inProgress) { + cardinalStatusPolicy.activateExclusively(cardinal) + } + } + + @Transactional + fun update(request: CardinalUpdateRequest) { + val cardinal = cardinalRepository.findById(request.id).orElseThrow { CardinalNotFoundException() } + cardinal.update(request.year, request.semester) + + if (request.inProgress) { + cardinalStatusPolicy.activateExclusively(cardinal) + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/query/GetCardinalQueryService.kt b/src/main/kotlin/com/weeth/domain/cardinal/application/usecase/query/GetCardinalQueryService.kt similarity index 61% rename from src/main/kotlin/com/weeth/domain/user/application/usecase/query/GetCardinalQueryService.kt rename to src/main/kotlin/com/weeth/domain/cardinal/application/usecase/query/GetCardinalQueryService.kt index 0010fa3a..4c5dcacd 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/usecase/query/GetCardinalQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/application/usecase/query/GetCardinalQueryService.kt @@ -1,8 +1,8 @@ -package com.weeth.domain.user.application.usecase.query +package com.weeth.domain.cardinal.application.usecase.query -import com.weeth.domain.user.application.dto.response.CardinalResponse -import com.weeth.domain.user.application.mapper.CardinalMapper -import com.weeth.domain.user.domain.repository.CardinalRepository +import com.weeth.domain.cardinal.application.dto.response.CardinalResponse +import com.weeth.domain.cardinal.application.mapper.CardinalMapper +import com.weeth.domain.cardinal.domain.repository.CardinalRepository import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional diff --git a/src/main/kotlin/com/weeth/domain/user/domain/entity/Cardinal.kt b/src/main/kotlin/com/weeth/domain/cardinal/domain/entity/Cardinal.kt similarity index 52% rename from src/main/kotlin/com/weeth/domain/user/domain/entity/Cardinal.kt rename to src/main/kotlin/com/weeth/domain/cardinal/domain/entity/Cardinal.kt index 946540c6..0b4dec4f 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/entity/Cardinal.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/domain/entity/Cardinal.kt @@ -1,6 +1,6 @@ -package com.weeth.domain.user.domain.entity +package com.weeth.domain.cardinal.domain.entity -import com.weeth.domain.user.domain.enums.CardinalStatus +import com.weeth.domain.cardinal.domain.enums.CardinalStatus import com.weeth.global.common.entity.BaseEntity import jakarta.persistence.Column import jakarta.persistence.Entity @@ -12,21 +12,34 @@ import jakarta.persistence.Id @Entity class Cardinal( + id: Long = 0L, + @Column(unique = true, nullable = false) + val cardinalNumber: Int, + year: Int? = null, + semester: Int? = null, + status: CardinalStatus = CardinalStatus.DONE, +) : BaseEntity() { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "cardinal_id") - val id: Long = 0L, - @Column(unique = true, nullable = false) - val cardinalNumber: Int, - var year: Int? = null, - var semester: Int? = null, + var id: Long = id + private set + + var year: Int? = year + private set + + var semester: Int? = semester + private set + @Enumerated(EnumType.STRING) - var status: CardinalStatus = CardinalStatus.DONE, -) : BaseEntity() { + var status: CardinalStatus = status + private set + fun update( year: Int, semester: Int, ) { + validatePeriod(year, semester) this.year = year this.semester = semester } @@ -45,12 +58,24 @@ class Cardinal( year: Int? = null, semester: Int? = null, status: CardinalStatus = CardinalStatus.DONE, - ): Cardinal = - Cardinal( + ): Cardinal { + require(cardinalNumber > 0) { "기수 번호는 0보다 커야 합니다." } + year?.let { require(it > 0) { "연도는 0보다 커야 합니다." } } + semester?.let { require(it in 1..2) { "학기는 1 또는 2여야 합니다." } } + return Cardinal( cardinalNumber = cardinalNumber, year = year, semester = semester, status = status, ) + } + + private fun validatePeriod( + year: Int, + semester: Int, + ) { + require(year > 0) { "연도는 0보다 커야 합니다." } + require(semester in 1..2) { "학기는 1 또는 2여야 합니다." } + } } } diff --git a/src/main/kotlin/com/weeth/domain/user/domain/enums/CardinalStatus.kt b/src/main/kotlin/com/weeth/domain/cardinal/domain/enums/CardinalStatus.kt similarity index 55% rename from src/main/kotlin/com/weeth/domain/user/domain/enums/CardinalStatus.kt rename to src/main/kotlin/com/weeth/domain/cardinal/domain/enums/CardinalStatus.kt index 0d04dd07..acbea0f7 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/enums/CardinalStatus.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/domain/enums/CardinalStatus.kt @@ -1,4 +1,4 @@ -package com.weeth.domain.user.domain.enums +package com.weeth.domain.cardinal.domain.enums enum class CardinalStatus { IN_PROGRESS, diff --git a/src/main/kotlin/com/weeth/domain/user/domain/repository/CardinalReader.kt b/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalReader.kt similarity index 72% rename from src/main/kotlin/com/weeth/domain/user/domain/repository/CardinalReader.kt rename to src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalReader.kt index 9b2a50cb..bb707701 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/repository/CardinalReader.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalReader.kt @@ -1,6 +1,6 @@ -package com.weeth.domain.user.domain.repository +package com.weeth.domain.cardinal.domain.repository -import com.weeth.domain.user.domain.entity.Cardinal +import com.weeth.domain.cardinal.domain.entity.Cardinal interface CardinalReader { fun getByCardinalNumber(cardinalNumber: Int): Cardinal diff --git a/src/main/kotlin/com/weeth/domain/user/domain/repository/CardinalRepository.kt b/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalRepository.kt similarity index 61% rename from src/main/kotlin/com/weeth/domain/user/domain/repository/CardinalRepository.kt rename to src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalRepository.kt index 8f659ba1..9f20103d 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/repository/CardinalRepository.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalRepository.kt @@ -1,9 +1,14 @@ -package com.weeth.domain.user.domain.repository +package com.weeth.domain.cardinal.domain.repository -import com.weeth.domain.user.application.exception.CardinalNotFoundException -import com.weeth.domain.user.domain.entity.Cardinal -import com.weeth.domain.user.domain.enums.CardinalStatus +import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException +import com.weeth.domain.cardinal.domain.entity.Cardinal +import com.weeth.domain.cardinal.domain.enums.CardinalStatus +import jakarta.persistence.LockModeType +import jakarta.persistence.QueryHint import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Lock +import org.springframework.data.jpa.repository.Query +import org.springframework.data.jpa.repository.QueryHints import java.util.Optional interface CardinalRepository : @@ -20,6 +25,11 @@ interface CardinalRepository : fun findAllByStatus(cardinalStatus: CardinalStatus): List + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query("SELECT c FROM Cardinal c WHERE c.status = 'IN_PROGRESS'") + fun findAllInProgressWithLock(): List + fun findAllByOrderByCardinalNumberAsc(): List fun findAllByOrderByCardinalNumberDesc(): List diff --git a/src/main/kotlin/com/weeth/domain/cardinal/domain/service/CardinalStatusPolicy.kt b/src/main/kotlin/com/weeth/domain/cardinal/domain/service/CardinalStatusPolicy.kt new file mode 100644 index 00000000..1d25d375 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/cardinal/domain/service/CardinalStatusPolicy.kt @@ -0,0 +1,16 @@ +package com.weeth.domain.cardinal.domain.service + +import com.weeth.domain.cardinal.domain.entity.Cardinal +import com.weeth.domain.cardinal.domain.repository.CardinalRepository +import org.springframework.stereotype.Service + +@Service +class CardinalStatusPolicy( + private val cardinalRepository: CardinalRepository, +) { + fun activateExclusively(cardinal: Cardinal) { + val inProgressCardinals = cardinalRepository.findAllInProgressWithLock() + inProgressCardinals.forEach(Cardinal::done) + cardinal.inProgress() + } +} diff --git a/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalAdminController.kt b/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalAdminController.kt new file mode 100644 index 00000000..831f395d --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalAdminController.kt @@ -0,0 +1,43 @@ +package com.weeth.domain.cardinal.presentation + +import com.weeth.domain.cardinal.application.dto.request.CardinalSaveRequest +import com.weeth.domain.cardinal.application.dto.request.CardinalUpdateRequest +import com.weeth.domain.cardinal.application.exception.CardinalErrorCode +import com.weeth.domain.cardinal.application.usecase.command.ManageCardinalUseCase +import com.weeth.global.auth.jwt.application.exception.JwtErrorCode +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "CARDINAL ADMIN", description = "[ADMIN] 기수 어드민 API") +@RestController +@RequestMapping("/api/v4/admin/cardinals") +@ApiErrorCodeExample(CardinalErrorCode::class, JwtErrorCode::class) +class CardinalAdminController( + private val manageCardinalUseCase: ManageCardinalUseCase, +) { + @PatchMapping + @Operation(summary = "기수 정보 수정 API") + fun update( + @RequestBody @Valid request: CardinalUpdateRequest, + ): CommonResponse { + manageCardinalUseCase.update(request) + return CommonResponse.success(CardinalResponseCode.CARDINAL_UPDATE_SUCCESS) + } + + @PostMapping + @Operation(summary = "새로운 기수 정보 저장 API") + fun save( + @RequestBody @Valid request: CardinalSaveRequest, + ): CommonResponse { + manageCardinalUseCase.save(request) + return CommonResponse.success(CardinalResponseCode.CARDINAL_SAVE_SUCCESS) + } +} diff --git a/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalController.kt b/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalController.kt new file mode 100644 index 00000000..60867f03 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalController.kt @@ -0,0 +1,26 @@ +package com.weeth.domain.cardinal.presentation + +import com.weeth.domain.cardinal.application.dto.response.CardinalResponse +import com.weeth.domain.cardinal.application.exception.CardinalErrorCode +import com.weeth.domain.cardinal.application.usecase.query.GetCardinalQueryService +import com.weeth.global.auth.jwt.application.exception.JwtErrorCode +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "CARDINAL") +@RestController +@RequestMapping("/api/v4/cardinals") +@ApiErrorCodeExample(CardinalErrorCode::class, JwtErrorCode::class) +class CardinalController( + private val getCardinalQueryService: GetCardinalQueryService, +) { + @GetMapping + @Operation(summary = "현재 저장된 기수 목록 조회 API") + fun findAllCardinals(): CommonResponse> = + CommonResponse.success(CardinalResponseCode.CARDINAL_FIND_ALL_SUCCESS, getCardinalQueryService.findAll()) +} diff --git a/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalResponseCode.kt b/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalResponseCode.kt new file mode 100644 index 00000000..3839ef35 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalResponseCode.kt @@ -0,0 +1,14 @@ +package com.weeth.domain.cardinal.presentation + +import com.weeth.global.common.response.ResponseCodeInterface +import org.springframework.http.HttpStatus + +enum class CardinalResponseCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ResponseCodeInterface { + CARDINAL_FIND_ALL_SUCCESS(1850, HttpStatus.OK, "전체 기수 조회에 성공했습니다."), + CARDINAL_SAVE_SUCCESS(1851, HttpStatus.OK, "기수 저장에 성공했습니다."), + CARDINAL_UPDATE_SUCCESS(1852, HttpStatus.OK, "기수 수정에 성공했습니다."), +} diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/dto/response/PenaltyResponse.kt b/src/main/kotlin/com/weeth/domain/penalty/application/dto/response/PenaltyResponse.kt index fa4a4b4e..a070bd2b 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/application/dto/response/PenaltyResponse.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/application/dto/response/PenaltyResponse.kt @@ -9,8 +9,6 @@ data class PenaltyResponse( val name: String, @field:Schema(description = "패널티 횟수", example = "2") val penaltyCount: Int, - @field:Schema(description = "경고 횟수", example = "3") - val warningCount: Int, @field:Schema(description = "소속 기수 목록", example = "[3, 4]") val cardinals: List, @field:Schema(description = "패널티 상세 목록") diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/mapper/PenaltyMapper.kt b/src/main/kotlin/com/weeth/domain/penalty/application/mapper/PenaltyMapper.kt index f4929e52..224118f3 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/application/mapper/PenaltyMapper.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/application/mapper/PenaltyMapper.kt @@ -1,12 +1,12 @@ package com.weeth.domain.penalty.application.mapper +import com.weeth.domain.cardinal.domain.entity.Cardinal import com.weeth.domain.penalty.application.dto.request.SavePenaltyRequest import com.weeth.domain.penalty.application.dto.response.PenaltyByCardinalResponse import com.weeth.domain.penalty.application.dto.response.PenaltyDetailResponse import com.weeth.domain.penalty.application.dto.response.PenaltyResponse import com.weeth.domain.penalty.domain.entity.Penalty import com.weeth.domain.penalty.domain.enums.PenaltyType -import com.weeth.domain.user.domain.entity.Cardinal import com.weeth.domain.user.domain.entity.User import com.weeth.domain.user.domain.entity.UserCardinal import org.springframework.stereotype.Component @@ -46,7 +46,6 @@ class PenaltyMapper { userId = user.id, name = user.name, penaltyCount = user.penaltyCount, - warningCount = user.warningCount, cardinals = userCardinals.map { it.cardinal.cardinalNumber }, penalties = penalties.map(::toDetailResponse), ) diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/DeletePenaltyUseCase.kt b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/DeletePenaltyUseCase.kt index c26220c9..472d9d54 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/DeletePenaltyUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/DeletePenaltyUseCase.kt @@ -6,7 +6,6 @@ import com.weeth.domain.penalty.domain.enums.PenaltyType import com.weeth.domain.penalty.domain.repository.PenaltyRepository import com.weeth.domain.user.application.exception.UserNotFoundException import com.weeth.domain.user.domain.repository.UserRepository -import org.springframework.data.domain.Pageable import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -39,25 +38,6 @@ class DeletePenaltyUseCase( user.decrementPenaltyCount() } - PenaltyType.WARNING -> { - if (user.warningCount % 2 == 0) { - val relatedAutoPenalty = - penaltyRepository - .findFirstAutoPenaltyAfter( - penalty.user.id, - penalty.cardinal.id, - PenaltyType.AUTO_PENALTY, - penalty.createdAt, - Pageable.ofSize(1), - ).firstOrNull() - if (relatedAutoPenalty != null) { - penaltyRepository.deleteById(relatedAutoPenalty.id) - } - user.decrementPenaltyCount() - } - user.decrementWarningCount() - } - else -> {} } diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/SavePenaltyUseCase.kt b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/SavePenaltyUseCase.kt index 80729311..eeba581f 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/SavePenaltyUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/SavePenaltyUseCase.kt @@ -17,10 +17,6 @@ class SavePenaltyUseCase( private val userCardinalPolicy: UserCardinalPolicy, private val mapper: PenaltyMapper, ) { - companion object { - private const val AUTO_PENALTY_DESCRIPTION = "누적경고 %d회" - } - @Transactional fun save(request: SavePenaltyRequest) { val user = @@ -37,19 +33,7 @@ class SavePenaltyUseCase( user.incrementPenaltyCount() } - PenaltyType.WARNING -> { - user.incrementWarningCount() - - val warningCount = user.warningCount - if (warningCount % 2 == 0) { - val description = AUTO_PENALTY_DESCRIPTION.format(warningCount) - val autoPenalty = mapper.toAutoPenalty(description, user, cardinal) - penaltyRepository.save(autoPenalty) - user.incrementPenaltyCount() - } - } - - else -> {} + else -> {} // BONUS 등 다른 유형은 카운트 변경 없음 } } } diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/query/GetPenaltyQueryService.kt b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/query/GetPenaltyQueryService.kt index d0321f8e..6edee56b 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/query/GetPenaltyQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/query/GetPenaltyQueryService.kt @@ -1,10 +1,10 @@ package com.weeth.domain.penalty.application.usecase.query +import com.weeth.domain.cardinal.domain.repository.CardinalReader import com.weeth.domain.penalty.application.dto.response.PenaltyByCardinalResponse import com.weeth.domain.penalty.application.dto.response.PenaltyResponse import com.weeth.domain.penalty.application.mapper.PenaltyMapper import com.weeth.domain.penalty.domain.repository.PenaltyRepository -import com.weeth.domain.user.domain.repository.CardinalReader import com.weeth.domain.user.domain.repository.UserCardinalReader import com.weeth.domain.user.domain.repository.UserReader import com.weeth.domain.user.domain.service.UserCardinalPolicy diff --git a/src/main/kotlin/com/weeth/domain/penalty/domain/entity/Penalty.kt b/src/main/kotlin/com/weeth/domain/penalty/domain/entity/Penalty.kt index 25f0db0c..093339cb 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/domain/entity/Penalty.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/domain/entity/Penalty.kt @@ -1,7 +1,7 @@ package com.weeth.domain.penalty.domain.entity +import com.weeth.domain.cardinal.domain.entity.Cardinal import com.weeth.domain.penalty.domain.enums.PenaltyType -import com.weeth.domain.user.domain.entity.Cardinal import com.weeth.domain.user.domain.entity.User import com.weeth.global.common.entity.BaseEntity import jakarta.persistence.Column diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/usecase/command/ManageEventUseCase.kt b/src/main/kotlin/com/weeth/domain/schedule/application/usecase/command/ManageEventUseCase.kt index af641a8a..56ee439f 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/application/usecase/command/ManageEventUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/application/usecase/command/ManageEventUseCase.kt @@ -1,11 +1,11 @@ package com.weeth.domain.schedule.application.usecase.command +import com.weeth.domain.cardinal.domain.repository.CardinalReader import com.weeth.domain.schedule.application.dto.request.ScheduleSaveRequest import com.weeth.domain.schedule.application.dto.request.ScheduleUpdateRequest import com.weeth.domain.schedule.application.exception.EventNotFoundException import com.weeth.domain.schedule.application.mapper.EventMapper import com.weeth.domain.schedule.domain.repository.EventRepository -import com.weeth.domain.user.domain.repository.CardinalReader import com.weeth.domain.user.domain.repository.UserReader import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt b/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt index e07112b8..e9094924 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt @@ -1,5 +1,6 @@ package com.weeth.domain.schedule.application.usecase.query +import com.weeth.domain.cardinal.domain.repository.CardinalReader import com.weeth.domain.schedule.application.dto.response.EventResponse import com.weeth.domain.schedule.application.dto.response.ScheduleResponse import com.weeth.domain.schedule.application.exception.EventNotFoundException @@ -7,7 +8,6 @@ import com.weeth.domain.schedule.application.mapper.EventMapper import com.weeth.domain.schedule.application.mapper.ScheduleMapper import com.weeth.domain.schedule.domain.repository.EventRepository import com.weeth.domain.session.domain.repository.SessionReader -import com.weeth.domain.user.domain.repository.CardinalReader import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional diff --git a/src/main/kotlin/com/weeth/domain/session/application/usecase/command/ManageSessionUseCase.kt b/src/main/kotlin/com/weeth/domain/session/application/usecase/command/ManageSessionUseCase.kt index 077d245c..67a27aa2 100644 --- a/src/main/kotlin/com/weeth/domain/session/application/usecase/command/ManageSessionUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/session/application/usecase/command/ManageSessionUseCase.kt @@ -3,13 +3,13 @@ package com.weeth.domain.session.application.usecase.command import com.weeth.domain.attendance.domain.entity.Attendance import com.weeth.domain.attendance.domain.enums.AttendanceStatus import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.cardinal.domain.repository.CardinalReader import com.weeth.domain.schedule.application.dto.request.ScheduleSaveRequest import com.weeth.domain.schedule.application.dto.request.ScheduleUpdateRequest import com.weeth.domain.schedule.application.mapper.SessionMapper import com.weeth.domain.session.application.exception.SessionNotFoundException import com.weeth.domain.session.domain.repository.SessionRepository import com.weeth.domain.user.domain.enums.Status -import com.weeth.domain.user.domain.repository.CardinalReader import com.weeth.domain.user.domain.repository.UserReader import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/request/SignUpRequest.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/request/SignUpRequest.kt deleted file mode 100644 index ec4a464f..00000000 --- a/src/main/kotlin/com/weeth/domain/user/application/dto/request/SignUpRequest.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.weeth.domain.user.application.dto.request - -import io.swagger.v3.oas.annotations.media.Schema -import jakarta.validation.constraints.Email -import jakarta.validation.constraints.NotBlank -import jakarta.validation.constraints.NotNull - -data class SignUpRequest( - @field:Schema(description = "이름", example = "홍길동") - @field:NotBlank - val name: String, - @field:Schema(description = "이메일", example = "hong@example.com") - @field:Email - @field:NotBlank - val email: String, - @field:Schema(description = "학번", example = "20201234") - @field:NotBlank - val studentId: String, - @field:Schema(description = "전화번호", example = "01012345678") - @field:NotBlank - val tel: String, - @field:Schema(description = "학과", example = "컴퓨터공학과") - @field:NotNull - val department: String, - @field:Schema(description = "지원 기수", example = "7") - @field:NotNull - val cardinal: Int, -) diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/request/SocialLoginRequest.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/request/SocialLoginRequest.kt index df1e5772..e8c609ff 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/dto/request/SocialLoginRequest.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/request/SocialLoginRequest.kt @@ -7,12 +7,4 @@ data class SocialLoginRequest( @field:Schema(description = "OAuth2 인가 코드(auth code)", example = "SplxlOBeZQQYbYS6WxSbIA") @field:NotBlank val authCode: String, - @field:Schema(description = "추가 입력 이름(선택)", example = "홍길동", nullable = true) - val name: String? = null, - @field:Schema(description = "추가 입력 학번(선택)", example = "20201234", nullable = true) - val studentId: String? = null, - @field:Schema(description = "추가 입력 전화번호(선택)", example = "01012345678", nullable = true) - val tel: String? = null, - @field:Schema(description = "추가 입력 학과(선택)", example = "컴퓨터공학과", nullable = true) - val department: String? = null, ) diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/request/UpdateUserProfileRequest.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/request/UpdateUserProfileRequest.kt index ed67dbfb..fdd8fd1c 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/dto/request/UpdateUserProfileRequest.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/request/UpdateUserProfileRequest.kt @@ -3,7 +3,6 @@ package com.weeth.domain.user.application.dto.request import io.swagger.v3.oas.annotations.media.Schema import jakarta.validation.constraints.Email import jakarta.validation.constraints.NotBlank -import jakarta.validation.constraints.NotNull data class UpdateUserProfileRequest( @field:Schema(description = "이름", example = "홍길동") @@ -20,6 +19,6 @@ data class UpdateUserProfileRequest( @field:NotBlank val tel: String, @field:Schema(description = "학과", example = "컴퓨터공학과") - @field:NotNull + @field:NotBlank val department: String, ) diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/request/UserApplyObRequest.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/request/UserApplyObRequest.kt index 214c87e6..7b68d0d6 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/dto/request/UserApplyObRequest.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/request/UserApplyObRequest.kt @@ -5,9 +5,7 @@ import jakarta.validation.constraints.NotNull data class UserApplyObRequest( @field:Schema(description = "대상 사용자 ID", example = "1") - @field:NotNull val userId: Long, @field:Schema(description = "적용할 기수", example = "8") - @field:NotNull val cardinal: Int, ) diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/request/UserIdsRequest.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/request/UserIdsRequest.kt index 7b05934a..3cdbba2a 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/dto/request/UserIdsRequest.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/request/UserIdsRequest.kt @@ -6,7 +6,6 @@ import jakarta.validation.constraints.NotNull data class UserIdsRequest( @field:Schema(description = "처리 대상 사용자 ID 목록", example = "[1, 2, 3]") - @field:NotNull @field:NotEmpty val userId: List, ) diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/request/UserRoleUpdateRequest.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/request/UserRoleUpdateRequest.kt index d3c10201..e0c927ba 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/dto/request/UserRoleUpdateRequest.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/request/UserRoleUpdateRequest.kt @@ -6,9 +6,7 @@ import jakarta.validation.constraints.NotNull data class UserRoleUpdateRequest( @field:Schema(description = "대상 사용자 ID", example = "1") - @field:NotNull val userId: Long, @field:Schema(description = "변경할 권한", example = "ADMIN") - @field:NotNull val role: Role, ) diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/response/AdminUserResponse.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/response/AdminUserResponse.kt index 6c566971..4a039c23 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/dto/response/AdminUserResponse.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/response/AdminUserResponse.kt @@ -32,8 +32,6 @@ data class AdminUserResponse( val attendanceRate: Int, @field:Schema(description = "패널티 횟수", example = "1") val penaltyCount: Int, - @field:Schema(description = "경고 횟수", example = "0") - val warningCount: Int, @field:Schema(description = "생성 시각") val createdAt: LocalDateTime?, @field:Schema(description = "수정 시각") diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/response/SocialLoginResponse.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/response/SocialLoginResponse.kt index 3ad05ed4..65ad7266 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/dto/response/SocialLoginResponse.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/response/SocialLoginResponse.kt @@ -3,14 +3,10 @@ package com.weeth.domain.user.application.dto.response import io.swagger.v3.oas.annotations.media.Schema data class SocialLoginResponse( - @field:Schema(description = "로그인 사용자 이메일", example = "hong@example.com") - val email: String, @field:Schema(description = "액세스 토큰") val accessToken: String, @field:Schema(description = "리프레시 토큰") val refreshToken: String, @field:Schema(description = "신규 회원 여부", example = "true") val isNewUser: Boolean, - @field:Schema(description = "프로필 완성 여부", example = "false") - val profileCompleted: Boolean, ) diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserInfoResponse.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserInfoResponse.kt deleted file mode 100644 index 020feb80..00000000 --- a/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserInfoResponse.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.weeth.domain.user.application.dto.response - -import com.weeth.domain.user.domain.enums.Role -import io.swagger.v3.oas.annotations.media.Schema - -data class UserInfoResponse( - @field:Schema(description = "사용자 ID", example = "1") - val id: Long, - @field:Schema(description = "이름", example = "홍길동") - val name: String, - @field:Schema(description = "소속 기수 목록", example = "[6, 7]") - val cardinals: List, - @field:Schema(description = "권한", example = "USER", nullable = true) - val role: Role?, -) diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/CardinalNotFoundException.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/CardinalNotFoundException.kt deleted file mode 100644 index 94ea712f..00000000 --- a/src/main/kotlin/com/weeth/domain/user/application/exception/CardinalNotFoundException.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.weeth.domain.user.application.exception - -import com.weeth.global.common.exception.BaseException - -class CardinalNotFoundException : BaseException(UserErrorCode.CARDINAL_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/DuplicateCardinalException.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/DuplicateCardinalException.kt deleted file mode 100644 index 8cacbd14..00000000 --- a/src/main/kotlin/com/weeth/domain/user/application/exception/DuplicateCardinalException.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.weeth.domain.user.application.exception - -import com.weeth.global.common.exception.BaseException - -class DuplicateCardinalException : BaseException(UserErrorCode.DUPLICATE_CARDINAL) diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/UserErrorCode.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/UserErrorCode.kt index 8e930967..d569c943 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/exception/UserErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/UserErrorCode.kt @@ -36,24 +36,18 @@ enum class UserErrorCode( @ExplainError("이미 등록된 전화번호로 회원가입을 시도할 때 발생합니다.") TEL_EXISTS(2808, HttpStatus.BAD_REQUEST, "이미 존재하는 전화번호입니다."), - @ExplainError("존재하지 않는 기수 정보로 조회할 때 발생합니다.") - CARDINAL_NOT_FOUND(2809, HttpStatus.NOT_FOUND, "기수를 찾을 수 없습니다."), - - @ExplainError("이미 존재하는 기수를 생성하려고 할 때 발생합니다.") - DUPLICATE_CARDINAL(2810, HttpStatus.BAD_REQUEST, "이미 존재하는 기수입니다."), - @ExplainError("사용자와 기수 간의 연결 정보를 찾을 수 없을 때 발생합니다.") - USER_CARDINAL_NOT_FOUND(2811, HttpStatus.NOT_FOUND, "사용자 기수 정보를 찾을 수 없습니다."), + USER_CARDINAL_NOT_FOUND(2809, HttpStatus.NOT_FOUND, "사용자 기수 정보를 찾을 수 없습니다."), @ExplainError("잘못된 학과 값이 입력되었을 때 발생합니다.") - DEPARTMENT_NOT_FOUND(2812, HttpStatus.BAD_REQUEST, "학과를 찾을 수 없습니다."), + DEPARTMENT_NOT_FOUND(2810, HttpStatus.BAD_REQUEST, "학과를 찾을 수 없습니다."), @ExplainError("잘못된 권한 값이 입력되었을 때 발생합니다.") - ROLE_NOT_FOUND(2813, HttpStatus.BAD_REQUEST, "권한을 찾을 수 없습니다."), + ROLE_NOT_FOUND(2811, HttpStatus.BAD_REQUEST, "권한을 찾을 수 없습니다."), @ExplainError("잘못된 상태 값이 입력되었을 때 발생합니다.") - STATUS_NOT_FOUND(2814, HttpStatus.BAD_REQUEST, "상태를 찾을 수 없습니다."), + STATUS_NOT_FOUND(2812, HttpStatus.BAD_REQUEST, "상태를 찾을 수 없습니다."), @ExplainError("사용자 순서 지정 시 잘못된 값이 입력되었을 때 발생합니다.") - INVALID_USER_ORDER(2815, HttpStatus.BAD_REQUEST, "잘못된 사용자 순서입니다."), + INVALID_USER_ORDER(2813, HttpStatus.BAD_REQUEST, "잘못된 사용자 순서입니다."), } diff --git a/src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt b/src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt index 63c25ff2..572721af 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt @@ -1,24 +1,25 @@ package com.weeth.domain.user.application.mapper -import com.weeth.domain.user.application.dto.request.SignUpRequest import com.weeth.domain.user.application.dto.response.AdminUserResponse +import com.weeth.domain.user.application.dto.response.SocialLoginResponse import com.weeth.domain.user.application.dto.response.UserDetailsResponse -import com.weeth.domain.user.application.dto.response.UserInfoResponse import com.weeth.domain.user.application.dto.response.UserProfileResponse import com.weeth.domain.user.application.dto.response.UserSummaryResponse import com.weeth.domain.user.domain.entity.User import com.weeth.domain.user.domain.entity.UserCardinal +import com.weeth.global.auth.jwt.application.dto.JwtDto import org.springframework.stereotype.Component @Component class UserMapper { - fun toEntity(request: SignUpRequest): User = - User.create( - name = request.name, - email = request.email, - studentId = request.studentId, - tel = request.tel, - department = request.department, + fun toSocialLoginResponse( + token: JwtDto, + isNewUser: Boolean, + ): SocialLoginResponse = + SocialLoginResponse( + accessToken = token.accessToken, + refreshToken = token.refreshToken, + isNewUser = isNewUser, ) fun toUserProfileResponse( @@ -54,7 +55,6 @@ class UserMapper { user.absenceCount, user.attendanceRate, user.penaltyCount, - user.warningCount, user.createdAt, user.modifiedAt, ) @@ -84,17 +84,6 @@ class UserMapper { user.role, ) - fun toUserInfoResponse( - user: User, - userCardinals: List, - ): UserInfoResponse = - UserInfoResponse( - user.id, - user.name, - toCardinalNumbers(userCardinals), - user.role, - ) - private fun toCardinalNumbers(userCardinals: List): List { if (userCardinals.isEmpty()) { return emptyList() diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AdminUserUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AdminUserUseCase.kt index af9fccfb..4ef6571e 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AdminUserUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AdminUserUseCase.kt @@ -2,15 +2,14 @@ package com.weeth.domain.user.application.usecase.command import com.weeth.domain.attendance.domain.entity.Attendance import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.cardinal.domain.entity.Cardinal +import com.weeth.domain.cardinal.domain.repository.CardinalReader import com.weeth.domain.session.domain.repository.SessionReader import com.weeth.domain.user.application.dto.request.UserApplyObRequest import com.weeth.domain.user.application.dto.request.UserIdsRequest import com.weeth.domain.user.application.dto.request.UserRoleUpdateRequest -import com.weeth.domain.user.application.exception.CardinalNotFoundException -import com.weeth.domain.user.domain.entity.Cardinal import com.weeth.domain.user.domain.entity.User import com.weeth.domain.user.domain.entity.UserCardinal -import com.weeth.domain.user.domain.repository.CardinalRepository import com.weeth.domain.user.domain.repository.UserCardinalRepository import com.weeth.domain.user.domain.repository.UserReader import com.weeth.domain.user.domain.service.UserCardinalPolicy @@ -20,21 +19,21 @@ import org.springframework.transaction.annotation.Transactional @Service class AdminUserUseCase( private val userReader: UserReader, + private val userCardinalPolicy: UserCardinalPolicy, + private val cardinalReader: CardinalReader, private val sessionReader: SessionReader, private val attendanceRepository: AttendanceRepository, - private val cardinalRepository: CardinalRepository, private val userCardinalRepository: UserCardinalRepository, - private val userCardinalPolicy: UserCardinalPolicy, ) { @Transactional fun accept(request: UserIdsRequest) { val users = userReader.findAllByIds(request.userId) users.forEach { user -> - val cardinal = userCardinalPolicy.getCurrentCardinal(user).cardinalNumber + val cardinal = userCardinalPolicy.getCurrentCardinal(user) + if (user.isInactive()) { user.accept() - val sessions = sessionReader.findAllByCardinal(cardinal) - attendanceRepository.saveAll(sessions.map { Attendance.create(it, user) }) + initializeAttendances(listOf(user), cardinal) } } } @@ -55,56 +54,56 @@ class AdminUserUseCase( } } + /** + * 이전 기수의 인원들을 다음 기수로 한 번에 등록하는 메서드. + * N+1을 해소하는 비용이 코드 가독성에 비해 지나치게 커서 배치 조회 + 캐싱 방식으로 절충하였다. + */ @Transactional - fun applyOb(requests: List) { // todo: 리팩토링 - if (requests.isEmpty()) return - - val distinctUserIds = requests.map { it.userId }.distinct() - val users = userReader.findAllByIds(distinctUserIds) - val userMap = users.associateBy { it.id } - distinctUserIds.firstOrNull { it !in userMap }?.let { userReader.getById(it) } - - val existingCardinalsByUser = userCardinalRepository.findAllByUsers(users).groupBy { it.user.id } - val cardinalMap = getOrCreateCardinals(requests.map { it.cardinal }.distinct()) - - val newLinks = mutableListOf>() - val initNeededByCardinal = mutableMapOf>() - - requests.forEach { req -> - val user = userMap.getValue(req.userId) - val nextCardinal = cardinalMap.getValue(req.cardinal) - val existing = existingCardinalsByUser[user.id] ?: emptyList() + fun applyOb(requests: List) { + // 동일한 (userId, cardinal) 요청은 한 번만 처리한다. + val uniqueRequests = requests.distinctBy { it.userId to it.cardinal } + if (uniqueRequests.isEmpty()) return - if (existing.any { it.cardinal.id == nextCardinal.id }) return@forEach + // 유저는 한 번에 조회해 요청 수만큼 getById가 반복되는 것을 줄인다. + val usersById = + userReader + .findAllByIds(uniqueRequests.map { it.userId }.distinct()) + .associateBy { it.id } - val maxCardinalNumber = - existing.maxOfOrNull { it.cardinal.cardinalNumber } ?: throw CardinalNotFoundException() + // 같은 기수 번호 조회가 반복되지 않도록 메모리 캐시를 사용한다. + val cardinalByNumber = mutableMapOf() - if (maxCardinalNumber < nextCardinal.cardinalNumber) { - user.resetAttendanceStats() - initNeededByCardinal.getOrPut(req.cardinal) { mutableListOf() }.add(user) - } - newLinks.add(user to nextCardinal) - } + uniqueRequests.forEach { req -> + // 배치 조회에서 누락된 id는 기존과 동일하게 getById로 예외를 발생시킨다. + val user = usersById[req.userId] ?: userReader.getById(req.userId) + val nextCardinal = + cardinalByNumber.getOrPut(req.cardinal) { + cardinalReader.getByCardinalNumber(req.cardinal) + } - if (initNeededByCardinal.isNotEmpty()) { - val sessionsByCardinal = - sessionReader.findAllByCardinalIn(initNeededByCardinal.keys.toList()).groupBy { it.cardinal } - initNeededByCardinal.forEach { (cardinalNumber, usersToInit) -> - val sessions = sessionsByCardinal[cardinalNumber] ?: emptyList() - usersToInit.forEach { user -> - attendanceRepository.saveAll(sessions.map { Attendance.create(it, user) }) + if (userCardinalPolicy.notContains(user, nextCardinal)) { + if (userCardinalPolicy.isCurrent(user, nextCardinal)) { + user.resetAttendanceStats() + initializeAttendances(listOf(user), nextCardinal) } + + userCardinalRepository.save(UserCardinal.create(user, nextCardinal)) } } - - newLinks.forEach { (user, cardinal) -> userCardinalRepository.save(UserCardinal(user, cardinal)) } } - private fun getOrCreateCardinals(cardinalNumbers: List): Map { - val existing = cardinalRepository.findAllByCardinalNumberIn(cardinalNumbers).associateBy { it.cardinalNumber } - return cardinalNumbers.associateWith { num -> - existing[num] ?: cardinalRepository.save(Cardinal.create(cardinalNumber = num)) - } + private fun initializeAttendances( + users: List, + cardinal: Cardinal, + ) { + if (users.isEmpty()) return + val sessions = sessionReader.findAllByCardinal(cardinal.cardinalNumber) + if (sessions.isEmpty()) return + + attendanceRepository.saveAll( + users.flatMap { user -> + sessions.map { Attendance.create(it, user) } + }, + ) } } diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCase.kt index 82e8e28d..2936caf0 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCase.kt @@ -1,240 +1,27 @@ package com.weeth.domain.user.application.usecase.command -import com.weeth.domain.user.application.dto.request.SignUpRequest -import com.weeth.domain.user.application.dto.request.SocialLoginRequest -import com.weeth.domain.user.application.dto.request.UpdateUserProfileRequest -import com.weeth.domain.user.application.dto.response.SocialLoginResponse -import com.weeth.domain.user.application.exception.EmailNotFoundException -import com.weeth.domain.user.application.exception.StudentIdExistsException -import com.weeth.domain.user.application.exception.TelExistsException -import com.weeth.domain.user.application.exception.UserInActiveException -import com.weeth.domain.user.application.mapper.UserMapper -import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.entity.UserCardinal -import com.weeth.domain.user.domain.entity.UserSocialAccount -import com.weeth.domain.user.domain.enums.SocialProvider -import com.weeth.domain.user.domain.enums.Status -import com.weeth.domain.user.domain.repository.CardinalReader -import com.weeth.domain.user.domain.repository.UserCardinalRepository import com.weeth.domain.user.domain.repository.UserReader -import com.weeth.domain.user.domain.repository.UserRepository -import com.weeth.domain.user.domain.repository.UserSocialAccountRepository -import com.weeth.global.auth.apple.AppleAuthService import com.weeth.global.auth.jwt.application.dto.JwtDto import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase -import com.weeth.global.auth.kakao.KakaoAuthService import jakarta.servlet.http.HttpServletRequest import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @Service class AuthUserUseCase( - private val userRepository: UserRepository, private val userReader: UserReader, - private val cardinalReader: CardinalReader, - private val userCardinalRepository: UserCardinalRepository, - private val mapper: UserMapper, - private val userSocialAccountRepository: UserSocialAccountRepository, - private val kakaoAuthService: KakaoAuthService, - private val appleAuthService: AppleAuthService, private val jwtManageUseCase: JwtManageUseCase, private val jwtTokenExtractor: JwtTokenExtractor, ) { - @Transactional - fun updateProfile( - request: UpdateUserProfileRequest, - userId: Long, - ) { - validate(request, userId) - val user = userReader.getById(userId) - user.update(request.name, request.email, request.studentId, request.tel, request.department) - } - - @Transactional - fun apply(request: SignUpRequest) { // todo: 리팩토링 - validate(request) - val cardinal = cardinalReader.getByCardinalNumber(request.cardinal) - val user = mapper.toEntity(request) - val userCardinal = UserCardinal(user, cardinal) - - userRepository.save(user) - userCardinalRepository.save(userCardinal) - } - @Transactional fun leave(userId: Long) { val user = userReader.getById(userId) user.leave() } - @Transactional - fun socialLoginByKakao(request: SocialLoginRequest): SocialLoginResponse { // todo: 리팩토링 - val kakaoToken = kakaoAuthService.getKakaoToken(request.authCode) - val userInfo = kakaoAuthService.getUserInfo(kakaoToken.accessToken) - val account = userInfo.kakaoAccount - val email = account.email?.trim()?.lowercase() - val providerName = - account.profile - ?.nickname - ?.trim() - ?.takeIf { it.isNotBlank() } - if (!account.isEmailValid || !account.isEmailVerified || email.isNullOrBlank()) { - throw EmailNotFoundException() - } - return loginOrCreate( - provider = SocialProvider.KAKAO, - providerUserId = userInfo.id.toString(), - providerEmail = email, - providerName = providerName, - request = request, - ) - } - - @Transactional - fun socialLoginByApple(request: SocialLoginRequest): SocialLoginResponse { // todo: 리팩토링 - val appleToken = appleAuthService.getAppleToken(request.authCode) - val userInfo = appleAuthService.verifyAndDecodeIdToken(appleToken.idToken) - val email = userInfo.email?.trim()?.lowercase() - val providerName = userInfo.name?.trim()?.takeIf { it.isNotBlank() } - if (!userInfo.emailVerified || email.isNullOrBlank()) { - throw EmailNotFoundException() - } - return loginOrCreate( - provider = SocialProvider.APPLE, - providerUserId = userInfo.appleId, - providerEmail = email, - providerName = providerName, - request = request, - ) - } - fun refreshToken(httpServletRequest: HttpServletRequest): JwtDto { val refreshToken = jwtTokenExtractor.extractRefreshToken(httpServletRequest) return jwtManageUseCase.reIssueToken(refreshToken) } - - private fun validate( - request: UpdateUserProfileRequest, - userId: Long, - ) { - if (userRepository.existsByStudentIdAndIdIsNot(request.studentId, userId)) { - throw StudentIdExistsException() - } - if (userRepository.existsByTelAndIdIsNotValue(request.tel, userId)) { - throw TelExistsException() - } - } - - private fun validate(request: SignUpRequest) { - if (userRepository.existsByStudentId(request.studentId)) { - throw StudentIdExistsException() - } - if (userRepository.existsByTelValue(request.tel)) { - throw TelExistsException() - } - } - - private fun loginOrCreate( - provider: SocialProvider, - providerUserId: String, - providerEmail: String, - providerName: String?, - request: SocialLoginRequest, - ): SocialLoginResponse { - val socialAccount = - userSocialAccountRepository - .findByProviderAndProviderUserId( - provider, - providerUserId, - ).orElse(null) - val (user, isNewUser) = - if (socialAccount != null) { - socialAccount.user to false - } else { - createAndPersistSocialAccount(provider, providerUserId, providerEmail, providerName) - } - - if (user.status == Status.BANNED || user.status == Status.LEFT) { - throw UserInActiveException() - } - - val hasExplicitPayload = - request.name != null || - request.studentId != null || - request.tel != null || - request.department != null - - if (isNewUser || hasExplicitPayload) { - applyOptionalProfile(user, request, providerName) - } - - val token = jwtManageUseCase.create(user.id, user.emailValue, user.role) - return SocialLoginResponse( - email = user.emailValue, - accessToken = token.accessToken, - refreshToken = token.refreshToken, - isNewUser = isNewUser, - profileCompleted = user.isProfileCompleted(), - ) - } - - private fun createAndPersistSocialAccount( - provider: SocialProvider, - providerUserId: String, - providerEmail: String, - providerName: String?, - ): Pair { - val existingUser = userRepository.findByEmailValue(providerEmail).orElse(null) - val user = - existingUser ?: userRepository.save( - User.create( - name = providerName ?: "", - email = providerEmail, - studentId = "", - tel = "", - department = "", - ), - ) - userSocialAccountRepository.save( - UserSocialAccount(provider = provider, providerUserId = providerUserId, user = user), - ) - return user to (existingUser == null) - } - - private fun applyOptionalProfile( - user: User, - request: SocialLoginRequest, - providerName: String?, - ) { - val hasProfilePayload = - providerName != null || - request.name != null || - request.studentId != null || - request.tel != null || - request.department != null - if (!hasProfilePayload) { - return - } - - val nextName = request.name?.trim()?.takeIf { it.isNotBlank() } ?: providerName ?: user.name - val nextStudentId = request.studentId ?: user.studentId - val nextTel = request.tel ?: user.telValue - val nextDepartment = request.department ?: user.department - - if ( - nextStudentId != user.studentId && - nextStudentId.isNotBlank() && - userRepository.existsByStudentIdAndIdIsNot(nextStudentId, user.id) - ) { - throw StudentIdExistsException() - } - if (nextTel != user.telValue && nextTel.isNotBlank() && - userRepository.existsByTelAndIdIsNotValue(nextTel, user.id) - ) { - throw TelExistsException() - } - - user.update(nextName, user.emailValue, nextStudentId, nextTel, nextDepartment) - } } diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/ManageCardinalUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/ManageCardinalUseCase.kt deleted file mode 100644 index 908f49a6..00000000 --- a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/ManageCardinalUseCase.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.weeth.domain.user.application.usecase.command - -import com.weeth.domain.user.application.dto.request.CardinalSaveRequest -import com.weeth.domain.user.application.dto.request.CardinalUpdateRequest -import com.weeth.domain.user.application.exception.CardinalNotFoundException -import com.weeth.domain.user.application.exception.DuplicateCardinalException -import com.weeth.domain.user.application.mapper.CardinalMapper -import com.weeth.domain.user.domain.entity.Cardinal -import com.weeth.domain.user.domain.enums.CardinalStatus -import com.weeth.domain.user.domain.repository.CardinalRepository -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional - -@Service -class ManageCardinalUseCase( - private val cardinalRepository: CardinalRepository, - private val cardinalMapper: CardinalMapper, -) { - @Transactional - fun save(request: CardinalSaveRequest) { - if (cardinalRepository.findByCardinalNumber(request.cardinalNumber).isPresent) { - throw DuplicateCardinalException() - } - - val cardinal = cardinalRepository.save(cardinalMapper.toEntity(request)) - if (request.inProgress) { - updateCardinalStatus(cardinal) - } - } - - @Transactional - fun update(request: CardinalUpdateRequest) { - val cardinal = cardinalRepository.findById(request.id).orElseThrow { CardinalNotFoundException() } - cardinal.update(request.year, request.semester) - - if (request.inProgress) { - updateCardinalStatus(cardinal) - } - } - - private fun updateCardinalStatus(cardinal: Cardinal) { - val inProgressCardinals = cardinalRepository.findAllByStatus(CardinalStatus.IN_PROGRESS) - inProgressCardinals.forEach(Cardinal::done) - cardinal.inProgress() - } -} diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCase.kt new file mode 100644 index 00000000..c02fb83b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCase.kt @@ -0,0 +1,81 @@ +package com.weeth.domain.user.application.usecase.command + +import com.weeth.domain.user.application.dto.request.SocialLoginRequest +import com.weeth.domain.user.application.dto.response.SocialLoginResponse +import com.weeth.domain.user.application.exception.EmailNotFoundException +import com.weeth.domain.user.application.exception.UserInActiveException +import com.weeth.domain.user.application.mapper.UserMapper +import com.weeth.domain.user.domain.entity.User +import com.weeth.domain.user.domain.entity.UserSocialAccount +import com.weeth.domain.user.domain.enums.SocialProvider +import com.weeth.domain.user.domain.enums.Status +import com.weeth.domain.user.domain.repository.UserRepository +import com.weeth.domain.user.domain.repository.UserSocialAccountRepository +import com.weeth.domain.user.domain.vo.SocialAuthResult +import com.weeth.domain.user.infrastructure.SocialAuthPortRegistry +import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class SocialLoginUseCase( + private val userRepository: UserRepository, + private val userSocialAccountRepository: UserSocialAccountRepository, + private val socialAuthPortRegistry: SocialAuthPortRegistry, + private val jwtManageUseCase: JwtManageUseCase, + private val userMapper: UserMapper, +) { + @Transactional + fun socialLoginByKakao(request: SocialLoginRequest): SocialLoginResponse = + socialLogin(SocialProvider.KAKAO, request) + + @Transactional + fun socialLoginByApple(request: SocialLoginRequest): SocialLoginResponse = + socialLogin(SocialProvider.APPLE, request) + + private fun socialLogin( + provider: SocialProvider, + request: SocialLoginRequest, + ): SocialLoginResponse { + val authResult = socialAuthPortRegistry.get(provider).authenticate(request.authCode) + val (user, isNewUser) = findOrCreateUser(authResult) + + if (user.isBannedOrLeft()) throw UserInActiveException() + + val token = jwtManageUseCase.create(user.id, user.emailValue, user.role) + + return userMapper.toSocialLoginResponse(token, isNewUser) + } + + // TODO: 실제 서비스 출시 시 이메일 기반 기존 사용자 연동 및 유저 알림 기능 필요 + private fun findOrCreateUser(authResult: SocialAuthResult): Pair { + val existing = + userSocialAccountRepository + .findByProviderAndProviderUserId(authResult.provider, authResult.providerUserId) + .orElse(null) + + if (existing != null) return existing.user to false + + val email = + authResult.email.takeIf { authResult.emailVerified && it.isNotBlank() } ?: throw EmailNotFoundException() + + val user = + userRepository.save( + User.create( + name = authResult.name ?: "", + email = email, + status = Status.ACTIVE, // 소셜 로그인으로 회원가입 한 경우 바로 가입 승인 + ), + ) + + userSocialAccountRepository.save( + UserSocialAccount( + provider = authResult.provider, + providerUserId = authResult.providerUserId, + user = user, + ), + ) + + return user to true + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/UpdateUserProfileUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/UpdateUserProfileUseCase.kt new file mode 100644 index 00000000..52441444 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/UpdateUserProfileUseCase.kt @@ -0,0 +1,43 @@ +package com.weeth.domain.user.application.usecase.command + +import com.weeth.domain.user.application.dto.request.UpdateUserProfileRequest +import com.weeth.domain.user.application.exception.StudentIdExistsException +import com.weeth.domain.user.application.exception.TelExistsException +import com.weeth.domain.user.domain.repository.UserRepository +import com.weeth.domain.user.domain.vo.Email +import com.weeth.domain.user.domain.vo.PhoneNumber +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class UpdateUserProfileUseCase( + private val userRepository: UserRepository, +) { + @Transactional + fun updateProfile( + request: UpdateUserProfileRequest, + userId: Long, + ) { + validate(request, userId) + val user = userRepository.getById(userId) + user.update( + name = request.name, + email = Email.from(request.email), + studentId = request.studentId, + tel = PhoneNumber.from(request.tel), + department = request.department, + ) + } + + private fun validate( + request: UpdateUserProfileRequest, + userId: Long, + ) { + if (userRepository.existsByStudentIdAndIdIsNot(request.studentId, userId)) { + throw StudentIdExistsException() + } + if (userRepository.existsByTelAndIdIsNotValue(request.tel, userId)) { + throw TelExistsException() + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryService.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryService.kt index bc0ff201..9d28e149 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryService.kt @@ -1,8 +1,8 @@ package com.weeth.domain.user.application.usecase.query +import com.weeth.domain.cardinal.domain.repository.CardinalReader import com.weeth.domain.user.application.dto.response.AdminUserResponse import com.weeth.domain.user.application.dto.response.UserDetailsResponse -import com.weeth.domain.user.application.dto.response.UserInfoResponse import com.weeth.domain.user.application.dto.response.UserProfileResponse import com.weeth.domain.user.application.dto.response.UserSummaryResponse import com.weeth.domain.user.application.mapper.UserMapper @@ -11,10 +11,7 @@ import com.weeth.domain.user.domain.entity.UserCardinal import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.enums.StatusPriority import com.weeth.domain.user.domain.enums.UsersOrderBy -import com.weeth.domain.user.domain.repository.CardinalReader -import com.weeth.domain.user.domain.repository.UserCardinalReader import com.weeth.domain.user.domain.repository.UserCardinalRepository -import com.weeth.domain.user.domain.repository.UserReader import com.weeth.domain.user.domain.repository.UserRepository import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Slice @@ -26,10 +23,8 @@ import java.util.LinkedHashMap @Transactional(readOnly = true) class GetUserQueryService( private val userRepository: UserRepository, - private val userReader: UserReader, // todo: 동일 도메인이므로 UserRespository 단일 사용) private val cardinalReader: CardinalReader, private val userCardinalRepository: UserCardinalRepository, - private val userCardinalReader: UserCardinalReader, private val mapper: UserMapper, ) { fun existsByEmail(email: String): Boolean = userRepository.existsByEmailValue(email) @@ -48,7 +43,7 @@ class GetUserQueryService( userRepository.findAllByCardinalOrderByNameAsc(Status.ACTIVE, inputCardinal, pageable) } - val allUserCardinals = userCardinalReader.findAllByUsersOrderByCardinalDesc(users.content) + val allUserCardinals = userCardinalRepository.findAllByUsers(users.content) val userCardinalMap = allUserCardinals.groupBy { it.user.id } return users.map { user -> val userCardinals = userCardinalMap[user.id] ?: emptyList() @@ -58,7 +53,7 @@ class GetUserQueryService( fun searchUser(keyword: String): List { val users = userRepository.findAllByNameContainingAndStatus(keyword, Status.ACTIVE) - val allUserCardinals = userCardinalReader.findAllByUsersOrderByCardinalDesc(users) + val allUserCardinals = userCardinalRepository.findAllByUsers(users) val userCardinalMap = allUserCardinals.groupBy { it.user.id } return users.map { user -> val userCardinals = userCardinalMap[user.id] ?: emptyList() @@ -67,27 +62,27 @@ class GetUserQueryService( } fun findUserDetails(userId: Long): UserDetailsResponse { - val user = userReader.getById(userId) - val userCardinals = userCardinalReader.findAllByUser(user) + val user = userRepository.getById(userId) + val userCardinals = userCardinalRepository.findAllByUser(user) return mapper.toUserDetailsResponse(user, userCardinals) } fun findMyProfile(userId: Long): UserProfileResponse { - val user = userReader.getById(userId) - val userCardinals = userCardinalReader.findAllByUser(user) + val user = userRepository.getById(userId) + val userCardinals = userCardinalRepository.findAllByUser(user) return mapper.toUserProfileResponse(user, userCardinals) } - fun findMyInfo(userId: Long): UserInfoResponse { - val user = userReader.getById(userId) - val userCardinals = userCardinalReader.findAllByUser(user) - return mapper.toUserInfoResponse(user, userCardinals) + fun findMyInfo(userId: Long): UserSummaryResponse { + val user = userRepository.getById(userId) + val userCardinals = userCardinalRepository.findAllByUser(user) + return mapper.toUserSummaryResponse(user, userCardinals) } fun findAllByAdmin(orderBy: UsersOrderBy): List { val userCardinalMap: LinkedHashMap> = LinkedHashMap( - userCardinalRepository.findAllByOrderByUserNameAsc().groupBy { it.user }, + userCardinalRepository.findAllWithUserAndCardinal().groupBy { it.user }, ) return when (orderBy) { diff --git a/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt b/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt index 8c99f958..8f310c79 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt @@ -17,65 +17,80 @@ import jakarta.persistence.Enumerated import jakarta.persistence.GeneratedValue import jakarta.persistence.GenerationType import jakarta.persistence.Id -import jakarta.persistence.PrePersist import jakarta.persistence.Table -/** - * Todo: private set 설정 - * Todo: 생성자 리팩토링 - */ @Entity @Table(name = "users") -class User( +class User protected constructor() : BaseEntity() { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "user_id") - var id: Long = 0L, - var name: String = "", + var id: Long = 0L + private set + + @Column(nullable = false, length = 50) + lateinit var name: String + private set + @Convert(converter = EmailConverter::class) - @Column(name = "email") - var email: Email = Email.from(""), - var studentId: String = "", + @Column(name = "email", nullable = false, length = 255) + lateinit var email: Email + private set + + @Column(nullable = false, length = 20) + lateinit var studentId: String + private set + @Convert(converter = PhoneNumberConverter::class) - @Column(name = "tel") - var tel: PhoneNumber = PhoneNumber.from(""), - var department: String = "", + @Column(name = "tel", nullable = false, length = 20) + lateinit var tel: PhoneNumber + private set + + @Column(nullable = false, length = 100) + lateinit var department: String + private set + @Enumerated(EnumType.STRING) - var status: Status = Status.WAITING, + @Column(nullable = false, length = 20) + var status: Status = Status.WAITING + private set + @Enumerated(EnumType.STRING) - var role: Role = Role.USER, + @Column(nullable = false, length = 20) + var role: Role = Role.USER + private set + @Embedded - var attendanceStats: AttendanceStats = AttendanceStats(), - var penaltyCount: Int = 0, - var warningCount: Int = 0, // todo: 경고시 자동 페널티 기능도 제거 -) : BaseEntity() { + var attendanceStats: AttendanceStats = AttendanceStats() + private set + + @Column(nullable = false) + var penaltyCount: Int = 0 + private set + constructor( id: Long = 0L, - name: String = "", - email: String = "", + name: String, + email: Email, studentId: String = "", - tel: String = "", + tel: PhoneNumber = PhoneNumber.from(""), department: String = "", status: Status = Status.WAITING, role: Role = Role.USER, - attendanceCount: Int = 0, - absenceCount: Int = 0, - attendanceRate: Int = 0, + attendanceStats: AttendanceStats = AttendanceStats(), penaltyCount: Int = 0, - warningCount: Int = 0, - ) : this( - id = id, - name = name, - email = Email.from(email), - studentId = studentId, - tel = PhoneNumber.from(tel), - department = department, - status = status, - role = role, - attendanceStats = AttendanceStats(attendanceCount, absenceCount, attendanceRate), - penaltyCount = penaltyCount, - warningCount = warningCount, - ) + ) : this() { + this.id = id + this.name = name.trim() + this.email = email + this.studentId = studentId + this.tel = tel + this.department = department + this.status = status + this.role = role + this.attendanceStats = attendanceStats + this.penaltyCount = penaltyCount + } val emailValue: String get() = email.value @@ -92,20 +107,15 @@ class User( val attendanceRate: Int get() = attendanceStats.attendanceRate - @PrePersist - fun init() { - status = Status.WAITING - role = Role.USER - attendanceStats.reset() - penaltyCount = 0 - warningCount = 0 - } - fun leave() { status = Status.LEFT } - fun isInactive(): Boolean = status != Status.ACTIVE + fun isActive(): Boolean = status == Status.ACTIVE + + fun isInactive(): Boolean = !isActive() + + fun isBannedOrLeft(): Boolean = status == Status.BANNED || status == Status.LEFT fun isProfileCompleted(): Boolean = name.isNotBlank() && @@ -115,15 +125,16 @@ class User( fun update( name: String, - email: String, + email: Email, studentId: String, - tel: String, + tel: PhoneNumber, department: String, ) { - this.name = name - this.email = Email.from(email) + require(name.isNotBlank()) { "이름은 공백일 수 없습니다." } + this.name = name.trim() + this.email = email this.studentId = studentId - this.tel = PhoneNumber.from(tel) + this.tel = tel this.department = department } @@ -169,25 +180,16 @@ class User( } } - fun incrementWarningCount() { - warningCount++ - } - - fun decrementWarningCount() { - if (warningCount > 0) { - warningCount-- - } - } - fun hasRole(role: Role): Boolean = this.role == role companion object { fun create( name: String, email: String, - studentId: String, - tel: String, - department: String, + studentId: String = "", + tel: String = "", + department: String = "", + status: Status = Status.WAITING, ): User = User( name = name, @@ -195,6 +197,7 @@ class User( studentId = studentId, tel = PhoneNumber.from(tel), department = department, + status = status, ) } } diff --git a/src/main/kotlin/com/weeth/domain/user/domain/entity/UserCardinal.kt b/src/main/kotlin/com/weeth/domain/user/domain/entity/UserCardinal.kt index c33c346b..7d0c3a94 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/entity/UserCardinal.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/entity/UserCardinal.kt @@ -1,5 +1,6 @@ package com.weeth.domain.user.domain.entity +import com.weeth.domain.cardinal.domain.entity.Cardinal import com.weeth.global.common.entity.BaseEntity import jakarta.persistence.Column import jakarta.persistence.Entity @@ -9,26 +10,39 @@ import jakarta.persistence.GenerationType import jakarta.persistence.Id import jakarta.persistence.JoinColumn import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint @Entity +@Table( + name = "user_cardinal", + uniqueConstraints = [ + UniqueConstraint( + name = "uk_user_id_cardinal_id", + columnNames = ["user_id", "cardinal_id"], + ), + ], +) class UserCardinal( - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "user_cardinal_id") - val id: Long = 0L, - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id") + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) val user: User, - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "cardinal_id") + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "cardinal_id", nullable = false) val cardinal: Cardinal, ) : BaseEntity() { - constructor( - user: User, - cardinal: Cardinal, - ) : this( - id = 0L, - user = user, - cardinal = cardinal, - ) + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_cardinal_id") + val id: Long = 0L + + companion object { + fun create( + user: User, + cardinal: Cardinal, + ) = UserCardinal( + user = user, + cardinal = cardinal, + ) + } } diff --git a/src/main/kotlin/com/weeth/domain/user/domain/entity/UserSocialAccount.kt b/src/main/kotlin/com/weeth/domain/user/domain/entity/UserSocialAccount.kt index af61353f..3ae8d03b 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/entity/UserSocialAccount.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/entity/UserSocialAccount.kt @@ -26,10 +26,6 @@ import jakarta.persistence.UniqueConstraint ], ) class UserSocialAccount( - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "user_social_account_id") - val id: Long = 0L, @Enumerated(EnumType.STRING) @Column(nullable = false) val provider: SocialProvider, @@ -38,4 +34,9 @@ class UserSocialAccount( @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) val user: User, -) : BaseEntity() +) : BaseEntity() { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_social_account_id") + val id: Long = 0L +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/port/SocialAuthPort.kt b/src/main/kotlin/com/weeth/domain/user/domain/port/SocialAuthPort.kt new file mode 100644 index 00000000..40cc5b54 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/port/SocialAuthPort.kt @@ -0,0 +1,10 @@ +package com.weeth.domain.user.domain.port + +import com.weeth.domain.user.domain.enums.SocialProvider +import com.weeth.domain.user.domain.vo.SocialAuthResult + +interface SocialAuthPort { + fun provider(): SocialProvider + + fun authenticate(authCode: String): SocialAuthResult +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserCardinalRepository.kt b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserCardinalRepository.kt index a41f9c71..8622449d 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserCardinalRepository.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserCardinalRepository.kt @@ -14,13 +14,28 @@ interface UserCardinalRepository : fun findTopByUserOrderByCardinalCardinalNumberDesc(user: User): UserCardinal? @Query( - "SELECT uc FROM UserCardinal uc WHERE uc.user IN :users ORDER BY uc.user.id, uc.cardinal.cardinalNumber DESC", + """ + SELECT uc + FROM UserCardinal uc + JOIN FETCH uc.cardinal + WHERE uc.user IN :users + ORDER BY uc.user.id, uc.cardinal.cardinalNumber DESC + """, ) fun findAllByUsers( @Param("users") users: List, ): List - fun findAllByOrderByUserNameAsc(): List + @Query( + """ + SELECT uc + FROM UserCardinal uc + JOIN FETCH uc.user + JOIN FETCH uc.cardinal + ORDER BY uc.user.name ASC + """, + ) + fun findAllWithUserAndCardinal(): List @Query( """ diff --git a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserReader.kt b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserReader.kt index f2c28ef7..ff2a24a9 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserReader.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserReader.kt @@ -1,6 +1,6 @@ package com.weeth.domain.user.domain.repository -import com.weeth.domain.user.domain.entity.Cardinal +import com.weeth.domain.cardinal.domain.entity.Cardinal import com.weeth.domain.user.domain.entity.User import com.weeth.domain.user.domain.enums.Status diff --git a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserRepository.kt b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserRepository.kt index 58c53040..462a9bfd 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserRepository.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserRepository.kt @@ -1,7 +1,7 @@ package com.weeth.domain.user.domain.repository +import com.weeth.domain.cardinal.domain.entity.Cardinal import com.weeth.domain.user.application.exception.UserNotFoundException -import com.weeth.domain.user.domain.entity.Cardinal import com.weeth.domain.user.domain.entity.User import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.vo.Email diff --git a/src/main/kotlin/com/weeth/domain/user/domain/service/UserCardinalPolicy.kt b/src/main/kotlin/com/weeth/domain/user/domain/service/UserCardinalPolicy.kt index 2b0a8e3c..0c565c33 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/service/UserCardinalPolicy.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/service/UserCardinalPolicy.kt @@ -1,7 +1,7 @@ package com.weeth.domain.user.domain.service -import com.weeth.domain.user.application.exception.CardinalNotFoundException -import com.weeth.domain.user.domain.entity.Cardinal +import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException +import com.weeth.domain.cardinal.domain.entity.Cardinal import com.weeth.domain.user.domain.entity.User import com.weeth.domain.user.domain.repository.UserCardinalReader import org.springframework.stereotype.Service diff --git a/src/main/kotlin/com/weeth/domain/user/domain/vo/AttendanceStats.kt b/src/main/kotlin/com/weeth/domain/user/domain/vo/AttendanceStats.kt index 12c357ee..ddbfa3f9 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/vo/AttendanceStats.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/vo/AttendanceStats.kt @@ -5,13 +5,22 @@ import jakarta.persistence.Embeddable @Embeddable class AttendanceStats( + attendanceCount: Int = 0, + absenceCount: Int = 0, + attendanceRate: Int = 0, +) { @Column(name = "attendance_count") - var attendanceCount: Int = 0, + var attendanceCount: Int = attendanceCount + private set + @Column(name = "absence_count") - var absenceCount: Int = 0, + var absenceCount: Int = absenceCount + private set + @Column(name = "attendance_rate") - var attendanceRate: Int = 0, -) { + var attendanceRate: Int = attendanceRate + private set + fun reset() { attendanceCount = 0 absenceCount = 0 diff --git a/src/main/kotlin/com/weeth/domain/user/domain/vo/SocialAuthResult.kt b/src/main/kotlin/com/weeth/domain/user/domain/vo/SocialAuthResult.kt new file mode 100644 index 00000000..d34b1bc7 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/vo/SocialAuthResult.kt @@ -0,0 +1,12 @@ +package com.weeth.domain.user.domain.vo + +import com.weeth.domain.user.domain.enums.SocialProvider + +data class SocialAuthResult( + val provider: SocialProvider, + val providerUserId: String, + val email: String, + val emailVerified: Boolean, + val profileImageUrl: String?, + val name: String?, +) diff --git a/src/main/kotlin/com/weeth/domain/user/infrastructure/AppleSocialAuthAdapter.kt b/src/main/kotlin/com/weeth/domain/user/infrastructure/AppleSocialAuthAdapter.kt new file mode 100644 index 00000000..e8911621 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/infrastructure/AppleSocialAuthAdapter.kt @@ -0,0 +1,30 @@ +package com.weeth.domain.user.infrastructure + +import com.weeth.domain.user.domain.enums.SocialProvider +import com.weeth.domain.user.domain.port.SocialAuthPort +import com.weeth.domain.user.domain.vo.SocialAuthResult +import com.weeth.global.auth.apple.AppleAuthService +import org.springframework.stereotype.Component + +@Component +class AppleSocialAuthAdapter( + private val appleAuthService: AppleAuthService, +) : SocialAuthPort { + override fun provider(): SocialProvider = SocialProvider.APPLE + + override fun authenticate(authCode: String): SocialAuthResult { + val appleToken = appleAuthService.getAppleToken(authCode) + val userInfo = appleAuthService.verifyAndDecodeIdToken(appleToken.idToken) + val email = userInfo.email?.trim()?.lowercase() ?: "" + val providerName = userInfo.name?.trim()?.takeIf { it.isNotBlank() } + + return SocialAuthResult( + provider = SocialProvider.APPLE, + providerUserId = userInfo.appleId, + email = email, + emailVerified = userInfo.emailVerified, + profileImageUrl = null, + name = providerName, + ) + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/infrastructure/KakaoSocialAuthAdapter.kt b/src/main/kotlin/com/weeth/domain/user/infrastructure/KakaoSocialAuthAdapter.kt new file mode 100644 index 00000000..b459513b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/infrastructure/KakaoSocialAuthAdapter.kt @@ -0,0 +1,41 @@ +package com.weeth.domain.user.infrastructure + +import com.weeth.domain.user.application.exception.EmailNotFoundException +import com.weeth.domain.user.domain.enums.SocialProvider +import com.weeth.domain.user.domain.port.SocialAuthPort +import com.weeth.domain.user.domain.vo.SocialAuthResult +import com.weeth.global.auth.kakao.KakaoAuthService +import org.springframework.stereotype.Component + +@Component +class KakaoSocialAuthAdapter( + private val kakaoAuthService: KakaoAuthService, +) : SocialAuthPort { + override fun provider(): SocialProvider = SocialProvider.KAKAO + + override fun authenticate(authCode: String): SocialAuthResult { + val kakaoToken = kakaoAuthService.getKakaoToken(authCode) + val userInfo = kakaoAuthService.getUserInfo(kakaoToken.accessToken) + val account = userInfo.kakaoAccount + val email = account.email?.trim()?.lowercase() + val profileImageUrl = account.profile?.profileImageUrl?.trim() + val providerName = + account.profile + ?.nickname + ?.trim() + ?.takeIf { it.isNotBlank() } + + if (!account.isEmailValid || !account.isEmailVerified || email.isNullOrBlank()) { + throw EmailNotFoundException() + } + + return SocialAuthResult( + provider = SocialProvider.KAKAO, + providerUserId = userInfo.id.toString(), + email = email, + emailVerified = account.isEmailVerified, + profileImageUrl = profileImageUrl, + name = providerName, + ) + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/infrastructure/SocialAuthPortRegistry.kt b/src/main/kotlin/com/weeth/domain/user/infrastructure/SocialAuthPortRegistry.kt new file mode 100644 index 00000000..791002d6 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/infrastructure/SocialAuthPortRegistry.kt @@ -0,0 +1,15 @@ +package com.weeth.domain.user.infrastructure + +import com.weeth.domain.user.domain.enums.SocialProvider +import com.weeth.domain.user.domain.port.SocialAuthPort +import org.springframework.stereotype.Component + +@Component +class SocialAuthPortRegistry( + ports: List, +) { + private val portsByProvider = ports.associateBy { it.provider() } + + fun get(provider: SocialProvider): SocialAuthPort = + requireNotNull(portsByProvider[provider]) { "소셜 로그인 제공자를 찾을 수 없습니다: $provider" } +} diff --git a/src/main/kotlin/com/weeth/domain/user/presentation/CardinalController.kt b/src/main/kotlin/com/weeth/domain/user/presentation/CardinalController.kt deleted file mode 100644 index 740b06b5..00000000 --- a/src/main/kotlin/com/weeth/domain/user/presentation/CardinalController.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.weeth.domain.user.presentation - -import com.weeth.domain.user.application.dto.request.CardinalSaveRequest -import com.weeth.domain.user.application.dto.request.CardinalUpdateRequest -import com.weeth.domain.user.application.dto.response.CardinalResponse -import com.weeth.domain.user.application.exception.UserErrorCode -import com.weeth.domain.user.application.usecase.command.ManageCardinalUseCase -import com.weeth.domain.user.application.usecase.query.GetCardinalQueryService -import com.weeth.global.auth.jwt.application.exception.JwtErrorCode -import com.weeth.global.common.exception.ApiErrorCodeExample -import com.weeth.global.common.response.CommonResponse -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.tags.Tag -import jakarta.validation.Valid -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PatchMapping -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController - -@Tag(name = "CARDINAL") -@RestController -@RequestMapping("/api/v4") -@ApiErrorCodeExample(UserErrorCode::class, JwtErrorCode::class) -class CardinalController( - private val manageCardinalUseCase: ManageCardinalUseCase, - private val getCardinalQueryService: GetCardinalQueryService, -) { - @GetMapping("/cardinals") - @Operation(summary = "현재 저장된 기수 목록 조회 API") - fun findAllCardinals(): CommonResponse> = - CommonResponse.success(UserResponseCode.CARDINAL_FIND_ALL_SUCCESS, getCardinalQueryService.findAll()) - - @PatchMapping("/admin/cardinals") // todo: 어드민 컨트롤러 분리 - @Operation(summary = "[admin] 기수 정보 수정 API") - fun updateCardinals( - @RequestBody @Valid request: CardinalUpdateRequest, - ): CommonResponse { - manageCardinalUseCase.update(request) - return CommonResponse.success(UserResponseCode.CARDINAL_UPDATE_SUCCESS) - } - - @PostMapping("/admin/cardinals") - @Operation(summary = "[admin] 새로운 기수 정보 저장 API") - fun save( - @RequestBody @Valid request: CardinalSaveRequest, - ): CommonResponse { - manageCardinalUseCase.save(request) - return CommonResponse.success(UserResponseCode.CARDINAL_SAVE_SUCCESS) - } -} diff --git a/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt b/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt index 047819ca..3ac82c1b 100644 --- a/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt +++ b/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt @@ -1,16 +1,15 @@ package com.weeth.domain.user.presentation -import com.weeth.domain.user.application.dto.request.SignUpRequest import com.weeth.domain.user.application.dto.request.SocialLoginRequest import com.weeth.domain.user.application.dto.request.UpdateUserProfileRequest import com.weeth.domain.user.application.dto.response.SocialLoginResponse import com.weeth.domain.user.application.dto.response.UserDetailsResponse -import com.weeth.domain.user.application.dto.response.UserInfoResponse import com.weeth.domain.user.application.dto.response.UserProfileResponse import com.weeth.domain.user.application.dto.response.UserSummaryResponse import com.weeth.domain.user.application.exception.UserErrorCode -import com.weeth.domain.user.application.usecase.command.AdminUserUseCase import com.weeth.domain.user.application.usecase.command.AuthUserUseCase +import com.weeth.domain.user.application.usecase.command.SocialLoginUseCase +import com.weeth.domain.user.application.usecase.command.UpdateUserProfileUseCase import com.weeth.domain.user.application.usecase.query.GetUserQueryService import com.weeth.global.auth.annotation.CurrentUser import com.weeth.global.auth.jwt.application.dto.JwtDto @@ -38,7 +37,8 @@ import org.springframework.web.bind.annotation.RestController @ApiErrorCodeExample(UserErrorCode::class, JwtErrorCode::class) class UserController( private val authUserUseCase: AuthUserUseCase, - private val adminUserUseCase: AdminUserUseCase, + private val socialLoginUseCase: SocialLoginUseCase, + private val updateUserProfileUseCase: UpdateUserProfileUseCase, private val getUserQueryService: GetUserQueryService, ) { @PostMapping("/social/kakao") @@ -46,29 +46,20 @@ class UserController( fun socialLoginByKakao( @RequestBody @Valid request: SocialLoginRequest, ): CommonResponse = - CommonResponse.success(UserResponseCode.SOCIAL_LOGIN_SUCCESS, authUserUseCase.socialLoginByKakao(request)) + CommonResponse.success(UserResponseCode.SOCIAL_LOGIN_SUCCESS, socialLoginUseCase.socialLoginByKakao(request)) @PostMapping("/social/apple") @Operation(summary = "애플 소셜 로그인(auth code flow)") fun socialLoginByApple( @RequestBody @Valid request: SocialLoginRequest, ): CommonResponse = - CommonResponse.success(UserResponseCode.SOCIAL_LOGIN_SUCCESS, authUserUseCase.socialLoginByApple(request)) + CommonResponse.success(UserResponseCode.SOCIAL_LOGIN_SUCCESS, socialLoginUseCase.socialLoginByApple(request)) @PostMapping("/social/refresh") @Operation(summary = "토큰 재발급") fun refreshToken(request: HttpServletRequest): CommonResponse = CommonResponse.success(UserResponseCode.JWT_REFRESH_SUCCESS, authUserUseCase.refreshToken(request)) - @PostMapping("/apply") - @Operation(summary = "동아리 지원 신청") - fun apply( - @RequestBody @Valid request: SignUpRequest, - ): CommonResponse { - authUserUseCase.apply(request) - return CommonResponse.success(UserResponseCode.USER_APPLY_SUCCESS) - } - @GetMapping("/email") @Operation(summary = "이메일 중복 확인") fun checkEmail( @@ -113,7 +104,7 @@ class UserController( @Operation(summary = "전역 내 정보 조회 API") fun findMyInfo( @Parameter(hidden = true) @CurrentUser userId: Long, - ): CommonResponse = + ): CommonResponse = CommonResponse.success(UserResponseCode.USER_FIND_BY_ID_SUCCESS, getUserQueryService.findMyInfo(userId)) @PatchMapping @@ -122,7 +113,7 @@ class UserController( @RequestBody @Valid request: UpdateUserProfileRequest, @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse { - authUserUseCase.updateProfile(request, userId) + updateUserProfileUseCase.updateProfile(request, userId) return CommonResponse.success(UserResponseCode.USER_UPDATE_SUCCESS) } diff --git a/src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt b/src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt index b7a71b4b..3fcb9253 100644 --- a/src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt @@ -8,20 +8,16 @@ enum class UserResponseCode( override val status: HttpStatus, override val message: String, ) : ResponseCodeInterface { - SOCIAL_LOGIN_SUCCESS(1815, HttpStatus.OK, "소셜 로그인이 성공적으로 처리되었습니다."), USER_FIND_ALL_SUCCESS(1800, HttpStatus.OK, "모든 회원 정보를 성공적으로 조회했습니다."), USER_DETAILS_SUCCESS(1801, HttpStatus.OK, "특정 회원의 상세 정보를 성공적으로 조회했습니다."), USER_ACCEPT_SUCCESS(1802, HttpStatus.OK, "회원 가입 승인이 성공적으로 처리되었습니다."), USER_BAN_SUCCESS(1803, HttpStatus.OK, "회원이 성공적으로 차단되었습니다."), USER_ROLE_UPDATE_SUCCESS(1804, HttpStatus.OK, "회원의 역할이 성공적으로 수정되었습니다."), USER_APPLY_OB_SUCCESS(1805, HttpStatus.OK, "OB 신청이 성공적으로 처리되었습니다."), - USER_APPLY_SUCCESS(1806, HttpStatus.OK, "회원 가입 신청이 성공적으로 처리되었습니다."), - USER_EMAIL_CHECK_SUCCESS(1807, HttpStatus.OK, "이메일 중복 검사가 성공적으로 처리되었습니다."), - USER_FIND_BY_ID_SUCCESS(1808, HttpStatus.OK, "회원 정보가 성공적으로 조회되었습니다."), - USER_UPDATE_SUCCESS(1809, HttpStatus.OK, "회원 정보가 성공적으로 수정되었습니다."), - USER_LEAVE_SUCCESS(1810, HttpStatus.OK, "회원 탈퇴가 성공적으로 처리되었습니다."), - CARDINAL_FIND_ALL_SUCCESS(1811, HttpStatus.OK, "전체 기수 조회에 성공했습니다."), - CARDINAL_SAVE_SUCCESS(1812, HttpStatus.OK, "기수 저장에 성공했습니다."), - CARDINAL_UPDATE_SUCCESS(1813, HttpStatus.OK, "기수 수정에 성공했습니다."), - JWT_REFRESH_SUCCESS(1814, HttpStatus.OK, "토큰 재발급에 성공했습니다."), + USER_EMAIL_CHECK_SUCCESS(1806, HttpStatus.OK, "이메일 중복 검사가 성공적으로 처리되었습니다."), + USER_FIND_BY_ID_SUCCESS(1807, HttpStatus.OK, "회원 정보가 성공적으로 조회되었습니다."), + USER_UPDATE_SUCCESS(1808, HttpStatus.OK, "회원 정보가 성공적으로 수정되었습니다."), + USER_LEAVE_SUCCESS(1809, HttpStatus.OK, "회원 탈퇴가 성공적으로 처리되었습니다."), + JWT_REFRESH_SUCCESS(1810, HttpStatus.OK, "토큰 재발급에 성공했습니다."), + SOCIAL_LOGIN_SUCCESS(1811, HttpStatus.OK, "소셜 로그인이 성공적으로 처리되었습니다."), } diff --git a/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoProfile.kt b/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoProfile.kt index e7ce2ef3..b21cb5e8 100644 --- a/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoProfile.kt +++ b/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoProfile.kt @@ -5,4 +5,6 @@ import com.fasterxml.jackson.annotation.JsonProperty data class KakaoProfile( @field:JsonProperty("nickname") val nickname: String?, + @field:JsonProperty("profile_image_url") + val profileImageUrl: String?, ) diff --git a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt index 3db6a33e..5d807630 100644 --- a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt +++ b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt @@ -41,12 +41,10 @@ class SecurityConfig( .authorizeHttpRequests { authorize -> authorize .requestMatchers( - "/api/v4/users/apply", "/api/v4/users/email", "/api/v4/users/social/kakao", "/api/v4/users/social/apple", "/api/v4/users/social/refresh", - "/api/v1/users/apply", "/api/v1/users/email", ).permitAll() .requestMatchers("/health-check") diff --git a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCaseTest.kt index bbffddb8..e52e6e92 100644 --- a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCaseTest.kt @@ -3,24 +3,23 @@ package com.weeth.domain.account.application.usecase.command import com.weeth.domain.account.application.dto.request.AccountSaveRequest import com.weeth.domain.account.application.exception.AccountExistsException import com.weeth.domain.account.domain.repository.AccountRepository -import com.weeth.domain.user.domain.entity.Cardinal -import com.weeth.domain.user.domain.repository.CardinalRepository +import com.weeth.domain.cardinal.domain.repository.CardinalReader +import com.weeth.domain.cardinal.fixture.CardinalTestFixture import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk import io.mockk.verify -import java.util.Optional class ManageAccountUseCaseTest : DescribeSpec({ val accountRepository = mockk(relaxed = true) - val cardinalRepository = mockk(relaxed = true) - val useCase = ManageAccountUseCase(accountRepository, cardinalRepository) + val cardinalReader = mockk(relaxed = true) + val useCase = ManageAccountUseCase(accountRepository, cardinalReader) beforeTest { - clearMocks(accountRepository, cardinalRepository) + clearMocks(accountRepository, cardinalReader) } describe("save") { @@ -37,13 +36,13 @@ class ManageAccountUseCaseTest : it("기수 존재를 보장하고 account를 저장한다") { val request = AccountSaveRequest("설명", 100_000, 40) every { accountRepository.existsByCardinal(40) } returns false - every { cardinalRepository.findByCardinalNumber(40) } returns Optional.of(mockk()) + every { cardinalReader.getByCardinalNumber(40) } returns + CardinalTestFixture.createCardinal(cardinalNumber = 40, year = 2026, semester = 1) every { accountRepository.save(any()) } answers { firstArg() } useCase.save(request) - verify(exactly = 1) { cardinalRepository.findByCardinalNumber(40) } - verify(exactly = 0) { cardinalRepository.save(any()) } + verify(exactly = 1) { cardinalReader.getByCardinalNumber(40) } verify(exactly = 1) { accountRepository.save(any()) } } } diff --git a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCaseTest.kt index 06198355..03cca591 100644 --- a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCaseTest.kt @@ -9,14 +9,14 @@ import com.weeth.domain.account.domain.repository.ReceiptRepository import com.weeth.domain.account.domain.vo.Money import com.weeth.domain.account.fixture.AccountTestFixture import com.weeth.domain.account.fixture.ReceiptTestFixture +import com.weeth.domain.cardinal.domain.repository.CardinalReader +import com.weeth.domain.cardinal.fixture.CardinalTestFixture import com.weeth.domain.file.application.dto.request.FileSaveRequest import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.entity.File import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.repository.FileReader import com.weeth.domain.file.domain.repository.FileRepository -import com.weeth.domain.user.domain.entity.Cardinal -import com.weeth.domain.user.domain.repository.CardinalRepository import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.mockk.clearMocks @@ -32,7 +32,7 @@ class ManageReceiptUseCaseTest : val accountRepository = mockk() val fileReader = mockk() val fileRepository = mockk(relaxed = true) - val cardinalRepository = mockk(relaxed = true) + val cardinalReader = mockk(relaxed = true) val fileMapper = mockk() val useCase = ManageReceiptUseCase( @@ -40,16 +40,17 @@ class ManageReceiptUseCaseTest : accountRepository, fileReader, fileRepository, - cardinalRepository, + cardinalReader, fileMapper, ) beforeTest { - clearMocks(receiptRepository, accountRepository, fileReader, fileRepository, cardinalRepository, fileMapper) + clearMocks(receiptRepository, accountRepository, fileReader, fileRepository, cardinalReader, fileMapper) } fun stubExistingCardinal(cardinalNumber: Int) { - every { cardinalRepository.findByCardinalNumber(cardinalNumber) } returns Optional.of(mockk()) + every { cardinalReader.getByCardinalNumber(cardinalNumber) } returns + CardinalTestFixture.createCardinal(cardinalNumber = cardinalNumber, year = 2026, semester = 1) } describe("save") { diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt index 4baa3f30..de6cc13f 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt @@ -8,9 +8,9 @@ import com.weeth.domain.attendance.application.mapper.AttendanceMapper import com.weeth.domain.attendance.domain.entity.Attendance import com.weeth.domain.attendance.domain.repository.AttendanceRepository import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createActiveUser +import com.weeth.domain.cardinal.domain.entity.Cardinal import com.weeth.domain.session.domain.entity.Session import com.weeth.domain.session.domain.repository.SessionReader -import com.weeth.domain.user.domain.entity.Cardinal import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.repository.UserReader import com.weeth.domain.user.domain.service.UserCardinalPolicy diff --git a/src/test/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepositoryTest.kt b/src/test/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepositoryTest.kt index 164199a8..09e97a23 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepositoryTest.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepositoryTest.kt @@ -43,14 +43,20 @@ class AttendanceRepositoryTest( sessionRepository.save(session) activeUser1 = - User( + User.create( name = "이지훈", - status = Status.ACTIVE, + email = "lee.jihoon@test.com", + studentId = "", + tel = "", + department = "", ) activeUser2 = - User( + User.create( name = "이강혁", - status = Status.ACTIVE, + email = "lee.ganghyuk@test.com", + studentId = "", + tel = "", + department = "", ) userRepository.saveAll(listOf(activeUser1, activeUser2)) activeUser1.accept() diff --git a/src/test/kotlin/com/weeth/domain/attendance/fixture/AttendanceTestFixture.kt b/src/test/kotlin/com/weeth/domain/attendance/fixture/AttendanceTestFixture.kt index 6e70666d..3db590ce 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/fixture/AttendanceTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/fixture/AttendanceTestFixture.kt @@ -7,20 +7,20 @@ import com.weeth.domain.user.domain.enums.Role import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.vo.AttendanceStats import org.springframework.test.util.ReflectionTestUtils +import java.util.UUID object AttendanceTestFixture { fun createActiveUser(name: String): User = - User( - name = name, - status = Status.ACTIVE, - ) + User + .create( + name = name, + email = "attendance-${UUID.randomUUID()}@test.com", + studentId = "", + tel = "", + department = "", + ).also { it.accept() } - fun createAdminUser(name: String): User = - User( - name = name, - status = Status.ACTIVE, - role = Role.ADMIN, - ) + fun createAdminUser(name: String): User = createActiveUser(name).also { it.updateRole(Role.ADMIN) } fun createAttendance( session: Session, diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt index 3bffc160..74b49940 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt @@ -22,6 +22,7 @@ import com.weeth.domain.user.domain.entity.User import com.weeth.domain.user.domain.enums.Role import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.repository.UserReader +import com.weeth.domain.user.domain.vo.Email import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec @@ -74,7 +75,7 @@ class ManagePostUseCaseTest : User( id = id, name = "적순", - email = "test1@test.com", + email = Email.from("test1@test.com"), status = Status.ACTIVE, role = role, ) diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/CardinalUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/cardinal/application/usecase/command/CardinalUseCaseTest.kt similarity index 81% rename from src/test/kotlin/com/weeth/domain/user/application/usecase/command/CardinalUseCaseTest.kt rename to src/test/kotlin/com/weeth/domain/cardinal/application/usecase/command/CardinalUseCaseTest.kt index 56d28f48..5817c021 100644 --- a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/CardinalUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/cardinal/application/usecase/command/CardinalUseCaseTest.kt @@ -1,14 +1,15 @@ -package com.weeth.domain.user.application.usecase.command - -import com.weeth.domain.user.application.dto.request.CardinalSaveRequest -import com.weeth.domain.user.application.dto.request.CardinalUpdateRequest -import com.weeth.domain.user.application.dto.response.CardinalResponse -import com.weeth.domain.user.application.mapper.CardinalMapper -import com.weeth.domain.user.application.usecase.query.GetCardinalQueryService -import com.weeth.domain.user.domain.entity.Cardinal -import com.weeth.domain.user.domain.enums.CardinalStatus -import com.weeth.domain.user.domain.repository.CardinalRepository -import com.weeth.domain.user.fixture.CardinalTestFixture +package com.weeth.domain.cardinal.application.usecase.command + +import com.weeth.domain.cardinal.application.dto.request.CardinalSaveRequest +import com.weeth.domain.cardinal.application.dto.request.CardinalUpdateRequest +import com.weeth.domain.cardinal.application.dto.response.CardinalResponse +import com.weeth.domain.cardinal.application.mapper.CardinalMapper +import com.weeth.domain.cardinal.application.usecase.query.GetCardinalQueryService +import com.weeth.domain.cardinal.domain.entity.Cardinal +import com.weeth.domain.cardinal.domain.enums.CardinalStatus +import com.weeth.domain.cardinal.domain.repository.CardinalRepository +import com.weeth.domain.cardinal.domain.service.CardinalStatusPolicy +import com.weeth.domain.cardinal.fixture.CardinalTestFixture import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe @@ -22,7 +23,8 @@ class CardinalUseCaseTest : DescribeSpec({ val cardinalRepository = mockk() val cardinalMapper = mockk() - val manageCardinalUseCase = ManageCardinalUseCase(cardinalRepository, cardinalMapper) + val cardinalStatusPolicy = CardinalStatusPolicy(cardinalRepository) + val manageCardinalUseCase = ManageCardinalUseCase(cardinalRepository, cardinalMapper, cardinalStatusPolicy) val getCardinalQueryService = GetCardinalQueryService(cardinalRepository, cardinalMapper) describe("save") { @@ -40,7 +42,7 @@ class CardinalUseCaseTest : verify { cardinalRepository.findByCardinalNumber(7) } verify { cardinalRepository.save(toSave) } - verify(exactly = 0) { cardinalRepository.findAllByStatus(CardinalStatus.IN_PROGRESS) } + verify(exactly = 0) { cardinalRepository.findAllInProgressWithLock() } } } @@ -55,13 +57,13 @@ class CardinalUseCaseTest : CardinalTestFixture.createCardinal(cardinalNumber = 7, year = 2025, semester = 1) every { cardinalRepository.findByCardinalNumber(7) } returns Optional.empty() - every { cardinalRepository.findAllByStatus(CardinalStatus.IN_PROGRESS) } returns listOf(oldCardinal) + every { cardinalRepository.findAllInProgressWithLock() } returns listOf(oldCardinal) every { cardinalMapper.toEntity(request) } returns newCardinalBeforeSave every { cardinalRepository.save(newCardinalBeforeSave) } returns newCardinalAfterSave manageCardinalUseCase.save(request) - verify { cardinalRepository.findAllByStatus(CardinalStatus.IN_PROGRESS) } + verify { cardinalRepository.findAllInProgressWithLock() } verify { cardinalRepository.save(newCardinalBeforeSave) } oldCardinal.status shouldBe CardinalStatus.DONE diff --git a/src/test/kotlin/com/weeth/domain/user/domain/entity/CardinalTest.kt b/src/test/kotlin/com/weeth/domain/cardinal/domain/entity/CardinalTest.kt similarity index 86% rename from src/test/kotlin/com/weeth/domain/user/domain/entity/CardinalTest.kt rename to src/test/kotlin/com/weeth/domain/cardinal/domain/entity/CardinalTest.kt index 38677016..9cc75cad 100644 --- a/src/test/kotlin/com/weeth/domain/user/domain/entity/CardinalTest.kt +++ b/src/test/kotlin/com/weeth/domain/cardinal/domain/entity/CardinalTest.kt @@ -1,6 +1,6 @@ -package com.weeth.domain.user.domain.entity +package com.weeth.domain.cardinal.domain.entity -import com.weeth.domain.user.domain.enums.CardinalStatus +import com.weeth.domain.cardinal.domain.enums.CardinalStatus import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.shouldBe diff --git a/src/test/kotlin/com/weeth/domain/user/domain/repository/CardinalRepositoryTest.kt b/src/test/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalRepositoryTest.kt similarity index 89% rename from src/test/kotlin/com/weeth/domain/user/domain/repository/CardinalRepositoryTest.kt rename to src/test/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalRepositoryTest.kt index decac4e6..43663356 100644 --- a/src/test/kotlin/com/weeth/domain/user/domain/repository/CardinalRepositoryTest.kt +++ b/src/test/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalRepositoryTest.kt @@ -1,7 +1,7 @@ -package com.weeth.domain.user.domain.repository +package com.weeth.domain.cardinal.domain.repository import com.weeth.config.TestContainersConfig -import com.weeth.domain.user.fixture.CardinalTestFixture +import com.weeth.domain.cardinal.fixture.CardinalTestFixture import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.optional.shouldBePresent import io.kotest.matchers.shouldBe diff --git a/src/test/kotlin/com/weeth/domain/user/fixture/CardinalTestFixture.kt b/src/test/kotlin/com/weeth/domain/cardinal/fixture/CardinalTestFixture.kt similarity index 82% rename from src/test/kotlin/com/weeth/domain/user/fixture/CardinalTestFixture.kt rename to src/test/kotlin/com/weeth/domain/cardinal/fixture/CardinalTestFixture.kt index 72af67df..305c2a2e 100644 --- a/src/test/kotlin/com/weeth/domain/user/fixture/CardinalTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/cardinal/fixture/CardinalTestFixture.kt @@ -1,7 +1,7 @@ -package com.weeth.domain.user.fixture +package com.weeth.domain.cardinal.fixture -import com.weeth.domain.user.domain.entity.Cardinal -import com.weeth.domain.user.domain.enums.CardinalStatus +import com.weeth.domain.cardinal.domain.entity.Cardinal +import com.weeth.domain.cardinal.domain.enums.CardinalStatus object CardinalTestFixture { fun createCardinal( diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt index 988c7742..c96d9ef8 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt @@ -13,6 +13,7 @@ import com.weeth.domain.comment.domain.repository.CommentRepository import com.weeth.domain.user.domain.entity.User import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.repository.UserRepository +import com.weeth.domain.user.domain.vo.Email import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe import jakarta.persistence.EntityManager @@ -68,7 +69,7 @@ class CommentConcurrencyTest( userRepository.save( User( name = "user$i", - email = "user$i@test.com", + email = Email.from("user$i@test.com"), status = Status.ACTIVE, ), ) diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt index 116ef5fe..82f560d1 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt @@ -20,6 +20,7 @@ import com.weeth.domain.user.domain.entity.User import com.weeth.domain.user.domain.enums.Role import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.repository.UserRepository +import com.weeth.domain.user.domain.vo.Email import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.longs.shouldBeLessThan import io.kotest.matchers.shouldBe @@ -48,7 +49,7 @@ class CommentQueryPerformanceTest( userRepository.save( User( name = "perf-user", - email = "perf-user@test.com", + email = Email.from("perf-user@test.com"), department = "컴퓨터공학과", status = Status.ACTIVE, role = Role.USER, diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AdminUserUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AdminUserUseCaseTest.kt index 14c9a41c..541b0c1c 100644 --- a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AdminUserUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AdminUserUseCaseTest.kt @@ -2,7 +2,8 @@ package com.weeth.domain.user.application.usecase.command import com.weeth.domain.attendance.domain.entity.Attendance import com.weeth.domain.attendance.domain.repository.AttendanceRepository -import com.weeth.domain.session.domain.entity.Session +import com.weeth.domain.cardinal.domain.repository.CardinalReader +import com.weeth.domain.cardinal.fixture.CardinalTestFixture import com.weeth.domain.session.domain.repository.SessionReader import com.weeth.domain.user.application.dto.request.UserApplyObRequest import com.weeth.domain.user.application.dto.request.UserIdsRequest @@ -10,11 +11,10 @@ import com.weeth.domain.user.application.dto.request.UserRoleUpdateRequest import com.weeth.domain.user.domain.entity.UserCardinal import com.weeth.domain.user.domain.enums.Role import com.weeth.domain.user.domain.enums.Status -import com.weeth.domain.user.domain.repository.CardinalRepository import com.weeth.domain.user.domain.repository.UserCardinalRepository import com.weeth.domain.user.domain.repository.UserReader import com.weeth.domain.user.domain.service.UserCardinalPolicy -import com.weeth.domain.user.fixture.CardinalTestFixture +import com.weeth.domain.user.fixture.SessionTestFixture import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe @@ -26,30 +26,30 @@ import io.mockk.verify class AdminUserUseCaseTest : DescribeSpec({ val userReader = mockk() - val sessionReader = mockk() - val attendanceRepository = mockk(relaxed = true) - val cardinalRepository = mockk() - val userCardinalRepository = mockk(relaxUnitFun = true) val userCardinalPolicy = mockk() + val cardinalReader = mockk() + val sessionReader = mockk() + val attendanceRepository = mockk() + val userCardinalRepository = mockk() val useCase = AdminUserUseCase( - userReader, - sessionReader, - attendanceRepository, - cardinalRepository, - userCardinalRepository, - userCardinalPolicy, + userReader = userReader, + userCardinalPolicy = userCardinalPolicy, + cardinalReader = cardinalReader, + sessionReader = sessionReader, + attendanceRepository = attendanceRepository, + userCardinalRepository = userCardinalRepository, ) beforeTest { clearMocks( userReader, + userCardinalPolicy, + cardinalReader, sessionReader, attendanceRepository, - cardinalRepository, userCardinalRepository, - userCardinalPolicy, ) } @@ -63,17 +63,38 @@ class AdminUserUseCaseTest : year = 2025, semester = 1, ) - val sessions = listOf(mockk()) + val sessions = listOf(SessionTestFixture.createSession(cardinalNumber = 8)) every { userReader.findAllByIds(listOf(1L)) } returns listOf(user) every { userCardinalPolicy.getCurrentCardinal(user) } returns currentCardinal every { sessionReader.findAllByCardinal(8) } returns sessions + every { attendanceRepository.saveAll(any>()) } answers { firstArg() } useCase.accept(UserIdsRequest(listOf(1L))) verify(exactly = 1) { attendanceRepository.saveAll(any>()) } user.status shouldBe Status.ACTIVE } + + it("이미 활성 상태인 유저는 승인 처리를 건너뛴다") { + val user = UserTestFixture.createActiveUser1(1L) + val currentCardinal = + CardinalTestFixture.createCardinal( + id = 1L, + cardinalNumber = 8, + year = 2025, + semester = 1, + ) + + every { userReader.findAllByIds(listOf(1L)) } returns listOf(user) + every { userCardinalPolicy.getCurrentCardinal(user) } returns currentCardinal + + useCase.accept(UserIdsRequest(listOf(1L))) + + user.status shouldBe Status.ACTIVE + verify(exactly = 0) { sessionReader.findAllByCardinal(any()) } + verify(exactly = 0) { attendanceRepository.saveAll(any>()) } + } } describe("updateRole") { @@ -99,111 +120,85 @@ class AdminUserUseCaseTest : } describe("applyOb") { - it("다음 기수로 OB 신청 시 출석을 초기화하고 user-cardinal을 저장한다") { + it("중복 요청을 제거하고 새 기수에 등록한다") { val user = UserTestFixture.createActiveUser1(1L) - val currentCardinal = + val nextCardinal = CardinalTestFixture.createCardinal( - id = 10L, - cardinalNumber = 3, + id = 2L, + cardinalNumber = 4, year = 2024, semester = 2, ) - val nextCardinal = - CardinalTestFixture.createCardinal( - id = 11L, - cardinalNumber = 4, - year = 2025, - semester = 1, + val sessions = listOf(SessionTestFixture.createSession(cardinalNumber = 4)) + + val requests = + listOf( + UserApplyObRequest(userId = 1L, cardinal = 4), + UserApplyObRequest(userId = 1L, cardinal = 4), ) - val session = mockk() - every { session.cardinal } returns 4 - val request = listOf(UserApplyObRequest(1L, 4)) every { userReader.findAllByIds(listOf(1L)) } returns listOf(user) - every { userCardinalRepository.findAllByUsers(listOf(user)) } returns - listOf(UserCardinal(user, currentCardinal)) - every { cardinalRepository.findAllByCardinalNumberIn(listOf(4)) } returns listOf(nextCardinal) - every { sessionReader.findAllByCardinalIn(listOf(4)) } returns listOf(session) - every { userCardinalRepository.save(any()) } answers { firstArg() } + every { cardinalReader.getByCardinalNumber(4) } returns nextCardinal + every { userCardinalPolicy.notContains(user, nextCardinal) } returns true + every { userCardinalPolicy.isCurrent(user, nextCardinal) } returns true + every { sessionReader.findAllByCardinal(4) } returns sessions + every { attendanceRepository.saveAll(any>()) } answers { firstArg() } + every { userCardinalRepository.save(any()) } answers { firstArg() } - useCase.applyOb(request) + useCase.applyOb(requests) + // 중복 제거되어 1번만 실행 + verify(exactly = 1) { userCardinalRepository.save(any()) } verify(exactly = 1) { attendanceRepository.saveAll(any>()) } - verify( - exactly = 1, - ) { userCardinalRepository.save(match { it.user == user && it.cardinal == nextCardinal }) } } - it("이미 해당 기수를 보유한 유저는 저장을 스킵한다") { + it("새 기수이지만 현재 기수보다 이전이면 출석 초기화 없이 등록만 한다") { val user = UserTestFixture.createActiveUser1(1L) - val cardinal = + val nextCardinal = CardinalTestFixture.createCardinal( - id = 11L, - cardinalNumber = 4, - year = 2025, - semester = 1, + id = 2L, + cardinalNumber = 3, + year = 2023, + semester = 2, ) - val request = listOf(UserApplyObRequest(1L, 4)) every { userReader.findAllByIds(listOf(1L)) } returns listOf(user) - every { userCardinalRepository.findAllByUsers(listOf(user)) } returns - listOf(UserCardinal(user, cardinal)) - every { cardinalRepository.findAllByCardinalNumberIn(listOf(4)) } returns listOf(cardinal) + every { cardinalReader.getByCardinalNumber(3) } returns nextCardinal + every { userCardinalPolicy.notContains(user, nextCardinal) } returns true + every { userCardinalPolicy.isCurrent(user, nextCardinal) } returns false + every { userCardinalRepository.save(any()) } answers { firstArg() } - useCase.applyOb(request) + useCase.applyOb(listOf(UserApplyObRequest(userId = 1L, cardinal = 3))) - verify(exactly = 0) { sessionReader.findAllByCardinalIn(any()) } - verify(exactly = 0) { userCardinalRepository.save(any()) } + verify(exactly = 1) { userCardinalRepository.save(any()) } + verify(exactly = 0) { sessionReader.findAllByCardinal(any()) } verify(exactly = 0) { attendanceRepository.saveAll(any>()) } } - it("요청 목록이 비어 있으면 아무 처리도 하지 않는다") { - useCase.applyOb(emptyList()) - - verify(exactly = 0) { userReader.findAllByIds(any()) } - verify(exactly = 0) { userCardinalRepository.save(any()) } - } - - it("존재하지 않는 기수라면 새로 생성한다") { + it("이미 등록된 기수이면 건너뛴다") { val user = UserTestFixture.createActiveUser1(1L) - val currentCardinal = + val nextCardinal = CardinalTestFixture.createCardinal( - id = 10L, - cardinalNumber = 3, + id = 2L, + cardinalNumber = 4, year = 2024, semester = 2, ) - val createdCardinal = - CardinalTestFixture.createCardinal( - id = 12L, - cardinalNumber = 5, - year = 2025, - semester = 2, - ) - val session = mockk() - every { session.cardinal } returns 5 - val request = listOf(UserApplyObRequest(1L, 5)) every { userReader.findAllByIds(listOf(1L)) } returns listOf(user) - every { userCardinalRepository.findAllByUsers(listOf(user)) } returns - listOf(UserCardinal(user, currentCardinal)) - every { cardinalRepository.findAllByCardinalNumberIn(listOf(5)) } returns emptyList() - every { cardinalRepository.save(any()) } returns createdCardinal - every { sessionReader.findAllByCardinalIn(listOf(5)) } returns listOf(session) - every { userCardinalRepository.save(any()) } answers { firstArg() } + every { cardinalReader.getByCardinalNumber(4) } returns nextCardinal + every { userCardinalPolicy.notContains(user, nextCardinal) } returns false - useCase.applyOb(request) + useCase.applyOb(listOf(UserApplyObRequest(userId = 1L, cardinal = 4))) - verify(exactly = 1) { cardinalRepository.save(any()) } - verify(exactly = 1) { attendanceRepository.saveAll(any>()) } - verify(exactly = 1) { - userCardinalRepository.save( - match { - it.user == user && - it.cardinal == createdCardinal - }, - ) - } + verify(exactly = 0) { userCardinalRepository.save(any()) } + } + + it("요청이 비어 있으면 아무 작업도 수행하지 않는다") { + useCase.applyOb(emptyList()) + + verify(exactly = 0) { userReader.findAllByIds(any()) } + verify(exactly = 0) { userCardinalRepository.save(any()) } } } }) diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCaseTest.kt index f329e09e..ca822e46 100644 --- a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCaseTest.kt @@ -1,119 +1,30 @@ package com.weeth.domain.user.application.usecase.command -import com.weeth.domain.user.application.dto.request.SignUpRequest -import com.weeth.domain.user.application.dto.request.SocialLoginRequest -import com.weeth.domain.user.application.dto.request.UpdateUserProfileRequest -import com.weeth.domain.user.application.exception.StudentIdExistsException -import com.weeth.domain.user.application.exception.UserInActiveException -import com.weeth.domain.user.application.mapper.UserMapper -import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.entity.UserCardinal import com.weeth.domain.user.domain.enums.Status -import com.weeth.domain.user.domain.repository.CardinalReader -import com.weeth.domain.user.domain.repository.UserCardinalRepository import com.weeth.domain.user.domain.repository.UserReader -import com.weeth.domain.user.domain.repository.UserRepository -import com.weeth.domain.user.domain.repository.UserSocialAccountRepository -import com.weeth.domain.user.fixture.CardinalTestFixture import com.weeth.domain.user.fixture.UserTestFixture -import com.weeth.global.auth.apple.AppleAuthService -import com.weeth.global.auth.apple.dto.AppleTokenResponse -import com.weeth.global.auth.apple.dto.AppleUserInfo import com.weeth.global.auth.jwt.application.dto.JwtDto import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase -import com.weeth.global.auth.kakao.KakaoAuthService -import com.weeth.global.auth.kakao.dto.KakaoAccount -import com.weeth.global.auth.kakao.dto.KakaoProfile -import com.weeth.global.auth.kakao.dto.KakaoTokenResponse -import com.weeth.global.auth.kakao.dto.KakaoUserInfoResponse -import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe import io.mockk.every import io.mockk.mockk -import io.mockk.verify import jakarta.servlet.http.HttpServletRequest -import java.util.Optional class AuthUserUseCaseTest : DescribeSpec({ - val userRepository = mockk(relaxed = true) val userReader = mockk() - val cardinalReader = mockk() - val userCardinalRepository = mockk(relaxed = true) - val userSocialAccountRepository = mockk(relaxed = true) - val mapper = mockk() - val kakaoAuthService = mockk() - val appleAuthService = mockk() val jwtManageUseCase = mockk() val jwtTokenExtractor = mockk() val useCase = AuthUserUseCase( - userRepository, userReader, - cardinalReader, - userCardinalRepository, - mapper, - userSocialAccountRepository, - kakaoAuthService, - appleAuthService, jwtManageUseCase, jwtTokenExtractor, ) - describe("apply") { - it("유저와 유저-기수 연관관계를 저장한다") { - val request = SignUpRequest("홍길동", "a@test.com", "20201234", "01012345678", "컴퓨터공학과", 7) - val user = UserTestFixture.createActiveUser1(1L) - val cardinal = - CardinalTestFixture.createCardinal( - id = 10L, - cardinalNumber = 7, - year = 2025, - semester = 1, - ) - - every { userRepository.existsByStudentId(request.studentId) } returns false - every { userRepository.existsByTelValue(request.tel) } returns false - every { cardinalReader.getByCardinalNumber(request.cardinal) } returns cardinal - every { mapper.toEntity(request) } returns user - every { userRepository.save(user) } returns user - every { userCardinalRepository.save(any()) } answers { firstArg() } - - useCase.apply(request) - - verify(exactly = 1) { userRepository.save(user) } - verify(exactly = 1) { userCardinalRepository.save(any()) } - } - - it("학번 중복이면 StudentIdExistsException") { - val request = SignUpRequest("홍길동", "a@test.com", "20201234", "01012345678", "컴퓨터공학과", 7) - every { userRepository.existsByStudentId(request.studentId) } returns true - - shouldThrow { - useCase.apply(request) - } - } - } - - describe("updateProfile") { - it("내 정보를 수정한다") { - val user = UserTestFixture.createActiveUser1(1L) - val request = UpdateUserProfileRequest("변경이름", "new@test.com", "20209999", "01099998888", "경영학과") - - every { userRepository.existsByStudentIdAndIdIsNot(request.studentId, 1L) } returns false - every { userRepository.existsByTelAndIdIsNotValue(request.tel, 1L) } returns false - every { userReader.getById(1L) } returns user - - useCase.updateProfile(request, 1L) - - user.name shouldBe "변경이름" - user.department shouldBe "경영학과" - } - } - describe("leave") { it("회원 탈퇴 시 상태를 LEFT로 변경한다") { val user = UserTestFixture.createActiveUser1(1L) @@ -125,165 +36,8 @@ class AuthUserUseCaseTest : } } - describe("socialLoginByKakao") { - it("가입된 활성 사용자면 토큰을 발급한다") { - val request = SocialLoginRequest("auth-code") - val tokenResponse = KakaoTokenResponse("bearer", "kakao-access", 3600, "kakao-refresh", 3600) - val userInfo = - KakaoUserInfoResponse( - id = 1L, - kakaoAccount = KakaoAccount(isEmailValid = true, isEmailVerified = true, email = "a@test.com"), - ) - val user = UserTestFixture.createActiveUser1(1L) - - every { kakaoAuthService.getKakaoToken("auth-code") } returns tokenResponse - every { kakaoAuthService.getUserInfo("kakao-access") } returns userInfo - every { userSocialAccountRepository.findByProviderAndProviderUserId(any(), any()) } returns - Optional.empty() - every { userRepository.findByEmailValue("a@test.com") } returns Optional.of(user) - every { userSocialAccountRepository.save(any()) } answers { firstArg() } - every { jwtManageUseCase.create(user.id, user.emailValue, user.role) } returns - JwtDto("access", "refresh") - - val result = useCase.socialLoginByKakao(request) - - result.isNewUser shouldBe false - result.profileCompleted shouldBe false - result.accessToken shouldBe "access" - result.refreshToken shouldBe "refresh" - } - - it("기존 사용자가 추가 프로필 payload 없이 로그인하면 provider 이름으로 덮어쓰지 않는다") { - val request = SocialLoginRequest("auth-code") - val tokenResponse = KakaoTokenResponse("bearer", "kakao-access", 3600, "kakao-refresh", 3600) - val userInfo = - KakaoUserInfoResponse( - id = 1L, - kakaoAccount = - KakaoAccount( - isEmailValid = true, - isEmailVerified = true, - email = "a@test.com", - profile = KakaoProfile(nickname = "카카오닉네임"), - ), - ) - val user = UserTestFixture.createActiveUser1(1L).also { it.name = "내가수정한이름" } - - every { kakaoAuthService.getKakaoToken("auth-code") } returns tokenResponse - every { kakaoAuthService.getUserInfo("kakao-access") } returns userInfo - every { userSocialAccountRepository.findByProviderAndProviderUserId(any(), any()) } returns - Optional.empty() - every { userRepository.findByEmailValue("a@test.com") } returns Optional.of(user) - every { userSocialAccountRepository.save(any()) } answers { firstArg() } - every { jwtManageUseCase.create(user.id, user.emailValue, user.role) } returns - JwtDto("access", "refresh") - - useCase.socialLoginByKakao(request) - - user.name shouldBe "내가수정한이름" - } - - it("식별자가 없고 이메일 사용자도 없으면 사용자를 생성하고 로그인한다") { - val request = SocialLoginRequest("auth-code") - val tokenResponse = KakaoTokenResponse("bearer", "kakao-access", 3600, "kakao-refresh", 3600) - val userInfo = - KakaoUserInfoResponse( - id = 1L, - kakaoAccount = - KakaoAccount( - isEmailValid = true, - isEmailVerified = true, - email = "new@test.com", - ), - ) - val createdUser = - User.create( - name = "", - email = "new@test.com", - studentId = "", - tel = "", - department = "", - ) - - every { kakaoAuthService.getKakaoToken("auth-code") } returns tokenResponse - every { kakaoAuthService.getUserInfo("kakao-access") } returns userInfo - every { userSocialAccountRepository.findByProviderAndProviderUserId(any(), any()) } returns - Optional.empty() - every { userRepository.findByEmailValue("new@test.com") } returns Optional.empty() - every { userRepository.save(any()) } returns createdUser - every { userSocialAccountRepository.save(any()) } answers { firstArg() } - every { - jwtManageUseCase.create( - createdUser.id, - createdUser.emailValue, - createdUser.role, - ) - } returns - JwtDto( - "access", - "refresh", - ) - - val result = useCase.socialLoginByKakao(request) - - result.isNewUser shouldBe true - result.email shouldBe "new@test.com" - result.accessToken shouldBe "access" - } - - it("추방된 사용자면 예외를 던진다") { - val request = SocialLoginRequest("auth-code") - val tokenResponse = KakaoTokenResponse("bearer", "kakao-access", 3600, "kakao-refresh", 3600) - val userInfo = - KakaoUserInfoResponse( - id = 1L, - kakaoAccount = - KakaoAccount( - isEmailValid = true, - isEmailVerified = true, - email = "ban@test.com", - ), - ) - val bannedUser = UserTestFixture.createActiveUser1(1L).also { it.ban() } - - every { kakaoAuthService.getKakaoToken("auth-code") } returns tokenResponse - every { kakaoAuthService.getUserInfo("kakao-access") } returns userInfo - every { userSocialAccountRepository.findByProviderAndProviderUserId(any(), any()) } returns - Optional.empty() - every { userRepository.findByEmailValue("ban@test.com") } returns Optional.of(bannedUser) - every { userSocialAccountRepository.save(any()) } answers { firstArg() } - - shouldThrow { - useCase.socialLoginByKakao(request) - } - } - } - - describe("socialLoginByApple") { - it("가입된 활성 사용자면 토큰을 발급한다") { - val request = SocialLoginRequest("apple-code") - val tokenResponse = AppleTokenResponse("apple-access", "bearer", 3600, "apple-refresh", "id-token") - val userInfo = AppleUserInfo(appleId = "apple-sub", email = "apple@test.com", emailVerified = true) - val user = UserTestFixture.createActiveUser1(1L) - - every { appleAuthService.getAppleToken("apple-code") } returns tokenResponse - every { appleAuthService.verifyAndDecodeIdToken("id-token") } returns userInfo - every { userSocialAccountRepository.findByProviderAndProviderUserId(any(), any()) } returns - Optional.empty() - every { userRepository.findByEmailValue("apple@test.com") } returns Optional.of(user) - every { userSocialAccountRepository.save(any()) } answers { firstArg() } - every { jwtManageUseCase.create(user.id, user.emailValue, user.role) } returns - JwtDto("access", "refresh") - - val result = useCase.socialLoginByApple(request) - - result.isNewUser shouldBe false - result.accessToken shouldBe "access" - } - } - describe("refreshToken") { - it("헤더의 리프레시 토큰으로 토큰을 재발급한다") { + it("요청에서 refresh token을 추출해 재발급한다") { val servletRequest = mockk() every { jwtTokenExtractor.extractRefreshToken(servletRequest) } returns "refresh-token" every { jwtManageUseCase.reIssueToken("refresh-token") } returns JwtDto("new-access", "new-refresh") diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCaseTest.kt new file mode 100644 index 00000000..bc297816 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCaseTest.kt @@ -0,0 +1,106 @@ +package com.weeth.domain.user.application.usecase.command + +import com.weeth.domain.user.application.dto.request.SocialLoginRequest +import com.weeth.domain.user.application.exception.EmailNotFoundException +import com.weeth.domain.user.application.mapper.UserMapper +import com.weeth.domain.user.domain.entity.UserSocialAccount +import com.weeth.domain.user.domain.enums.SocialProvider +import com.weeth.domain.user.domain.port.SocialAuthPort +import com.weeth.domain.user.domain.repository.UserRepository +import com.weeth.domain.user.domain.repository.UserSocialAccountRepository +import com.weeth.domain.user.domain.vo.SocialAuthResult +import com.weeth.domain.user.fixture.UserTestFixture +import com.weeth.domain.user.infrastructure.SocialAuthPortRegistry +import com.weeth.global.auth.jwt.application.dto.JwtDto +import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import java.util.Optional + +class SocialLoginUseCaseTest : + DescribeSpec({ + val userRepository = mockk() + val userSocialAccountRepository = mockk() + val socialAuthPortRegistry = mockk() + val socialAuthPort = mockk() + val jwtManageUseCase = mockk() + val userMapper = UserMapper() + + val useCase = + SocialLoginUseCase( + userRepository = userRepository, + userSocialAccountRepository = userSocialAccountRepository, + socialAuthPortRegistry = socialAuthPortRegistry, + jwtManageUseCase = jwtManageUseCase, + userMapper = userMapper, + ) + + describe("socialLoginByApple") { + it("기존 연동 계정은 이메일이 없어도 로그인된다") { + val request = SocialLoginRequest(authCode = "apple-auth-code") + val user = UserTestFixture.createActiveUser1(1L) + val account = + UserSocialAccount( + provider = SocialProvider.APPLE, + providerUserId = "apple-user-1", + user = user, + ) + val authResult = + SocialAuthResult( + provider = SocialProvider.APPLE, + providerUserId = "apple-user-1", + email = "", + emailVerified = false, + profileImageUrl = null, + name = null, + ) + + every { socialAuthPortRegistry.get(SocialProvider.APPLE) } returns socialAuthPort + every { socialAuthPort.authenticate("apple-auth-code") } returns authResult + every { + userSocialAccountRepository.findByProviderAndProviderUserId(SocialProvider.APPLE, "apple-user-1") + } returns Optional.of(account) + every { jwtManageUseCase.create(user.id, user.emailValue, user.role) } returns + JwtDto("access", "refresh") + + val result = useCase.socialLoginByApple(request) + + result.accessToken shouldBe "access" + result.refreshToken shouldBe "refresh" + result.isNewUser shouldBe false + + verify(exactly = 0) { userRepository.save(any()) } + verify(exactly = 0) { userSocialAccountRepository.save(any()) } + } + + it("신규 연동 계정은 이메일이 없으면 예외가 발생한다") { + val request = SocialLoginRequest(authCode = "apple-auth-code") + val authResult = + SocialAuthResult( + provider = SocialProvider.APPLE, + providerUserId = "apple-user-2", + email = "", + emailVerified = false, + profileImageUrl = null, + name = null, + ) + + every { socialAuthPortRegistry.get(SocialProvider.APPLE) } returns socialAuthPort + every { socialAuthPort.authenticate("apple-auth-code") } returns authResult + every { + userSocialAccountRepository.findByProviderAndProviderUserId(SocialProvider.APPLE, "apple-user-2") + } returns Optional.empty() + + shouldThrow { + useCase.socialLoginByApple(request) + } + + verify(exactly = 0) { userRepository.save(any()) } + verify(exactly = 0) { userSocialAccountRepository.save(any()) } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryServiceTest.kt index 05fb2180..ebeb5c15 100644 --- a/src/test/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryServiceTest.kt @@ -1,15 +1,14 @@ package com.weeth.domain.user.application.usecase.query +import com.weeth.domain.cardinal.domain.repository.CardinalReader +import com.weeth.domain.cardinal.fixture.CardinalTestFixture import com.weeth.domain.user.application.dto.response.UserDetailsResponse import com.weeth.domain.user.application.dto.response.UserProfileResponse +import com.weeth.domain.user.application.dto.response.UserSummaryResponse import com.weeth.domain.user.application.mapper.UserMapper import com.weeth.domain.user.domain.entity.UserCardinal -import com.weeth.domain.user.domain.repository.CardinalReader -import com.weeth.domain.user.domain.repository.UserCardinalReader import com.weeth.domain.user.domain.repository.UserCardinalRepository -import com.weeth.domain.user.domain.repository.UserReader import com.weeth.domain.user.domain.repository.UserRepository -import com.weeth.domain.user.fixture.CardinalTestFixture import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe @@ -19,19 +18,15 @@ import io.mockk.mockk class GetUserQueryServiceTest : DescribeSpec({ val userRepository = mockk() - val userReader = mockk() val cardinalReader = mockk() val userCardinalRepository = mockk() - val userCardinalReader = mockk() val mapper = mockk() val queryService = GetUserQueryService( userRepository, - userReader, cardinalReader, userCardinalRepository, - userCardinalReader, mapper, ) @@ -53,7 +48,7 @@ class GetUserQueryServiceTest : year = 2024, semester = 2, ) - val userCardinals = listOf(UserCardinal(user, cardinal)) + val userCardinals = listOf(UserCardinal.create(user, cardinal)) val response = UserDetailsResponse( 1, @@ -65,8 +60,8 @@ class GetUserQueryServiceTest : user.role, ) - every { userReader.getById(1L) } returns user - every { userCardinalReader.findAllByUser(user) } returns userCardinals + every { userRepository.getById(1L) } returns user + every { userCardinalRepository.findAllByUser(user) } returns userCardinals every { mapper.toUserDetailsResponse(user, userCardinals) } returns response queryService.findUserDetails(1L) shouldBe response @@ -83,7 +78,7 @@ class GetUserQueryServiceTest : year = 2025, semester = 1, ) - val userCardinals = listOf(UserCardinal(user, cardinal)) + val userCardinals = listOf(UserCardinal.create(user, cardinal)) val response = UserProfileResponse( 2, @@ -96,11 +91,38 @@ class GetUserQueryServiceTest : user.role, ) - every { userReader.getById(2L) } returns user - every { userCardinalReader.findAllByUser(user) } returns userCardinals + every { userRepository.getById(2L) } returns user + every { userCardinalRepository.findAllByUser(user) } returns userCardinals every { mapper.toUserProfileResponse(user, userCardinals) } returns response queryService.findMyProfile(2L) shouldBe response } } + + describe("findMyInfo") { + it("내 정보를 UserSummaryResponse로 매핑한다") { + val user = UserTestFixture.createActiveUser1(3L) + val cardinal = + CardinalTestFixture.createCardinal( + id = 12L, + cardinalNumber = 8, + year = 2025, + semester = 2, + ) + val userCardinals = listOf(UserCardinal.create(user, cardinal)) + val response = + UserSummaryResponse( + 3, + user.name, + listOf(8), + user.role, + ) + + every { userRepository.getById(3L) } returns user + every { userCardinalRepository.findAllByUser(user) } returns userCardinals + every { mapper.toUserSummaryResponse(user, userCardinals) } returns response + + queryService.findMyInfo(3L) shouldBe response + } + } }) diff --git a/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt b/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt index a1285ffc..3a75b187 100644 --- a/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt @@ -2,13 +2,16 @@ package com.weeth.domain.user.domain.entity import com.weeth.domain.user.domain.enums.Role import com.weeth.domain.user.domain.enums.Status +import com.weeth.domain.user.domain.vo.Email +import com.weeth.domain.user.domain.vo.PhoneNumber +import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.shouldBe class UserTest : StringSpec({ "accept/ban/leave 상태 전환" { - val user = User(name = "test", email = "test@test.com", studentId = "20200001") + val user = User(name = "test", email = Email.from("test@test.com"), studentId = "20200001") user.accept() user.status shouldBe Status.ACTIVE @@ -21,7 +24,7 @@ class UserTest : } "attendance 카운터 및 출석률 계산" { - val user = User(name = "test", email = "test@test.com", studentId = "20200001") + val user = User(name = "test", email = Email.from("test@test.com"), studentId = "20200001") user.attend() user.attend() user.absent() @@ -32,9 +35,85 @@ class UserTest : } "updateRole / hasRole" { - val user = User(name = "test", email = "test@test.com", studentId = "20200001") + val user = User(name = "test", email = Email.from("test@test.com"), studentId = "20200001") user.updateRole(Role.ADMIN) user.hasRole(Role.ADMIN) shouldBe true } + + "User.create 기본 status는 WAITING이다" { + val user = User.create(name = "test", email = "test@test.com") + + user.status shouldBe Status.WAITING + } + + "User.create에 status를 명시하면 해당 상태로 생성된다" { + val user = User.create(name = "test", email = "test@test.com", status = Status.ACTIVE) + + user.status shouldBe Status.ACTIVE + } + + "update에서 빈 이름은 예외가 발생한다" { + val user = User(name = "test", email = Email.from("test@test.com")) + + shouldThrow { + user.update( + name = "", + email = Email.from("test@test.com"), + studentId = "123", + tel = PhoneNumber.from("01012345678"), + department = "CS", + ) + } + } + + "프로필 미완성 판정 — 기본 생성 시 false" { + val user = User.create(name = "test", email = "test@test.com") + + user.isProfileCompleted() shouldBe false + } + + "프로필 완성 판정 — 모든 필드 채워졌을 때 true" { + val user = + User.create( + name = "test", + email = "test@test.com", + studentId = "20200001", + tel = "01012345678", + department = "CS", + ) + + user.isProfileCompleted() shouldBe true + } + + "패널티 카운트 0일 때 감소해도 0 유지" { + val user = User(name = "test", email = Email.from("test@test.com")) + user.penaltyCount shouldBe 0 + + user.decrementPenaltyCount() + + user.penaltyCount shouldBe 0 + } + + "isActive / isInactive 동작" { + val user = User(name = "test", email = Email.from("test@test.com")) + user.isActive() shouldBe false + user.isInactive() shouldBe true + + user.accept() + user.isActive() shouldBe true + user.isInactive() shouldBe false + } + + "isBannedOrLeft 동작" { + val user = User(name = "test", email = Email.from("test@test.com")) + user.isBannedOrLeft() shouldBe false + + user.ban() + user.isBannedOrLeft() shouldBe true + + user.accept() + user.leave() + user.isBannedOrLeft() shouldBe true + } }) diff --git a/src/test/kotlin/com/weeth/domain/user/domain/repository/UserCardinalRepositoryTest.kt b/src/test/kotlin/com/weeth/domain/user/domain/repository/UserCardinalRepositoryTest.kt index 029420d8..9433bc03 100644 --- a/src/test/kotlin/com/weeth/domain/user/domain/repository/UserCardinalRepositoryTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/domain/repository/UserCardinalRepositoryTest.kt @@ -1,8 +1,9 @@ package com.weeth.domain.user.domain.repository import com.weeth.config.TestContainersConfig +import com.weeth.domain.cardinal.domain.repository.CardinalRepository +import com.weeth.domain.cardinal.fixture.CardinalTestFixture import com.weeth.domain.user.domain.entity.UserCardinal -import com.weeth.domain.user.fixture.CardinalTestFixture import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.collections.shouldHaveSize @@ -40,9 +41,9 @@ class UserCardinalRepositoryTest( userCardinalRepository.saveAll( listOf( - UserCardinal(user, cardinal1), - UserCardinal(user, cardinal2), - UserCardinal(user, cardinal3), + UserCardinal.create(user, cardinal1), + UserCardinal.create(user, cardinal2), + UserCardinal.create(user, cardinal3), ), ) @@ -81,14 +82,14 @@ class UserCardinalRepositoryTest( userCardinalRepository.saveAll( listOf( - UserCardinal(user1, c3), - UserCardinal(user1, c2), + UserCardinal.create(user1, c3), + UserCardinal.create(user1, c2), ), ) userCardinalRepository.saveAll( listOf( - UserCardinal(user2, c4), - UserCardinal(user2, c1), + UserCardinal.create(user2, c4), + UserCardinal.create(user2, c1), ), ) diff --git a/src/test/kotlin/com/weeth/domain/user/domain/repository/UserRepositoryTest.kt b/src/test/kotlin/com/weeth/domain/user/domain/repository/UserRepositoryTest.kt index c3415a4f..b25a4540 100644 --- a/src/test/kotlin/com/weeth/domain/user/domain/repository/UserRepositoryTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/domain/repository/UserRepositoryTest.kt @@ -1,8 +1,9 @@ package com.weeth.domain.user.domain.repository import com.weeth.config.TestContainersConfig +import com.weeth.domain.cardinal.domain.repository.CardinalRepository +import com.weeth.domain.cardinal.fixture.CardinalTestFixture import com.weeth.domain.user.domain.enums.Status -import com.weeth.domain.user.fixture.CardinalTestFixture import com.weeth.domain.user.fixture.UserCardinalTestFixture import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.core.spec.style.DescribeSpec @@ -22,8 +23,8 @@ class UserRepositoryTest( private val cardinalRepository: CardinalRepository, ) : DescribeSpec({ - lateinit var cardinal7: com.weeth.domain.user.domain.entity.Cardinal - lateinit var cardinal8: com.weeth.domain.user.domain.entity.Cardinal + lateinit var cardinal7: com.weeth.domain.cardinal.domain.entity.Cardinal + lateinit var cardinal8: com.weeth.domain.cardinal.domain.entity.Cardinal beforeEach { cardinal7 = diff --git a/src/test/kotlin/com/weeth/domain/user/domain/service/UserCardinalPolicyTest.kt b/src/test/kotlin/com/weeth/domain/user/domain/service/UserCardinalPolicyTest.kt index 9a50e3d1..833f3f0d 100644 --- a/src/test/kotlin/com/weeth/domain/user/domain/service/UserCardinalPolicyTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/domain/service/UserCardinalPolicyTest.kt @@ -1,8 +1,8 @@ package com.weeth.domain.user.domain.service -import com.weeth.domain.user.application.exception.CardinalNotFoundException +import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException +import com.weeth.domain.cardinal.fixture.CardinalTestFixture import com.weeth.domain.user.domain.repository.UserCardinalReader -import com.weeth.domain.user.fixture.CardinalTestFixture import com.weeth.domain.user.fixture.UserCardinalTestFixture import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.assertions.throwables.shouldThrow diff --git a/src/test/kotlin/com/weeth/domain/user/fixture/SessionTestFixture.kt b/src/test/kotlin/com/weeth/domain/user/fixture/SessionTestFixture.kt new file mode 100644 index 00000000..9c87c018 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/user/fixture/SessionTestFixture.kt @@ -0,0 +1,21 @@ +package com.weeth.domain.user.fixture + +import com.weeth.domain.session.domain.entity.Session +import java.time.LocalDateTime + +object SessionTestFixture { + fun createSession( + cardinalNumber: Int, + title: String = "테스트 세션", + start: LocalDateTime = LocalDateTime.of(2025, 3, 1, 14, 0), + end: LocalDateTime = LocalDateTime.of(2025, 3, 1, 16, 0), + code: Int = 1234, + ): Session = + Session( + title = title, + cardinal = cardinalNumber, + start = start, + end = end, + code = code, + ) +} diff --git a/src/test/kotlin/com/weeth/domain/user/fixture/UserCardinalTestFixture.kt b/src/test/kotlin/com/weeth/domain/user/fixture/UserCardinalTestFixture.kt index 2636c8f9..11b01744 100644 --- a/src/test/kotlin/com/weeth/domain/user/fixture/UserCardinalTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/user/fixture/UserCardinalTestFixture.kt @@ -1,6 +1,6 @@ package com.weeth.domain.user.fixture -import com.weeth.domain.user.domain.entity.Cardinal +import com.weeth.domain.cardinal.domain.entity.Cardinal import com.weeth.domain.user.domain.entity.User import com.weeth.domain.user.domain.entity.UserCardinal @@ -8,5 +8,5 @@ object UserCardinalTestFixture { fun linkUserCardinal( user: User, cardinal: Cardinal, - ): UserCardinal = UserCardinal(user, cardinal) + ): UserCardinal = UserCardinal.create(user, cardinal) } diff --git a/src/test/kotlin/com/weeth/domain/user/fixture/UserTestFixture.kt b/src/test/kotlin/com/weeth/domain/user/fixture/UserTestFixture.kt index c8b13073..30b10b07 100644 --- a/src/test/kotlin/com/weeth/domain/user/fixture/UserTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/user/fixture/UserTestFixture.kt @@ -3,13 +3,14 @@ package com.weeth.domain.user.fixture import com.weeth.domain.user.domain.entity.User import com.weeth.domain.user.domain.enums.Role import com.weeth.domain.user.domain.enums.Status +import com.weeth.domain.user.domain.vo.Email object UserTestFixture { fun createActiveUser1(id: Long? = null): User = User( id = id ?: 0L, name = "적순", - email = "test1@test.com", + email = Email.from("test1@test.com"), status = Status.ACTIVE, ) @@ -17,7 +18,7 @@ object UserTestFixture { User( id = id ?: 0L, name = "적순2", - email = "test2@test.com", + email = Email.from("test2@test.com"), status = Status.ACTIVE, ) @@ -25,7 +26,7 @@ object UserTestFixture { User( id = id ?: 0L, name = "순적", - email = "test2@test.com", + email = Email.from("test2@test.com"), status = Status.WAITING, ) @@ -33,7 +34,7 @@ object UserTestFixture { User( id = id ?: 0L, name = "순적2", - email = "test3@test.com", + email = Email.from("test3@test.com"), status = Status.WAITING, ) @@ -41,7 +42,7 @@ object UserTestFixture { User( id = id ?: 0L, name = "적순", - email = "admin@test.com", + email = Email.from("admin@test.com"), status = Status.ACTIVE, role = Role.ADMIN, ) From 219bee8f59fc51b98fdb34efdc064a3a979b6284 Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Wed, 4 Mar 2026 21:00:27 +0900 Subject: [PATCH 16/73] =?UTF-8?q?[WTH-150]=20board=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 미사용 enum 제거 * refactor: 어드민용 API에서는 전체 게시판이 조회되도록 수정 * refactor: 게시판 수정 API PATCH 관련 주석 및 테스트 추가 * refactor: 게시글 수정시 PATCH 규칙 개선 및 삭제된 게시판의 글인 경우 반환하지 않도록 설정 * refactor: 게시판 상세 정보 조회 API 어드민으로 이전 * refactor: 미사용 메서드 제거 * refactor: 미사용 메서드 제거 * refactor: 게시판 정보가 바뀌거나, 유저 권한이 바뀌는 케이스 대응 * docs: todo 주석 추가 * docs: todo 주석 설정 * refactor: private set 설정 * chore: lint 수정 * docs: 엔티티 구조 업데이트 * docs: 엔티티 구조 업데이트 * refactor: 엔티티 구조 개선 * refactor: 엔티티 구조 변경에 따른 테스트 수정 * docs: 주석 추가 * test: 불필요한 문구 제거 --- .claude/rules/architecture.md | 41 ++++- .../dto/request/UpdatePostRequest.kt | 13 +- .../usecase/command/ManageBoardUseCase.kt | 4 +- .../usecase/command/ManagePostUseCase.kt | 13 +- .../usecase/query/GetBoardQueryService.kt | 18 +- .../usecase/query/GetPostQueryService.kt | 2 +- .../weeth/domain/board/domain/entity/Board.kt | 32 +++- .../weeth/domain/board/domain/entity/Post.kt | 84 +++++---- .../weeth/domain/board/domain/enums/Part.kt | 9 - .../board/domain/repository/PostRepository.kt | 13 ++ .../presentation/BoardAdminController.kt | 10 ++ .../board/presentation/BoardController.kt | 13 -- .../weeth/domain/user/domain/entity/User.kt | 2 +- .../usecase/command/ManageBoardUseCaseTest.kt | 16 +- .../usecase/command/ManagePostUseCaseTest.kt | 163 ++++++++++++++---- .../usecase/query/GetBoardQueryServiceTest.kt | 72 ++++++-- .../usecase/query/GetPostQueryServiceTest.kt | 58 +++++-- .../board/domain/entity/BoardEntityTest.kt | 42 +---- .../domain/board/fixture/BoardTestFixture.kt | 8 +- .../domain/board/fixture/PostTestFixture.kt | 8 +- .../command/ManageCommentUseCaseTest.kt | 23 ++- .../query/GetCommentQueryServiceTest.kt | 2 +- .../domain/entity/CommentEntityTest.kt | 12 +- 23 files changed, 432 insertions(+), 226 deletions(-) delete mode 100644 src/main/kotlin/com/weeth/domain/board/domain/enums/Part.kt diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index 0df5ad09..1ea5db0f 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -79,11 +79,50 @@ presentation → application → domain (owns Port) ## Entity (Rich Domain Model) -- **Factory method**: `companion object` with `create()` / `of()` including validation - **State changes**: named methods (`publish()`, `softDelete()`) — no public setters - **Validation**: `require` for argument checks, `check` for state preconditions - **Business decisions**: `isEditableBy()`, `canPublish()` belong to Entity +### Constructor Pattern + +Primary constructor takes **business creation params only** (non-property) — JPA-managed fields (`id`, `isDeleted`) belong in the body with `private set` and default values. + +```kotlin +@Entity +class Post( + title: String, + content: String, + user: User, + board: Board, +) : BaseEntity() { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long = 0L + private set + + var title: String = title + private set + // ... + + companion object { + fun create(title: String, content: String, user: User, board: Board): Post { + require(title.isNotBlank()) { "제목은 비어 있을 수 없습니다" } + return Post(title = title, content = content, user = user, board = board) + } + } +} +``` + +| Concern | Location | +|---------|----------| +| JPA-managed fields (`id`, `isDeleted`) | Body, `private set`, default value | +| Business creation params | Primary constructor (non-property) | +| Validation | `create()` / named mutation methods — not constructor | + +- **Factory method** (`companion object`): use when the entity has creation logic or validation. Expresses domain intent. +- **Simple entities** (e.g., `Board`): public constructor is fine; no factory method needed if creation is trivial. + ## Value Object (VO) - **Location**: `domain/vo/` diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdatePostRequest.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdatePostRequest.kt index bb685e29..c60e41c3 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdatePostRequest.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdatePostRequest.kt @@ -3,19 +3,16 @@ package com.weeth.domain.board.application.dto.request import com.weeth.domain.file.application.dto.request.FileSaveRequest import io.swagger.v3.oas.annotations.media.Schema import jakarta.validation.Valid -import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.NotNull import jakarta.validation.constraints.Size data class UpdatePostRequest( - @field:Schema(description = "게시글 제목") - @field:NotBlank + @field:Schema(description = "게시글 제목 (null=변경 안 함)") @field:Size(max = 200) - val title: String, - @field:Schema(description = "게시글 내용") - @field:NotBlank - val content: String, - @field:Schema(description = "기수", nullable = true) + val title: String? = null, + @field:Schema(description = "게시글 내용 (null=변경 안 함)") + val content: String? = null, + @field:Schema(description = "기수 (null=변경 안 함)", nullable = true) val cardinalNumber: Int? = null, @field:Schema(description = "첨부 파일 변경 규약: null=변경 안 함, []=전체 삭제, 배열 전달=해당 목록으로 교체", nullable = true) @field:Valid diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt index dc1443a3..d48485e3 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt @@ -40,11 +40,11 @@ class ManageBoardUseCase( ): BoardDetailResponse { val board = findBoard(boardId) - // TODO: PATCH 규칙 - 요청 값이 현재 값과 다를 때만 반영하도록 수정 필요 request.name?.let { board.rename(it) } + // BoardConfig는 불변 VO이므로 개별 필드 수정이 불가능하여 copy()로 새 객체를 만들어 통째로 교체한다. null이면 기존 값을 명시적으로 채운다. + // 바깥 if 문은 config 관련 필드가 전부 null인 요청에서 불필요한 VO 생성을 방지한다. if (request.commentEnabled != null || request.writePermission != null || request.isPrivate != null) { - // TODO: PATCH 규칙 - 각 필드별로 변경 여부를 비교해 바뀐 값만 업데이트하도록 수정 필요 board.updateConfig( board.config.copy( commentEnabled = request.commentEnabled ?: board.config.commentEnabled, diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt index 6636fb97..41cc630e 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt @@ -40,7 +40,7 @@ class ManagePostUseCase( ): PostSaveResponse { val user = userReader.getById(userId) val board = findBoard(boardId) - checkWritePermission(board, user) + validateWritePermission(board, user) val post = Post.create( @@ -48,7 +48,7 @@ class ManagePostUseCase( content = request.content, user = user, board = board, - cardinalNumber = request.cardinalNumber, + cardinalNumber = request.cardinalNumber, // 기수의 경우는 프론트에서 명시적으로 입력을 받을지, 백엔드에서 최신 기수를 넣을지 UX 고민 후 결정 ) val savedPost = postRepository.save(post) @@ -62,10 +62,11 @@ class ManagePostUseCase( request: UpdatePostRequest, userId: Long, ): PostSaveResponse { + val user = userReader.getById(userId) val post = findPost(postId) validateOwner(post, userId) + validateWritePermission(post.board, user) - // TODO: PATCH 규칙 - title/content/cardinalNumber는 실제 변경된 경우에만 반영하도록 수정 필요 post.update( newTitle = request.title, newContent = request.content, @@ -81,8 +82,10 @@ class ManagePostUseCase( postId: Long, userId: Long, ) { + val user = userReader.getById(userId) val post = findPost(postId) validateOwner(post, userId) + validateWritePermission(post.board, user) markPostFilesDeleted(post.id) post.markDeleted() @@ -92,7 +95,7 @@ class ManagePostUseCase( boardRepository.findByIdAndIsDeletedFalse(boardId) ?: throw BoardNotFoundException() private fun findPost(postId: Long): Post = - postRepository.findByIdAndIsDeletedFalse(postId) ?: throw PostNotFoundException() + postRepository.findActivePostById(postId) ?: throw PostNotFoundException() private fun validateOwner( post: Post, @@ -103,7 +106,7 @@ class ManagePostUseCase( } } - private fun checkWritePermission( + private fun validateWritePermission( board: Board, user: User, ) { diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt index d4f56f9e..b4bc2d97 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt @@ -6,6 +6,7 @@ import com.weeth.domain.board.application.exception.BoardNotFoundException import com.weeth.domain.board.application.mapper.BoardMapper import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.user.domain.enums.Role +import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -18,23 +19,16 @@ class GetBoardQueryService( fun findBoards(role: Role): List = boardRepository .findAllByIsDeletedFalseOrderByIdAsc() - .filter { it.isAccessibleBy(role) } + .filter { it.isAccessibleBy(role) } // todo: Club 기반 쿼리로 개선 시 DB 레벨 필터링으로 전환 .map(boardMapper::toListResponse) - fun findBoard( - boardId: Long, - role: Role, - ): BoardDetailResponse { - val board = - boardRepository - .findByIdAndIsDeletedFalse(boardId) - ?.takeIf { it.isAccessibleBy(role) } - ?: throw BoardNotFoundException() - return boardMapper.toDetailResponse(board) + fun findBoardDetailForAdmin(boardId: Long): BoardDetailResponse { + val board = boardRepository.findByIdOrNull(boardId) ?: throw BoardNotFoundException() + return boardMapper.toDetailResponseForAdmin(board) } fun findAllBoardsForAdmin(): List = boardRepository - .findAllByIsDeletedFalseOrderByIdAsc() + .findAllByOrderByIdAsc() .map(boardMapper::toDetailResponseForAdmin) } diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt index c42f4628..cd533b14 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt @@ -111,7 +111,7 @@ class GetPostQueryService( return postIds.associateWith { filesGrouped.containsKey(it) } } - private fun validateBoardVisibility( + private fun validateBoardVisibility( // todo: 볼 권한이 없는 경우 권한 관련 예외를 던져주는게 나을지 UX 상의 후 결정 boardId: Long, role: Role, ) { diff --git a/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt b/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt index 341f3434..61585ae4 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt @@ -18,29 +18,43 @@ import jakarta.persistence.Table @Entity @Table(name = "board") class Board( + name: String, + type: BoardType, + config: BoardConfig = BoardConfig(), +) : BaseEntity() { + init { + require(name.isNotBlank()) { "게시판 이름은 공백이 될 수 없습니다" } + } + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - val id: Long = 0, + var id: Long = 0L + private set + @Column(nullable = false) - var name: String, + var name: String = name + private set + @Enumerated(EnumType.STRING) @Column(nullable = false) - val type: BoardType, + var type: BoardType = type + private set + @Column(columnDefinition = "JSON") // Json 속성 사용으로 인한 커스텀 컨버터 적용 @Convert(converter = BoardConfigConverter::class) - var config: BoardConfig = BoardConfig(), + var config: BoardConfig = config + private set + @Column(nullable = false) - var isDeleted: Boolean = false, -) : BaseEntity() { + var isDeleted: Boolean = false + private set + val isCommentEnabled: Boolean get() = config.commentEnabled val isAdminOnly: Boolean get() = config.writePermission == Role.ADMIN - val isRestricted: Boolean - get() = isAdminOnly || config.isPrivate - fun isAccessibleBy(role: Role): Boolean = role == Role.ADMIN || !config.isPrivate fun canWriteBy(role: Role): Boolean = isAccessibleBy(role) && (!isAdminOnly || role == Role.ADMIN) diff --git a/src/main/kotlin/com/weeth/domain/board/domain/entity/Post.kt b/src/main/kotlin/com/weeth/domain/board/domain/entity/Post.kt index 9545169c..a74119d8 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/entity/Post.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/entity/Post.kt @@ -15,34 +15,57 @@ import jakarta.persistence.Table @Entity @Table(name = "post") class Post( + title: String, + content: String, + user: User, + board: Board, + cardinalNumber: Int? = null, +) : BaseEntity() { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - val id: Long = 0, + var id: Long = 0L + private set + @Column(nullable = false) - var title: String, + var title: String = title + private set + @Column(columnDefinition = "TEXT", nullable = false) - var content: String, - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id") - val user: User, - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "board_id") - val board: Board, + var content: String = content + private set + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + var user: User = user + private set + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "board_id", nullable = false) + var board: Board = board + private set + @Column(nullable = false) - var commentCount: Int = 0, + var commentCount: Int = 0 + private set + @Column(nullable = false) - var likeCount: Int = 0, + var likeCount: Int = 0 + private set + @Column - var cardinalNumber: Int? = null, + var cardinalNumber: Int? = cardinalNumber + private set + @Column(nullable = false) - var isDeleted: Boolean = false, -) : BaseEntity() { + var isDeleted: Boolean = false + private set + fun increaseCommentCount() { commentCount++ } fun decreaseCommentCount() { - check(commentCount > 0) { "comment count cannot be negative" } + check(commentCount > 0) { "댓글 수는 0보다 작아질 수 없습니다" } commentCount-- } @@ -51,29 +74,26 @@ class Post( } fun decreaseLikeCount() { - check(likeCount > 0) { "like count cannot be negative" } + check(likeCount > 0) { "좋아요 수는 0보다 작아질 수 없습니다" } likeCount-- } - fun updateContent( - newTitle: String, - newContent: String, - ) { - require(newTitle.isNotBlank()) { "title must not be blank" } - require(newContent.isNotBlank()) { "content must not be blank" } - title = newTitle - content = newContent - } - fun isOwnedBy(userId: Long): Boolean = user.id == userId fun update( - newTitle: String, - newContent: String, + newTitle: String?, + newContent: String?, newCardinalNumber: Int?, ) { - updateContent(newTitle, newContent) - cardinalNumber = newCardinalNumber + newTitle?.let { + require(it.isNotBlank()) { "제목은 비어 있을 수 없습니다" } + title = it + } + newContent?.let { + require(it.isNotBlank()) { "내용은 비어 있을 수 없습니다" } + content = it + } + newCardinalNumber?.let { cardinalNumber = it } } fun markDeleted() { @@ -92,8 +112,8 @@ class Post( board: Board, cardinalNumber: Int? = null, ): Post { - require(title.isNotBlank()) { "title must not be blank" } - require(content.isNotBlank()) { "content must not be blank" } + require(title.isNotBlank()) { "제목은 비어 있을 수 없습니다" } + require(content.isNotBlank()) { "내용은 비어 있을 수 없습니다" } return Post( title = title, content = content, diff --git a/src/main/kotlin/com/weeth/domain/board/domain/enums/Part.kt b/src/main/kotlin/com/weeth/domain/board/domain/enums/Part.kt deleted file mode 100644 index dc6ed0da..00000000 --- a/src/main/kotlin/com/weeth/domain/board/domain/enums/Part.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.board.domain.enums - -enum class Part { - D, - BE, - FE, - PM, - ALL, -} diff --git a/src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt index 1f5a64c3..297acba0 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt @@ -30,6 +30,19 @@ interface PostRepository : JpaRepository { fun findByIdAndIsDeletedFalse(id: Long): Post? + @Query( + """ + SELECT p + FROM Post p + WHERE p.id = :id + AND p.isDeleted = false + AND p.board.isDeleted = false + """, + ) + fun findActivePostById( + @Param("id") id: Long, + ): Post? + @Lock(LockModeType.PESSIMISTIC_WRITE) @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) @Query( diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt b/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt index 9ed701f9..b69215ae 100644 --- a/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt @@ -35,6 +35,16 @@ class BoardAdminController( fun findAllBoards(): CommonResponse> = CommonResponse.success(BoardResponseCode.BOARD_FIND_ALL_SUCCESS, getBoardQueryService.findAllBoardsForAdmin()) + @GetMapping("/{boardId}") + @Operation(summary = "게시판 상세 조회 (삭제된 게시판 포함)") + fun findBoard( + @PathVariable boardId: Long, + ): CommonResponse = + CommonResponse.success( + BoardResponseCode.BOARD_FIND_BY_ID_SUCCESS, + getBoardQueryService.findBoardDetailForAdmin(boardId), + ) + @PostMapping @Operation(summary = "게시판 생성") fun createBoard( diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt b/src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt index a7e41807..1d4c81ae 100644 --- a/src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt +++ b/src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt @@ -1,6 +1,5 @@ package com.weeth.domain.board.presentation -import com.weeth.domain.board.application.dto.response.BoardDetailResponse import com.weeth.domain.board.application.dto.response.BoardListResponse import com.weeth.domain.board.application.exception.BoardErrorCode import com.weeth.domain.board.application.usecase.query.GetBoardQueryService @@ -12,7 +11,6 @@ import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @@ -29,15 +27,4 @@ class BoardController( @Parameter(hidden = true) @CurrentUserRole role: Role, ): CommonResponse> = CommonResponse.success(BoardResponseCode.BOARD_FIND_ALL_SUCCESS, getBoardQueryService.findBoards(role)) - - @GetMapping("/{boardId}") - @Operation(summary = "게시판 상세 조회") - fun findBoard( - @PathVariable boardId: Long, - @Parameter(hidden = true) @CurrentUserRole role: Role, - ): CommonResponse = - CommonResponse.success( - BoardResponseCode.BOARD_FIND_BY_ID_SUCCESS, - getBoardQueryService.findBoard(boardId, role), - ) } diff --git a/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt b/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt index 8f310c79..0d664c2d 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt @@ -21,7 +21,7 @@ import jakarta.persistence.Table @Entity @Table(name = "users") -class User protected constructor() : BaseEntity() { +class User protected constructor() : BaseEntity() { // todo: 엔티티 정리 (생성자 정리, lateinit 제거 등) @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "user_id") diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt index b1987610..8e5dbf4e 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt @@ -48,7 +48,7 @@ class ManageBoardUseCaseTest : describe("update") { it("일부 필드만 전달되면 해당 필드만 갱신한다") { - val board = Board(id = 1L, name = "기존", type = BoardType.GENERAL) + val board = Board(name = "기존", type = BoardType.GENERAL) every { boardRepository.findByIdAndIsDeletedFalse(1L) } returns board val result = useCase.update(1L, UpdateBoardRequest(name = "변경", isPrivate = true)) @@ -59,6 +59,18 @@ class ManageBoardUseCaseTest : result.isPrivate shouldBe true } + it("아무 필드도 전달되지 않으면 기존 값이 그대로 유지된다") { + val board = Board(name = "기존", type = BoardType.GENERAL) + every { boardRepository.findByIdAndIsDeletedFalse(1L) } returns board + + val result = useCase.update(1L, UpdateBoardRequest()) + + result.name shouldBe "기존" + result.commentEnabled shouldBe true + result.writePermission shouldBe Role.USER + result.isPrivate shouldBe false + } + it("존재하지 않는 게시판이면 예외를 던진다") { every { boardRepository.findByIdAndIsDeletedFalse(999L) } returns null @@ -70,7 +82,7 @@ class ManageBoardUseCaseTest : describe("delete") { it("게시판을 soft delete 처리한다") { - val board = Board(id = 1L, name = "일반", type = BoardType.GENERAL) + val board = Board(name = "일반", type = BoardType.GENERAL) every { boardRepository.findByIdAndIsDeletedFalse(1L) } returns board useCase.delete(1L) diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt index 74b49940..0ffda1dc 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt @@ -5,13 +5,16 @@ import com.weeth.domain.board.application.dto.request.UpdatePostRequest import com.weeth.domain.board.application.dto.response.PostSaveResponse import com.weeth.domain.board.application.exception.BoardNotFoundException import com.weeth.domain.board.application.exception.CategoryAccessDeniedException +import com.weeth.domain.board.application.exception.PostNotFoundException +import com.weeth.domain.board.application.exception.PostNotOwnedException import com.weeth.domain.board.application.mapper.PostMapper -import com.weeth.domain.board.domain.entity.Board import com.weeth.domain.board.domain.entity.Post import com.weeth.domain.board.domain.enums.BoardType import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.board.domain.repository.PostRepository import com.weeth.domain.board.domain.vo.BoardConfig +import com.weeth.domain.board.fixture.BoardTestFixture +import com.weeth.domain.board.fixture.PostTestFixture import com.weeth.domain.file.application.dto.request.FileSaveRequest import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.entity.File @@ -88,12 +91,14 @@ class ManagePostUseCaseTest : every { fileReader.findAll(any(), any(), any()) } returns emptyList() every { postMapper.toSaveResponse(any()) } returns PostSaveResponse(1L) every { fileRepository.delete(any()) } just runs + // update/delete 공통 기본값: USER 역할의 작성자 + every { userReader.getById(any()) } returns createUser(1L, Role.USER) } describe("save") { it("일반 게시판에서 게시글을 저장한다") { val user = createUser(1L, Role.USER) - val board = Board(id = 10L, name = "일반", type = BoardType.GENERAL) + val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val request = CreatePostRequest(title = "제목", content = "내용") every { userReader.getById(1L) } returns user @@ -108,8 +113,7 @@ class ManagePostUseCaseTest : it("ADMIN 전용 게시판에 일반 사용자가 작성하면 예외를 던진다") { val user = createUser(1L, Role.USER) val board = - Board( - id = 20L, + BoardTestFixture.create( name = "공지", type = BoardType.NOTICE, config = BoardConfig(writePermission = Role.ADMIN), @@ -129,8 +133,7 @@ class ManagePostUseCaseTest : it("비공개 게시판에 일반 사용자가 작성하면 예외를 던진다") { val user = createUser(1L, Role.USER) val board = - Board( - id = 21L, + BoardTestFixture.create( name = "비공개", type = BoardType.GENERAL, config = BoardConfig(isPrivate = true), @@ -149,7 +152,7 @@ class ManagePostUseCaseTest : it("cardinalNumber가 전달되면 게시글에 반영된다") { val user = createUser(1L, Role.USER) - val board = Board(id = 11L, name = "일반", type = BoardType.GENERAL) + val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val request = CreatePostRequest( title = "게시글", @@ -164,9 +167,7 @@ class ManagePostUseCaseTest : verify { postRepository.save( - match { - it.cardinalNumber == 6 - }, + match { it.cardinalNumber == 6 }, ) } } @@ -186,12 +187,13 @@ class ManagePostUseCaseTest : describe("update") { it("files가 null이면 기존 파일을 유지한다") { - val user = UserTestFixture.createActiveUser1(1L) - val board = Board(id = 1L, name = "일반", type = BoardType.GENERAL) + val user = createUser(1L, Role.USER) + val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val post = Post.create("제목", "내용", user, board) val request = UpdatePostRequest(title = "수정", content = "수정") - every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post + every { userReader.getById(1L) } returns user + every { postRepository.findActivePostById(1L) } returns post useCase.update(1L, request, 1L) @@ -201,13 +203,10 @@ class ManagePostUseCaseTest : it("files가 있으면 기존 파일을 soft delete 후 교체한다") { val user = UserTestFixture.createActiveUser1(1L) - val board = Board(id = 1L, name = "일반", type = BoardType.GENERAL) - val post = Post(id = 1L, title = "제목", content = "내용", user = user, board = board) + val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val post = PostTestFixture.create(title = "제목", content = "내용", user = user, board = board) val oldFile = createUploadedPostFile("old.png") - val newFiles = - listOf( - createUploadedPostFile("new.png"), - ) + val newFiles = listOf(createUploadedPostFile("new.png")) val request = UpdatePostRequest( title = "수정", @@ -223,9 +222,10 @@ class ManagePostUseCaseTest : ), ) - every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post - every { fileReader.findAll(FileOwnerType.POST, 1L, any()) } returns listOf(oldFile) - every { fileMapper.toFileList(request.files, FileOwnerType.POST, 1L) } returns newFiles + every { userReader.getById(1L) } returns user + every { postRepository.findActivePostById(1L) } returns post + every { fileReader.findAll(FileOwnerType.POST, any(), any()) } returns listOf(oldFile) + every { fileMapper.toFileList(request.files, FileOwnerType.POST, any()) } returns newFiles every { fileRepository.saveAll(newFiles) } returns newFiles useCase.update(1L, request, 1L) @@ -235,17 +235,92 @@ class ManagePostUseCaseTest : post.content shouldBe "수정" verify(exactly = 1) { fileRepository.saveAll(newFiles) } } + + it("title이 null이면 기존 제목을 유지한다") { + val user = UserTestFixture.createActiveUser1(1L) + val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val post = Post.create("원래 제목", "원래 내용", user, board) + val request = UpdatePostRequest(content = "수정된 내용") + + every { userReader.getById(1L) } returns user + every { postRepository.findActivePostById(1L) } returns post + + useCase.update(1L, request, 1L) + + post.title shouldBe "원래 제목" + post.content shouldBe "수정된 내용" + } + + it("content가 null이면 기존 내용을 유지한다") { + val user = UserTestFixture.createActiveUser1(1L) + val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val post = Post.create("원래 제목", "원래 내용", user, board) + val request = UpdatePostRequest(title = "수정된 제목") + + every { userReader.getById(1L) } returns user + every { postRepository.findActivePostById(1L) } returns post + + useCase.update(1L, request, 1L) + + post.title shouldBe "수정된 제목" + post.content shouldBe "원래 내용" + } + + it("삭제된 Board 소속 Post를 수정하면 예외를 던진다") { + every { postRepository.findActivePostById(1L) } returns null + + shouldThrow { + useCase.update(1L, UpdatePostRequest(title = "수정"), 1L) + } + } + + it("게시판이 ADMIN 전용으로 바뀐 후 일반 사용자가 수정하면 예외를 던진다") { + val user = createUser(1L, Role.USER) + val board = + BoardTestFixture.create( + name = "공지", + type = BoardType.NOTICE, + config = BoardConfig(writePermission = Role.ADMIN), + ) + val post = PostTestFixture.create(title = "제목", content = "내용", user = user, board = board) + + every { userReader.getById(1L) } returns user + every { postRepository.findActivePostById(1L) } returns post + + shouldThrow { + useCase.update(1L, UpdatePostRequest(title = "수정"), 1L) + } + } + + it("게시판이 비공개로 바뀐 후 일반 사용자가 수정하면 예외를 던진다") { + val user = createUser(1L, Role.USER) + val board = + BoardTestFixture.create( + name = "비공개", + type = BoardType.GENERAL, + config = BoardConfig(isPrivate = true), + ) + val post = PostTestFixture.create(title = "제목", content = "내용", user = user, board = board) + + every { userReader.getById(1L) } returns user + every { postRepository.findActivePostById(1L) } returns post + + shouldThrow { + useCase.update(1L, UpdatePostRequest(title = "수정"), 1L) + } + } } describe("delete") { it("삭제 시 첨부 파일과 게시글을 soft delete한다") { val user = UserTestFixture.createActiveUser1(1L) - val board = Board(id = 1L, name = "일반", type = BoardType.GENERAL) - val post = Post(id = 1L, title = "제목", content = "내용", user = user, board = board) + val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val post = PostTestFixture.create(title = "제목", content = "내용", user = user, board = board) val oldFile = createUploadedPostFile("old.png") - every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post - every { fileReader.findAll(FileOwnerType.POST, 1L, any()) } returns listOf(oldFile) + every { userReader.getById(1L) } returns user + every { postRepository.findActivePostById(1L) } returns post + every { fileReader.findAll(FileOwnerType.POST, any(), any()) } returns listOf(oldFile) useCase.delete(1L, 1L) @@ -253,18 +328,46 @@ class ManagePostUseCaseTest : post.isDeleted shouldBe true verify(exactly = 0) { postRepository.delete(any()) } } + + it("삭제된 Board 소속 Post를 삭제하면 예외를 던진다") { + every { postRepository.findActivePostById(1L) } returns null + + shouldThrow { + useCase.delete(1L, 1L) + } + } + + it("게시판이 ADMIN 전용으로 바뀐 후 일반 사용자가 삭제하면 예외를 던진다") { + val user = createUser(1L, Role.USER) + val board = + BoardTestFixture.create( + name = "공지", + type = BoardType.NOTICE, + config = BoardConfig(writePermission = Role.ADMIN), + ) + val post = PostTestFixture.create(title = "제목", content = "내용", user = user, board = board) + + every { userReader.getById(1L) } returns user + every { postRepository.findActivePostById(1L) } returns post + + shouldThrow { + useCase.delete(1L, 1L) + } + } } describe("owner validation") { it("작성자가 아니면 수정 시 예외를 던진다") { val owner = UserTestFixture.createActiveUser1(1L) - val board = Board(id = 1L, name = "일반", type = BoardType.GENERAL) - val post = Post(id = 1L, title = "제목", content = "내용", user = owner, board = board) + val otherUser = createUser(2L, Role.USER) + val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val post = PostTestFixture.create(title = "제목", content = "내용", user = owner, board = board) val request = UpdatePostRequest(title = "수정", content = "수정") - every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post + every { userReader.getById(2L) } returns otherUser + every { postRepository.findActivePostById(1L) } returns post - shouldThrow { + shouldThrow { useCase.update(1L, request, 2L) } } diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt index 29b1d291..3c879c19 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt @@ -12,6 +12,7 @@ import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe import io.mockk.every import io.mockk.mockk +import org.springframework.data.repository.findByIdOrNull class GetBoardQueryServiceTest : DescribeSpec({ @@ -21,9 +22,9 @@ class GetBoardQueryServiceTest : describe("findBoards") { it("일반 사용자에게는 공개 게시판만 반환한다") { - val publicBoard = Board(id = 1L, name = "일반", type = BoardType.GENERAL) + val publicBoard = Board(name = "일반", type = BoardType.GENERAL) val privateBoard = - Board(id = 2L, name = "운영", type = BoardType.NOTICE).apply { + Board(name = "운영", type = BoardType.NOTICE).apply { updateConfig(config.copy(isPrivate = true)) } @@ -33,13 +34,13 @@ class GetBoardQueryServiceTest : val result = queryService.findBoards(Role.USER) result shouldHaveSize 1 - result.first().id shouldBe 1L + result.first().name shouldBe "일반" } it("관리자에게는 비공개 게시판도 포함해 반환한다") { - val publicBoard = Board(id = 1L, name = "일반", type = BoardType.GENERAL) + val publicBoard = Board(name = "일반", type = BoardType.GENERAL) val privateBoard = - Board(id = 2L, name = "운영", type = BoardType.NOTICE).apply { + Board(name = "운영", type = BoardType.NOTICE).apply { updateConfig(config.copy(isPrivate = true)) } @@ -52,30 +53,67 @@ class GetBoardQueryServiceTest : } } - describe("findBoard") { - it("일반 사용자가 비공개 게시판 상세를 조회하면 예외를 던진다") { + describe("findAllBoardsForAdmin") { + it("삭제된 게시판을 포함해 전체 목록을 반환한다") { + val activeBoard = Board(name = "일반", type = BoardType.GENERAL) + val deletedBoard = + Board(name = "삭제됨", type = BoardType.GENERAL).apply { + markDeleted() + } + + every { boardRepository.findAllByOrderByIdAsc() } returns listOf(activeBoard, deletedBoard) + + val result = queryService.findAllBoardsForAdmin() + + result shouldHaveSize 2 + } + + it("활성 게시판과 비공개 게시판도 모두 포함해 반환한다") { + val publicBoard = Board(name = "일반", type = BoardType.GENERAL) val privateBoard = - Board(id = 2L, name = "운영", type = BoardType.NOTICE).apply { + Board(name = "운영", type = BoardType.NOTICE).apply { updateConfig(config.copy(isPrivate = true)) } - every { boardRepository.findByIdAndIsDeletedFalse(2L) } returns privateBoard - shouldThrow { - queryService.findBoard(2L, Role.USER) - } + every { boardRepository.findAllByOrderByIdAsc() } returns listOf(publicBoard, privateBoard) + + val result = queryService.findAllBoardsForAdmin() + + result shouldHaveSize 2 + } + } + + describe("findBoardDetailForAdmin") { + it("삭제된 게시판도 조회할 수 있다") { + val deletedBoard = + Board(name = "삭제됨", type = BoardType.GENERAL).apply { + markDeleted() + } + every { boardRepository.findByIdOrNull(3L) } returns deletedBoard + + val result = queryService.findBoardDetailForAdmin(3L) + + result.isDeleted shouldBe true } - it("관리자는 비공개 게시판 상세를 조회할 수 있다") { + it("비공개 게시판도 조회할 수 있다") { val privateBoard = - Board(id = 2L, name = "운영", type = BoardType.NOTICE).apply { + Board(name = "운영", type = BoardType.NOTICE).apply { updateConfig(config.copy(isPrivate = true)) } - every { boardRepository.findByIdAndIsDeletedFalse(2L) } returns privateBoard + every { boardRepository.findByIdOrNull(2L) } returns privateBoard - val result = queryService.findBoard(2L, Role.ADMIN) + val result = queryService.findBoardDetailForAdmin(2L) - result.id shouldBe 2L result.isPrivate shouldBe true } + + it("존재하지 않는 boardId면 예외를 던진다") { + every { boardRepository.findByIdOrNull(999L) } returns null + + shouldThrow { + queryService.findBoardDetailForAdmin(999L) + } + } } }) diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt index e5e22418..c8ef9c3d 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt @@ -5,11 +5,11 @@ import com.weeth.domain.board.application.exception.NoSearchResultException import com.weeth.domain.board.application.exception.PageNotFoundException import com.weeth.domain.board.application.exception.PostNotFoundException import com.weeth.domain.board.application.mapper.PostMapper -import com.weeth.domain.board.domain.entity.Board -import com.weeth.domain.board.domain.entity.Post import com.weeth.domain.board.domain.enums.BoardType import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.board.domain.repository.PostRepository +import com.weeth.domain.board.fixture.BoardTestFixture +import com.weeth.domain.board.fixture.PostTestFixture import com.weeth.domain.comment.application.dto.response.CommentResponse import com.weeth.domain.comment.application.usecase.query.GetCommentQueryService import com.weeth.domain.comment.domain.repository.CommentReader @@ -76,8 +76,14 @@ class GetPostQueryServiceTest : it("댓글/파일을 포함한 상세 응답을 반환한다") { val user = UserTestFixture.createActiveUser1(1L) - val board = Board(id = 1L, name = "일반", type = BoardType.GENERAL) - val post = Post(id = 1L, title = "제목", content = "내용", user = user, board = board, commentCount = 1) + val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val post = + PostTestFixture.create( + title = "제목", + content = "내용", + user = user, + board = board, + ) val comments = listOf(mockk()) val fileResponses = listOf( @@ -116,9 +122,9 @@ class GetPostQueryServiceTest : ) every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post - every { commentReader.findAllByPostId(1L) } returns emptyList() + every { commentReader.findAllByPostId(any()) } returns emptyList() every { getCommentQueryService.toCommentTreeResponses(any()) } returns comments - every { fileReader.findAll(FileOwnerType.POST, 1L, any()) } returns files + every { fileReader.findAll(FileOwnerType.POST, any(), any()) } returns files every { postMapper.toDetailResponse(post, comments, fileResponses) } returns detail every { fileMapper.toFileResponse(files.first()) } returns fileResponses.first() @@ -131,10 +137,15 @@ class GetPostQueryServiceTest : it("비공개 게시판 게시글은 일반/익명에게 노출하지 않는다") { val user = UserTestFixture.createActiveUser1(1L) - val privateBoard = Board(id = 2L, name = "비공개", type = BoardType.GENERAL) + val privateBoard = BoardTestFixture.create(name = "비공개", type = BoardType.GENERAL) privateBoard.updateConfig(privateBoard.config.copy(isPrivate = true)) val post = - Post(id = 1L, title = "제목", content = "내용", user = user, board = privateBoard, commentCount = 0) + PostTestFixture.create( + title = "제목", + content = "내용", + user = user, + board = privateBoard, + ) every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post @@ -145,9 +156,19 @@ class GetPostQueryServiceTest : it("삭제된 게시판의 게시글은 조회할 수 없다") { val user = UserTestFixture.createActiveUser1(1L) - val deletedBoard = Board(id = 3L, name = "삭제", type = BoardType.GENERAL, isDeleted = true) + val deletedBoard = + BoardTestFixture + .create( + name = "삭제", + type = BoardType.GENERAL, + ).also { it.markDeleted() } val post = - Post(id = 1L, title = "제목", content = "내용", user = user, board = deletedBoard, commentCount = 0) + PostTestFixture.create( + title = "제목", + content = "내용", + user = user, + board = deletedBoard, + ) every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post @@ -160,7 +181,7 @@ class GetPostQueryServiceTest : describe("searchPosts") { it("검색 결과가 없으면 예외를 던진다") { val pageable = PageRequest.of(0, 10) - val board = Board(id = 1L, name = "일반", type = BoardType.GENERAL) + val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) every { boardRepository.findByIdAndIsDeletedFalse(1L) } returns board every { postRepository.searchByBoardId(1L, "키워드", any()) } returns SliceImpl(emptyList(), pageable, false) @@ -171,7 +192,7 @@ class GetPostQueryServiceTest : } it("비공개 게시판은 일반/익명이 검색할 수 없다") { - val privateBoard = Board(id = 1L, name = "비공개", type = BoardType.GENERAL) + val privateBoard = BoardTestFixture.create(name = "비공개", type = BoardType.GENERAL) privateBoard.updateConfig(privateBoard.config.copy(isPrivate = true)) every { boardRepository.findByIdAndIsDeletedFalse(1L) } returns privateBoard @@ -204,8 +225,14 @@ class GetPostQueryServiceTest : describe("findPosts") { it("목록 조회 시 mapper를 통해 응답으로 변환한다") { val user = UserTestFixture.createActiveUser1(1L) - val board = Board(id = 1L, name = "일반", type = BoardType.GENERAL) - val post = Post(id = 10L, title = "제목", content = "내용", user = user, board = board) + val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val post = + PostTestFixture.create( + title = "제목", + content = "내용", + user = user, + board = board, + ) val pageable = PageRequest.of(0, 10) val postSlice = SliceImpl(listOf(post), pageable, false) val response = @@ -229,8 +256,7 @@ class GetPostQueryServiceTest : val result = queryService.findPosts(1L, 0, 10, Role.USER) result.content.size shouldBe 1 - result.content.first().id shouldBe 10L - verify(exactly = 1) { fileReader.findAll(FileOwnerType.POST, listOf(10L), any()) } + verify(exactly = 1) { fileReader.findAll(FileOwnerType.POST, any>(), any()) } } } }) diff --git a/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt b/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt index 7ac6b386..f212a85e 100644 --- a/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt @@ -12,7 +12,6 @@ class BoardEntityTest : "isCommentEnabled는 config 값을 반영한다" { val board = Board( - id = 1L, name = "공지사항", type = BoardType.NOTICE, config = BoardConfig(commentEnabled = false), @@ -22,7 +21,7 @@ class BoardEntityTest : } "rename은 빈 이름이면 예외를 던진다" { - val board = Board(id = 1L, name = "게시판", type = BoardType.GENERAL) + val board = Board(name = "게시판", type = BoardType.GENERAL) shouldThrow { board.rename(" ") @@ -32,7 +31,6 @@ class BoardEntityTest : "isAdminOnly는 writePermission이 ADMIN일 때 true를 반환한다" { val board = Board( - id = 2L, name = "공지", type = BoardType.NOTICE, config = BoardConfig(writePermission = Role.ADMIN), @@ -41,38 +39,9 @@ class BoardEntityTest : board.isAdminOnly shouldBe true } - "isRestricted는 ADMIN 전용 또는 비공개 게시판이면 true를 반환한다" { - val adminOnlyBoard = - Board( - id = 21L, - name = "공지", - type = BoardType.NOTICE, - config = BoardConfig(writePermission = Role.ADMIN), - ) - val privateBoard = - Board( - id = 22L, - name = "비공개", - type = BoardType.GENERAL, - config = BoardConfig(isPrivate = true), - ) - val publicBoard = - Board( - id = 23L, - name = "일반", - type = BoardType.GENERAL, - config = BoardConfig(), - ) - - adminOnlyBoard.isRestricted shouldBe true - privateBoard.isRestricted shouldBe true - publicBoard.isRestricted shouldBe false - } - "isAccessibleBy는 비공개 게시판을 ADMIN에게만 허용한다" { val privateBoard = Board( - id = 20L, name = "운영", type = BoardType.NOTICE, config = BoardConfig(isPrivate = true), @@ -84,15 +53,14 @@ class BoardEntityTest : "canWriteBy는 비공개/관리자 전용 설정을 모두 고려한다" { val privateBoard = - Board(id = 24L, name = "비공개", type = BoardType.GENERAL, config = BoardConfig(isPrivate = true)) + Board(name = "비공개", type = BoardType.GENERAL, config = BoardConfig(isPrivate = true)) val adminOnlyBoard = Board( - id = 25L, name = "공지", type = BoardType.NOTICE, config = BoardConfig(writePermission = Role.ADMIN), ) - val publicBoard = Board(id = 26L, name = "일반", type = BoardType.GENERAL, config = BoardConfig()) + val publicBoard = Board(name = "일반", type = BoardType.GENERAL, config = BoardConfig()) privateBoard.canWriteBy(Role.USER) shouldBe false privateBoard.canWriteBy(Role.ADMIN) shouldBe true @@ -102,7 +70,7 @@ class BoardEntityTest : } "updateConfig는 config를 교체한다" { - val board = Board(id = 3L, name = "일반", type = BoardType.GENERAL) + val board = Board(name = "일반", type = BoardType.GENERAL) val newConfig = BoardConfig(commentEnabled = false, isPrivate = true) board.updateConfig(newConfig) @@ -111,7 +79,7 @@ class BoardEntityTest : } "markDeleted와 restore는 삭제 상태를 토글한다" { - val board = Board(id = 4L, name = "운영", type = BoardType.GENERAL) + val board = Board(name = "운영", type = BoardType.GENERAL) board.markDeleted() board.isDeleted shouldBe true diff --git a/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt b/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt index 9060baf8..80178524 100644 --- a/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt @@ -7,24 +7,18 @@ import com.weeth.domain.user.domain.enums.Role object BoardTestFixture { fun create( - id: Long = 1L, name: String = "일반 게시판", type: BoardType = BoardType.GENERAL, config: BoardConfig = BoardConfig(), ): Board = Board( - id = id, name = name, type = type, config = config, ) - fun createNoticeBoard( - id: Long = 2L, - name: String = "공지사항", - ): Board = + fun createNoticeBoard(name: String = "공지사항"): Board = create( - id = id, name = name, type = BoardType.NOTICE, config = BoardConfig(writePermission = Role.ADMIN), diff --git a/src/test/kotlin/com/weeth/domain/board/fixture/PostTestFixture.kt b/src/test/kotlin/com/weeth/domain/board/fixture/PostTestFixture.kt index 2c6b6754..52f1ce29 100644 --- a/src/test/kotlin/com/weeth/domain/board/fixture/PostTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/board/fixture/PostTestFixture.kt @@ -1,21 +1,23 @@ package com.weeth.domain.board.fixture +import com.weeth.domain.board.domain.entity.Board import com.weeth.domain.board.domain.entity.Post import com.weeth.domain.user.domain.entity.User import com.weeth.domain.user.fixture.UserTestFixture object PostTestFixture { fun create( - id: Long = 2L, title: String = "게시글", content: String = "내용", user: User = UserTestFixture.createActiveUser1(1L), + board: Board = BoardTestFixture.create(), + cardinalNumber: Int? = null, ): Post = Post( - id = id, title = title, content = content, user = user, - board = BoardTestFixture.create(), + board = board, + cardinalNumber = cardinalNumber, ) } diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt index 9060c35c..38446825 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt @@ -58,7 +58,7 @@ class ManageCommentUseCaseTest : describe("savePostComment") { it("최상위 댓글 저장 시 댓글 수가 증가한다") { val user = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.create(id = 10L, user = user) + val post = PostTestFixture.create(user = user) val dto = CommentSaveRequest(parentCommentId = null, content = "최상위 댓글", files = null) every { userReader.getById(1L) } returns user @@ -73,7 +73,7 @@ class ManageCommentUseCaseTest : it("부모 댓글이 존재하지 않으면 예외를 던진다") { val user = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.create(id = 10L, user = user) + val post = PostTestFixture.create(user = user) val dto = CommentSaveRequest(parentCommentId = 999L, content = "대댓글", files = null) every { userReader.getById(1L) } returns user @@ -89,7 +89,7 @@ class ManageCommentUseCaseTest : describe("updatePostComment") { it("작성자가 아니면 예외를 던진다") { val owner = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.create(id = 10L, user = owner) + val post = PostTestFixture.create(user = owner) val comment = Comment(id = 200L, content = "old", post = post, user = owner) val dto = CommentUpdateRequest(content = "new", files = null) @@ -102,7 +102,7 @@ class ManageCommentUseCaseTest : it("files가 있으면 기존 파일은 삭제되고 새 파일이 저장된다") { val owner = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.create(id = 10L, user = owner) + val post = PostTestFixture.create(user = owner) val comment = Comment(id = 202L, content = "old", post = post, user = owner) val dto = CommentUpdateRequest( @@ -151,8 +151,10 @@ class ManageCommentUseCaseTest : describe("deletePostComment") { it("리프 댓글 삭제 시 hard delete 되고 댓글 수가 감소한다") { val owner = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.create(id = 10L, user = owner, title = "title") - post.commentCount = 1 + val post = + PostTestFixture.create(user = owner, title = "title").also { + it.increaseCommentCount() + } val comment = Comment(id = 310L, content = "leaf", post = post, user = owner) every { postRepository.findByIdWithLock(10L) } returns post @@ -166,8 +168,11 @@ class ManageCommentUseCaseTest : it("자식이 있는 댓글 삭제 시 soft delete 된다") { val owner = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.create(id = 10L, user = owner) - post.commentCount = 2 + val post = + PostTestFixture.create(user = owner).also { + it.increaseCommentCount() + it.increaseCommentCount() + } val comment = Comment(id = 300L, content = "target", post = post, user = owner) val child = Comment(id = 301L, content = "child", post = post, user = owner, parent = comment) @@ -186,7 +191,7 @@ class ManageCommentUseCaseTest : it("이미 삭제된 댓글은 삭제할 수 없다") { val owner = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.create(id = 10L, user = owner) + val post = PostTestFixture.create(user = owner) val comment = Comment(id = 320L, content = "삭제된 댓글입니다.", post = post, user = owner, isDeleted = true) every { postRepository.findByIdWithLock(10L) } returns post diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt index a66f1e9f..4d212e80 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt @@ -25,7 +25,7 @@ class GetCommentQueryServiceTest : val service = GetCommentQueryService(fileReader, fileMapper, commentMapper) val user = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.create(id = 10L, user = user) + val post = PostTestFixture.create(user = user) beforeTest { clearMocks(fileReader, fileMapper, commentMapper) diff --git a/src/test/kotlin/com/weeth/domain/comment/domain/entity/CommentEntityTest.kt b/src/test/kotlin/com/weeth/domain/comment/domain/entity/CommentEntityTest.kt index 43a51029..d7fecad9 100644 --- a/src/test/kotlin/com/weeth/domain/comment/domain/entity/CommentEntityTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/domain/entity/CommentEntityTest.kt @@ -3,14 +3,13 @@ package com.weeth.domain.comment.domain.entity import com.weeth.domain.board.fixture.PostTestFixture import com.weeth.domain.comment.fixture.CommentTestFixture import com.weeth.domain.user.fixture.UserTestFixture -import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe class CommentEntityTest : DescribeSpec({ val user = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.create(id = 10L, title = "title") + val post = PostTestFixture.create(title = "title") describe("createForPost") { it("부모 없이 최상위 댓글을 생성한다") { @@ -21,15 +20,6 @@ class CommentEntityTest : comment.user shouldBe user comment.parent shouldBe null } - - it("부모 댓글이 다른 게시글이면 예외를 던진다") { - val otherPost = PostTestFixture.create(id = 99L, title = "other") - val parent = CommentTestFixture.createPostComment(id = 100L, post = otherPost, user = user) - - shouldThrow { - Comment.createForPost(content = "대댓글", post = post, user = user, parent = parent) - } - } } describe("markAsDeleted") { From 4da842607ad4789a1ea788f52e0b7f5576433e19 Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Wed, 4 Mar 2026 21:07:47 +0900 Subject: [PATCH 17/73] =?UTF-8?q?[WTH-174]=20weeth=20server=20=ED=81=B4?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EC=BD=94=EB=93=9C=20=EC=BB=A4=EC=8A=A4?= =?UTF-8?q?=ED=85=80=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20(#19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: 응답코드 5자리로 개선 * docs: 응답코드 5자리로 개선 * chore: ktlint hook 추가 * docs: 클로드 코드 관련 컨텍스트 최신화 * docs: 리드미 업데이트 --- .claude/hooks/ktlint-format.sh | 16 + .claude/rules/api-design.md | 82 +++-- .claude/rules/architecture.md | 2 +- .claude/rules/exception-handling.md | 36 +- .claude/rules/mapper-dto.md | 18 +- .claude/rules/testing.md | 5 +- .claude/settings.json | 15 + CLAUDE.md | 23 +- README.md | 317 ++++++------------ .../application/exception/AccountErrorCode.kt | 8 +- .../presentation/AccountResponseCode.kt | 10 +- .../exception/AttendanceErrorCode.kt | 6 +- .../presentation/AttendanceResponseCode.kt | 12 +- .../application/exception/BoardErrorCode.kt | 12 +- .../board/presentation/BoardResponseCode.kt | 22 +- .../exception/CardinalErrorCode.kt | 4 +- .../presentation/CardinalResponseCode.kt | 6 +- .../application/exception/CommentErrorCode.kt | 6 +- .../presentation/CommentResponseCode.kt | 6 +- .../application/exception/FileErrorCode.kt | 8 +- .../file/presentation/FileResponseCode.kt | 2 +- .../application/exception/PenaltyErrorCode.kt | 4 +- .../presentation/PenaltyResponseCode.kt | 10 +- .../application/exception/EventErrorCode.kt | 2 +- .../presentation/ScheduleResponseCode.kt | 12 +- .../application/exception/SessionErrorCode.kt | 2 +- .../presentation/SessionResponseCode.kt | 10 +- .../application/exception/UserErrorCode.kt | 29 +- .../user/presentation/UserResponseCode.kt | 24 +- .../jwt/application/exception/JwtErrorCode.kt | 10 +- .../com/weeth/global/config/SwaggerConfig.kt | 33 +- .../exception/CommonExceptionHandlerTest.kt | 2 +- 32 files changed, 331 insertions(+), 423 deletions(-) create mode 100755 .claude/hooks/ktlint-format.sh diff --git a/.claude/hooks/ktlint-format.sh b/.claude/hooks/ktlint-format.sh new file mode 100755 index 00000000..c3b285f2 --- /dev/null +++ b/.claude/hooks/ktlint-format.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# PostToolUse hook: .kt 파일 수정 시 ktlint 자동 포맷 + +FILE_PATH=$(cat | jq -r '.tool_input.file_path // empty') + +if [ -z "$FILE_PATH" ]; then + exit 0 +fi + +# .kt 파일만 처리 +if [[ "$FILE_PATH" != *.kt ]]; then + exit 0 +fi + +cd "$CLAUDE_PROJECT_DIR" || exit 0 +./gradlew ktlintFormat 2>&1 >&2 diff --git a/.claude/rules/api-design.md b/.claude/rules/api-design.md index 6c08cbfe..bf96cf69 100644 --- a/.claude/rules/api-design.md +++ b/.claude/rules/api-design.md @@ -61,9 +61,9 @@ enum class UserResponseCode( override val status: HttpStatus, override val message: String ) : ResponseCodeInterface { - GET_MY_INFO(1100, HttpStatus.OK, "내 정보 조회에 성공했습니다."), - GET_USER_INFO(1101, HttpStatus.OK, "다른 사용자 정보 조회에 성공했습니다."), - UPDATE_PROFILE_IMAGE(1102, HttpStatus.OK, "프로필 이미지 수정에 성공했습니다.") + USER_FIND_ALL_SUCCESS(10900, HttpStatus.OK, "모든 회원 정보를 성공적으로 조회했습니다."), + USER_FIND_BY_ID_SUCCESS(10907, HttpStatus.OK, "회원 정보가 성공적으로 조회되었습니다."), + USER_UPDATE_SUCCESS(10908, HttpStatus.OK, "회원 정보가 성공적으로 수정되었습니다."), } ``` @@ -72,44 +72,62 @@ enum class UserResponseCode( - `CommonResponse.success(USER_FIND_BY_ID_SUCCESS, data)` - `CommonResponse.success(USER_UPDATE_SUCCESS)` -## Domain Success Codes +## Code Format + +| | Mean | Value | +|---|-----------------|----------------------------------------------------------------------------| +| X | Category | 1=Success, 2=Domain Error, 3=Infra/Server Error, 4=Client/Validation Error | +| DD | Domain ID | 01~99 | +| NN | In Domain Count | 00~99 | + +## Domain ID + +| DD | Domain | Success Range | Domain Error Range | Infra Error Range | +|----|------------|---------------|--------------------|-------------------| +| 01 | account | 10100~ | 20100~ | — | +| 02 | attendance | 10200~ | 20200~ | — | +| 03 | session | 10300~ | 20300~ | — | +| 04 | board | 10400~ | 20400~ | — | +| 05 | comment | 10500~ | 20500~ | — | +| 06 | file | 10600~ | 20600~ | 30600~ | +| 07 | penalty | 10700~ | 20700~ | — | +| 08 | schedule | 10800~ | 20800~ | — | +| 09 | user | 10900~ | 20900~ | — | +| 10 | cardinal | 11000~ | 21000~ | — | +| 11 | club | () | () | — | +| 90 | jwt/auth | — | 29000~ | — | +| 99 | common | — | — | 39900~ | -Current project uses domain-specific success enums under `src/main/java/com/weeth/domain/*/presentation/*ResponseCode.java`. +## Domain Success Codes | Domain | ResponseCode Enum | Code Range | Location | |--------|------------------|------------|----------| -| Account | `AccountResponseCode` | `11xx` | `domain/account/presentation/` | -| Attendance | `AttendanceResponseCode` | `12xx` | `domain/attendance/presentation/` | -| Board | `BoardResponseCode` | `13xx` | `domain/board/presentation/` | -| Comment | `CommentResponseCode` | `140xx` | `domain/comment/presentation/` | -| File | `FileResponseCode` | `15xx` | `domain/file/presentation/` | -| Penalty | `PenaltyResponseCode` | `160xx` | `domain/penalty/presentation/` | -| Schedule | `ScheduleResponseCode` | `17xx` | `domain/schedule/presentation/` | -| User | `UserResponseCode` | `18xx` | `domain/user/presentation/` | +| Account | `AccountResponseCode` | `101xx` | `domain/account/presentation/` | +| Attendance | `AttendanceResponseCode` | `102xx` | `domain/attendance/presentation/` | +| Session | `SessionResponseCode` | `103xx` | `domain/session/presentation/` | +| Board | `BoardResponseCode` | `104xx` | `domain/board/presentation/` | +| Comment | `CommentResponseCode` | `105xx` | `domain/comment/presentation/` | +| File | `FileResponseCode` | `106xx` | `domain/file/presentation/` | +| Penalty | `PenaltyResponseCode` | `107xx` | `domain/penalty/presentation/` | +| Schedule | `ScheduleResponseCode` | `108xx` | `domain/schedule/presentation/` | +| User | `UserResponseCode` | `109xx` | `domain/user/presentation/` | +| Cardinal | `CardinalResponseCode` | `110xx` | `domain/cardinal/presentation/` | ## Domain Error Codes -Domain-specific error enums under `src/main/java/com/weeth/domain/*/application/exception/*ErrorCode.java`. - | Domain | ErrorCode Enum | Code Range | Location | |--------|---------------|------------|----------| -| Account | `AccountErrorCode` | `21xx` | `domain/account/application/exception/` | -| Attendance | `AttendanceErrorCode` | `22xx` | `domain/attendance/application/exception/` | -| Board | `BoardErrorCode`, `NoticeErrorCode`, `PostErrorCode` | `23xx` | `domain/board/application/exception/` | -| Comment | `CommentErrorCode` | `240x` | `domain/comment/application/exception/` | -| Penalty | `PenaltyErrorCode` | `260x` | `domain/penalty/application/exception/` | -| Schedule | `EventErrorCode`, `MeetingErrorCode` | `27xx` | `domain/schedule/application/exception/` | -| User | `UserErrorCode` | `28xx` | `domain/user/application/exception/` | -| JWT (Global) | `JwtErrorCode` | `29xx` | `global/auth/jwt/exception/` | - -## Code Numbering - -| Range | Category | -|-------|----------| -| 1XXX | Success responses | -| 2XXX | Domain-specific errors | -| 3XXX | Server errors | -| 4XXX | Client errors | +| Account | `AccountErrorCode` | `201xx` | `domain/account/application/exception/` | +| Attendance | `AttendanceErrorCode` | `202xx` | `domain/attendance/application/exception/` | +| Session | `SessionErrorCode` | `203xx` | `domain/session/application/exception/` | +| Board | `BoardErrorCode` | `204xx` | `domain/board/application/exception/` | +| Comment | `CommentErrorCode` | `205xx` | `domain/comment/application/exception/` | +| File | `FileErrorCode` | `206xx` (domain), `306xx` (infra) | `domain/file/application/exception/` | +| Penalty | `PenaltyErrorCode` | `207xx` | `domain/penalty/application/exception/` | +| Schedule | `EventErrorCode` | `208xx` | `domain/schedule/application/exception/` | +| User | `UserErrorCode` | `209xx` | `domain/user/application/exception/` | +| Cardinal | `CardinalErrorCode` | `210xx` | `domain/cardinal/application/exception/` | +| JWT (Global) | `JwtErrorCode` | `290xx` | `global/auth/jwt/application/exception/` | ## HTTP Methods diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index 1ea5db0f..47cfa649 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -3,7 +3,7 @@ ## Package Structure ```text -src/main/kotlin/weeth/ +src/main/kotlin/com/weeth/ ├── domain/{domain-name}/ │ ├── application/ │ │ ├── dto/request/, dto/response/ diff --git a/.claude/rules/exception-handling.md b/.claude/rules/exception-handling.md index 58bb93bd..5bee6001 100644 --- a/.claude/rules/exception-handling.md +++ b/.claude/rules/exception-handling.md @@ -6,7 +6,7 @@ RuntimeException └── BaseException (abstract) ├── UserNotFoundException - ├── OrderNotFoundException + ├── BoardNotFoundException └── ... (domain-specific exceptions) ``` @@ -40,17 +40,19 @@ enum class UserErrorCode( override val message: String ) : ErrorCodeInterface { @ExplainError("사용자 ID로 조회했으나 해당 사용자가 존재하지 않을 때 발생합니다.") - USER_NOT_FOUND(2100, HttpStatus.NOT_FOUND, "존재하지 않는 사용자입니다."), - - @ExplainError("사용자 설정을 조회했으나 설정 정보가 존재하지 않을 때 발생합니다.") - USER_SETTING_NOT_FOUND(2101, HttpStatus.NOT_FOUND, "존재하지 않는 사용자 설정입니다."), - - @ExplainError("이미 탈퇴 처리된 사용자 계정에 접근을 시도할 때 발생합니다.") - USER_ALREADY_LEAVE(2102, HttpStatus.BAD_REQUEST, "이미 탈퇴한 사용자입니다."), + USER_NOT_FOUND(20900, HttpStatus.NOT_FOUND, "존재하지 않는 유저입니다."), + + @ExplainError("가입 승인 대기 중인 사용자가 접근을 시도할 때 발생합니다.") + USER_INACTIVE(20901, HttpStatus.FORBIDDEN, "가입 승인이 허가되지 않은 계정입니다."), + + @ExplainError("이미 가입된 이메일로 회원가입을 시도할 때 발생합니다.") + USER_EXISTS(20902, HttpStatus.BAD_REQUEST, "이미 가입된 사용자입니다."), } ``` -## Common Error Codes +## Common Error Codes (pattern example, not yet implemented) + +Follow the pattern below when introducing a common error code enum. Currently, `CommonExceptionHandler` uses `CommonResponse.createFailure()` directly. ```kotlin enum class CommonErrorCode( @@ -58,13 +60,13 @@ enum class CommonErrorCode( override val status: HttpStatus, override val message: String ) : ErrorCodeInterface { - // 3XXX: Server errors - INTERNAL_SERVER_ERROR(3001, HttpStatus.INTERNAL_SERVER_ERROR, "Internal server error"), - JSON_PROCESSING_ERROR(3002, HttpStatus.INTERNAL_SERVER_ERROR, "JSON processing error"), + // 3DDNN: Infra/Server errors (DD=99 for common) + INTERNAL_SERVER_ERROR(39901, HttpStatus.INTERNAL_SERVER_ERROR, "Internal server error"), + JSON_PROCESSING_ERROR(39902, HttpStatus.INTERNAL_SERVER_ERROR, "JSON processing error"), - // 4XXX: Client errors - INVALID_ARGUMENT(4001, HttpStatus.BAD_REQUEST, "Invalid argument"), - RESOURCE_NOT_FOUND(4003, HttpStatus.NOT_FOUND, "Resource not found"), + // 4DDNN: Client/Validation errors (DD=99 for common) + INVALID_ARGUMENT(49901, HttpStatus.BAD_REQUEST, "Invalid argument"), + RESOURCE_NOT_FOUND(49903, HttpStatus.NOT_FOUND, "Resource not found"), } ``` @@ -115,8 +117,8 @@ enum class UserErrorCode( override val status: HttpStatus, override val message: String ) : ErrorCodeInterface { - @ExplainError("Raised when no user exists for the given user ID.") - USER_NOT_FOUND(2100, HttpStatus.NOT_FOUND, "존재하지 않는 사용자입니다."), + @ExplainError("사용자 ID로 조회했으나 해당 사용자가 존재하지 않을 때 발생합니다.") + USER_NOT_FOUND(20900, HttpStatus.NOT_FOUND, "존재하지 않는 유저입니다."), } ``` diff --git a/.claude/rules/mapper-dto.md b/.claude/rules/mapper-dto.md index 1ceae391..c1fd5289 100644 --- a/.claude/rules/mapper-dto.md +++ b/.claude/rules/mapper-dto.md @@ -2,18 +2,15 @@ ## Mapper Pattern -AS-IS (Java): MapStruct 사용 → TO-BE (Kotlin): 수동 Mapper 패턴으로 마이그레이션 +Manual `@Component` Mapper pattern (no MapStruct). ```kotlin @Component -class UserMapper( - private val profileMapper: ProfileMapper -) { +class UserMapper { fun toResponse(user: User) = UserResponse( id = user.id, name = user.name, email = user.email, - profile = profileMapper.toResponse(user.profile) ) fun toEntity(request: CreateUserRequest) = User( @@ -82,7 +79,9 @@ data class UserResponse( - Use non-nullable types for required fields - Use nullable types with default `null` for optional fields -## List Response with Pagination +## List Response with Pagination (pattern example) + +Follow the pattern below when introducing a pagination response DTO. ```kotlin data class UserListResponse( @@ -114,12 +113,11 @@ data class PageResponse( ## Mapper Dependencies -Mappers can inject other mappers: +Mappers can inject other mappers when needed: ```kotlin @Component -class UserMapper( - private val profileMapper: ProfileMapper, - private val addressMapper: AddressMapper +class PostMapper( + private val commentMapper: CommentMapper ) ``` diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 963e9c3c..e99df07e 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -20,12 +20,11 @@ ## Directory Structure ```text -src/test/kotlin/weeth/domain/{domain-name}/ +src/test/kotlin/com/weeth/domain/{domain-name}/ ├── application/usecase/command/ # Command UseCase tests ├── application/usecase/query/ # QueryService tests ├── domain/service/ # Domain service tests (multi-entity logic) ├── domain/entity/ # Entity behavior tests -├── presentation/ # Controller tests (@WebMvcTest) └── fixture/ # Shared fixtures for the domain ``` @@ -74,7 +73,7 @@ object UserTestFixture { } ``` -- Location: `src/test/kotlin/weeth/domain/{domain-name}/fixture/` +- Location: `src/test/kotlin/com/weeth/domain/{domain-name}/fixture/` - Use `object` with factory methods - Provide sensible defaults for all parameters - Reuse across test classes in the same domain diff --git a/.claude/settings.json b/.claude/settings.json index 63881359..89d0d3af 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,4 +1,19 @@ { + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/ktlint-format.sh", + "timeout": 120, + "statusMessage": "Running ktlint format..." + } + ] + } + ] + }, "permissions": { "deny": [ "Edit(**/.env*)", diff --git a/CLAUDE.md b/CLAUDE.md index 86300bc4..519291c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ presentation → application → domain ← infrastructure - **infrastructure/**: Port implementations (Adapters for S3, external APIs, etc.) ### Domain Package Layout -Each of the 8 domains (`user`, `attendance`, `schedule`, `board`, `comment`, `file`, `penalty`, `account`) follows: +Each of the 10 domains (`user`, `attendance`, `session`, `schedule`, `board`, `comment`, `file`, `penalty`, `account`, `cardinal`) follows: ``` domain/{name}/ ├── application/ @@ -64,22 +64,7 @@ domain/{name}/ - **`@Transactional` on UseCase only** — Domain Services have no transaction annotations ### Response Format -All API responses wrapped in `CommonResponse` with code/message/data. Success codes use `ResponseCodeInterface` enums (1xxx range), error codes use `ErrorCodeInterface` enums (2xxx domain errors, 3xxx server, 4xxx client). - -### Error Code Ranges - -| Domain | Success | Error | -|--------------|---------|-------| -| Account | 11xx | 21xx | -| Attendance | 12xx | 22xx | -| Board | 13xx | 23xx | -| Comment | 140xx | 240x | -| File | 15xx | 25xx | -| Penalty | 160xx | 260x | -| Schedule | 17xx | 27xx | -| User | 18xx | 28xx | -| Cardinal | 185x | 285x | -| JWT (Global) | — | 29xx | +All API responses wrapped in `CommonResponse` with code/message/data. 5-digit code format `XDDNN`: X=category (1=Success, 2=Domain Error, 3=Infra Error, 4=Client Error), DD=domain ID, NN=sequence. See `.claude/rules/api-design.md` for full domain ID mapping and code ranges. ### Authentication JWT with symmetric key (JJWT 0.13.0), OAuth2 via Kakao and Apple. `@CurrentUser` annotation injects authenticated user ID into controller methods. @@ -94,7 +79,7 @@ JWT with symmetric key (JJWT 0.13.0), OAuth2 via Kakao and Apple. `@CurrentUser` ## Kotlin Migration Status -**✅ Complete** — 294 Kotlin files (100%) +**✅ Complete** — 305 Kotlin files (100%) - Java → Kotlin migration fully complete - Lombok and MapStruct dependencies removed @@ -120,4 +105,4 @@ JWT with symmetric key (JJWT 0.13.0), OAuth2 via Kakao and Apple. `@CurrentUser` ## Detailed Rules -Architecture, code style, testing, API design, exception handling, transactions, git conventions, and logging rules are documented in `.claude/rules/`. Refer to those files for comprehensive guidance on each topic. +Architecture, code style, testing, API design, exception handling, transactions, and git conventions are documented in `.claude/rules/`. Refer to those files for comprehensive guidance on each topic. diff --git a/README.md b/README.md index 485a5d04..14cfc128 100644 --- a/README.md +++ b/README.md @@ -1,263 +1,140 @@ # Weeth Server -동아리 관리 서비스 백엔드 저장소 +Spring Boot 3.5.10 + Kotlin 기반 동아리 커뮤니티 플랫폼 백엔드 -> **Java → Kotlin 마이그레이션 진행 중** -> 새로운 코드는 Kotlin으로 작성되며, 기존 Java 코드(~271 파일)는 점진적으로 마이그레이션됩니다. +## 기술 스택 -## 📋 목차 +| 분류 | 스택 | +|------|------| +| 언어 | Kotlin 2.1.0 | +| 프레임워크 | Spring Boot 3.5.10, Gradle 8.12 (Kotlin DSL) | +| 데이터베이스 | MySQL 8.0, Redis 7.0+, Spring Data JPA | +| 스토리지 | AWS S3 (SDK v2) | +| 인증 | JWT (JJWT 0.13.0), OAuth2 (Kakao, Apple) | +| API 문서 | SpringDoc OpenAPI 3 (Swagger UI) | +| 테스트 | Kotest 5.9.1, MockK, Testcontainers | +| 코드 품질 | ktlint 1.8.0 | +| 모니터링 | Spring Actuator, Micrometer Prometheus | -- [기술 스택](#-기술-스택) -- [빠른 시작](#-빠른-시작) -- [아키텍처](#-아키텍처) -- [프로젝트 구조](#-프로젝트-구조) -- [개발 가이드](#-개발-가이드) -- [테스트](#-테스트) +## 빠른 시작 -## 🛠 기술 스택 - -### Core -- **Language**: Kotlin 2.1.0 (Java 코드에서 점진적 마이그레이션) -- **Framework**: Spring Boot 3.5.10 -- **Build**: Gradle 8.12 (Kotlin DSL) - -### Database & Cache -- **Database**: MySQL 8.0 -- **Cache**: Redis 7.0+ -- **ORM**: Spring Data JPA - -### Infrastructure -- **Storage**: AWS S3 (SDK v2) -- **Auth**: JWT (JJWT 0.13.0, Symmetric Key), OAuth2 (Kakao, Apple) -- **API Docs**: SpringDoc OpenAPI 3 (Swagger UI) -- **Monitoring**: Spring Actuator, Micrometer Prometheus - -### Testing -- **Framework**: Kotest 5.9.1 (DescribeSpec, BehaviorSpec, StringSpec) -- **Mocking**: MockK 1.13.14, SpringMockK 4.0.2 -- **Integration**: Testcontainers 2.0.3 (MySQL) - -### Code Quality -- **Linter/Formatter**: ktlint 1.8.0 -- **Logging**: SLF4J + Logback, Loki aggregation - -## 🚀 빠른 시작 - -### 사전 요구사항 - -- JDK 21 -- MySQL 8.0 -- Redis 7.0+ -- Gradle 8.12 (Wrapper 포함) - -### 환경 변수 설정 - -.env` 파일 생성 or 환경변수 주입 - - -### 빌드 및 실행 +**사전 요구사항:** JDK 21, MySQL 8.0, Redis 7.0+ ```bash -# 빌드 -./gradlew clean build - -# 로컬 실행 (기본 프로파일) -./gradlew bootRun - -# 특정 프로파일로 실행 -./gradlew bootRun --args='--spring.profiles.active=dev' +./gradlew clean build # 빌드 +./gradlew bootRun # 실행 (local 프로파일) +./gradlew bootRun --args='--spring.profiles.active=dev' # 프로파일 지정 실행 +./gradlew test # 전체 테스트 +./gradlew ktlintFormat # 자동 포맷팅 ``` ### 프로파일 -| Profile | 용도 | DDL Auto | Swagger | -|---------|------|----------|---------| -| `local` | 로컬 개발 (기본) | `update` | 활성화 | -| `dev` | 개발 서버 | `update` | 활성화 | -| `prod` | 운영 서버 | `validate` | 비활성화 | -| `test` | 테스트 실행 | `create-drop` | 비활성화 | +| Profile | DDL Auto | Swagger | +|---------|----------|---------| +| `local` (기본) | `update` | 활성화 | +| `dev` | `update` | 활성화 | +| `prod` | `validate` | 비활성화 | +| `test` | `create-drop` | 비활성화 | -## 🏗 아키텍처 - -### 레이어 구조 +## 아키텍처 ``` presentation → application → domain ← infrastructure ``` -- **presentation**: Controller, ResponseCode 열거형 -- **application**: UseCase (command/query), DTO, Mapper, Exception, Validator -- **domain**: Entity (Rich Domain Model), VO, Enum, Repository, Port, Domain Service -- **infrastructure**: Port 구현체 (S3, 외부 API 어댑터) - -### 핵심 패턴 - -#### 1. Rich Domain Model -- Entity가 비즈니스 로직, 검증, 상태 전이를 담당 -- UseCase는 오케스트레이션만 수행 (얇은 조정 계층) +- **Rich Domain Model** — Entity가 비즈니스 로직, 검증, 상태 전이를 소유 +- **UseCase = 오케스트레이션** — Command (`@Transactional`) / Query (`readOnly = true`) +- **Port-Adapter** — domain이 Port 인터페이스 소유, infrastructure가 구현 +- **No thin wrappers** — UseCase가 Repository를 직접 호출 -#### 2. Port-Adapter Pattern -- `domain/port/`: 도메인 언어로 작성된 인터페이스 (예: `FileStorage`, `PushNotificationSender`) -- `infrastructure/`: 기술 구현체 (예: `S3FileStorage`, `FcmPushNotificationSender`) -- UseCase는 Port 인터페이스만 의존 → 테스트 용이, 교체 가능 +### 응답 코드 형식 (`XDDNN`) -#### 3. UseCase 분리 -- **Command UseCase** (`usecase/command/`): 상태 변경, `@Transactional` -- **Query Service** (`usecase/query/`): 읽기 전용, `@Transactional(readOnly = true)` +| X | 분류 | +|---|------| +| 1 | 성공 | +| 2 | 도메인 에러 | +| 3 | 인프라/서버 에러 | +| 4 | 클라이언트/검증 에러 | -#### 4. 도메인 간 참조 -- **읽기**: 대상 도메인의 Reader 인터페이스 사용 -- **쓰기 (동일 트랜잭션)**: Repository 직접 호출 -- **쓰기 (트랜잭션 분리)**: Domain Event 활용 +DD = 도메인 ID (2자리), NN = 순번 (00~99) -### 응답 형식 +## 프로젝트 구조 -모든 API 응답은 `CommonResponse`로 래핑: - -```json -{ - "code": 1100, - "message": "사용자 조회 성공", - "data": { ... } -} ``` - -- **성공 코드**: `1xxx` (도메인별 `*ResponseCode` 열거형) -- **에러 코드**: `2xxx` (도메인 에러), `3xxx` (서버), `4xxx` (클라이언트) - -### 에러 코드 범위 - -| Domain | Success | Error | -|--------|---------|-------| -| Account | 11xx | 21xx | -| Attendance | 12xx | 22xx | -| Board | 13xx | 23xx | -| Comment | 14xx | 24xx | -| File | 15xx | 25xx | -| Penalty | 16xx | 26xx | -| Schedule | 17xx | 27xx | -| User | 18xx | 28xx | -| JWT (전역) | — | 29xx | - -## 📁 프로젝트 구조 - -``` -src/main/ -├── java/com/weeth/ # 레거시 Java 코드 (~271 파일, 점진적 마이그레이션) -└── kotlin/weeth/ - ├── domain/ - │ ├── user/ # 사용자 관리 - │ ├── attendance/ # 출석 관리 - │ ├── schedule/ # 일정 관리 (Event, Meeting) - │ ├── board/ # 게시판 (Notice, Post) - │ ├── comment/ # 댓글 - │ ├── file/ # 파일 업로드 (S3) - │ ├── penalty/ # 페널티 - │ └── account/ # 회계 - └── global/ - ├── auth/ # JWT, OAuth2, @CurrentUser - ├── config/ # Spring Configuration - └── common/ # 공통 유틸, 응답 포맷 - -각 도메인 내부 구조: -domain/{name}/ -├── application/ -│ ├── dto/request/ -│ ├── dto/response/ -│ ├── mapper/ # 수동 Mapper (MapStruct 대체) -│ ├── usecase/command/ # 상태 변경 UseCase -│ ├── usecase/query/ # 읽기 전용 QueryService -│ ├── exception/ # {Domain}ErrorCode enum -│ └── validator/ +src/main/kotlin/com/weeth/ ├── domain/ -│ ├── entity/ # JPA Entity (비즈니스 로직 포함) -│ ├── vo/ # Value Object (@Embeddable, value class) -│ ├── enums/ -│ ├── repository/ # JpaRepository + Reader 인터페이스 -│ ├── port/ # 외부 시스템 추상화 인터페이스 -│ └── service/ # 다중 엔티티 로직만 (얇은 래퍼 금지) -├── infrastructure/ # Port 구현체 -└── presentation/ - └── {Domain}Controller.kt +│ ├── user/ # 사용자 관리, 소셜 로그인 +│ ├── attendance/ # 출석 체크 +│ ├── session/ # 스터디 세션 관리 +│ ├── schedule/ # 일정 관리 +│ ├── board/ # 게시판, 게시글 CRUD +│ ├── comment/ # 댓글, 대댓글 +│ ├── file/ # 파일 업로드 (S3) +│ ├── penalty/ # 페널티 관리 +│ ├── account/ # 회계, 영수증 관리 +│ └── cardinal/ # 기수 관리 +└── global/ + ├── auth/ # JWT, OAuth2, @CurrentUser + ├── config/ # Spring 설정 + └── common/ # 공통 유틸, 응답 포맷 ``` -## 💻 개발 가이드 +## 인프라 -### 코드 포맷팅 +### 배포 아키텍처 -```bash -# 자동 포맷 (커밋 전 필수) -./gradlew ktlintFormat - -# 검사만 수행 -./gradlew ktlintCheck ``` - - -### Git 컨벤션 - -#### 브랜치 네이밍 -``` -feat/{TICKET}-description # 예: feat/WTH-123-user-login -fix/{TICKET}-description # 예: fix/WTH-456-token-expiry -refactor/{TICKET}-description +GitHub Actions (CI/CD) +├── CI: PR/Push → ktlint 검사 → 빌드 및 테스트 +├── Deploy-Dev: dev 브랜치 → Docker Hub → EC2 배포 +└── Deploy-Prod: Release 발행 → Docker Hub → EC2 배포 + +EC2 (ARM64) +├── Caddy 2.8 (리버스 프록시, 자동 HTTPS) +├── Spring Boot App (Blue-Green 배포) +│ ├── app-blue (:18081) +│ └── app-green (:18082) +├── MySQL 8.0 (Dev만, Prod는 RDS) +└── Redis 7.0 ``` -#### 커밋 메시지 -``` -type: message +### Blue-Green 배포 -예시: -feat: Add user authentication -fix: Resolve null pointer in UserService -refactor: Extract validation logic to Entity -test: Add UserUseCase integration tests -``` +무중단 배포를 위해 Blue-Green 방식을 사용합니다. -## 🧪 테스트 +1. 새 컨테이너(Blue/Green) 시작 +2. `/actuator/health` 헬스 체크 (20회, 3초 간격) +3. Caddy upstream 전환 +4. 이전 컨테이너 종료 -### 실행 +### CI/CD 파이프라인 -```bash -# 전체 테스트 -./gradlew test +| 워크플로우 | 트리거 | 동작 | +|-----------|--------|------| +| CI | PR / Push (dev, main) | ktlint 검사 → 빌드 → 테스트 | +| Deploy Dev | CI 완료 (dev) | Docker 빌드 (ARM64) → Docker Hub → EC2 배포 | +| Deploy Prod | Release 발행 | Docker 빌드 (ARM64) → Docker Hub → EC2 배포 | -# 패턴 매칭 -./gradlew test --tests "*UseCaseTest" +### 모니터링 -# 특정 클래스 -./gradlew test --tests "CreateUserUseCaseTest" -``` +- `/actuator/health` — 헬스 체크 (배포 시 사용) +- `/actuator/prometheus` — Prometheus 메트릭 노출 -## 🐳 Docker +### 인프라 파일 구조 -```bash -# 개발 환경 빌드 -docker build -f Dockerfile-dev -t weeth-server:dev . - -# 운영 환경 빌드 -docker build -f Dockerfile-prod -t weeth-server:prod . +``` +infra/ +├── dev/ +│ ├── docker-compose.yml # Dev 환경 (Caddy + MySQL + Redis + App) +│ ├── caddy/Caddyfile # Caddy 리버스 프록시 설정 +│ └── scripts/deploy.sh # Blue-Green 배포 스크립트 +└── prod/ + ├── docker-compose.yml # Prod 환경 (Caddy + Redis + App, MySQL은 RDS) + ├── caddy/Caddyfile + └── scripts/deploy.sh ``` +## 라이선스 -## 📝 주요 기능 - -### 인증/인가 -- JWT 기반 인증 (대칭키, JJWT 0.13.0) -- OAuth2 소셜 로그인 (Kakao, Apple) -- Access Token / Refresh Token - -### 도메인 기능 -- **User**: 사용자 관리, 소셜 로그인, 프로필 관리 -- **Attendance**: 출석 체크, 출석 기록 조회 -- **Schedule**: 일정 생성/조회/관리 (Event, Meeting) -- **Board**: 게시판, 게시글 CRUD (Notice, Post) -- **Comment**: 댓글 CRUD, 대댓글 -- **File**: 파일 업로드 (AWS S3), 이미지 관리 -- **Penalty**: 페널티 관리 -- **Account**: 회계 관리, 영수증 관리 - - -## 📄 라이선스 - -Copyright © 2024 Weeth Team +Copyright 2024 Weeth Team diff --git a/src/main/kotlin/com/weeth/domain/account/application/exception/AccountErrorCode.kt b/src/main/kotlin/com/weeth/domain/account/application/exception/AccountErrorCode.kt index 9bc75b76..1318ca1b 100644 --- a/src/main/kotlin/com/weeth/domain/account/application/exception/AccountErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/account/application/exception/AccountErrorCode.kt @@ -10,14 +10,14 @@ enum class AccountErrorCode( override val message: String, ) : ErrorCodeInterface { @ExplainError("요청한 회비 장부 ID가 존재하지 않을 때 발생합니다.") - ACCOUNT_NOT_FOUND(2100, HttpStatus.NOT_FOUND, "존재하지 않는 장부입니다."), + ACCOUNT_NOT_FOUND(20100, HttpStatus.NOT_FOUND, "존재하지 않는 장부입니다."), @ExplainError("이미 존재하는 장부를 중복 생성하려고 할 때 발생합니다.") - ACCOUNT_EXISTS(2101, HttpStatus.BAD_REQUEST, "이미 생성된 장부입니다."), + ACCOUNT_EXISTS(20101, HttpStatus.BAD_REQUEST, "이미 생성된 장부입니다."), @ExplainError("요청한 영수증 내역이 존재하지 않을 때 발생합니다.") - RECEIPT_NOT_FOUND(2102, HttpStatus.NOT_FOUND, "존재하지 않는 내역입니다."), + RECEIPT_NOT_FOUND(20102, HttpStatus.NOT_FOUND, "존재하지 않는 내역입니다."), @ExplainError("영수증이 요청한 기수의 장부에 속하지 않을 때 발생합니다.") - RECEIPT_ACCOUNT_MISMATCH(2103, HttpStatus.BAD_REQUEST, "영수증이 해당 기수의 장부에 속하지 않습니다."), + RECEIPT_ACCOUNT_MISMATCH(20103, HttpStatus.BAD_REQUEST, "영수증이 해당 기수의 장부에 속하지 않습니다."), } diff --git a/src/main/kotlin/com/weeth/domain/account/presentation/AccountResponseCode.kt b/src/main/kotlin/com/weeth/domain/account/presentation/AccountResponseCode.kt index 647dfc5e..e93d5a60 100644 --- a/src/main/kotlin/com/weeth/domain/account/presentation/AccountResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/account/presentation/AccountResponseCode.kt @@ -8,9 +8,9 @@ enum class AccountResponseCode( override val status: HttpStatus, override val message: String, ) : ResponseCodeInterface { - ACCOUNT_SAVE_SUCCESS(1100, HttpStatus.OK, "회비가 성공적으로 저장되었습니다."), - ACCOUNT_FIND_SUCCESS(1101, HttpStatus.OK, "회비가 성공적으로 조회되었습니다."), - RECEIPT_SAVE_SUCCESS(1102, HttpStatus.OK, "영수증이 성공적으로 저장되었습니다."), - RECEIPT_DELETE_SUCCESS(1103, HttpStatus.OK, "영수증이 성공적으로 삭제되었습니다."), - RECEIPT_UPDATE_SUCCESS(1104, HttpStatus.OK, "영수증이 성공적으로 업데이트 되었습니다."), + ACCOUNT_SAVE_SUCCESS(10100, HttpStatus.OK, "회비가 성공적으로 저장되었습니다."), + ACCOUNT_FIND_SUCCESS(10101, HttpStatus.OK, "회비가 성공적으로 조회되었습니다."), + RECEIPT_SAVE_SUCCESS(10102, HttpStatus.OK, "영수증이 성공적으로 저장되었습니다."), + RECEIPT_DELETE_SUCCESS(10103, HttpStatus.OK, "영수증이 성공적으로 삭제되었습니다."), + RECEIPT_UPDATE_SUCCESS(10104, HttpStatus.OK, "영수증이 성공적으로 업데이트 되었습니다."), } diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceErrorCode.kt b/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceErrorCode.kt index 8682af4e..ec80543b 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceErrorCode.kt @@ -10,11 +10,11 @@ enum class AttendanceErrorCode( override val message: String, ) : ErrorCodeInterface { @ExplainError("출석 정보를 찾을 수 없을 때 발생합니다.") - ATTENDANCE_NOT_FOUND(2200, HttpStatus.NOT_FOUND, "출석 정보가 존재하지 않습니다."), + ATTENDANCE_NOT_FOUND(20200, HttpStatus.NOT_FOUND, "출석 정보가 존재하지 않습니다."), @ExplainError("입력한 출석 코드가 생성된 코드와 일치하지 않을 때 발생합니다.") - ATTENDANCE_CODE_MISMATCH(2201, HttpStatus.BAD_REQUEST, "출석 코드가 일치하지 않습니다."), + ATTENDANCE_CODE_MISMATCH(20201, HttpStatus.BAD_REQUEST, "출석 코드가 일치하지 않습니다."), @ExplainError("사용자가 출석 일정을 직접 수정하려고 시도할 때 발생합니다. (출석 로직 위반)") - ATTENDANCE_EVENT_TYPE_NOT_MATCH(2202, HttpStatus.BAD_REQUEST, "출석일정은 직접 수정할 수 없습니다."), + ATTENDANCE_EVENT_TYPE_NOT_MATCH(20202, HttpStatus.BAD_REQUEST, "출석일정은 직접 수정할 수 없습니다."), } diff --git a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceResponseCode.kt b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceResponseCode.kt index e67ef08a..982b70bb 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceResponseCode.kt @@ -9,12 +9,12 @@ enum class AttendanceResponseCode( override val message: String, ) : ResponseCodeInterface { // AttendanceAdminController 관련 - ATTENDANCE_CLOSE_SUCCESS(1200, HttpStatus.OK, "출석이 성공적으로 마감되었습니다."), - ATTENDANCE_UPDATED_SUCCESS(1201, HttpStatus.OK, "개별 출석 상태가 성공적으로 수정되었습니다."), - ATTENDANCE_FIND_DETAIL_SUCCESS(1202, HttpStatus.OK, "모든 인원의 정기모임 출석 정보가 성공적으로 조회되었습니다."), + ATTENDANCE_CLOSE_SUCCESS(10200, HttpStatus.OK, "출석이 성공적으로 마감되었습니다."), + ATTENDANCE_UPDATED_SUCCESS(10201, HttpStatus.OK, "개별 출석 상태가 성공적으로 수정되었습니다."), + ATTENDANCE_FIND_DETAIL_SUCCESS(10202, HttpStatus.OK, "모든 인원의 정기모임 출석 정보가 성공적으로 조회되었습니다."), // AttendanceController 관련 - ATTENDANCE_CHECKIN_SUCCESS(1203, HttpStatus.OK, "출석이 성공적으로 처리되었습니다."), - ATTENDANCE_FIND_SUCCESS(1204, HttpStatus.OK, "사용자의 출석 정보가 성공적으로 조회되었습니다."), - ATTENDANCE_FIND_ALL_SUCCESS(1205, HttpStatus.OK, "사용자의 상세 출석 정보가 성공적으로 조회되었습니다."), + ATTENDANCE_CHECKIN_SUCCESS(10203, HttpStatus.OK, "출석이 성공적으로 처리되었습니다."), + ATTENDANCE_FIND_SUCCESS(10204, HttpStatus.OK, "사용자의 출석 정보가 성공적으로 조회되었습니다."), + ATTENDANCE_FIND_ALL_SUCCESS(10205, HttpStatus.OK, "사용자의 상세 출석 정보가 성공적으로 조회되었습니다."), } diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt index 7e1c38b3..4dca1f23 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt @@ -10,20 +10,20 @@ enum class BoardErrorCode( override val message: String, ) : ErrorCodeInterface { @ExplainError("검색 결과가 없을 때 발생합니다.") - NO_SEARCH_RESULT(2300, HttpStatus.NOT_FOUND, "검색 결과가 없습니다."), + NO_SEARCH_RESULT(20400, HttpStatus.NOT_FOUND, "검색 결과가 없습니다."), @ExplainError("유효하지 않은 페이지 번호를 요청할 때 발생합니다.") - PAGE_NOT_FOUND(2301, HttpStatus.BAD_REQUEST, "유효하지 않은 페이지입니다."), + PAGE_NOT_FOUND(20401, HttpStatus.BAD_REQUEST, "유효하지 않은 페이지입니다."), @ExplainError("ADMIN 전용 게시판에 일반 사용자가 글을 작성할 때 발생합니다.") - CATEGORY_ACCESS_DENIED(2302, HttpStatus.FORBIDDEN, "해당 카테고리에 대한 권한이 없습니다."), + CATEGORY_ACCESS_DENIED(20402, HttpStatus.FORBIDDEN, "해당 카테고리에 대한 권한이 없습니다."), @ExplainError("게시판 ID로 조회했으나 해당 게시판이 존재하지 않을 때 발생합니다.") - BOARD_NOT_FOUND(2303, HttpStatus.NOT_FOUND, "존재하지 않는 게시판입니다."), + BOARD_NOT_FOUND(20403, HttpStatus.NOT_FOUND, "존재하지 않는 게시판입니다."), @ExplainError("게시글 ID로 조회했으나 해당 게시글이 존재하지 않을 때 발생합니다.") - POST_NOT_FOUND(2304, HttpStatus.NOT_FOUND, "존재하지 않는 게시글입니다."), + POST_NOT_FOUND(20404, HttpStatus.NOT_FOUND, "존재하지 않는 게시글입니다."), @ExplainError("게시글 작성자가 아닌 사용자가 수정/삭제를 시도할 때 발생합니다.") - POST_NOT_OWNED(2305, HttpStatus.FORBIDDEN, "게시글 작성자만 수정/삭제할 수 있습니다."), + POST_NOT_OWNED(20405, HttpStatus.FORBIDDEN, "게시글 작성자만 수정/삭제할 수 있습니다."), } diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt b/src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt index 2f45b492..ae3bf958 100644 --- a/src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt @@ -8,15 +8,15 @@ enum class BoardResponseCode( override val status: HttpStatus, override val message: String, ) : ResponseCodeInterface { - BOARD_CREATED_SUCCESS(1300, HttpStatus.OK, "게시판이 성공적으로 생성되었습니다."), - POST_CREATED_SUCCESS(1301, HttpStatus.OK, "게시글이 성공적으로 생성되었습니다."), - POST_UPDATED_SUCCESS(1302, HttpStatus.OK, "게시글이 성공적으로 수정되었습니다."), - POST_DELETED_SUCCESS(1303, HttpStatus.OK, "게시글이 성공적으로 삭제되었습니다."), - POST_FIND_ALL_SUCCESS(1304, HttpStatus.OK, "게시글 목록이 성공적으로 조회되었습니다."), - POST_FIND_BY_ID_SUCCESS(1305, HttpStatus.OK, "게시글이 성공적으로 조회되었습니다."), - POST_SEARCH_SUCCESS(1306, HttpStatus.OK, "게시글 검색 결과가 성공적으로 조회되었습니다."), - BOARD_UPDATED_SUCCESS(1307, HttpStatus.OK, "게시판이 성공적으로 수정되었습니다."), - BOARD_DELETED_SUCCESS(1308, HttpStatus.OK, "게시판이 성공적으로 삭제되었습니다."), - BOARD_FIND_ALL_SUCCESS(1309, HttpStatus.OK, "게시판 목록이 성공적으로 조회되었습니다."), - BOARD_FIND_BY_ID_SUCCESS(1310, HttpStatus.OK, "게시판이 성공적으로 조회되었습니다."), + BOARD_CREATED_SUCCESS(10400, HttpStatus.OK, "게시판이 성공적으로 생성되었습니다."), + POST_CREATED_SUCCESS(10401, HttpStatus.OK, "게시글이 성공적으로 생성되었습니다."), + POST_UPDATED_SUCCESS(10402, HttpStatus.OK, "게시글이 성공적으로 수정되었습니다."), + POST_DELETED_SUCCESS(10403, HttpStatus.OK, "게시글이 성공적으로 삭제되었습니다."), + POST_FIND_ALL_SUCCESS(10404, HttpStatus.OK, "게시글 목록이 성공적으로 조회되었습니다."), + POST_FIND_BY_ID_SUCCESS(10405, HttpStatus.OK, "게시글이 성공적으로 조회되었습니다."), + POST_SEARCH_SUCCESS(10406, HttpStatus.OK, "게시글 검색 결과가 성공적으로 조회되었습니다."), + BOARD_UPDATED_SUCCESS(10407, HttpStatus.OK, "게시판이 성공적으로 수정되었습니다."), + BOARD_DELETED_SUCCESS(10408, HttpStatus.OK, "게시판이 성공적으로 삭제되었습니다."), + BOARD_FIND_ALL_SUCCESS(10409, HttpStatus.OK, "게시판 목록이 성공적으로 조회되었습니다."), + BOARD_FIND_BY_ID_SUCCESS(10410, HttpStatus.OK, "게시판이 성공적으로 조회되었습니다."), } diff --git a/src/main/kotlin/com/weeth/domain/cardinal/application/exception/CardinalErrorCode.kt b/src/main/kotlin/com/weeth/domain/cardinal/application/exception/CardinalErrorCode.kt index 7216cedc..3dbcfd80 100644 --- a/src/main/kotlin/com/weeth/domain/cardinal/application/exception/CardinalErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/application/exception/CardinalErrorCode.kt @@ -10,8 +10,8 @@ enum class CardinalErrorCode( override val message: String, ) : ErrorCodeInterface { @ExplainError("존재하지 않는 기수 ID 또는 번호로 조회했을 때 발생합니다.") - CARDINAL_NOT_FOUND(2850, HttpStatus.NOT_FOUND, "기수를 찾을 수 없습니다."), + CARDINAL_NOT_FOUND(21000, HttpStatus.NOT_FOUND, "기수를 찾을 수 없습니다."), @ExplainError("이미 존재하는 기수를 생성하려고 할 때 발생합니다.") - DUPLICATE_CARDINAL(2851, HttpStatus.BAD_REQUEST, "이미 존재하는 기수입니다."), + DUPLICATE_CARDINAL(21001, HttpStatus.BAD_REQUEST, "이미 존재하는 기수입니다."), } diff --git a/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalResponseCode.kt b/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalResponseCode.kt index 3839ef35..6bf3ca2a 100644 --- a/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalResponseCode.kt @@ -8,7 +8,7 @@ enum class CardinalResponseCode( override val status: HttpStatus, override val message: String, ) : ResponseCodeInterface { - CARDINAL_FIND_ALL_SUCCESS(1850, HttpStatus.OK, "전체 기수 조회에 성공했습니다."), - CARDINAL_SAVE_SUCCESS(1851, HttpStatus.OK, "기수 저장에 성공했습니다."), - CARDINAL_UPDATE_SUCCESS(1852, HttpStatus.OK, "기수 수정에 성공했습니다."), + CARDINAL_FIND_ALL_SUCCESS(11000, HttpStatus.OK, "전체 기수 조회에 성공했습니다."), + CARDINAL_SAVE_SUCCESS(11001, HttpStatus.OK, "기수 저장에 성공했습니다."), + CARDINAL_UPDATE_SUCCESS(11002, HttpStatus.OK, "기수 수정에 성공했습니다."), } diff --git a/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentErrorCode.kt b/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentErrorCode.kt index 7f2af7aa..be155174 100644 --- a/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentErrorCode.kt @@ -10,11 +10,11 @@ enum class CommentErrorCode( override val message: String, ) : ErrorCodeInterface { @ExplainError("요청한 댓글 ID에 해당하는 댓글이 존재하지 않을 때 발생합니다.") - COMMENT_NOT_FOUND(2400, HttpStatus.NOT_FOUND, "존재하지 않는 댓글입니다."), + COMMENT_NOT_FOUND(20500, HttpStatus.NOT_FOUND, "존재하지 않는 댓글입니다."), @ExplainError("댓글 작성자가 아닌 사용자가 수정/삭제를 시도할 때 발생합니다.") - COMMENT_NOT_OWNED(2401, HttpStatus.FORBIDDEN, "댓글 작성자만 수정/삭제할 수 있습니다."), + COMMENT_NOT_OWNED(20501, HttpStatus.FORBIDDEN, "댓글 작성자만 수정/삭제할 수 있습니다."), @ExplainError("이미 삭제된 댓글에 대해 삭제를 재시도할 때 발생합니다.") - COMMENT_ALREADY_DELETED(2402, HttpStatus.BAD_REQUEST, "이미 삭제된 댓글입니다."), + COMMENT_ALREADY_DELETED(20502, HttpStatus.BAD_REQUEST, "이미 삭제된 댓글입니다."), } diff --git a/src/main/kotlin/com/weeth/domain/comment/presentation/CommentResponseCode.kt b/src/main/kotlin/com/weeth/domain/comment/presentation/CommentResponseCode.kt index 0a485e33..15706d08 100644 --- a/src/main/kotlin/com/weeth/domain/comment/presentation/CommentResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/comment/presentation/CommentResponseCode.kt @@ -8,7 +8,7 @@ enum class CommentResponseCode( override val status: HttpStatus, override val message: String, ) : ResponseCodeInterface { - POST_COMMENT_CREATED_SUCCESS(1403, HttpStatus.OK, "게시글 댓글이 성공적으로 생성되었습니다."), - POST_COMMENT_UPDATED_SUCCESS(1404, HttpStatus.OK, "게시글 댓글이 성공적으로 수정되었습니다."), - POST_COMMENT_DELETED_SUCCESS(1405, HttpStatus.OK, "게시글 댓글이 성공적으로 삭제되었습니다."), + POST_COMMENT_CREATED_SUCCESS(10500, HttpStatus.OK, "게시글 댓글이 성공적으로 생성되었습니다."), + POST_COMMENT_UPDATED_SUCCESS(10501, HttpStatus.OK, "게시글 댓글이 성공적으로 수정되었습니다."), + POST_COMMENT_DELETED_SUCCESS(10502, HttpStatus.OK, "게시글 댓글이 성공적으로 삭제되었습니다."), } diff --git a/src/main/kotlin/com/weeth/domain/file/application/exception/FileErrorCode.kt b/src/main/kotlin/com/weeth/domain/file/application/exception/FileErrorCode.kt index fe12e06a..35fce693 100644 --- a/src/main/kotlin/com/weeth/domain/file/application/exception/FileErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/file/application/exception/FileErrorCode.kt @@ -10,14 +10,14 @@ enum class FileErrorCode( override val message: String, ) : ErrorCodeInterface { @ExplainError("파일 ID로 조회했으나 해당 파일이 존재하지 않을 때 발생합니다.") - FILE_NOT_FOUND(2500, HttpStatus.NOT_FOUND, "존재하지 않는 파일입니다."), + FILE_NOT_FOUND(20600, HttpStatus.NOT_FOUND, "존재하지 않는 파일입니다."), @ExplainError("Presigned URL 생성 중 S3 연결 오류가 발생했을 때 발생합니다.") - PRESIGNED_URL_GENERATION_FAILED(2501, HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드 URL 생성에 실패했습니다."), + PRESIGNED_URL_GENERATION_FAILED(30600, HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드 URL 생성에 실패했습니다."), @ExplainError("허용되지 않은 Content-Type으로 파일 업로드를 시도했을 때 발생합니다.") - UNSUPPORTED_CONTENT_TYPE(2502, HttpStatus.BAD_REQUEST, "지원하지 않는 파일 형식입니다."), + UNSUPPORTED_CONTENT_TYPE(20601, HttpStatus.BAD_REQUEST, "지원하지 않는 파일 형식입니다."), @ExplainError("허용되지 않은 확장자로 파일 업로드를 시도했을 때 발생합니다.") - UNSUPPORTED_FILE_EXTENSION(2503, HttpStatus.BAD_REQUEST, "지원하지 않는 파일 확장자입니다."), + UNSUPPORTED_FILE_EXTENSION(20602, HttpStatus.BAD_REQUEST, "지원하지 않는 파일 확장자입니다."), } diff --git a/src/main/kotlin/com/weeth/domain/file/presentation/FileResponseCode.kt b/src/main/kotlin/com/weeth/domain/file/presentation/FileResponseCode.kt index eadec132..a73a221c 100644 --- a/src/main/kotlin/com/weeth/domain/file/presentation/FileResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/file/presentation/FileResponseCode.kt @@ -8,5 +8,5 @@ enum class FileResponseCode( override val status: HttpStatus, override val message: String, ) : ResponseCodeInterface { - PRESIGNED_URL_GET_SUCCESS(1500, HttpStatus.OK, "Presigned Url 반환에 성공했습니다"), + PRESIGNED_URL_GET_SUCCESS(10600, HttpStatus.OK, "Presigned Url 반환에 성공했습니다"), } diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/exception/PenaltyErrorCode.kt b/src/main/kotlin/com/weeth/domain/penalty/application/exception/PenaltyErrorCode.kt index 34de9120..a1c0c6f5 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/application/exception/PenaltyErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/application/exception/PenaltyErrorCode.kt @@ -10,8 +10,8 @@ enum class PenaltyErrorCode( override val message: String, ) : ErrorCodeInterface { @ExplainError("요청한 패널티 ID가 존재하지 않을 때 발생합니다.") - PENALTY_NOT_FOUND(2600, HttpStatus.NOT_FOUND, "존재하지 않는 패널티입니다."), + PENALTY_NOT_FOUND(20700, HttpStatus.NOT_FOUND, "존재하지 않는 패널티입니다."), @ExplainError("시스템에 의해 자동 부여된 패널티를 수동으로 삭제하려 할 때 발생합니다.") - AUTO_PENALTY_DELETE_NOT_ALLOWED(2601, HttpStatus.BAD_REQUEST, "자동 생성된 패널티는 삭제할 수 없습니다"), + AUTO_PENALTY_DELETE_NOT_ALLOWED(20701, HttpStatus.BAD_REQUEST, "자동 생성된 패널티는 삭제할 수 없습니다"), } diff --git a/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyResponseCode.kt b/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyResponseCode.kt index f6b05674..20226bb2 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyResponseCode.kt @@ -8,9 +8,9 @@ enum class PenaltyResponseCode( override val status: HttpStatus, override val message: String, ) : ResponseCodeInterface { - PENALTY_ASSIGN_SUCCESS(1600, HttpStatus.OK, "페널티가 성공적으로 부여되었습니다."), - PENALTY_FIND_ALL_SUCCESS(1601, HttpStatus.OK, "모든 패널티가 성공적으로 조회되었습니다."), - PENALTY_DELETE_SUCCESS(1602, HttpStatus.OK, "패널티가 성공적으로 삭제되었습니다."), - PENALTY_UPDATE_SUCCESS(1603, HttpStatus.OK, "패널티를 성공적으로 수정했습니다."), - PENALTY_USER_FIND_SUCCESS(1604, HttpStatus.OK, "패널티가 성공적으로 조회되었습니다."), + PENALTY_ASSIGN_SUCCESS(10700, HttpStatus.OK, "페널티가 성공적으로 부여되었습니다."), + PENALTY_FIND_ALL_SUCCESS(10701, HttpStatus.OK, "모든 패널티가 성공적으로 조회되었습니다."), + PENALTY_DELETE_SUCCESS(10702, HttpStatus.OK, "패널티가 성공적으로 삭제되었습니다."), + PENALTY_UPDATE_SUCCESS(10703, HttpStatus.OK, "패널티를 성공적으로 수정했습니다."), + PENALTY_USER_FIND_SUCCESS(10704, HttpStatus.OK, "패널티가 성공적으로 조회되었습니다."), } diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/exception/EventErrorCode.kt b/src/main/kotlin/com/weeth/domain/schedule/application/exception/EventErrorCode.kt index 8df47f00..c0cfbb38 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/application/exception/EventErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/application/exception/EventErrorCode.kt @@ -10,5 +10,5 @@ enum class EventErrorCode( override val message: String, ) : ErrorCodeInterface { @ExplainError("요청한 일정 ID에 해당하는 일정이 존재하지 않을 때 발생합니다.") - EVENT_NOT_FOUND(2700, HttpStatus.NOT_FOUND, "존재하지 않는 일정입니다."), + EVENT_NOT_FOUND(20800, HttpStatus.NOT_FOUND, "존재하지 않는 일정입니다."), } diff --git a/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleResponseCode.kt b/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleResponseCode.kt index 92230c61..cb7d5c5f 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleResponseCode.kt @@ -8,10 +8,10 @@ enum class ScheduleResponseCode( override val status: HttpStatus, override val message: String, ) : ResponseCodeInterface { - EVENT_SAVE_SUCCESS(1700, HttpStatus.OK, "일정이 성공적으로 생성되었습니다."), - EVENT_UPDATE_SUCCESS(1701, HttpStatus.OK, "일정이 성공적으로 수정되었습니다."), - EVENT_DELETE_SUCCESS(1702, HttpStatus.OK, "일정이 성공적으로 삭제되었습니다."), - EVENT_FIND_SUCCESS(1703, HttpStatus.OK, "일정이 성공적으로 조회되었습니다."), - SCHEDULE_MONTHLY_FIND_SUCCESS(1704, HttpStatus.OK, "월별 일정이 성공적으로 조회되었습니다."), - SCHEDULE_YEARLY_FIND_SUCCESS(1705, HttpStatus.OK, "연도별 일정이 성공적으로 조회되었습니다."), + EVENT_SAVE_SUCCESS(10800, HttpStatus.OK, "일정이 성공적으로 생성되었습니다."), + EVENT_UPDATE_SUCCESS(10801, HttpStatus.OK, "일정이 성공적으로 수정되었습니다."), + EVENT_DELETE_SUCCESS(10802, HttpStatus.OK, "일정이 성공적으로 삭제되었습니다."), + EVENT_FIND_SUCCESS(10803, HttpStatus.OK, "일정이 성공적으로 조회되었습니다."), + SCHEDULE_MONTHLY_FIND_SUCCESS(10804, HttpStatus.OK, "월별 일정이 성공적으로 조회되었습니다."), + SCHEDULE_YEARLY_FIND_SUCCESS(10805, HttpStatus.OK, "연도별 일정이 성공적으로 조회되었습니다."), } diff --git a/src/main/kotlin/com/weeth/domain/session/application/exception/SessionErrorCode.kt b/src/main/kotlin/com/weeth/domain/session/application/exception/SessionErrorCode.kt index 13563c4f..eaf0088b 100644 --- a/src/main/kotlin/com/weeth/domain/session/application/exception/SessionErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/session/application/exception/SessionErrorCode.kt @@ -10,5 +10,5 @@ enum class SessionErrorCode( override val message: String, ) : ErrorCodeInterface { @ExplainError("요청한 정기모임 ID에 해당하는 정기모임이 존재하지 않을 때 발생합니다.") - SESSION_NOT_FOUND(2203, HttpStatus.NOT_FOUND, "존재하지 않는 정기모임입니다."), + SESSION_NOT_FOUND(20300, HttpStatus.NOT_FOUND, "존재하지 않는 정기모임입니다."), } diff --git a/src/main/kotlin/com/weeth/domain/session/presentation/SessionResponseCode.kt b/src/main/kotlin/com/weeth/domain/session/presentation/SessionResponseCode.kt index 78f20ead..50c42980 100644 --- a/src/main/kotlin/com/weeth/domain/session/presentation/SessionResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/session/presentation/SessionResponseCode.kt @@ -9,11 +9,11 @@ enum class SessionResponseCode( override val message: String, ) : ResponseCodeInterface { // SessionAdminController 관련 - SESSION_INFOS_FIND_SUCCESS(1206, HttpStatus.OK, "기수별 정기모임 리스트를 성공적으로 조회했습니다."), - SESSION_SAVE_SUCCESS(1207, HttpStatus.OK, "정기모임이 성공적으로 생성되었습니다."), - SESSION_UPDATE_SUCCESS(1208, HttpStatus.OK, "정기모임이 성공적으로 수정되었습니다."), - SESSION_DELETE_SUCCESS(1209, HttpStatus.OK, "정기모임이 성공적으로 삭제되었습니다."), + SESSION_INFOS_FIND_SUCCESS(10300, HttpStatus.OK, "기수별 정기모임 리스트를 성공적으로 조회했습니다."), + SESSION_SAVE_SUCCESS(10301, HttpStatus.OK, "정기모임이 성공적으로 생성되었습니다."), + SESSION_UPDATE_SUCCESS(10302, HttpStatus.OK, "정기모임이 성공적으로 수정되었습니다."), + SESSION_DELETE_SUCCESS(10303, HttpStatus.OK, "정기모임이 성공적으로 삭제되었습니다."), // SessionController 관련 - SESSION_FIND_SUCCESS(1210, HttpStatus.OK, "정기모임이 성공적으로 조회되었습니다."), + SESSION_FIND_SUCCESS(10304, HttpStatus.OK, "정기모임이 성공적으로 조회되었습니다."), } diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/UserErrorCode.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/UserErrorCode.kt index d569c943..246444b3 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/exception/UserErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/UserErrorCode.kt @@ -10,44 +10,41 @@ enum class UserErrorCode( override val message: String, ) : ErrorCodeInterface { @ExplainError("사용자 ID로 조회했으나 해당 사용자가 존재하지 않을 때 발생합니다.") - USER_NOT_FOUND(2800, HttpStatus.NOT_FOUND, "존재하지 않는 유저입니다."), + USER_NOT_FOUND(20900, HttpStatus.NOT_FOUND, "존재하지 않는 유저입니다."), @ExplainError("가입 승인 대기 중인 사용자가 접근을 시도할 때 발생합니다.") - USER_INACTIVE(2801, HttpStatus.FORBIDDEN, "가입 승인이 허가되지 않은 계정입니다."), + USER_INACTIVE(20901, HttpStatus.FORBIDDEN, "가입 승인이 허가되지 않은 계정입니다."), @ExplainError("이미 가입된 이메일로 회원가입을 시도할 때 발생합니다.") - USER_EXISTS(2802, HttpStatus.BAD_REQUEST, "이미 가입된 사용자입니다."), + USER_EXISTS(20902, HttpStatus.BAD_REQUEST, "이미 가입된 사용자입니다."), @ExplainError("요청한 사용자 정보와 실제 사용자 정보가 일치하지 않을 때 발생합니다.") - USER_MISMATCH(2803, HttpStatus.FORBIDDEN, "사용자 정보가 일치하지 않습니다."), + USER_MISMATCH(20903, HttpStatus.FORBIDDEN, "사용자 정보가 일치하지 않습니다."), @ExplainError("다른 사용자의 리소스에 접근하려고 할 때 발생합니다.") - USER_NOT_MATCH(2804, HttpStatus.FORBIDDEN, "해당 사용자가 아닙니다."), + USER_NOT_MATCH(20904, HttpStatus.FORBIDDEN, "해당 사용자가 아닙니다."), @ExplainError("로그인 시 비밀번호가 일치하지 않을 때 발생합니다.") - PASSWORD_MISMATCH(2805, HttpStatus.BAD_REQUEST, "비밀번호가 일치하지 않습니다."), + PASSWORD_MISMATCH(20905, HttpStatus.BAD_REQUEST, "비밀번호가 일치하지 않습니다."), @ExplainError("입력한 이메일로 등록된 사용자가 없을 때 발생합니다.") - EMAIL_NOT_FOUND(2806, HttpStatus.NOT_FOUND, "이메일을 찾을 수 없습니다."), + EMAIL_NOT_FOUND(20906, HttpStatus.NOT_FOUND, "이메일을 찾을 수 없습니다."), @ExplainError("이미 등록된 학번으로 회원가입을 시도할 때 발생합니다.") - STUDENT_ID_EXISTS(2807, HttpStatus.BAD_REQUEST, "이미 존재하는 학번입니다."), + STUDENT_ID_EXISTS(20907, HttpStatus.BAD_REQUEST, "이미 존재하는 학번입니다."), @ExplainError("이미 등록된 전화번호로 회원가입을 시도할 때 발생합니다.") - TEL_EXISTS(2808, HttpStatus.BAD_REQUEST, "이미 존재하는 전화번호입니다."), + TEL_EXISTS(20908, HttpStatus.BAD_REQUEST, "이미 존재하는 전화번호입니다."), @ExplainError("사용자와 기수 간의 연결 정보를 찾을 수 없을 때 발생합니다.") - USER_CARDINAL_NOT_FOUND(2809, HttpStatus.NOT_FOUND, "사용자 기수 정보를 찾을 수 없습니다."), - - @ExplainError("잘못된 학과 값이 입력되었을 때 발생합니다.") - DEPARTMENT_NOT_FOUND(2810, HttpStatus.BAD_REQUEST, "학과를 찾을 수 없습니다."), + USER_CARDINAL_NOT_FOUND(20909, HttpStatus.NOT_FOUND, "사용자 기수 정보를 찾을 수 없습니다."), @ExplainError("잘못된 권한 값이 입력되었을 때 발생합니다.") - ROLE_NOT_FOUND(2811, HttpStatus.BAD_REQUEST, "권한을 찾을 수 없습니다."), + ROLE_NOT_FOUND(20911, HttpStatus.BAD_REQUEST, "권한을 찾을 수 없습니다."), @ExplainError("잘못된 상태 값이 입력되었을 때 발생합니다.") - STATUS_NOT_FOUND(2812, HttpStatus.BAD_REQUEST, "상태를 찾을 수 없습니다."), + STATUS_NOT_FOUND(20912, HttpStatus.BAD_REQUEST, "상태를 찾을 수 없습니다."), @ExplainError("사용자 순서 지정 시 잘못된 값이 입력되었을 때 발생합니다.") - INVALID_USER_ORDER(2813, HttpStatus.BAD_REQUEST, "잘못된 사용자 순서입니다."), + INVALID_USER_ORDER(20913, HttpStatus.BAD_REQUEST, "잘못된 사용자 순서입니다."), } diff --git a/src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt b/src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt index 3fcb9253..29b5bd07 100644 --- a/src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt @@ -8,16 +8,16 @@ enum class UserResponseCode( override val status: HttpStatus, override val message: String, ) : ResponseCodeInterface { - USER_FIND_ALL_SUCCESS(1800, HttpStatus.OK, "모든 회원 정보를 성공적으로 조회했습니다."), - USER_DETAILS_SUCCESS(1801, HttpStatus.OK, "특정 회원의 상세 정보를 성공적으로 조회했습니다."), - USER_ACCEPT_SUCCESS(1802, HttpStatus.OK, "회원 가입 승인이 성공적으로 처리되었습니다."), - USER_BAN_SUCCESS(1803, HttpStatus.OK, "회원이 성공적으로 차단되었습니다."), - USER_ROLE_UPDATE_SUCCESS(1804, HttpStatus.OK, "회원의 역할이 성공적으로 수정되었습니다."), - USER_APPLY_OB_SUCCESS(1805, HttpStatus.OK, "OB 신청이 성공적으로 처리되었습니다."), - USER_EMAIL_CHECK_SUCCESS(1806, HttpStatus.OK, "이메일 중복 검사가 성공적으로 처리되었습니다."), - USER_FIND_BY_ID_SUCCESS(1807, HttpStatus.OK, "회원 정보가 성공적으로 조회되었습니다."), - USER_UPDATE_SUCCESS(1808, HttpStatus.OK, "회원 정보가 성공적으로 수정되었습니다."), - USER_LEAVE_SUCCESS(1809, HttpStatus.OK, "회원 탈퇴가 성공적으로 처리되었습니다."), - JWT_REFRESH_SUCCESS(1810, HttpStatus.OK, "토큰 재발급에 성공했습니다."), - SOCIAL_LOGIN_SUCCESS(1811, HttpStatus.OK, "소셜 로그인이 성공적으로 처리되었습니다."), + USER_FIND_ALL_SUCCESS(10900, HttpStatus.OK, "모든 회원 정보를 성공적으로 조회했습니다."), + USER_DETAILS_SUCCESS(10901, HttpStatus.OK, "특정 회원의 상세 정보를 성공적으로 조회했습니다."), + USER_ACCEPT_SUCCESS(10902, HttpStatus.OK, "회원 가입 승인이 성공적으로 처리되었습니다."), + USER_BAN_SUCCESS(10903, HttpStatus.OK, "회원이 성공적으로 차단되었습니다."), + USER_ROLE_UPDATE_SUCCESS(10904, HttpStatus.OK, "회원의 역할이 성공적으로 수정되었습니다."), + USER_APPLY_OB_SUCCESS(10905, HttpStatus.OK, "OB 신청이 성공적으로 처리되었습니다."), + USER_EMAIL_CHECK_SUCCESS(10906, HttpStatus.OK, "이메일 중복 검사가 성공적으로 처리되었습니다."), + USER_FIND_BY_ID_SUCCESS(10907, HttpStatus.OK, "회원 정보가 성공적으로 조회되었습니다."), + USER_UPDATE_SUCCESS(10908, HttpStatus.OK, "회원 정보가 성공적으로 수정되었습니다."), + USER_LEAVE_SUCCESS(10909, HttpStatus.OK, "회원 탈퇴가 성공적으로 처리되었습니다."), + JWT_REFRESH_SUCCESS(10910, HttpStatus.OK, "토큰 재발급에 성공했습니다."), + SOCIAL_LOGIN_SUCCESS(10911, HttpStatus.OK, "소셜 로그인이 성공적으로 처리되었습니다."), } diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/JwtErrorCode.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/JwtErrorCode.kt index f3f6ff4e..5b88435e 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/JwtErrorCode.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/JwtErrorCode.kt @@ -10,17 +10,17 @@ enum class JwtErrorCode( override val message: String, ) : ErrorCodeInterface { @ExplainError("토큰의 구조가 올바르지 않거나(Malformed), 서명이 유효하지 않은 경우 발생합니다. 토큰을 재발급 받아주세요.") - INVALID_TOKEN(2900, HttpStatus.BAD_REQUEST, "올바르지 않은 Token 입니다."), + INVALID_TOKEN(29000, HttpStatus.BAD_REQUEST, "올바르지 않은 Token 입니다."), @ExplainError("Redis에 해당 리프레시 토큰이 존재하지 않습니다. 토큰이 만료되었거나, 이미 로그아웃(삭제)된 상태일 수 있습니다. 다시 로그인해주세요.") - REDIS_TOKEN_NOT_FOUND(2901, HttpStatus.NOT_FOUND, "저장된 리프레시 토큰이 존재하지 않습니다."), + REDIS_TOKEN_NOT_FOUND(29001, HttpStatus.NOT_FOUND, "저장된 리프레시 토큰이 존재하지 않습니다."), @ExplainError("API 요청 헤더(Authorization)에 토큰 값이 포함되지 않았거나 비어있을 때 발생합니다.") - TOKEN_NOT_FOUND(2902, HttpStatus.NOT_FOUND, "헤더에서 토큰을 찾을 수 없습니다."), + TOKEN_NOT_FOUND(29002, HttpStatus.NOT_FOUND, "헤더에서 토큰을 찾을 수 없습니다."), @ExplainError("인증이 필요한 리소스에 인증 정보 없이(Anonymous) 접근을 시도했을 때 발생합니다. (Spring Security 필터 단계 차단)") - ANONYMOUS_AUTHENTICATION(2903, HttpStatus.UNAUTHORIZED, "인증정보가 존재하지 않습니다."), + ANONYMOUS_AUTHENTICATION(29003, HttpStatus.UNAUTHORIZED, "인증정보가 존재하지 않습니다."), @ExplainError("Apple 인증 과정에서 토큰 교환 또는 검증에 실패했을 때 발생합니다.") - APPLE_AUTHENTICATION_FAILED(2904, HttpStatus.UNAUTHORIZED, "애플 로그인에 실패했습니다."), + APPLE_AUTHENTICATION_FAILED(29004, HttpStatus.UNAUTHORIZED, "애플 로그인에 실패했습니다."), } diff --git a/src/main/kotlin/com/weeth/global/config/SwaggerConfig.kt b/src/main/kotlin/com/weeth/global/config/SwaggerConfig.kt index 6fd24812..96603b35 100644 --- a/src/main/kotlin/com/weeth/global/config/SwaggerConfig.kt +++ b/src/main/kotlin/com/weeth/global/config/SwaggerConfig.kt @@ -24,23 +24,24 @@ import org.springframework.context.annotation.Configuration import org.springframework.web.method.HandlerMethod private const val SWAGGER_DESCRIPTION = - "## Response Code 규칙\n" + - "- Success: **1xxx**\n" + - "- Domain Error: **2xxx**\n" + - "- Server Error: **3xxx**\n" + - "- Client Error: **4xxx**\n\n" + + "## Response Code 규칙 (5자리: XDDNN)\n" + + "- **X**: 1=Success, 2=Domain Error, 3=Infra/Server Error, 4=Client/Validation Error\n" + + "- **DD**: 도메인 ID (2자리)\n" + + "- **NN**: 도메인 내 순번 (00~99)\n\n" + "## 도메인별 코드 범위\n" + - "| Domain | Success | Error |\n" + - "|--------|---------|------|\n" + - "| Account | 11xx | 21xx |\n" + - "| Attendance/Session | 12xx | 22xx |\n" + - "| Board | 13xx | 23xx |\n" + - "| Comment | 14xx | 24xx |\n" + - "| File | 15xx | 25xx |\n" + - "| Penalty | 16xx | 26xx |\n" + - "| Schedule | 17xx | 27xx |\n" + - "| User | 18xx | 28xx |\n" + - "| Auth/JWT (Global) | - | 29xx |\n\n" + + "| Domain | DD | Success | Domain Error | Infra Error |\n" + + "|--------|----|---------|-------------|-------------|\n" + + "| Account | 01 | 101xx | 201xx | — |\n" + + "| Attendance | 02 | 102xx | 202xx | — |\n" + + "| Session | 03 | 103xx | 203xx | — |\n" + + "| Board | 04 | 104xx | 204xx | — |\n" + + "| Comment | 05 | 105xx | 205xx | — |\n" + + "| File | 06 | 106xx | 206xx | 306xx |\n" + + "| Penalty | 07 | 107xx | 207xx | — |\n" + + "| Schedule | 08 | 108xx | 208xx | — |\n" + + "| User | 09 | 109xx | 209xx | — |\n" + + "| Cardinal | 10 | 110xx | 210xx | — |\n" + + "| Auth/JWT | 90 | — | 290xx | — |\n\n" + "> 각 API의 상세 응답 예시는 Swagger의 **Responses** 섹션에서 확인하세요." @Configuration diff --git a/src/test/kotlin/com/weeth/global/common/exception/CommonExceptionHandlerTest.kt b/src/test/kotlin/com/weeth/global/common/exception/CommonExceptionHandlerTest.kt index 31576538..a84d7ee0 100644 --- a/src/test/kotlin/com/weeth/global/common/exception/CommonExceptionHandlerTest.kt +++ b/src/test/kotlin/com/weeth/global/common/exception/CommonExceptionHandlerTest.kt @@ -18,7 +18,7 @@ class CommonExceptionHandlerTest : val response = handler.handle(ex) response.statusCode.value() shouldBe 404 - response.body?.code shouldBe 2902 + response.body?.code shouldBe JwtErrorCode.TOKEN_NOT_FOUND.code } } From 9dfe46a6dd6f33c5407d5f6fe3c152301064c928 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=84=EC=88=98=ED=98=84?= <128474444+soo0711@users.noreply.github.com> Date: Wed, 4 Mar 2026 22:33:35 +0900 Subject: [PATCH 18/73] =?UTF-8?q?[WTH-169]=20QR=20=EC=B6=9C=EC=84=9D?= =?UTF-8?q?=EC=B2=B4=ED=81=AC=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 사용되지 않는 ScheduleTimeCheck 관련 코드 제거 * refactor: 출석 코드 자릿수 4자리에서 6자리로 변경 * feat: QR 출석 Redis 포트 및 어댑터 추가 * feat: QR 출석 예외 클래스 및 에러/응답 코드 추가 * feat: QR 토큰 응답 DTO 및 어드민 QR 생성 UseCase 추가 * feat: 출석 체크인 로직을 Redis QR 코드 기반으로 변경 * feat: 어드민 QR 코드 생성 엔드포인트 추가 * test: GenerateQrTokenUseCase 및 ManageAttendanceUseCase checkIn 테스트 추가 * style: 린트 적용 * refactor: ManageAttendanceUseCase 가독성 개선 * style: 개행 추가해서 가독성 개선 * refactor: Attendance status 필드 private set 적용 * style: 린트 적용 * fix: 출석 에러코드 번호 수정 * fix: 세션 에러코드 번호 수정 * refactor: 불필요한 @Transactional 어노테이션 제거 * refactor: 난수 생성 방식을 SecureRandom으로 변경 * feat: 출석 체크 시간 검증 추가 * refactor: QR 출석 Redis 키를 code에서 sessionId로 변경 및 세션 ID API 추가 * docs: QrAttendancePort 메서드 주석 추가 * test: QR 출석 체크인 테스트 수정 및 시간 검증 케이스 추가 * style: 린트 적용 * refactor: TTL_SECONDS 중복 상수를 QrAttendancePort로 통합 --- .../application/dto/request/CheckInRequest.kt | 4 +- .../dto/response/QrTokenResponse.kt | 13 ++ .../exception/AlreadyAttendedException.kt | 5 + .../exception/AttendanceErrorCode.kt | 6 + .../exception/QrTokenExpiredException.kt | 5 + .../application/mapper/AttendanceMapper.kt | 13 ++ .../usecase/command/GenerateQrTokenUseCase.kt | 25 ++++ .../command/ManageAttendanceUseCase.kt | 56 +++++--- .../query/GetAttendanceQueryService.kt | 1 + .../attendance/domain/entity/Attendance.kt | 7 +- .../domain/port/QrAttendancePort.kt | 22 +++ .../RedisQrAttendanceAdapter.kt | 26 ++++ .../presentation/AttendanceAdminController.kt | 17 ++- .../presentation/AttendanceController.kt | 2 +- .../presentation/AttendanceResponseCode.kt | 3 + .../annotation/ScheduleTimeCheck.kt | 18 --- .../dto/request/ScheduleTimeRequest.kt | 17 --- .../validator/ScheduleTimeCheckValidator.kt | 16 --- .../application/exception/SessionErrorCode.kt | 3 + .../SessionNotInProgressException.kt | 5 + .../usecase/command/ManageSessionUseCase.kt | 8 +- .../usecase/query/GetSessionQueryService.kt | 6 +- .../domain/session/domain/entity/Session.kt | 12 +- .../command/GenerateQrTokenUseCaseTest.kt | 65 +++++++++ .../command/ManageAttendanceUseCaseTest.kt | 136 ++++++++++++++++++ .../session/fixture/SessionTestFixture.kt | 2 +- 26 files changed, 407 insertions(+), 86 deletions(-) create mode 100644 src/main/kotlin/com/weeth/domain/attendance/application/dto/response/QrTokenResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/attendance/application/exception/AlreadyAttendedException.kt create mode 100644 src/main/kotlin/com/weeth/domain/attendance/application/exception/QrTokenExpiredException.kt create mode 100644 src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCase.kt create mode 100644 src/main/kotlin/com/weeth/domain/attendance/domain/port/QrAttendancePort.kt create mode 100644 src/main/kotlin/com/weeth/domain/attendance/infrastructure/RedisQrAttendanceAdapter.kt delete mode 100644 src/main/kotlin/com/weeth/domain/schedule/application/annotation/ScheduleTimeCheck.kt delete mode 100644 src/main/kotlin/com/weeth/domain/schedule/application/dto/request/ScheduleTimeRequest.kt delete mode 100644 src/main/kotlin/com/weeth/domain/schedule/application/validator/ScheduleTimeCheckValidator.kt create mode 100644 src/main/kotlin/com/weeth/domain/session/application/exception/SessionNotInProgressException.kt create mode 100644 src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCaseTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCaseTest.kt diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/dto/request/CheckInRequest.kt b/src/main/kotlin/com/weeth/domain/attendance/application/dto/request/CheckInRequest.kt index 104b72a4..16457161 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/dto/request/CheckInRequest.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/dto/request/CheckInRequest.kt @@ -3,6 +3,8 @@ package com.weeth.domain.attendance.application.dto.request import io.swagger.v3.oas.annotations.media.Schema data class CheckInRequest( - @field:Schema(description = "출석 코드", example = "1234") + @field:Schema(description = "세션 ID", example = "1") + val sessionId: Long, + @field:Schema(description = "출석 코드", example = "123456") val code: Int, ) diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/QrTokenResponse.kt b/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/QrTokenResponse.kt new file mode 100644 index 00000000..45694ab5 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/QrTokenResponse.kt @@ -0,0 +1,13 @@ +package com.weeth.domain.attendance.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class QrTokenResponse( + @field:Schema(description = "세션 ID", example = "1") + val sessionId: Long, + @field:Schema(description = "6자리 출석 코드", example = "123456") + val code: Int, + @field:Schema(description = "QR 만료 시각", example = "2025-03-02T10:30:00") + val expiredAt: LocalDateTime, +) diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/exception/AlreadyAttendedException.kt b/src/main/kotlin/com/weeth/domain/attendance/application/exception/AlreadyAttendedException.kt new file mode 100644 index 00000000..4fe6fa9c --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/exception/AlreadyAttendedException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.attendance.application.exception + +import com.weeth.global.common.exception.BaseException + +class AlreadyAttendedException : BaseException(AttendanceErrorCode.ALREADY_ATTENDED) diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceErrorCode.kt b/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceErrorCode.kt index ec80543b..7b98d1ed 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceErrorCode.kt @@ -17,4 +17,10 @@ enum class AttendanceErrorCode( @ExplainError("사용자가 출석 일정을 직접 수정하려고 시도할 때 발생합니다. (출석 로직 위반)") ATTENDANCE_EVENT_TYPE_NOT_MATCH(20202, HttpStatus.BAD_REQUEST, "출석일정은 직접 수정할 수 없습니다."), + + @ExplainError("QR 코드가 만료되었거나 어드민이 아직 QR을 생성하지 않았을 때 발생합니다.") + QR_TOKEN_EXPIRED(20203, HttpStatus.BAD_REQUEST, "QR 코드가 만료되었거나 존재하지 않습니다."), + + @ExplainError("해당 세션에 이미 출석 처리된 사용자가 다시 출석을 시도할 때 발생합니다.") + ALREADY_ATTENDED(20204, HttpStatus.CONFLICT, "이미 출석 처리된 세션입니다."), } diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/exception/QrTokenExpiredException.kt b/src/main/kotlin/com/weeth/domain/attendance/application/exception/QrTokenExpiredException.kt new file mode 100644 index 00000000..7aa762f5 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/exception/QrTokenExpiredException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.attendance.application.exception + +import com.weeth.global.common.exception.BaseException + +class QrTokenExpiredException : BaseException(AttendanceErrorCode.QR_TOKEN_EXPIRED) diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapper.kt b/src/main/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapper.kt index fffc3d3a..b9bf9f88 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapper.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapper.kt @@ -4,9 +4,12 @@ import com.weeth.domain.attendance.application.dto.response.AttendanceDetailResp import com.weeth.domain.attendance.application.dto.response.AttendanceInfoResponse import com.weeth.domain.attendance.application.dto.response.AttendanceResponse import com.weeth.domain.attendance.application.dto.response.AttendanceSummaryResponse +import com.weeth.domain.attendance.application.dto.response.QrTokenResponse import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.session.domain.entity.Session import com.weeth.domain.user.domain.entity.User import org.springframework.stereotype.Component +import java.time.LocalDateTime @Component class AttendanceMapper { @@ -54,4 +57,14 @@ class AttendanceMapper { department = attendance.user.department, studentId = attendance.user.studentId, ) + + fun toQrTokenResponse( + session: Session, + expiredAt: LocalDateTime, + ): QrTokenResponse = + QrTokenResponse( + sessionId = session.id, + code = session.code, + expiredAt = expiredAt, + ) } diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCase.kt b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCase.kt new file mode 100644 index 00000000..56e3cd2a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCase.kt @@ -0,0 +1,25 @@ +package com.weeth.domain.attendance.application.usecase.command + +import com.weeth.domain.attendance.application.dto.response.QrTokenResponse +import com.weeth.domain.attendance.application.mapper.AttendanceMapper +import com.weeth.domain.attendance.domain.port.QrAttendancePort +import com.weeth.domain.session.domain.repository.SessionReader +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +class GenerateQrTokenUseCase( + private val sessionReader: SessionReader, + private val qrAttendancePort: QrAttendancePort, + private val attendanceMapper: AttendanceMapper, +) { + fun execute(sessionId: Long): QrTokenResponse { + val session = sessionReader.getById(sessionId) + + val expiredAt = LocalDateTime.now().plusSeconds(QrAttendancePort.TTL_SECONDS) + qrAttendancePort.store(sessionId, session.code) + + return attendanceMapper.toQrTokenResponse(session, expiredAt) + } +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCase.kt b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCase.kt index 785170fd..c6e1b4c5 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCase.kt @@ -1,12 +1,16 @@ package com.weeth.domain.attendance.application.usecase.command import com.weeth.domain.attendance.application.dto.request.UpdateAttendanceStatusRequest +import com.weeth.domain.attendance.application.exception.AlreadyAttendedException import com.weeth.domain.attendance.application.exception.AttendanceCodeMismatchException import com.weeth.domain.attendance.application.exception.AttendanceNotFoundException +import com.weeth.domain.attendance.application.exception.QrTokenExpiredException import com.weeth.domain.attendance.domain.entity.Attendance import com.weeth.domain.attendance.domain.enums.AttendanceStatus +import com.weeth.domain.attendance.domain.port.QrAttendancePort import com.weeth.domain.attendance.domain.repository.AttendanceRepository import com.weeth.domain.session.application.exception.SessionNotFoundException +import com.weeth.domain.session.application.exception.SessionNotInProgressException import com.weeth.domain.session.domain.enums.SessionStatus import com.weeth.domain.session.domain.repository.SessionReader import com.weeth.domain.user.domain.enums.Status @@ -16,36 +20,36 @@ import org.springframework.transaction.annotation.Transactional import java.time.LocalDate import java.time.LocalDateTime -/** - * Todo: 개행을 추가해 가독성 개선 - * Todo: if 문 가독성 개선 - */ @Service class ManageAttendanceUseCase( private val userReader: UserReader, private val sessionReader: SessionReader, private val attendanceRepository: AttendanceRepository, + private val qrAttendancePort: QrAttendancePort, ) { @Transactional fun checkIn( userId: Long, + sessionId: Long, code: Int, ) { + val storedCode = qrAttendancePort.getCode(sessionId) ?: throw QrTokenExpiredException() + if (storedCode != code) throw AttendanceCodeMismatchException() + + val session = sessionReader.getById(sessionId) + + if (!session.isCheckInAllowed(LocalDateTime.now())) throw SessionNotInProgressException() + val user = userReader.getById(userId) - val now = LocalDateTime.now() - val todayAttendance = - attendanceRepository.findCurrentByUserId(userId, now, now.plusMinutes(10)) - ?: throw AttendanceNotFoundException() - if (todayAttendance.isWrong(code)) { - throw AttendanceCodeMismatchException() - } + val lockedAttendance = - attendanceRepository.findBySessionAndUserWithLock(todayAttendance.session, user) + attendanceRepository.findBySessionAndUserWithLock(session, user) ?: throw AttendanceNotFoundException() - if (lockedAttendance.status != AttendanceStatus.ATTEND) { - lockedAttendance.attend() - user.attend() - } + + if (lockedAttendance.status == AttendanceStatus.ATTEND) throw AlreadyAttendedException() + + lockedAttendance.attend() + user.attend() } @Transactional @@ -61,6 +65,7 @@ class ManageAttendanceUseCase( session.end.toLocalDate().isEqual(now) } ?: throw SessionNotFoundException() + val attendances = attendanceRepository.findAllBySessionAndUserStatus(targetSession, Status.ACTIVE) closePendingAttendances(attendances) } @@ -68,6 +73,7 @@ class ManageAttendanceUseCase( @Transactional fun autoClose() { val sessions = sessionReader.findAllByStatusAndEndBeforeOrderByEndAsc(SessionStatus.OPEN, LocalDateTime.now()) + sessions.forEach { session -> session.close() val attendances = attendanceRepository.findAllBySessionAndUserStatus(session, Status.ACTIVE) @@ -81,6 +87,7 @@ class ManageAttendanceUseCase( val attendance = attendanceRepository.findByIdWithUser(update.attendanceId) ?: throw AttendanceNotFoundException() + val user = attendance.user val newStatus = AttendanceStatus.valueOf(update.status) @@ -88,12 +95,17 @@ class ManageAttendanceUseCase( val prevStatus = attendance.status attendance.adminOverride(newStatus) - if (newStatus == AttendanceStatus.ABSENT) { - if (prevStatus == AttendanceStatus.ATTEND) user.removeAttend() - user.absent() - } else { - if (prevStatus == AttendanceStatus.ABSENT) user.removeAbsent() - user.attend() + + when (newStatus) { + AttendanceStatus.ABSENT -> { + if (prevStatus == AttendanceStatus.ATTEND) user.removeAttend() + user.absent() + } + + else -> { + if (prevStatus == AttendanceStatus.ABSENT) user.removeAbsent() + user.attend() + } } } } diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt index 4bfca946..5f63a538 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt @@ -51,6 +51,7 @@ class GetAttendanceQueryService( fun findAllAttendanceBySession(sessionId: Long): List { val session = sessionReader.getById(sessionId) + val attendances = attendanceRepository.findAllBySessionAndUserStatus(session, Status.ACTIVE) return attendances.map(attendanceMapper::toInfoResponse) } diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/entity/Attendance.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/entity/Attendance.kt index 1aab45c9..9b1d2ab9 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/domain/entity/Attendance.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/entity/Attendance.kt @@ -26,14 +26,17 @@ class Attendance( @JoinColumn(name = "user_id") @OnDelete(action = OnDeleteAction.CASCADE) val user: User, - @Enumerated(EnumType.STRING) - var status: AttendanceStatus = AttendanceStatus.PENDING, + status: AttendanceStatus = AttendanceStatus.PENDING, ) : BaseEntity() { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "attendance_id") val id: Long = 0 + @Enumerated(EnumType.STRING) + var status: AttendanceStatus = status + private set + fun attend() { check(status == AttendanceStatus.PENDING) { "이미 처리된 출석입니다" } status = AttendanceStatus.ATTEND diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/port/QrAttendancePort.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/port/QrAttendancePort.kt new file mode 100644 index 00000000..e14f7f05 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/port/QrAttendancePort.kt @@ -0,0 +1,22 @@ +package com.weeth.domain.attendance.domain.port + +interface QrAttendancePort { + companion object { + const val TTL_SECONDS = 600L + } + + /** + * QR 출석 코드를 Redis에 저장합니다. + * key: sessionId, value: code (TTL 10분) + */ + fun store( + sessionId: Long, + code: Int, + ) + + /** + * sessionId에 해당하는 활성화된 QR 코드를 반환합니다. + * QR이 생성된 적 없거나 TTL이 만료된 경우 null을 반환합니다. + */ + fun getCode(sessionId: Long): Int? +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/infrastructure/RedisQrAttendanceAdapter.kt b/src/main/kotlin/com/weeth/domain/attendance/infrastructure/RedisQrAttendanceAdapter.kt new file mode 100644 index 00000000..7d6fb480 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/infrastructure/RedisQrAttendanceAdapter.kt @@ -0,0 +1,26 @@ +package com.weeth.domain.attendance.infrastructure + +import com.weeth.domain.attendance.domain.port.QrAttendancePort +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.stereotype.Component +import java.util.concurrent.TimeUnit + +@Component +class RedisQrAttendanceAdapter( + private val redisTemplate: RedisTemplate, +) : QrAttendancePort { + override fun store( + sessionId: Long, + code: Int, + ) { + redisTemplate.opsForValue().set(key(sessionId), code.toString(), QrAttendancePort.TTL_SECONDS, TimeUnit.SECONDS) + } + + override fun getCode(sessionId: Long): Int? = redisTemplate.opsForValue().get(key(sessionId))?.toIntOrNull() + + private fun key(sessionId: Long) = "$PREFIX$sessionId" + + companion object { + private const val PREFIX = "qr:" + } +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceAdminController.kt b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceAdminController.kt index 86b82904..79b5d0f2 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceAdminController.kt @@ -2,9 +2,12 @@ package com.weeth.domain.attendance.presentation import com.weeth.domain.attendance.application.dto.request.UpdateAttendanceStatusRequest import com.weeth.domain.attendance.application.dto.response.AttendanceInfoResponse +import com.weeth.domain.attendance.application.dto.response.QrTokenResponse import com.weeth.domain.attendance.application.exception.AttendanceErrorCode +import com.weeth.domain.attendance.application.usecase.command.GenerateQrTokenUseCase import com.weeth.domain.attendance.application.usecase.command.ManageAttendanceUseCase import com.weeth.domain.attendance.application.usecase.query.GetAttendanceQueryService +import com.weeth.domain.session.application.exception.SessionErrorCode import com.weeth.global.common.exception.ApiErrorCodeExample import com.weeth.global.common.response.CommonResponse import io.swagger.v3.oas.annotations.Operation @@ -13,6 +16,7 @@ import jakarta.validation.Valid import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PatchMapping import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam @@ -22,10 +26,11 @@ import java.time.LocalDate @Tag(name = "ATTENDANCE ADMIN", description = "[ADMIN] 출석 어드민 API") @RestController @RequestMapping("/api/v4/admin/attendances") -@ApiErrorCodeExample(AttendanceErrorCode::class) +@ApiErrorCodeExample(AttendanceErrorCode::class, SessionErrorCode::class) class AttendanceAdminController( private val manageAttendanceUseCase: ManageAttendanceUseCase, private val getAttendanceQueryService: GetAttendanceQueryService, + private val generateQrTokenUseCase: GenerateQrTokenUseCase, ) { @PatchMapping("/close") @Operation(summary = "출석 마감") @@ -55,4 +60,14 @@ class AttendanceAdminController( manageAttendanceUseCase.updateStatus(attendanceUpdates) return CommonResponse.success(AttendanceResponseCode.ATTENDANCE_UPDATED_SUCCESS) } + + @PostMapping("/{sessionId}/qr") + @Operation(summary = "QR 코드 생성") + fun generateQr( + @PathVariable sessionId: Long, + ): CommonResponse = + CommonResponse.success( + AttendanceResponseCode.QR_TOKEN_GENERATE_SUCCESS, + generateQrTokenUseCase.execute(sessionId), + ) } diff --git a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt index 83abc93d..98544d27 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt @@ -32,7 +32,7 @@ class AttendanceController( @Parameter(hidden = true) @CurrentUser userId: Long, @RequestBody checkIn: CheckInRequest, ): CommonResponse { - manageAttendanceUseCase.checkIn(userId, checkIn.code) + manageAttendanceUseCase.checkIn(userId, checkIn.sessionId, checkIn.code) return CommonResponse.success(AttendanceResponseCode.ATTENDANCE_CHECKIN_SUCCESS) } diff --git a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceResponseCode.kt b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceResponseCode.kt index 982b70bb..a22bb431 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceResponseCode.kt @@ -17,4 +17,7 @@ enum class AttendanceResponseCode( ATTENDANCE_CHECKIN_SUCCESS(10203, HttpStatus.OK, "출석이 성공적으로 처리되었습니다."), ATTENDANCE_FIND_SUCCESS(10204, HttpStatus.OK, "사용자의 출석 정보가 성공적으로 조회되었습니다."), ATTENDANCE_FIND_ALL_SUCCESS(10205, HttpStatus.OK, "사용자의 상세 출석 정보가 성공적으로 조회되었습니다."), + + // QR 관련 + QR_TOKEN_GENERATE_SUCCESS(10206, HttpStatus.OK, "QR 코드가 성공적으로 생성되었습니다."), } diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/annotation/ScheduleTimeCheck.kt b/src/main/kotlin/com/weeth/domain/schedule/application/annotation/ScheduleTimeCheck.kt deleted file mode 100644 index ee4c546f..00000000 --- a/src/main/kotlin/com/weeth/domain/schedule/application/annotation/ScheduleTimeCheck.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.weeth.domain.schedule.application.annotation - -import com.weeth.domain.schedule.application.validator.ScheduleTimeCheckValidator -import jakarta.validation.Constraint -import jakarta.validation.Payload -import kotlin.reflect.KClass - -/** - * Todo: 사용처 있는지 확인하고 없으면 제거 - */ -@Target(AnnotationTarget.FIELD) -@Retention(AnnotationRetention.RUNTIME) -@Constraint(validatedBy = [ScheduleTimeCheckValidator::class]) -annotation class ScheduleTimeCheck( - val message: String = "마감 시간이 시작 시간보다 빠를 수 없습니다.", - val groups: Array> = [], - val payload: Array> = [], -) diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/dto/request/ScheduleTimeRequest.kt b/src/main/kotlin/com/weeth/domain/schedule/application/dto/request/ScheduleTimeRequest.kt deleted file mode 100644 index debc0e90..00000000 --- a/src/main/kotlin/com/weeth/domain/schedule/application/dto/request/ScheduleTimeRequest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.weeth.domain.schedule.application.dto.request - -import io.swagger.v3.oas.annotations.media.Schema -import jakarta.validation.constraints.NotNull -import org.springframework.format.annotation.DateTimeFormat -import java.time.LocalDateTime - -data class ScheduleTimeRequest( - @field:Schema(description = "시작 시간", example = "2024-03-01T10:00:00") - @field:NotNull - @field:DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) - val start: LocalDateTime, - @field:Schema(description = "종료 시간", example = "2024-03-01T12:00:00") - @field:NotNull - @field:DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) - val end: LocalDateTime, -) diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/validator/ScheduleTimeCheckValidator.kt b/src/main/kotlin/com/weeth/domain/schedule/application/validator/ScheduleTimeCheckValidator.kt deleted file mode 100644 index 6970af94..00000000 --- a/src/main/kotlin/com/weeth/domain/schedule/application/validator/ScheduleTimeCheckValidator.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.weeth.domain.schedule.application.validator - -import com.weeth.domain.schedule.application.annotation.ScheduleTimeCheck -import com.weeth.domain.schedule.application.dto.request.ScheduleTimeRequest -import jakarta.validation.ConstraintValidator -import jakarta.validation.ConstraintValidatorContext - -/** - * Todo: 사용처 있는지 확인하고 없으면 제거 - */ -class ScheduleTimeCheckValidator : ConstraintValidator { - override fun isValid( - time: ScheduleTimeRequest?, - context: ConstraintValidatorContext, - ): Boolean = time == null || time.start.isBefore(time.end.plusMinutes(1)) -} diff --git a/src/main/kotlin/com/weeth/domain/session/application/exception/SessionErrorCode.kt b/src/main/kotlin/com/weeth/domain/session/application/exception/SessionErrorCode.kt index eaf0088b..b7f4876c 100644 --- a/src/main/kotlin/com/weeth/domain/session/application/exception/SessionErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/session/application/exception/SessionErrorCode.kt @@ -11,4 +11,7 @@ enum class SessionErrorCode( ) : ErrorCodeInterface { @ExplainError("요청한 정기모임 ID에 해당하는 정기모임이 존재하지 않을 때 발생합니다.") SESSION_NOT_FOUND(20300, HttpStatus.NOT_FOUND, "존재하지 않는 정기모임입니다."), + + @ExplainError("출석 요청 시각이 정기모임 시작 10분 전 ~ 종료 10분 후 범위를 벗어날 때 발생합니다.") + SESSION_NOT_IN_PROGRESS(20301, HttpStatus.BAD_REQUEST, "출석 가능한 시간이 아닙니다."), } diff --git a/src/main/kotlin/com/weeth/domain/session/application/exception/SessionNotInProgressException.kt b/src/main/kotlin/com/weeth/domain/session/application/exception/SessionNotInProgressException.kt new file mode 100644 index 00000000..1c7e82f8 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/exception/SessionNotInProgressException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.session.application.exception + +import com.weeth.global.common.exception.BaseException + +class SessionNotInProgressException : BaseException(SessionErrorCode.SESSION_NOT_IN_PROGRESS) diff --git a/src/main/kotlin/com/weeth/domain/session/application/usecase/command/ManageSessionUseCase.kt b/src/main/kotlin/com/weeth/domain/session/application/usecase/command/ManageSessionUseCase.kt index 67a27aa2..c47f5af4 100644 --- a/src/main/kotlin/com/weeth/domain/session/application/usecase/command/ManageSessionUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/session/application/usecase/command/ManageSessionUseCase.kt @@ -14,9 +14,6 @@ import com.weeth.domain.user.domain.repository.UserReader import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -/** - * Todo: 개행을 추가해 가독성 개선 - */ @Service class ManageSessionUseCase( private val sessionRepository: SessionRepository, @@ -33,8 +30,10 @@ class ManageSessionUseCase( val user = userReader.getById(userId) val cardinal = cardinalReader.getByCardinalNumber(request.cardinal) val users = userReader.findAllByCardinalAndStatus(cardinal, Status.ACTIVE) + val session = sessionMapper.toEntity(request, user) sessionRepository.save(session) + attendanceRepository.saveAll(users.map { Attendance.Companion.create(session, it) }) } @@ -46,6 +45,7 @@ class ManageSessionUseCase( ) { val session = sessionRepository.findByIdWithLock(sessionId) ?: throw SessionNotFoundException() val user = userReader.getById(userId) + session.updateInfo(request.title, request.content, request.location, request.start, request.end, user) } @@ -53,6 +53,7 @@ class ManageSessionUseCase( fun delete(sessionId: Long) { val session = sessionRepository.findByIdWithLock(sessionId) ?: throw SessionNotFoundException() val attendances = attendanceRepository.findAllBySessionAndUserStatusWithLock(session, Status.ACTIVE) + attendances.forEach { a -> when (a.status) { AttendanceStatus.ATTEND -> a.user.removeAttend() @@ -60,6 +61,7 @@ class ManageSessionUseCase( else -> Unit } } + attendanceRepository.deleteAllBySession(session) sessionRepository.delete(session) } diff --git a/src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt b/src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt index f6ae59b2..be17c0fe 100644 --- a/src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt @@ -15,9 +15,6 @@ import java.time.DayOfWeek import java.time.LocalDate import java.time.temporal.TemporalAdjusters -/** - * Todo: 개행을 추가해 가독성 개선 - */ @Service @Transactional(readOnly = true) class GetSessionQueryService( @@ -31,6 +28,7 @@ class GetSessionQueryService( ): SessionResponse { val user = userReader.getById(userId) val session = sessionRepository.findByIdOrNull(sessionId) ?: throw SessionNotFoundException() + return if (user.role == Role.ADMIN) { sessionMapper.toAdminResponse(session) } else { @@ -45,6 +43,7 @@ class GetSessionQueryService( } else { sessionRepository.findAllByCardinalOrderByStartDesc(cardinal) } + val thisWeek = findThisWeek(sessions) return sessionMapper.toInfos(thisWeek, sessions) } @@ -53,6 +52,7 @@ class GetSessionQueryService( val today = LocalDate.now() val startOfWeek = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) val endOfWeek = today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)) + return sessions.firstOrNull { s -> val d = s.start.toLocalDate() !d.isBefore(startOfWeek) && !d.isAfter(endOfWeek) diff --git a/src/main/kotlin/com/weeth/domain/session/domain/entity/Session.kt b/src/main/kotlin/com/weeth/domain/session/domain/entity/Session.kt index 387094d3..f5d03d18 100644 --- a/src/main/kotlin/com/weeth/domain/session/domain/entity/Session.kt +++ b/src/main/kotlin/com/weeth/domain/session/domain/entity/Session.kt @@ -14,7 +14,9 @@ import jakarta.persistence.Id import jakarta.persistence.JoinColumn import jakarta.persistence.ManyToOne import jakarta.persistence.Table +import java.security.SecureRandom import java.time.LocalDateTime +import kotlin.random.asKotlinRandom @Entity @Table(name = "meeting") // 테이블명 Session으로 수정 @@ -64,7 +66,15 @@ class Session( fun isInProgress(now: LocalDateTime): Boolean = !now.isBefore(start) && !now.isAfter(end) + fun isCheckInAllowed(now: LocalDateTime): Boolean { + val from = start.minusMinutes(10) + val to = end.plusMinutes(10) + return !now.isBefore(from) && !now.isAfter(to) + } + companion object { + private val secureRandom = SecureRandom().asKotlinRandom() + fun create( title: String, content: String?, @@ -88,6 +98,6 @@ class Session( ) } - private fun generateCode(): Int = (1000..9999).random() + private fun generateCode(): Int = (100000..999999).random(secureRandom) } } diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCaseTest.kt new file mode 100644 index 00000000..010cf54d --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCaseTest.kt @@ -0,0 +1,65 @@ +package com.weeth.domain.attendance.application.usecase.command + +import com.weeth.domain.attendance.application.dto.response.QrTokenResponse +import com.weeth.domain.attendance.application.mapper.AttendanceMapper +import com.weeth.domain.attendance.domain.port.QrAttendancePort +import com.weeth.domain.session.application.exception.SessionNotFoundException +import com.weeth.domain.session.domain.repository.SessionReader +import com.weeth.domain.session.fixture.SessionTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.Runs +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import java.time.LocalDateTime + +class GenerateQrTokenUseCaseTest : + DescribeSpec({ + val sessionReader = mockk() + val qrAttendancePort = mockk() + val attendanceMapper = mockk() + + val useCase = GenerateQrTokenUseCase(sessionReader, qrAttendancePort, attendanceMapper) + + beforeTest { clearMocks(sessionReader, qrAttendancePort, attendanceMapper) } + + describe("execute") { + val sessionId = 1L + val code = 123456 + + context("유효한 sessionId") { + it("Redis에 코드를 저장하고 QrTokenResponse를 반환한다") { + val session = SessionTestFixture.createSession(id = sessionId, code = code) + val expectedResponse = + QrTokenResponse( + sessionId = sessionId, + code = code, + expiredAt = LocalDateTime.now().plusSeconds(600), + ) + + every { sessionReader.getById(sessionId) } returns session + every { qrAttendancePort.store(sessionId, code) } just Runs + every { attendanceMapper.toQrTokenResponse(eq(session), any()) } returns expectedResponse + + val result = useCase.execute(sessionId) + + result shouldBe expectedResponse + verify(exactly = 1) { qrAttendancePort.store(sessionId, code) } + } + } + + context("존재하지 않는 sessionId") { + it("SessionNotFoundException을 던진다") { + every { sessionReader.getById(sessionId) } throws SessionNotFoundException() + + shouldThrow { useCase.execute(sessionId) } + + verify(exactly = 0) { qrAttendancePort.store(any(), any()) } + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCaseTest.kt new file mode 100644 index 00000000..e2bd93c3 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCaseTest.kt @@ -0,0 +1,136 @@ +package com.weeth.domain.attendance.application.usecase.command + +import com.weeth.domain.attendance.application.exception.AlreadyAttendedException +import com.weeth.domain.attendance.application.exception.AttendanceCodeMismatchException +import com.weeth.domain.attendance.application.exception.AttendanceNotFoundException +import com.weeth.domain.attendance.application.exception.QrTokenExpiredException +import com.weeth.domain.attendance.domain.enums.AttendanceStatus +import com.weeth.domain.attendance.domain.port.QrAttendancePort +import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.attendance.fixture.AttendanceTestFixture +import com.weeth.domain.session.application.exception.SessionNotInProgressException +import com.weeth.domain.session.domain.repository.SessionReader +import com.weeth.domain.session.fixture.SessionTestFixture +import com.weeth.domain.user.domain.repository.UserReader +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import java.time.LocalDateTime + +class ManageAttendanceUseCaseTest : + DescribeSpec({ + val userReader = mockk() + val sessionReader = mockk() + val attendanceRepository = mockk() + val qrAttendancePort = mockk() + + val useCase = ManageAttendanceUseCase(userReader, sessionReader, attendanceRepository, qrAttendancePort) + + beforeTest { clearMocks(userReader, sessionReader, attendanceRepository, qrAttendancePort) } + + describe("checkIn") { + val userId = 1L + val code = 123456 + val sessionId = 10L + + context("유효한 코드 + PENDING 상태 + 출석 가능 시간") { + it("출석 상태를 ATTEND로 변경하고 user.attend()를 호출한다") { + val session = + SessionTestFixture.createSession( + id = sessionId, + code = code, + start = LocalDateTime.now().minusMinutes(5), + end = LocalDateTime.now().plusMinutes(55), + ) + val user = AttendanceTestFixture.createActiveUser("홍길동") + val attendance = AttendanceTestFixture.createAttendance(session, user) + + every { qrAttendancePort.getCode(sessionId) } returns code + every { sessionReader.getById(sessionId) } returns session + every { userReader.getById(userId) } returns user + every { attendanceRepository.findBySessionAndUserWithLock(session, user) } returns attendance + + useCase.checkIn(userId, sessionId, code) + + attendance.status shouldBe AttendanceStatus.ATTEND + } + } + + context("만료된 QR (Redis miss)") { + it("QrTokenExpiredException을 던진다") { + every { qrAttendancePort.getCode(sessionId) } returns null + + shouldThrow { useCase.checkIn(userId, sessionId, code) } + } + } + + context("코드 불일치") { + it("AttendanceCodeMismatchException을 던진다") { + every { qrAttendancePort.getCode(sessionId) } returns 999999 + + shouldThrow { useCase.checkIn(userId, sessionId, code) } + } + } + + context("출석 가능 시간 외 (세션 시작 10분 전 ~ 종료 10분 후 범위 초과)") { + it("SessionNotInProgressException을 던진다") { + val session = + SessionTestFixture.createSession( + id = sessionId, + code = code, + start = LocalDateTime.now().minusHours(3), + end = LocalDateTime.now().minusHours(1), + ) + + every { qrAttendancePort.getCode(sessionId) } returns code + every { sessionReader.getById(sessionId) } returns session + + shouldThrow { useCase.checkIn(userId, sessionId, code) } + } + } + + context("이미 ATTEND 상태인 출석") { + it("AlreadyAttendedException을 던진다") { + val session = + SessionTestFixture.createSession( + id = sessionId, + code = code, + start = LocalDateTime.now().minusMinutes(5), + end = LocalDateTime.now().plusMinutes(55), + ) + val user = AttendanceTestFixture.createActiveUser("홍길동") + val attendance = AttendanceTestFixture.createAttendance(session, user).also { it.attend() } + + every { qrAttendancePort.getCode(sessionId) } returns code + every { sessionReader.getById(sessionId) } returns session + every { userReader.getById(userId) } returns user + every { attendanceRepository.findBySessionAndUserWithLock(session, user) } returns attendance + + shouldThrow { useCase.checkIn(userId, sessionId, code) } + } + } + + context("Attendance 레코드가 없는 경우") { + it("AttendanceNotFoundException을 던진다") { + val session = + SessionTestFixture.createSession( + id = sessionId, + code = code, + start = LocalDateTime.now().minusMinutes(5), + end = LocalDateTime.now().plusMinutes(55), + ) + val user = AttendanceTestFixture.createActiveUser("홍길동") + + every { qrAttendancePort.getCode(sessionId) } returns code + every { sessionReader.getById(sessionId) } returns session + every { userReader.getById(userId) } returns user + every { attendanceRepository.findBySessionAndUserWithLock(session, user) } returns null + + shouldThrow { useCase.checkIn(userId, sessionId, code) } + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/session/fixture/SessionTestFixture.kt b/src/test/kotlin/com/weeth/domain/session/fixture/SessionTestFixture.kt index ba4d36e3..d3216b78 100644 --- a/src/test/kotlin/com/weeth/domain/session/fixture/SessionTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/session/fixture/SessionTestFixture.kt @@ -13,7 +13,7 @@ object SessionTestFixture { content: String = "Test Content", location: String = "Test Location", cardinal: Int = 1, - code: Int = 1234, + code: Int = 123456, status: SessionStatus = SessionStatus.OPEN, start: LocalDateTime = LocalDateTime.of(2026, 3, 1, 10, 0), end: LocalDateTime = LocalDateTime.of(2026, 3, 1, 12, 0), From 6d790c9fd819c748707acea335b73dbba5df5338 Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:42:57 +0900 Subject: [PATCH 19/73] =?UTF-8?q?[WTH-176]=20club=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=20(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: 누락 작업 추가(게시판 조회 시 검증 추가) * feat: Club 도메인 entity, vo 구현 * feat: vo 구현 * feat: repository 구현 * feat: 예외 추가 * feat: enums 추가 * feat: ClubRepository 추가 * feat: ClubMember Test 추가 * feat: Club Test 추가 * chore: lint 설정 * feat: tsid 생성 유틸 추가 * docs: club 응답 코드 추가 * refactor: 동아리 이름은 중복 가능하도록 수정 * refactor: 학교 이름 + 동아리 이름 Unique 제약조건 추가 * test: 테스트 보강 --- .claude/rules/api-design.md | 2 +- build.gradle.kts | 3 + .../exception/AlreadyJoinedException.kt | 5 + .../exception/CannotLeaveAsAdminException.kt | 5 + .../application/exception/ClubErrorCode.kt | 32 +++ .../exception/ClubMemberNotFoundException.kt | 5 + .../exception/ClubNotFoundException.kt | 5 + .../exception/InvalidClubCodeException.kt | 5 + .../exception/MemberNotActiveException.kt | 5 + .../exception/NotClubAdminException.kt | 5 + .../weeth/domain/club/domain/entity/Club.kt | 109 ++++++++++ .../domain/club/domain/entity/ClubMember.kt | 127 ++++++++++++ .../club/domain/entity/ClubMemberCardinal.kt | 44 +++++ .../domain/club/domain/enums/MemberRole.kt | 7 + .../domain/club/domain/enums/MemberStatus.kt | 8 + .../repository/ClubMemberCardinalReader.kt | 15 ++ .../ClubMemberCardinalRepository.kt | 29 +++ .../domain/repository/ClubMemberReader.kt | 18 ++ .../domain/repository/ClubMemberRepository.kt | 23 +++ .../club/domain/repository/ClubReader.kt | 13 ++ .../club/domain/repository/ClubRepository.kt | 22 +++ .../club/domain/vo/ClubAttendanceStats.kt | 58 ++++++ .../domain/club/domain/vo/ClubContact.kt | 41 ++++ .../weeth/global/common/id/TsidGenerator.kt | 14 ++ .../usecase/query/GetBoardQueryServiceTest.kt | 4 + .../club/domain/entity/ClubMemberTest.kt | 187 ++++++++++++++++++ .../domain/club/domain/entity/ClubTest.kt | 132 +++++++++++++ .../club/fixture/ClubMemberTestFixture.kt | 44 +++++ .../domain/club/fixture/ClubTestFixture.kt | 24 +++ 29 files changed, 990 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/com/weeth/domain/club/application/exception/AlreadyJoinedException.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/application/exception/CannotLeaveAsAdminException.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/application/exception/ClubMemberNotFoundException.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/application/exception/ClubNotFoundException.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/application/exception/InvalidClubCodeException.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/application/exception/MemberNotActiveException.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/application/exception/NotClubAdminException.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/domain/entity/Club.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/domain/entity/ClubMember.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/domain/entity/ClubMemberCardinal.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/domain/enums/MemberRole.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/domain/enums/MemberStatus.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberCardinalReader.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberCardinalRepository.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/domain/repository/ClubReader.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/domain/repository/ClubRepository.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/domain/vo/ClubAttendanceStats.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/domain/vo/ClubContact.kt create mode 100644 src/main/kotlin/com/weeth/global/common/id/TsidGenerator.kt create mode 100644 src/test/kotlin/com/weeth/domain/club/domain/entity/ClubMemberTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/club/domain/entity/ClubTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/club/fixture/ClubMemberTestFixture.kt create mode 100644 src/test/kotlin/com/weeth/domain/club/fixture/ClubTestFixture.kt diff --git a/.claude/rules/api-design.md b/.claude/rules/api-design.md index bf96cf69..2705ffdc 100644 --- a/.claude/rules/api-design.md +++ b/.claude/rules/api-design.md @@ -94,7 +94,7 @@ enum class UserResponseCode( | 08 | schedule | 10800~ | 20800~ | — | | 09 | user | 10900~ | 20900~ | — | | 10 | cardinal | 11000~ | 21000~ | — | -| 11 | club | () | () | — | +| 11 | club | 11100~ | 21100~ | — | | 90 | jwt/auth | — | 29000~ | — | | 99 | common | — | — | 39900~ | diff --git a/build.gradle.kts b/build.gradle.kts index ff51eb14..d37fb3f4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -61,6 +61,9 @@ dependencies { runtimeOnly("io.jsonwebtoken:jjwt-impl:$jjwtVersion") runtimeOnly("io.jsonwebtoken:jjwt-jackson:$jjwtVersion") + // --- TSID --- + implementation("io.hypersistence:hypersistence-tsid:2.1.4") + // --- DB --- runtimeOnly("com.mysql:mysql-connector-j") diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/AlreadyJoinedException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/AlreadyJoinedException.kt new file mode 100644 index 00000000..13756229 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/AlreadyJoinedException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class AlreadyJoinedException : BaseException(ClubErrorCode.ALREADY_JOINED) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/CannotLeaveAsAdminException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/CannotLeaveAsAdminException.kt new file mode 100644 index 00000000..39797570 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/CannotLeaveAsAdminException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class CannotLeaveAsAdminException : BaseException(ClubErrorCode.CANNOT_LEAVE_AS_LEAD) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt new file mode 100644 index 00000000..51396601 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt @@ -0,0 +1,32 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.ErrorCodeInterface +import com.weeth.global.common.exception.ExplainError +import org.springframework.http.HttpStatus + +enum class ClubErrorCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ErrorCodeInterface { + @ExplainError("동아리 ID로 조회했으나 존재하지 않을 때 발생합니다.") + CLUB_NOT_FOUND(21100, HttpStatus.NOT_FOUND, "존재하지 않는 동아리입니다."), + + @ExplainError("가입 신청 시 초대 코드가 일치하지 않을 때 발생합니다.") + INVALID_CLUB_CODE(21101, HttpStatus.BAD_REQUEST, "유효하지 않은 초대 코드입니다."), + + @ExplainError("이미 가입한 동아리에 재가입 시도할 때 발생합니다.") + ALREADY_JOINED(21102, HttpStatus.CONFLICT, "이미 가입된 동아리입니다."), + + @ExplainError("동아리 멤버가 아닌 사용자가 동아리 리소스에 접근할 때 발생합니다.") + CLUB_MEMBER_NOT_FOUND(21103, HttpStatus.NOT_FOUND, "동아리 멤버가 아닙니다."), + + @ExplainError("동아리 관리자 권한이 필요한 작업을 일반 멤버가 시도할 때 발생합니다.") + NOT_CLUB_ADMIN(21104, HttpStatus.FORBIDDEN, "동아리 관리자 권한이 필요합니다."), + + @ExplainError("리더가 권한 이양 없이 동아리를 탈퇴하려 할 때 발생합니다.") + CANNOT_LEAVE_AS_LEAD(21105, HttpStatus.BAD_REQUEST, "리더는 권한 이양 후 탈퇴할 수 있습니다."), + + @ExplainError("비활성 멤버가 동아리 리소스에 접근할 때 발생합니다.") + MEMBER_NOT_ACTIVE(21106, HttpStatus.FORBIDDEN, "비활성 멤버입니다."), +} diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/ClubMemberNotFoundException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubMemberNotFoundException.kt new file mode 100644 index 00000000..0152a799 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubMemberNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class ClubMemberNotFoundException : BaseException(ClubErrorCode.CLUB_MEMBER_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/ClubNotFoundException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubNotFoundException.kt new file mode 100644 index 00000000..31192dc1 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class ClubNotFoundException : BaseException(ClubErrorCode.CLUB_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/InvalidClubCodeException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/InvalidClubCodeException.kt new file mode 100644 index 00000000..cbda5c74 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/InvalidClubCodeException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class InvalidClubCodeException : BaseException(ClubErrorCode.INVALID_CLUB_CODE) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/MemberNotActiveException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/MemberNotActiveException.kt new file mode 100644 index 00000000..2341175d --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/MemberNotActiveException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class MemberNotActiveException : BaseException(ClubErrorCode.MEMBER_NOT_ACTIVE) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/NotClubAdminException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/NotClubAdminException.kt new file mode 100644 index 00000000..682c05af --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/NotClubAdminException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class NotClubAdminException : BaseException(ClubErrorCode.NOT_CLUB_ADMIN) diff --git a/src/main/kotlin/com/weeth/domain/club/domain/entity/Club.kt b/src/main/kotlin/com/weeth/domain/club/domain/entity/Club.kt new file mode 100644 index 00000000..a69789b3 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/domain/entity/Club.kt @@ -0,0 +1,109 @@ +package com.weeth.domain.club.domain.entity + +import com.weeth.domain.club.domain.vo.ClubContact +import com.weeth.global.common.entity.BaseEntity +import com.weeth.global.common.id.TsidGenerator +import jakarta.persistence.Column +import jakarta.persistence.Embedded +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.PrePersist +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint + +@Entity +@Table( + name = "club", + uniqueConstraints = [ + UniqueConstraint( + name = "uk_club_school_name_club_name", + columnNames = ["school_name", "name"], + ), + ], +) +class Club( + name: String, + code: String, + description: String? = null, + schoolName: String, + clubContact: ClubContact, +) : BaseEntity() { + // TSID(Time-Sorted Unique Identifier)로 관리 + // Client 반환시 Base62 인코딩해서 String으로 반환 + @Id + @Column(name = "club_id") + var id: Long = 0L + private set + + @Column(nullable = false, unique = false, length = 100) + var name: String = name.trim() + private set + + @Column(nullable = false, unique = true, length = 20) + var code: String = code + private set + + @Column(length = 100) + var description: String? = description + private set + + @Column(length = 50) + var schoolName: String = schoolName + private set + + @Embedded + var clubContact: ClubContact = clubContact + private set + + fun update( + name: String, + description: String?, + ) { + require(name.isNotBlank()) { "동아리 이름은 비어 있을 수 없습니다." } + this.name = name.trim() + this.description = description + } + + fun updateContact( + email: String?, + phoneNumber: String?, + ) { + clubContact.update(email = email, phoneNumber = phoneNumber) + } + + fun regenerateCode(newCode: String) { + require(newCode.isNotBlank()) { "초대 코드는 비어 있을 수 없습니다." } + this.code = newCode + } + + @PrePersist + fun assignIdIfAbsent() { + if (id == 0L) { + id = TsidGenerator.nextId() + } + } + + companion object { + fun create( + name: String, + code: String, + schoolName: String, + clubContact: ClubContact, + description: String? = null, + ): Club { + require(name.isNotBlank()) { "동아리 이름은 비어 있을 수 없습니다." } + require(code.isNotBlank()) { "초대 코드는 비어 있을 수 없습니다." } + require(schoolName.isNotBlank()) { "학교 이름은 비어 있을 수 없습니다." } + return Club( + name = name, + code = code, + description = description, + schoolName = schoolName, + clubContact = clubContact, + ).apply { + // 객체 생성시 TSID 할당 + id = TsidGenerator.nextId() + } + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/club/domain/entity/ClubMember.kt b/src/main/kotlin/com/weeth/domain/club/domain/entity/ClubMember.kt new file mode 100644 index 00000000..c37bef70 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/domain/entity/ClubMember.kt @@ -0,0 +1,127 @@ +package com.weeth.domain.club.domain.entity + +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.vo.ClubAttendanceStats +import com.weeth.domain.user.domain.entity.User +import com.weeth.global.common.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Embedded +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint + +@Entity +@Table( + name = "club_member", + uniqueConstraints = [ + UniqueConstraint( + name = "uk_club_id_user_id", + columnNames = ["club_id", "user_id"], + ), + ], +) +class ClubMember( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "club_id", nullable = false) + val club: Club, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + val user: User, + memberStatus: MemberStatus = MemberStatus.WAITING, + memberRole: MemberRole = MemberRole.USER, +) : BaseEntity() { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "club_member_id") + var id: Long = 0L + private set + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + var memberStatus: MemberStatus = memberStatus + private set + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + var memberRole: MemberRole = memberRole + private set + + @Embedded + var attendanceStats: ClubAttendanceStats = ClubAttendanceStats() + private set + + @Column(nullable = false) + var penaltyCount: Int = 0 + private set + + fun accept() { + check(memberStatus == MemberStatus.WAITING) { "대기 상태인 멤버만 승인할 수 있습니다." } + memberStatus = MemberStatus.ACTIVE + } + + fun ban() { + check(memberStatus != MemberStatus.BANNED) { "이미 차단된 멤버입니다." } + check(memberStatus != MemberStatus.LEFT) { "탈퇴한 멤버는 차단할 수 없습니다." } + memberStatus = MemberStatus.BANNED + } + + fun leave() { + check(memberStatus == MemberStatus.ACTIVE) { "활동 중인 멤버만 탈퇴할 수 있습니다." } + memberStatus = MemberStatus.LEFT + } + + fun isActive(): Boolean = memberStatus == MemberStatus.ACTIVE + + fun updateRole(role: MemberRole) { + this.memberRole = role + } + + fun isAdmin(): Boolean = memberRole == MemberRole.ADMIN + + fun attend() { + attendanceStats.attend() + } + + fun removeAttend() { + attendanceStats.removeAttend() + } + + fun absent() { + attendanceStats.absent() + } + + fun removeAbsent() { + attendanceStats.removeAbsent() + } + + fun resetAttendanceStats() { + attendanceStats.reset() + } + + fun incrementPenaltyCount() { + penaltyCount++ + } + + fun decrementPenaltyCount() { + if (penaltyCount > 0) { + penaltyCount-- + } + } + + companion object { + fun create( + club: Club, + user: User, + memberRole: MemberRole = MemberRole.USER, + ): ClubMember = ClubMember(club = club, user = user, memberRole = memberRole) + } +} diff --git a/src/main/kotlin/com/weeth/domain/club/domain/entity/ClubMemberCardinal.kt b/src/main/kotlin/com/weeth/domain/club/domain/entity/ClubMemberCardinal.kt new file mode 100644 index 00000000..93b7f626 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/domain/entity/ClubMemberCardinal.kt @@ -0,0 +1,44 @@ +package com.weeth.domain.club.domain.entity + +import com.weeth.domain.cardinal.domain.entity.Cardinal +import com.weeth.global.common.entity.BaseEntity +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint + +@Entity +@Table( + name = "club_member_cardinal", + uniqueConstraints = [ + UniqueConstraint( + name = "uk_club_member_id_cardinal_id", + columnNames = ["club_member_id", "cardinal_id"], + ), + ], +) +class ClubMemberCardinal( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "club_member_id", nullable = false) + val clubMember: ClubMember, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "cardinal_id", nullable = false) + val cardinal: Cardinal, +) : BaseEntity() { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long = 0L + private set + + companion object { + fun create( + clubMember: ClubMember, + cardinal: Cardinal, + ): ClubMemberCardinal = ClubMemberCardinal(clubMember = clubMember, cardinal = cardinal) + } +} diff --git a/src/main/kotlin/com/weeth/domain/club/domain/enums/MemberRole.kt b/src/main/kotlin/com/weeth/domain/club/domain/enums/MemberRole.kt new file mode 100644 index 00000000..139a8ffc --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/domain/enums/MemberRole.kt @@ -0,0 +1,7 @@ +package com.weeth.domain.club.domain.enums + +enum class MemberRole { + USER, + ADMIN, + LEAD, // 동아리 개설한 인원의 역할. 추후 LEAD 권한 이양 API도 추가 +} diff --git a/src/main/kotlin/com/weeth/domain/club/domain/enums/MemberStatus.kt b/src/main/kotlin/com/weeth/domain/club/domain/enums/MemberStatus.kt new file mode 100644 index 00000000..afd6ebcf --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/domain/enums/MemberStatus.kt @@ -0,0 +1,8 @@ +package com.weeth.domain.club.domain.enums + +enum class MemberStatus { + WAITING, + ACTIVE, + BANNED, + LEFT, +} diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberCardinalReader.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberCardinalReader.kt new file mode 100644 index 00000000..93cc5122 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberCardinalReader.kt @@ -0,0 +1,15 @@ +package com.weeth.domain.club.domain.repository + +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.entity.ClubMemberCardinal + +interface ClubMemberCardinalReader { + fun findAllByClubMember(clubMember: ClubMember): List + + fun findLatestCardinalByClubMember(clubMember: ClubMember): ClubMemberCardinal? + + fun existsByClubMemberAndCardinalId( // todo: 실제 사용처에 따라 파라미터 확정 + clubMember: ClubMember, + cardinalId: Long, + ): Boolean +} diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberCardinalRepository.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberCardinalRepository.kt new file mode 100644 index 00000000..b9f2f8b7 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberCardinalRepository.kt @@ -0,0 +1,29 @@ +package com.weeth.domain.club.domain.repository + +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.entity.ClubMemberCardinal +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query + +interface ClubMemberCardinalRepository : + JpaRepository, + ClubMemberCardinalReader { + override fun findAllByClubMember(clubMember: ClubMember): List + + @Query( + "SELECT cmc FROM ClubMemberCardinal cmc " + + "JOIN cmc.cardinal c " + + "WHERE cmc.clubMember = :clubMember " + + "ORDER BY c.cardinalNumber DESC " + + "LIMIT 1", + ) + fun findTopByClubMemberOrderedByCardinalNumberDesc(clubMember: ClubMember): ClubMemberCardinal? + + override fun findLatestCardinalByClubMember(clubMember: ClubMember): ClubMemberCardinal? = + findTopByClubMemberOrderedByCardinalNumberDesc(clubMember) + + override fun existsByClubMemberAndCardinalId( + clubMember: ClubMember, + cardinalId: Long, + ): Boolean +} diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt new file mode 100644 index 00000000..4d2da44a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt @@ -0,0 +1,18 @@ +package com.weeth.domain.club.domain.repository + +import com.weeth.domain.club.domain.entity.ClubMember + +interface ClubMemberReader { + fun getClubMemberById(clubMemberId: Long): ClubMember + + fun findByIdOrNull(clubMemberId: Long): ClubMember? + + fun findByClubIdAndUserId( + clubId: Long, + userId: Long, + ): ClubMember? + + fun findAllByClubId(clubId: Long): List + + fun findAllByUserId(userId: Long): List +} diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt new file mode 100644 index 00000000..1e29a0eb --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt @@ -0,0 +1,23 @@ +package com.weeth.domain.club.domain.repository + +import com.weeth.domain.club.application.exception.ClubMemberNotFoundException +import com.weeth.domain.club.domain.entity.ClubMember +import org.springframework.data.jpa.repository.JpaRepository + +interface ClubMemberRepository : + JpaRepository, + ClubMemberReader { + override fun getClubMemberById(clubMemberId: Long): ClubMember = + findById(clubMemberId).orElseThrow { ClubMemberNotFoundException() } + + override fun findByIdOrNull(clubMemberId: Long): ClubMember? = findById(clubMemberId).orElse(null) + + override fun findByClubIdAndUserId( + clubId: Long, + userId: Long, + ): ClubMember? + + override fun findAllByClubId(clubId: Long): List + + override fun findAllByUserId(userId: Long): List +} diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubReader.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubReader.kt new file mode 100644 index 00000000..31a8f5ac --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubReader.kt @@ -0,0 +1,13 @@ +package com.weeth.domain.club.domain.repository + +import com.weeth.domain.club.domain.entity.Club + +interface ClubReader { + fun getClubById(clubId: Long): Club + + fun findByIdOrNull(clubId: Long): Club? + + fun findClubByCode(code: String): Club? + + fun findClubByName(name: String): Club? +} diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubRepository.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubRepository.kt new file mode 100644 index 00000000..a85f8e00 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubRepository.kt @@ -0,0 +1,22 @@ +package com.weeth.domain.club.domain.repository + +import com.weeth.domain.club.application.exception.ClubNotFoundException +import com.weeth.domain.club.domain.entity.Club +import org.springframework.data.jpa.repository.JpaRepository +import java.util.Optional + +interface ClubRepository : + JpaRepository, + ClubReader { + fun findByCode(code: String): Optional + + fun findByName(name: String): Optional + + override fun getClubById(clubId: Long): Club = findById(clubId).orElseThrow { ClubNotFoundException() } + + override fun findByIdOrNull(clubId: Long): Club? = findById(clubId).orElse(null) + + override fun findClubByCode(code: String): Club? = findByCode(code).orElse(null) + + override fun findClubByName(name: String): Club? = findByName(name).orElse(null) +} diff --git a/src/main/kotlin/com/weeth/domain/club/domain/vo/ClubAttendanceStats.kt b/src/main/kotlin/com/weeth/domain/club/domain/vo/ClubAttendanceStats.kt new file mode 100644 index 00000000..67424747 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/domain/vo/ClubAttendanceStats.kt @@ -0,0 +1,58 @@ +package com.weeth.domain.club.domain.vo + +import jakarta.persistence.Column +import jakarta.persistence.Embeddable + +@Embeddable +class ClubAttendanceStats( + attendanceCount: Int = 0, + absenceCount: Int = 0, + attendanceRate: Int = 0, +) { + @Column(name = "attendance_count") + var attendanceCount: Int = attendanceCount + private set + + @Column(name = "absence_count") + var absenceCount: Int = absenceCount + private set + + @Column(name = "attendance_rate") + var attendanceRate: Int = attendanceRate + private set + + fun reset() { + attendanceCount = 0 + absenceCount = 0 + attendanceRate = 0 + } + + fun attend() { + attendanceCount++ + recalculateRate() + } + + fun removeAttend() { + if (attendanceCount > 0) { + attendanceCount-- + recalculateRate() + } + } + + fun absent() { + absenceCount++ + recalculateRate() + } + + fun removeAbsent() { + if (absenceCount > 0) { + absenceCount-- + recalculateRate() + } + } + + private fun recalculateRate() { + val total = attendanceCount + absenceCount + attendanceRate = if (total > 0) (attendanceCount * 100) / total else 0 + } +} diff --git a/src/main/kotlin/com/weeth/domain/club/domain/vo/ClubContact.kt b/src/main/kotlin/com/weeth/domain/club/domain/vo/ClubContact.kt new file mode 100644 index 00000000..6f42ebb0 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/domain/vo/ClubContact.kt @@ -0,0 +1,41 @@ +package com.weeth.domain.club.domain.vo + +import jakarta.persistence.Column +import jakarta.persistence.Embeddable + +/** + * 동아리 연락처를 저장하기 위한 VO. + * email 혹은 phoneNumber 둘 중 하나는 반드시 존재해야 하며, 값이 있다면 둘 다 저장 가능. + */ +@Embeddable +class ClubContact( + email: String? = null, + phoneNumber: String? = null, +) { + @Column(name = "contact_email", length = 100) + var email: String? = email + private set + + @Column(name = "contact_phone_number", length = 20) + var phoneNumber: String? = phoneNumber + private set + + fun update( + email: String?, + phoneNumber: String?, + ) { + require(email != null || phoneNumber != null) { "이메일 또는 전화번호 중 하나는 반드시 입력해야 합니다." } + this.email = email + this.phoneNumber = phoneNumber + } + + companion object { + fun from( + email: String?, + phoneNumber: String?, + ): ClubContact { + require(email != null || phoneNumber != null) { "이메일 또는 전화번호 중 하나는 반드시 입력해야 합니다." } + return ClubContact(email = email, phoneNumber = phoneNumber) + } + } +} diff --git a/src/main/kotlin/com/weeth/global/common/id/TsidGenerator.kt b/src/main/kotlin/com/weeth/global/common/id/TsidGenerator.kt new file mode 100644 index 00000000..2c5b6c0c --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/id/TsidGenerator.kt @@ -0,0 +1,14 @@ +package com.weeth.global.common.id + +import io.hypersistence.tsid.TSID + +/** + * TSID (Time-Sorted Unique Identifier) 생성 유틸리티. + * 참고: 애플리케이션 단에서 ID를 할당하므로, 생성된 ID는 테스트에서 ReflectionTestUtils로 덮어씌울 수 있음. + */ +object TsidGenerator { + /** + * 새로운 TSID를 생성하여 Long 값으로 반환합니다. + */ + fun nextId(): Long = TSID.Factory.getTsid().toLong() +} diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt index 3c879c19..ce786458 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt @@ -8,6 +8,7 @@ import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.user.domain.enums.Role import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe import io.mockk.every @@ -50,6 +51,7 @@ class GetBoardQueryServiceTest : val result = queryService.findBoards(Role.ADMIN) result shouldHaveSize 2 + result.map { it.name } shouldBe listOf("일반", "운영") } } @@ -66,6 +68,7 @@ class GetBoardQueryServiceTest : val result = queryService.findAllBoardsForAdmin() result shouldHaveSize 2 + result.map { it.name } shouldBe listOf("일반", "삭제됨") } it("활성 게시판과 비공개 게시판도 모두 포함해 반환한다") { @@ -80,6 +83,7 @@ class GetBoardQueryServiceTest : val result = queryService.findAllBoardsForAdmin() result shouldHaveSize 2 + result.map { it.name } shouldBe listOf("일반", "운영") } } diff --git a/src/test/kotlin/com/weeth/domain/club/domain/entity/ClubMemberTest.kt b/src/test/kotlin/com/weeth/domain/club/domain/entity/ClubMemberTest.kt new file mode 100644 index 00000000..51567ca0 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/club/domain/entity/ClubMemberTest.kt @@ -0,0 +1,187 @@ +package com.weeth.domain.club.domain.entity + +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.vo.ClubContact +import com.weeth.domain.user.fixture.UserTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe + +class ClubMemberTest : + StringSpec({ + val club = + Club.create( + name = "리츠", + code = "LEETS001", + schoolName = "가천대학교", + clubContact = ClubContact.from(email = "leets@test.com", phoneNumber = null), + ) + val user = UserTestFixture.createActiveUser1() + + "ClubMember 생성 — 기본 상태는 WAITING, 역할은 USER, 패널티 횟수는 0" { + val member = ClubMember(club = club, user = user) + + member.memberStatus shouldBe MemberStatus.WAITING + member.memberRole shouldBe MemberRole.USER + member.penaltyCount shouldBe 0 + } + + "accept — 상태를 ACTIVE로 전환한다" { + val member = ClubMember(club = club, user = user) + + member.accept() + + member.memberStatus shouldBe MemberStatus.ACTIVE + } + + "ban — 상태를 BANNED로 전환한다" { + val member = ClubMember(club = club, user = user) + + member.ban() + + member.memberStatus shouldBe MemberStatus.BANNED + } + + "leave — ACTIVE 상태에서 LEFT로 전환한다" { + val member = ClubMember(club = club, user = user) + member.accept() + + member.leave() + + member.memberStatus shouldBe MemberStatus.LEFT + } + + "isActive — ACTIVE 상태일 때 true" { + val member = ClubMember(club = club, user = user) + member.accept() + + member.isActive() shouldBe true + } + + "isActive — WAITING 상태일 때 false" { + val member = ClubMember(club = club, user = user) + + member.isActive() shouldBe false + } + + "updateRole — 역할을 ADMIN으로 변경한다" { + val member = ClubMember(club = club, user = user) + + member.updateRole(MemberRole.ADMIN) + + member.memberRole shouldBe MemberRole.ADMIN + member.isAdmin() shouldBe true + } + + "isAdmin — USER 역할일 때 false" { + val member = ClubMember(club = club, user = user) + + member.isAdmin() shouldBe false + } + + "attend/absent — 출석 통계를 올바르게 계산한다" { + val member = ClubMember(club = club, user = user) + member.attend() + member.attend() + member.absent() + + member.attendanceStats.attendanceCount shouldBe 2 + member.attendanceStats.absenceCount shouldBe 1 + member.attendanceStats.attendanceRate shouldBe (2 * 100 / 3) + } + + "removeAttend — 출석 카운트를 감소시킨다" { + val member = ClubMember(club = club, user = user) + member.attend() + member.attend() + + member.removeAttend() + + member.attendanceStats.attendanceCount shouldBe 1 + } + + "removeAbsent — 결석 카운트를 감소시킨다" { + val member = ClubMember(club = club, user = user) + member.absent() + + member.removeAbsent() + + member.attendanceStats.absenceCount shouldBe 0 + } + + "resetAttendanceStats — 출석 통계를 초기화한다" { + val member = ClubMember(club = club, user = user) + member.attend() + member.attend() + member.absent() + + member.resetAttendanceStats() + + member.attendanceStats.attendanceCount shouldBe 0 + member.attendanceStats.absenceCount shouldBe 0 + member.attendanceStats.attendanceRate shouldBe 0 + } + + "incrementPenaltyCount — 패널티를 증가시킨다" { + val member = ClubMember(club = club, user = user) + + member.incrementPenaltyCount() + member.incrementPenaltyCount() + + member.penaltyCount shouldBe 2 + } + + "decrementPenaltyCount — 패널티를 감소시킨다" { + val member = ClubMember(club = club, user = user) + member.incrementPenaltyCount() + + member.decrementPenaltyCount() + + member.penaltyCount shouldBe 0 + } + + "decrementPenaltyCount — 0일 때 감소해도 0을 유지한다" { + val member = ClubMember(club = club, user = user) + + member.decrementPenaltyCount() + + member.penaltyCount shouldBe 0 + } + + "accept — WAITING이 아닌 상태에서 호출 시 예외가 발생한다" { + val member = ClubMember(club = club, user = user) + member.accept() + + shouldThrow { + member.accept() + } + } + + "ban — 이미 BANNED 상태에서 호출 시 예외가 발생한다" { + val member = ClubMember(club = club, user = user) + member.ban() + + shouldThrow { + member.ban() + } + } + + "ban — LEFT 상태에서 호출 시 예외가 발생한다" { + val member = ClubMember(club = club, user = user) + member.accept() + member.leave() + + shouldThrow { + member.ban() + } + } + + "leave — ACTIVE가 아닌 상태에서 호출 시 예외가 발생한다" { + val member = ClubMember(club = club, user = user) + + shouldThrow { + member.leave() + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/club/domain/entity/ClubTest.kt b/src/test/kotlin/com/weeth/domain/club/domain/entity/ClubTest.kt new file mode 100644 index 00000000..20c12cda --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/club/domain/entity/ClubTest.kt @@ -0,0 +1,132 @@ +package com.weeth.domain.club.domain.entity + +import com.weeth.domain.club.domain.vo.ClubContact +import com.weeth.domain.club.fixture.ClubTestFixture +import io.hypersistence.tsid.TSID +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.comparables.shouldBeGreaterThan +import io.kotest.matchers.shouldBe + +class ClubTest : + StringSpec({ + val defaultContact = ClubContact.from(email = "leets@test.com", phoneNumber = null) + + "Club 생성 — 이름과 코드를 가진다" { + val club = Club.create(name = "리츠", code = "LEETS001", schoolName = "가천대학교", clubContact = defaultContact) + + club.name shouldBe "리츠" + club.code shouldBe "LEETS001" + club.description shouldBe null + } + + "Club 생성 — 소개(description)를 선택적으로 가진다" { + val club = + Club.create( + name = "리츠", + code = "LEETS001", + description = "IT 동아리", + schoolName = "가천대학교", + clubContact = defaultContact, + ) + + club.description shouldBe "IT 동아리" + } + + "update — 이름과 소개를 수정한다" { + val club = Club.create(name = "리츠", code = "LEETS001", schoolName = "가천대학교", clubContact = defaultContact) + + club.update(name = "리츠2기", description = "업데이트된 소개") + + club.name shouldBe "리츠2기" + club.description shouldBe "업데이트된 소개" + } + + "update — 빈 이름은 예외가 발생한다" { + val club = Club.create(name = "리츠", code = "LEETS001", schoolName = "가천대학교", clubContact = defaultContact) + + shouldThrow { + club.update(name = "", description = null) + } + } + + "update — 공백만 있는 이름은 예외가 발생한다" { + val club = Club.create(name = "리츠", code = "LEETS001", schoolName = "가천대학교", clubContact = defaultContact) + + shouldThrow { + club.update(name = " ", description = null) + } + } + + "regenerateCode — 초대 코드를 갱신한다" { + val club = Club.create(name = "리츠", code = "OLD_CODE", schoolName = "가천대학교", clubContact = defaultContact) + + club.regenerateCode("NEW_CODE") + + club.code shouldBe "NEW_CODE" + } + + "regenerateCode — 빈 코드는 예외가 발생한다" { + val club = Club.create(name = "리츠", code = "OLD_CODE", schoolName = "가천대학교", clubContact = defaultContact) + + shouldThrow { + club.regenerateCode("") + } + } + + "create — Club id는 TSID 형식으로 생성된다" { + val club = Club.create(name = "리츠", code = "LEETS001", schoolName = "가천대학교", clubContact = defaultContact) + + shouldNotThrowAny { + TSID.from(club.id) + } + } + + "create - Club id는 TSID 형식으로 시간순 정렬이 가능하다" { + val club1 = ClubTestFixture.createClub() + val club2 = ClubTestFixture.createClub() + + club2.id shouldBeGreaterThan club1.id + } + + "create — 유효한 인자로 생성에 성공한다" { + val club = + Club.create( + name = "리츠", + code = "LEETS001", + schoolName = "가천대학교", + clubContact = defaultContact, + description = "IT 동아리", + ) + + club.name shouldBe "리츠" + club.code shouldBe "LEETS001" + club.schoolName shouldBe "가천대학교" + club.description shouldBe "IT 동아리" + } + + "create — 빈 이름은 예외가 발생한다" { + shouldThrow { + Club.create(name = "", code = "LEETS001", schoolName = "가천대학교", clubContact = defaultContact) + } + } + + "create — 공백만 있는 이름은 예외가 발생한다" { + shouldThrow { + Club.create(name = " ", code = "LEETS001", schoolName = "가천대학교", clubContact = defaultContact) + } + } + + "create — 빈 코드는 예외가 발생한다" { + shouldThrow { + Club.create(name = "리츠", code = "", schoolName = "가천대학교", clubContact = defaultContact) + } + } + + "create — 빈 학교 이름은 예외가 발생한다" { + shouldThrow { + Club.create(name = "리츠", code = "LEETS001", schoolName = "", clubContact = defaultContact) + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/club/fixture/ClubMemberTestFixture.kt b/src/test/kotlin/com/weeth/domain/club/fixture/ClubMemberTestFixture.kt new file mode 100644 index 00000000..3971ad06 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/club/fixture/ClubMemberTestFixture.kt @@ -0,0 +1,44 @@ +package com.weeth.domain.club.fixture + +import com.weeth.domain.club.domain.entity.Club +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.user.domain.entity.User +import com.weeth.domain.user.fixture.UserTestFixture + +object ClubMemberTestFixture { + fun createActiveMember( + club: Club = ClubTestFixture.createClub(), + user: User = UserTestFixture.createActiveUser1(), + memberRole: MemberRole = MemberRole.USER, + ): ClubMember = + ClubMember( + club = club, + user = user, + memberStatus = MemberStatus.ACTIVE, + memberRole = memberRole, + ) + + fun createWaitingMember( + club: Club = ClubTestFixture.createClub(), + user: User = UserTestFixture.createWaitingUser1(), + ): ClubMember = + ClubMember( + club = club, + user = user, + memberStatus = MemberStatus.WAITING, + memberRole = MemberRole.USER, + ) + + fun createAdminMember( + club: Club = ClubTestFixture.createClub(), + user: User = UserTestFixture.createAdmin(), + ): ClubMember = + ClubMember( + club = club, + user = user, + memberStatus = MemberStatus.ACTIVE, + memberRole = MemberRole.ADMIN, + ) +} diff --git a/src/test/kotlin/com/weeth/domain/club/fixture/ClubTestFixture.kt b/src/test/kotlin/com/weeth/domain/club/fixture/ClubTestFixture.kt new file mode 100644 index 00000000..725072ec --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/club/fixture/ClubTestFixture.kt @@ -0,0 +1,24 @@ +package com.weeth.domain.club.fixture + +import com.weeth.domain.club.domain.entity.Club +import com.weeth.domain.club.domain.vo.ClubContact + +object ClubTestFixture { + fun createClub( + name: String = "테스트 동아리", + code: String = "TEST001", + description: String? = "테스트 동아리 소개", + schoolName: String = "가천대학교", + clubContact: ClubContact = ClubContact.from(email = "test@leets.com", phoneNumber = null), + ): Club { + val club = + Club.create( + name = name, + code = code, + description = description, + schoolName = schoolName, + clubContact = clubContact, + ) + return club + } +} From 5c2314eb78b927bb745a7e7b03f9a5b867bd5373 Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Sun, 8 Mar 2026 00:26:50 +0900 Subject: [PATCH 20/73] =?UTF-8?q?[WTH-181]=20club=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=B6=94=EA=B0=80=20=EC=9E=91=EC=97=85=202=20(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: UUID 사용으로 인한 code 길이 수정 및 프로필, 배경 사진 추가 * feat: Club Code UUID 적용 * feat: ClubMember 관련 정책 추가 * feat: dto 추가 * feat: 동아리 조회 서비스 구현 * feat: 동아리 멤버 조회 서비스 구현 * feat: 동아리 관리 유스케이스 구현 * feat: 동아리 가입/탈퇴 유스케이스 구현 * feat: 동아리 멤버 관리 유스케이스 구현 * feat: 컨트롤러 추가 * refactor: 스프링 빈 등록 * feat: 응답 코드 추가 * refactor: 주석 추가, 예외 이름 변경 * refactor: 테스트 케이스 보강 * refactor: MVP 기획에 맞게 다중 동아리 지원 막기 * refactor: 기수 정보 및 추가 사용자 정보 반환 * feat: mapper 구현 * refactor: id 기반 검증 제거 * feat: TSID를 Long으로 디코딩하는 어노테이션 추가 * docs: 주석 추가 * chore: lint 설정 * refactor: 중복 에러 코드 제거 * refactor: ClubMember 관련 DTO 수정 * refactor: 전처리 추가 * refactor: 테스트 케이스 추가 * refactor: 동시 가입 오류가 터지지 않게 락 적용 * refactor: 동시 가입 오류가 터지지 않게 락 적용 * refactor: 유스케이스명 수정 * refactor: ClubMember 관리시 해당 동아리에 속했는지 검증 추가 * refactor: 동아리 업데이트시 PATCH 계약에 맞게 수정 * feat:프로필, 배경 사진 삭제 API 추가 --- .../dto/request/ClubCreateRequest.kt | 26 ++++ .../dto/request/ClubJoinRequest.kt | 10 ++ .../request/ClubMemberRoleUpdateRequest.kt | 13 ++ .../dto/request/ClubUpdateRequest.kt | 23 +++ .../dto/response/ClubDetailResponse.kt | 24 +++ .../dto/response/ClubInfoResponse.kt | 20 +++ .../dto/response/ClubMemberProfileResponse.kt | 29 ++++ .../dto/response/ClubMemberResponse.kt | 43 ++++++ .../application/dto/response/ClubResponse.kt | 18 +++ ...ption.kt => CannotLeaveAsLeadException.kt} | 2 +- .../exception/ClubCantJoinException.kt | 5 + .../application/exception/ClubErrorCode.kt | 6 + .../exception/ClubMemberNotInClubException.kt | 5 + .../club/application/mapper/ClubMapper.kt | 97 ++++++++++++ .../usecase/command/AdminClubMemberUseCase.kt | 52 +++++++ .../command/ManageClubMemberUsecase.kt | 85 +++++++++++ .../usecase/command/ManageClubUseCase.kt | 131 ++++++++++++++++ .../query/GetClubMemberQueryService.kt | 49 ++++++ .../usecase/query/GetClubQueryService.kt | 45 ++++++ .../weeth/domain/club/domain/entity/Club.kt | 69 +++++++-- .../repository/ClubMemberCardinalReader.kt | 2 + .../ClubMemberCardinalRepository.kt | 13 ++ .../domain/repository/ClubMemberReader.kt | 5 + .../domain/repository/ClubMemberRepository.kt | 19 ++- .../club/domain/service/ClubCodePolicy.kt | 26 ++++ .../club/domain/service/ClubMemberPolicy.kt | 54 +++++++ .../club/presentation/ClubAdminController.kt | 143 +++++++++++++++++ .../club/presentation/ClubController.kt | 111 ++++++++++++++ .../club/presentation/ClubResponseCode.kt | 26 ++++ .../user/domain/repository/UserReader.kt | 2 + .../user/domain/repository/UserRepository.kt | 2 + .../global/common/id/TsidBase62Encoder.kt | 46 ++++++ .../com/weeth/global/common/web/TsidParam.kt | 16 ++ .../global/common/web/TsidPathVariable.kt | 7 + .../web/TsidPathVariableArgumentResolver.kt | 73 +++++++++ .../com/weeth/global/config/WebMvcConfig.kt | 2 + .../command/AdminClubMemberUseCaseTest.kt | 73 +++++++++ .../command/ManageClubMemberUseCaseTest.kt | 67 ++++++++ .../usecase/command/ManageClubUseCaseTest.kt | 138 +++++++++++++++++ .../query/GetClubMemberQueryServiceTest.kt | 74 +++++++++ .../domain/club/domain/entity/ClubTest.kt | 14 +- .../club/domain/service/ClubCodePolicyTest.kt | 62 ++++++++ .../domain/service/ClubMemberPolicyTest.kt | 144 ++++++++++++++++++ .../domain/club/fixture/ClubTestFixture.kt | 21 +++ .../global/common/id/TsidBase62EncoderTest.kt | 63 ++++++++ .../TsidPathVariableArgumentResolverTest.kt | 64 ++++++++ 46 files changed, 2005 insertions(+), 14 deletions(-) create mode 100644 src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubCreateRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubJoinRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubMemberRoleUpdateRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubUpdateRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubDetailResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubInfoResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberProfileResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubResponse.kt rename src/main/kotlin/com/weeth/domain/club/application/exception/{CannotLeaveAsAdminException.kt => CannotLeaveAsLeadException.kt} (55%) create mode 100644 src/main/kotlin/com/weeth/domain/club/application/exception/ClubCantJoinException.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/application/exception/ClubMemberNotInClubException.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryService.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubQueryService.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/domain/service/ClubCodePolicy.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/presentation/ClubAdminController.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt create mode 100644 src/main/kotlin/com/weeth/global/common/id/TsidBase62Encoder.kt create mode 100644 src/main/kotlin/com/weeth/global/common/web/TsidParam.kt create mode 100644 src/main/kotlin/com/weeth/global/common/web/TsidPathVariable.kt create mode 100644 src/main/kotlin/com/weeth/global/common/web/TsidPathVariableArgumentResolver.kt create mode 100644 src/test/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCaseTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryServiceTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/club/domain/service/ClubCodePolicyTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicyTest.kt create mode 100644 src/test/kotlin/com/weeth/global/common/id/TsidBase62EncoderTest.kt create mode 100644 src/test/kotlin/com/weeth/global/common/web/TsidPathVariableArgumentResolverTest.kt diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubCreateRequest.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubCreateRequest.kt new file mode 100644 index 00000000..eebb2245 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubCreateRequest.kt @@ -0,0 +1,26 @@ +package com.weeth.domain.club.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size + +data class ClubCreateRequest( + @field:Schema(description = "동아리 이름", example = "Leets") + @field:NotBlank + @field:Size(max = 100) + val name: String, + @field:Schema(description = "학교 이름", example = "가천대학교") + @field:NotBlank + @field:Size(max = 50) + val schoolName: String, + @field:Schema(description = "동아리 소개", example = "함께 배우고 성장하는 개발자 커뮤니티") + val description: String? = null, + @field:Schema(description = "연락 이메일", example = "club@example.com") + val contactEmail: String? = null, + @field:Schema(description = "연락 전화번호", example = "010-1234-5678") + val contactPhoneNumber: String? = null, + @field:Schema(description = "프로필 사진 S3 URL", example = "https://s3.amazonaws.com/bucket/profile.jpg") + val profileImageUrl: String? = null, + @field:Schema(description = "배경 사진 S3 URL", example = "https://s3.amazonaws.com/bucket/background.jpg") + val backgroundImageUrl: String? = null, +) diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubJoinRequest.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubJoinRequest.kt new file mode 100644 index 00000000..c49c0e05 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubJoinRequest.kt @@ -0,0 +1,10 @@ +package com.weeth.domain.club.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank + +data class ClubJoinRequest( + @field:Schema(description = "초대 코드", example = "550e8400-e29b-41d4-a716-446655440000") + @field:NotBlank + val code: String, +) diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubMemberRoleUpdateRequest.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubMemberRoleUpdateRequest.kt new file mode 100644 index 00000000..665b1fa7 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubMemberRoleUpdateRequest.kt @@ -0,0 +1,13 @@ +package com.weeth.domain.club.application.dto.request + +import com.weeth.domain.club.domain.enums.MemberRole +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.Positive + +data class ClubMemberRoleUpdateRequest( + @field:Schema(description = "멤버 ID", example = "1") + @field:Positive + val clubMemberId: Long, + @field:Schema(description = "변경할 권한", example = "ADMIN") + val memberRole: MemberRole, +) diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubUpdateRequest.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubUpdateRequest.kt new file mode 100644 index 00000000..494af9d9 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubUpdateRequest.kt @@ -0,0 +1,23 @@ +package com.weeth.domain.club.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.Size + +data class ClubUpdateRequest( + @field:Schema(description = "동아리 이름 (null=변경 안 함)", example = "Leets") + @field:Size(max = 100) + val name: String? = null, + @field:Schema(description = "학교 이름 (null=변경 안 함)", example = "가천대학교") + @field:Size(max = 50) + val schoolName: String? = null, + @field:Schema(description = "동아리 소개 (null=변경 안 함)", example = "함께 배우고 성장하는 개발자 커뮤니티") + val description: String? = null, + @field:Schema(description = "연락 이메일 (null=변경 안 함)", example = "club@example.com") + val contactEmail: String? = null, + @field:Schema(description = "연락 전화번호 (null=변경 안 함)", example = "010-1234-5678") + val contactPhoneNumber: String? = null, + @field:Schema(description = "프로필 사진 URL (null=변경 안 함)", example = "https://s3.amazonaws.com/bucket/profile.jpg") + val profileImageUrl: String? = null, + @field:Schema(description = "배경 사진 URL (null=변경 안 함)", example = "https://s3.amazonaws.com/bucket/background.jpg") + val backgroundImageUrl: String? = null, +) diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubDetailResponse.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubDetailResponse.kt new file mode 100644 index 00000000..0a8d8c92 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubDetailResponse.kt @@ -0,0 +1,24 @@ +package com.weeth.domain.club.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class ClubDetailResponse( + @field:Schema(description = "동아리 ID (Base62 인코딩)", example = "1A2b3C") + val id: String, + @field:Schema(description = "동아리 이름", example = "Leets") + val name: String, + @field:Schema(description = "초대 코드", example = "550e8400-e29b-41d4-a716-446655440000") + val code: String, + @field:Schema(description = "학교 이름", example = "가천대학교") + val schoolName: String, + @field:Schema(description = "동아리 소개", example = "함께 배우고 성장하는 개발자 커뮤니티") + val description: String?, + @field:Schema(description = "연락 이메일", example = "club@example.com") + val contactEmail: String?, + @field:Schema(description = "연락 전화번호", example = "010-1234-5678") + val contactPhoneNumber: String?, + @field:Schema(description = "프로필 사진 URL") + val profileImageUrl: String?, + @field:Schema(description = "배경 사진 URL") + val backgroundImageUrl: String?, +) diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubInfoResponse.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubInfoResponse.kt new file mode 100644 index 00000000..40dd8723 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubInfoResponse.kt @@ -0,0 +1,20 @@ +package com.weeth.domain.club.application.dto.response + +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.enums.MemberStatus +import io.swagger.v3.oas.annotations.media.Schema + +data class ClubInfoResponse( + @field:Schema(description = "동아리 ID (Base62 인코딩)", example = "1A2b3C") + val id: String, + @field:Schema(description = "동아리 이름", example = "Leets") + val name: String, + @field:Schema(description = "학교 이름", example = "가천대학교") + val schoolName: String, + @field:Schema(description = "동아리 설명", example = "함께 배우고 성장하는 개발자 커뮤니티") + val description: String?, + @field:Schema(description = "나의 권한", example = "USER") + val memberRole: MemberRole, + @field:Schema(description = "나의 멤버 상태", example = "ACTIVE") + val memberStatus: MemberStatus, +) diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberProfileResponse.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberProfileResponse.kt new file mode 100644 index 00000000..425d1e30 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberProfileResponse.kt @@ -0,0 +1,29 @@ +package com.weeth.domain.club.application.dto.response + +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.enums.MemberStatus +import io.swagger.v3.oas.annotations.media.Schema + +/** + * 내 멤버 정보 조회 API에 사용 + */ +data class ClubMemberProfileResponse( + @field:Schema(description = "사용자 ID", example = "1") + val userId: Long, + @field:Schema(description = "멤버 ID", example = "1") + val clubMemberId: Long, + @field:Schema(description = "사용자 이름", example = "홍길동") + val name: String, + @field:Schema(description = "이메일", example = "hong@example.com") + val email: String, + @field:Schema(description = "전화번호", example = "01012345678") + val tel: String, + @field:Schema(description = "학교", example = "가천대학교") + val school: String?, + @field:Schema(description = "학과", example = "컴퓨터공학과") + val department: String, + @field:Schema(description = "학번", example = "20201234") + val studentId: String, + @field:Schema(description = "소속 기수 목록", example = "[6, 7]") + val cardinals: List, +) diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberResponse.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberResponse.kt new file mode 100644 index 00000000..635eec8e --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberResponse.kt @@ -0,0 +1,43 @@ +package com.weeth.domain.club.application.dto.response + +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonInclude +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.enums.MemberStatus +import io.swagger.v3.oas.annotations.media.Schema + +/** + * 동아리 멤버 목록 조회(관리자용) API에 사용 + */ +data class ClubMemberResponse( + @field:Schema(description = "사용자 ID", example = "1") + val userId: Long, + @field:Schema(description = "멤버 ID", example = "1") + val clubMemberId: Long, + @field:Schema(description = "사용자 이름", example = "홍길동") + val name: String, + @field:Schema(description = "이메일", example = "hong@example.com") + val email: String, + @field:Schema(description = "전화번호", example = "01012345678") + val tel: String, + @field:Schema(description = "학교", example = "가천대학교") + val school: String?, + @field:Schema(description = "학과", example = "컴퓨터공학과") + val department: String, + @field:Schema(description = "학번", example = "20201234") + val studentId: String, + @field:Schema(description = "소속 기수 목록", example = "[6, 7]") + val cardinals: List, + @field:Schema(description = "멤버 상태", example = "ACTIVE") + val memberStatus: MemberStatus, + @field:Schema(description = "멤버 권한", example = "USER") + val memberRole: MemberRole, + @field:Schema(description = "출석 횟수", example = "10") + val attendanceCount: Int, + @field:Schema(description = "결석 횟수", example = "2") + val absenceCount: Int, + @field:Schema(description = "출석률 (%)", example = "83") + val attendanceRate: Int, + @field:Schema(description = "패널티 횟수", example = "1") + val penaltyCount: Int, +) diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubResponse.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubResponse.kt new file mode 100644 index 00000000..2287cef8 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubResponse.kt @@ -0,0 +1,18 @@ +package com.weeth.domain.club.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class ClubResponse( + @field:Schema(description = "동아리 ID (Base62 인코딩)", example = "1A2b3C") + val id: String, + @field:Schema(description = "동아리 이름", example = "Leets") + val name: String, + @field:Schema(description = "학교 이름", example = "가천대학교") + val schoolName: String, + @field:Schema(description = "동아리 소개", example = "함께 배우고 성장하는 개발자 커뮤니티") + val description: String?, + @field:Schema(description = "프로필 사진 URL") + val profileImageUrl: String?, + @field:Schema(description = "배경 사진 URL") + val backgroundImageUrl: String?, +) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/CannotLeaveAsAdminException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/CannotLeaveAsLeadException.kt similarity index 55% rename from src/main/kotlin/com/weeth/domain/club/application/exception/CannotLeaveAsAdminException.kt rename to src/main/kotlin/com/weeth/domain/club/application/exception/CannotLeaveAsLeadException.kt index 39797570..316ebe87 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/exception/CannotLeaveAsAdminException.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/CannotLeaveAsLeadException.kt @@ -2,4 +2,4 @@ package com.weeth.domain.club.application.exception import com.weeth.global.common.exception.BaseException -class CannotLeaveAsAdminException : BaseException(ClubErrorCode.CANNOT_LEAVE_AS_LEAD) +class CannotLeaveAsLeadException : BaseException(ClubErrorCode.CANNOT_LEAVE_AS_LEAD) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/ClubCantJoinException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubCantJoinException.kt new file mode 100644 index 00000000..41b4c451 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubCantJoinException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class ClubCantJoinException : BaseException(ClubErrorCode.CLUB_CANT_JOIN) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt index 51396601..d00851d0 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt @@ -29,4 +29,10 @@ enum class ClubErrorCode( @ExplainError("비활성 멤버가 동아리 리소스에 접근할 때 발생합니다.") MEMBER_NOT_ACTIVE(21106, HttpStatus.FORBIDDEN, "비활성 멤버입니다."), + + @ExplainError("MVP 단계에서 여러 동아리에 지원하려고 하는 경우 발생합니다. MVP는 단일 동아리 지원만 가능합니다.") + CLUB_CANT_JOIN(21107, HttpStatus.BAD_REQUEST, "MVP에서 동아리는 1개만 지원 가능합니다."), + + @ExplainError("요청한 멤버가 해당 동아리에 속하지 않을 때 발생합니다.") + CLUB_MEMBER_NOT_IN_CLUB(21108, HttpStatus.BAD_REQUEST, "해당 동아리에 속한 멤버가 아닙니다."), } diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/ClubMemberNotInClubException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubMemberNotInClubException.kt new file mode 100644 index 00000000..bc239b40 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubMemberNotInClubException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class ClubMemberNotInClubException : BaseException(ClubErrorCode.CLUB_MEMBER_NOT_IN_CLUB) diff --git a/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt b/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt new file mode 100644 index 00000000..a9ed2660 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt @@ -0,0 +1,97 @@ +package com.weeth.domain.club.application.mapper + +import com.weeth.domain.club.application.dto.response.ClubDetailResponse +import com.weeth.domain.club.application.dto.response.ClubInfoResponse +import com.weeth.domain.club.application.dto.response.ClubMemberProfileResponse +import com.weeth.domain.club.application.dto.response.ClubMemberResponse +import com.weeth.domain.club.application.dto.response.ClubResponse +import com.weeth.domain.club.domain.entity.Club +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.entity.ClubMemberCardinal +import com.weeth.global.common.id.TsidBase62Encoder +import org.hibernate.metamodel.model.domain.internal.MapMember +import org.springframework.stereotype.Component + +@Component +class ClubMapper { + fun toInfoResponse( + club: Club, + member: ClubMember, + ) = ClubInfoResponse( + id = TsidBase62Encoder.encode(club.id), + name = club.name, + schoolName = club.schoolName, + description = club.description, + memberRole = member.memberRole, + memberStatus = member.memberStatus, + ) + + fun toResponse(club: Club) = + ClubResponse( + id = TsidBase62Encoder.encode(club.id), + name = club.name, + schoolName = club.schoolName, + description = club.description, + profileImageUrl = club.profileImageUrl, + backgroundImageUrl = club.backgroundImageUrl, + ) + + fun toDetailResponse(club: Club) = + ClubDetailResponse( + id = TsidBase62Encoder.encode(club.id), + name = club.name, + code = club.code, + schoolName = club.schoolName, + description = club.description, + contactEmail = club.clubContact.email, + contactPhoneNumber = club.clubContact.phoneNumber, + profileImageUrl = club.profileImageUrl, + backgroundImageUrl = club.backgroundImageUrl, + ) + + fun toMemberResponse( + member: ClubMember, + cardinals: List, + ) = ClubMemberResponse( + userId = member.user.id, + clubMemberId = member.id, + name = member.user.name, + email = member.user.emailValue, + tel = member.user.telValue, + school = null, // todo: User 도메인 반영 작업시 학교 정보 추가 + department = member.user.department, + studentId = member.user.studentId, + cardinals = toCardinalNumbers(cardinals), + memberStatus = member.memberStatus, + memberRole = member.memberRole, + attendanceCount = member.attendanceStats.attendanceCount, + absenceCount = member.attendanceStats.absenceCount, + attendanceRate = member.attendanceStats.attendanceRate, + penaltyCount = member.penaltyCount, + ) + + fun toMemberProfileResponse( + member: ClubMember, + cardinals: List, + ) = ClubMemberProfileResponse( + userId = member.user.id, + clubMemberId = member.id, + name = member.user.name, + email = member.user.emailValue, + tel = member.user.telValue, + school = null, // todo: User 도메인 반영 작업시 학교 정보 추가 + department = member.user.department, + studentId = member.user.studentId, + cardinals = toCardinalNumbers(cardinals), + ) + + private fun toCardinalNumbers(cardinals: List): List { + if (cardinals.isEmpty()) { + return emptyList() + } + + return cardinals + .map { it.cardinal.cardinalNumber } + .sorted() + } +} diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt new file mode 100644 index 00000000..7dfd6f9d --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt @@ -0,0 +1,52 @@ +package com.weeth.domain.club.application.usecase.command + +import com.weeth.domain.club.application.dto.request.ClubMemberRoleUpdateRequest +import com.weeth.domain.club.domain.repository.ClubMemberRepository +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 동아리 관리자 전용 멤버 관리 UseCase + */ +@Service +class AdminClubMemberUseCase( + private val clubMemberRepository: ClubMemberRepository, + private val clubMemberPolicy: ClubMemberPolicy, +) { + @Transactional + fun accept( + clubId: Long, + userId: Long, + clubMemberId: Long, + ) { + clubMemberPolicy.requireAdmin(clubId, userId) + + val member = clubMemberPolicy.getMemberInClub(clubId, clubMemberId) + member.accept() + } + + @Transactional + fun ban( + clubId: Long, + userId: Long, + clubMemberId: Long, + ) { + clubMemberPolicy.requireAdmin(clubId, userId) + + val member = clubMemberPolicy.getMemberInClub(clubId, clubMemberId) + member.ban() + } + + @Transactional + fun updateMemberRole( + clubId: Long, + userId: Long, + request: ClubMemberRoleUpdateRequest, + ) { + clubMemberPolicy.requireAdmin(clubId, userId) + + val member = clubMemberPolicy.getMemberInClub(clubId, request.clubMemberId) + member.updateRole(request.memberRole) + } +} diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt new file mode 100644 index 00000000..6d840612 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt @@ -0,0 +1,85 @@ +package com.weeth.domain.club.application.usecase.command + +import com.weeth.domain.club.application.dto.request.ClubJoinRequest +import com.weeth.domain.club.application.exception.AlreadyJoinedException +import com.weeth.domain.club.application.exception.CannotLeaveAsLeadException +import com.weeth.domain.club.application.exception.ClubCantJoinException +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.repository.ClubMemberRepository +import com.weeth.domain.club.domain.repository.ClubRepository +import com.weeth.domain.club.domain.service.ClubCodePolicy +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.user.domain.repository.UserReader +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 동아리 가입, 탈퇴 UseCase. + */ +@Service +class ManageClubMemberUsecase( + private val clubRepository: ClubRepository, + private val clubMemberRepository: ClubMemberRepository, + private val userReader: UserReader, + private val clubMemberPolicy: ClubMemberPolicy, +) { + /** + * 초대 코드가 일치하면 자동으로 활성 상태로 가입됨 + * MVP에서는 단일 동아리 지원만 가능 + */ + @Transactional + fun join( + clubId: Long, + userId: Long, + request: ClubJoinRequest, + ) { + val club = clubRepository.getClubById(clubId) + val user = + userReader.getByIdWithLock(userId) + + clubMemberRepository.findByClubIdAndUserId(clubId, userId)?.let { + throw AlreadyJoinedException() + } + + val isJoinedAnotherClub = + clubMemberRepository + .findAllByUserId(userId) + .any { it.club.id != clubId && it.isActive() } + + if (isJoinedAnotherClub) { + throw ClubCantJoinException() + } + + ClubCodePolicy.validate(club.code, request.code) + + val member = + ClubMember + .create( + club = club, + user = user, + memberRole = MemberRole.USER, + ).apply { + accept() + } + + clubMemberRepository.save(member) + } + + /** + * LEAD 권한을 가진 멤버는 탈퇴 불가 + */ + @Transactional + fun leave( + clubId: Long, + userId: Long, + ) { + val member = clubMemberPolicy.getActiveMember(clubId, userId) + + if (member.memberRole == MemberRole.LEAD) { + throw CannotLeaveAsLeadException() + } + + member.leave() + } +} diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt new file mode 100644 index 00000000..6eff19e5 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt @@ -0,0 +1,131 @@ +package com.weeth.domain.club.application.usecase.command + +import com.weeth.domain.club.application.dto.request.ClubCreateRequest +import com.weeth.domain.club.application.dto.request.ClubUpdateRequest +import com.weeth.domain.club.application.mapper.ClubMapper +import com.weeth.domain.club.domain.entity.Club +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.repository.ClubMemberRepository +import com.weeth.domain.club.domain.repository.ClubRepository +import com.weeth.domain.club.domain.service.ClubCodePolicy +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.vo.ClubContact +import com.weeth.domain.user.domain.repository.UserReader +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 동아리 관리 유스케이스 + * 생성은 누구나 가능하지만 그 외 작업은 관리자만 가능 + */ +@Service +class ManageClubUseCase( + private val clubRepository: ClubRepository, + private val clubMemberRepository: ClubMemberRepository, + private val userReader: UserReader, + private val clubMemberPolicy: ClubMemberPolicy, + private val clubMapper: ClubMapper, +) { + /** + * 새로운 동아리를 생성 + * 생성자는 자동으로 LEAD 권한 설정 + * 동아리 생성은 관리자 권한이 필요 없음 + * todo: 기수 관련 설정 필수 처리 + */ + @Transactional + fun create( + userId: Long, + request: ClubCreateRequest, + ) { + val user = + userReader.getById(userId) + + val code = ClubCodePolicy.generateCode() + val clubContact = + ClubContact.from( + email = request.contactEmail, + phoneNumber = request.contactPhoneNumber, + ) + + val club = + Club.create( + name = request.name, + code = code, + schoolName = request.schoolName, + clubContact = clubContact, + description = request.description, + profileImageUrl = request.profileImageUrl, + backgroundImageUrl = request.backgroundImageUrl, + ) + + clubRepository.save(club) + + val leadMember = + ClubMember + .create( + club = club, + user = user, + memberRole = MemberRole.LEAD, + ).apply { + accept() + } + + clubMemberRepository.save(leadMember) + } + + @Transactional + fun update( + clubId: Long, + userId: Long, + request: ClubUpdateRequest, + ) { + clubMemberPolicy.requireAdmin(clubId, userId) + + val club = clubRepository.getClubById(clubId) + + club.update( + name = request.name, + schoolName = request.schoolName, + description = request.description, + contactEmail = request.contactEmail, + contactPhoneNumber = request.contactPhoneNumber, + profileImageUrl = request.profileImageUrl, + backgroundImageUrl = request.backgroundImageUrl, + ) + } + + @Transactional + fun regenerateCode( + clubId: Long, + userId: Long, + ) { + clubMemberPolicy.requireAdmin(clubId, userId) + + val club = clubRepository.getClubById(clubId) + val newCode = ClubCodePolicy.generateCode() + club.regenerateCode(newCode) + } + + @Transactional + fun deleteProfileImage( + clubId: Long, + userId: Long, + ) { + clubMemberPolicy.requireAdmin(clubId, userId) + + val club = clubRepository.getClubById(clubId) + club.removeProfileImage() + } + + @Transactional + fun deleteBackgroundImage( + clubId: Long, + userId: Long, + ) { + clubMemberPolicy.requireAdmin(clubId, userId) + + val club = clubRepository.getClubById(clubId) + club.removeBackgroundImage() + } +} diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryService.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryService.kt new file mode 100644 index 00000000..7695326a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryService.kt @@ -0,0 +1,49 @@ +package com.weeth.domain.club.application.usecase.query + +import com.weeth.domain.club.application.dto.response.ClubMemberProfileResponse +import com.weeth.domain.club.application.dto.response.ClubMemberResponse +import com.weeth.domain.club.application.mapper.ClubMapper +import com.weeth.domain.club.domain.repository.ClubMemberCardinalReader +import com.weeth.domain.club.domain.repository.ClubMemberReader +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class GetClubMemberQueryService( + private val clubMemberReader: ClubMemberReader, + private val clubMemberCardinalReader: ClubMemberCardinalReader, + private val clubMemberPolicy: ClubMemberPolicy, + private val clubMapper: ClubMapper, +) { + fun findClubMembersForAdmin( + clubId: Long, + userId: Long, + ): List { + clubMemberPolicy.requireAdmin(clubId, userId) + val members = clubMemberReader.findAllByClubId(clubId) + + if (members.isEmpty()) { + return emptyList() + } + + val allMemberCardinals = clubMemberCardinalReader.findAllByClubMembers(members) + val memberCardinalMap = allMemberCardinals.groupBy { it.clubMember.id } + + return members.map { member -> + val cardinals = memberCardinalMap[member.id] ?: emptyList() + clubMapper.toMemberResponse(member, cardinals) + } + } + + fun findMyMemberProfile( + clubId: Long, + userId: Long, + ): ClubMemberProfileResponse { + val member = clubMemberPolicy.getActiveMember(clubId, userId) + val cardinals = clubMemberCardinalReader.findAllByClubMember(member) + + return clubMapper.toMemberProfileResponse(member, cardinals) + } +} diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubQueryService.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubQueryService.kt new file mode 100644 index 00000000..119b6620 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubQueryService.kt @@ -0,0 +1,45 @@ +package com.weeth.domain.club.application.usecase.query + +import com.weeth.domain.club.application.dto.response.ClubDetailResponse +import com.weeth.domain.club.application.dto.response.ClubInfoResponse +import com.weeth.domain.club.application.dto.response.ClubResponse +import com.weeth.domain.club.application.mapper.ClubMapper +import com.weeth.domain.club.domain.repository.ClubMemberReader +import com.weeth.domain.club.domain.repository.ClubReader +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class GetClubQueryService( + private val clubReader: ClubReader, + private val clubMemberReader: ClubMemberReader, + private val clubMemberPolicy: ClubMemberPolicy, + private val clubMapper: ClubMapper, +) { + fun findMyClubs(userId: Long): List { + val members = clubMemberReader.findAllByUserId(userId) + + return members.map { member -> + val club = clubReader.getClubById(member.club.id) + clubMapper.toInfoResponse(club, member) + } + } + + fun findClub(clubId: Long): ClubResponse { + val club = clubReader.getClubById(clubId) + + return clubMapper.toResponse(club) + } + + fun findClubDetailForAdmin( + clubId: Long, + userId: Long, + ): ClubDetailResponse { + clubMemberPolicy.requireAdmin(clubId, userId) + val club = clubReader.getClubById(clubId) + + return clubMapper.toDetailResponse(club) + } +} diff --git a/src/main/kotlin/com/weeth/domain/club/domain/entity/Club.kt b/src/main/kotlin/com/weeth/domain/club/domain/entity/Club.kt index a69789b3..e7af4609 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/entity/Club.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/entity/Club.kt @@ -27,6 +27,8 @@ class Club( description: String? = null, schoolName: String, clubContact: ClubContact, + profileImageUrl: String? = null, + backgroundImageUrl: String? = null, ) : BaseEntity() { // TSID(Time-Sorted Unique Identifier)로 관리 // Client 반환시 Base62 인코딩해서 String으로 반환 @@ -39,7 +41,7 @@ class Club( var name: String = name.trim() private set - @Column(nullable = false, unique = true, length = 20) + @Column(nullable = false, unique = true, length = 36) var code: String = code private set @@ -55,20 +57,57 @@ class Club( var clubContact: ClubContact = clubContact private set + @Column(length = 500) + var profileImageUrl: String? = profileImageUrl // 우선 URL로 저장 후 File로 붙일지 논의 + private set + + @Column(length = 500) + var backgroundImageUrl: String? = backgroundImageUrl + private set + fun update( - name: String, + name: String?, + schoolName: String?, description: String?, + contactEmail: String?, + contactPhoneNumber: String?, + profileImageUrl: String?, + backgroundImageUrl: String?, + ) { + name?.let { + require(it.isNotBlank()) { "동아리 이름은 비어 있을 수 없습니다." } + this.name = it.trim() + } + schoolName?.let { + require(it.isNotBlank()) { "학교 이름은 비어 있을 수 없습니다." } + this.schoolName = it.trim() + } + description?.let { this.description = it } + + updateContact(contactEmail, contactPhoneNumber) + updateImageUrl(profileImageUrl, backgroundImageUrl) + } + + private fun updateContact( + contactEmail: String?, + contactPhoneNumber: String?, ) { - require(name.isNotBlank()) { "동아리 이름은 비어 있을 수 없습니다." } - this.name = name.trim() - this.description = description + if (contactEmail != null || contactPhoneNumber != null) { + clubContact.update( + email = contactEmail ?: clubContact.email, + phoneNumber = contactPhoneNumber ?: clubContact.phoneNumber, + ) + } } - fun updateContact( - email: String?, - phoneNumber: String?, + private fun updateImageUrl( + profileImageUrl: String?, + backgroundImageUrl: String?, ) { - clubContact.update(email = email, phoneNumber = phoneNumber) + if (profileImageUrl != null || backgroundImageUrl != null) { + this.profileImageUrl = profileImageUrl ?: this.profileImageUrl + this.backgroundImageUrl = backgroundImageUrl ?: this.backgroundImageUrl + } } fun regenerateCode(newCode: String) { @@ -76,6 +115,14 @@ class Club( this.code = newCode } + fun removeProfileImage() { + this.profileImageUrl = null + } + + fun removeBackgroundImage() { + this.backgroundImageUrl = null + } + @PrePersist fun assignIdIfAbsent() { if (id == 0L) { @@ -90,6 +137,8 @@ class Club( schoolName: String, clubContact: ClubContact, description: String? = null, + profileImageUrl: String? = null, + backgroundImageUrl: String? = null, ): Club { require(name.isNotBlank()) { "동아리 이름은 비어 있을 수 없습니다." } require(code.isNotBlank()) { "초대 코드는 비어 있을 수 없습니다." } @@ -100,6 +149,8 @@ class Club( description = description, schoolName = schoolName, clubContact = clubContact, + profileImageUrl = profileImageUrl, + backgroundImageUrl = backgroundImageUrl, ).apply { // 객체 생성시 TSID 할당 id = TsidGenerator.nextId() diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberCardinalReader.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberCardinalReader.kt index 93cc5122..a7acdbab 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberCardinalReader.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberCardinalReader.kt @@ -6,6 +6,8 @@ import com.weeth.domain.club.domain.entity.ClubMemberCardinal interface ClubMemberCardinalReader { fun findAllByClubMember(clubMember: ClubMember): List + fun findAllByClubMembers(clubMembers: List): List + fun findLatestCardinalByClubMember(clubMember: ClubMember): ClubMemberCardinal? fun existsByClubMemberAndCardinalId( // todo: 실제 사용처에 따라 파라미터 확정 diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberCardinalRepository.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberCardinalRepository.kt index b9f2f8b7..b030e197 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberCardinalRepository.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberCardinalRepository.kt @@ -4,12 +4,25 @@ import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.club.domain.entity.ClubMemberCardinal import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param interface ClubMemberCardinalRepository : JpaRepository, ClubMemberCardinalReader { override fun findAllByClubMember(clubMember: ClubMember): List + @Query( + """ + SELECT cmc + FROM ClubMemberCardinal cmc + JOIN FETCH cmc.cardinal + WHERE cmc.clubMember IN :clubMembers + """, + ) + override fun findAllByClubMembers( + @Param("clubMembers") clubMembers: List, + ): List + @Query( "SELECT cmc FROM ClubMemberCardinal cmc " + "JOIN cmc.cardinal c " + diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt index 4d2da44a..b855d82d 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt @@ -7,6 +7,11 @@ interface ClubMemberReader { fun findByIdOrNull(clubMemberId: Long): ClubMember? + fun findByIdAndClubId( + clubMemberId: Long, + clubId: Long, + ): ClubMember? + fun findByClubIdAndUserId( clubId: Long, userId: Long, diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt index 1e29a0eb..f13ec47c 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt @@ -3,6 +3,8 @@ package com.weeth.domain.club.domain.repository import com.weeth.domain.club.application.exception.ClubMemberNotFoundException import com.weeth.domain.club.domain.entity.ClubMember import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param interface ClubMemberRepository : JpaRepository, @@ -12,12 +14,27 @@ interface ClubMemberRepository : override fun findByIdOrNull(clubMemberId: Long): ClubMember? = findById(clubMemberId).orElse(null) + override fun findByIdAndClubId( + clubMemberId: Long, + clubId: Long, + ): ClubMember? + override fun findByClubIdAndUserId( clubId: Long, userId: Long, ): ClubMember? - override fun findAllByClubId(clubId: Long): List + @Query( + """ + SELECT cm + FROM ClubMember cm + JOIN FETCH cm.user + WHERE cm.club.id = :clubId + """, + ) + override fun findAllByClubId( + @Param("clubId") clubId: Long, + ): List override fun findAllByUserId(userId: Long): List } diff --git a/src/main/kotlin/com/weeth/domain/club/domain/service/ClubCodePolicy.kt b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubCodePolicy.kt new file mode 100644 index 00000000..bbf5fd7b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubCodePolicy.kt @@ -0,0 +1,26 @@ +package com.weeth.domain.club.domain.service + +import com.weeth.domain.club.application.exception.InvalidClubCodeException +import java.util.UUID + +/** + * 동아리 초대 코드 생성 및 검증 정책. + * 형식: UUID(36자) + */ +object ClubCodePolicy { + fun generateCode(): String = UUID.randomUUID().toString() + + /** + * 제공된 코드가 클럽의 초대 코드와 일치하는지 검증 + */ + fun validate( + clubCode: String, + providedCode: String, + ) { + if (clubCode != providedCode) { + if (!clubCode.equals(providedCode.trim(), ignoreCase = true)) { + throw InvalidClubCodeException() + } + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt new file mode 100644 index 00000000..b603282c --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt @@ -0,0 +1,54 @@ +package com.weeth.domain.club.domain.service + +import com.weeth.domain.club.application.exception.ClubMemberNotFoundException +import com.weeth.domain.club.application.exception.ClubMemberNotInClubException +import com.weeth.domain.club.application.exception.MemberNotActiveException +import com.weeth.domain.club.application.exception.NotClubAdminException +import com.weeth.domain.club.domain.repository.ClubMemberReader +import org.springframework.stereotype.Service + +/** + * 동아리 멤버 관련 비즈니스 규칙 및 권한 검증 + */ +@Service +class ClubMemberPolicy( + private val clubMemberReader: ClubMemberReader, +) { + /** + * 동아리의 활성 멤버를 조회 + */ + fun getActiveMember( + clubId: Long, + userId: Long, + ) = clubMemberReader + .findByClubIdAndUserId(clubId, userId) + ?.takeIf { it.isActive() } + ?: throw if (clubMemberReader.findByClubIdAndUserId(clubId, userId) != null) { + MemberNotActiveException() + } else { + ClubMemberNotFoundException() + } + + /** + * 사용자가 동아리 관리자인지 검증 + * 활성 상태이고 + 관리자 권한 + */ + fun requireAdmin( + clubId: Long, + userId: Long, + ) = getActiveMember(clubId, userId).also { + if (!it.isAdmin()) { + throw NotClubAdminException() + } + } + + fun getMemberInClub( + clubId: Long, + clubMemberId: Long, + ) = clubMemberReader.findByIdAndClubId(clubMemberId, clubId) + ?: throw if (clubMemberReader.findByIdOrNull(clubMemberId) != null) { + ClubMemberNotInClubException() + } else { + ClubMemberNotFoundException() + } +} diff --git a/src/main/kotlin/com/weeth/domain/club/presentation/ClubAdminController.kt b/src/main/kotlin/com/weeth/domain/club/presentation/ClubAdminController.kt new file mode 100644 index 00000000..5403724f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/presentation/ClubAdminController.kt @@ -0,0 +1,143 @@ +package com.weeth.domain.club.presentation + +import com.weeth.domain.club.application.dto.request.ClubMemberRoleUpdateRequest +import com.weeth.domain.club.application.dto.request.ClubUpdateRequest +import com.weeth.domain.club.application.dto.response.ClubDetailResponse +import com.weeth.domain.club.application.dto.response.ClubMemberResponse +import com.weeth.domain.club.application.exception.ClubErrorCode +import com.weeth.domain.club.application.usecase.command.AdminClubMemberUseCase +import com.weeth.domain.club.application.usecase.command.ManageClubUseCase +import com.weeth.domain.club.application.usecase.query.GetClubMemberQueryService +import com.weeth.domain.club.application.usecase.query.GetClubQueryService +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "CLUB-ADMIN", description = "동아리 관리자 API") +@RestController +@RequestMapping("/api/v4/admin/clubs/{clubId}") +@ApiErrorCodeExample(ClubErrorCode::class) +class ClubAdminController( + private val manageClubUseCase: ManageClubUseCase, + private val adminClubMemberUseCase: AdminClubMemberUseCase, + private val getClubQueryService: GetClubQueryService, + private val getClubMemberQueryService: GetClubMemberQueryService, +) { + @GetMapping + @Operation(summary = "동아리 상세 정보 조회") + fun getClubDetail( + @Parameter(hidden = true) @CurrentUser userId: Long, + @TsidParam + @TsidPathVariable("clubId") clubId: Long, + ): CommonResponse { + val detail = getClubQueryService.findClubDetailForAdmin(clubId, userId) + return CommonResponse.success(ClubResponseCode.CLUB_FIND_BY_ID_SUCCESS, detail) + } + + @PatchMapping + @Operation(summary = "동아리 정보 수정") + fun update( + @Parameter(hidden = true) @CurrentUser userId: Long, + @TsidParam + @TsidPathVariable("clubId") clubId: Long, + @Valid @RequestBody request: ClubUpdateRequest, + ): CommonResponse { + manageClubUseCase.update(clubId, userId, request) + return CommonResponse.success(ClubResponseCode.CLUB_UPDATED_SUCCESS) + } + + @DeleteMapping("/profile-image") + @Operation(summary = "동아리 프로필 사진 삭제") + fun deleteProfileImage( + @Parameter(hidden = true) @CurrentUser userId: Long, + @TsidParam + @TsidPathVariable("clubId") clubId: Long, + ): CommonResponse { + manageClubUseCase.deleteProfileImage(clubId, userId) + return CommonResponse.success(ClubResponseCode.CLUB_PROFILE_IMAGE_DELETED_SUCCESS) + } + + @DeleteMapping("/background-image") + @Operation(summary = "동아리 배경 사진 삭제") + fun deleteBackgroundImage( + @Parameter(hidden = true) @CurrentUser userId: Long, + @TsidParam + @TsidPathVariable("clubId") clubId: Long, + ): CommonResponse { + manageClubUseCase.deleteBackgroundImage(clubId, userId) + return CommonResponse.success(ClubResponseCode.CLUB_BACKGROUND_IMAGE_DELETED_SUCCESS) + } + + @PostMapping("/code/regenerate") + @Operation(summary = "초대 코드 재생성 (MVP 미사용)", deprecated = true) + fun regenerateCode( + @Parameter(hidden = true) @CurrentUser userId: Long, + @TsidParam + @TsidPathVariable("clubId") clubId: Long, + ): CommonResponse { + manageClubUseCase.regenerateCode(clubId, userId) + return CommonResponse.success(ClubResponseCode.CLUB_CODE_REGENERATED_SUCCESS) + } + + @GetMapping("/members") + @Operation(summary = "동아리 멤버 목록 조회") + fun getClubMembers( + @Parameter(hidden = true) @CurrentUser userId: Long, + @TsidParam + @TsidPathVariable("clubId") clubId: Long, + ): CommonResponse> { + val members = getClubMemberQueryService.findClubMembersForAdmin(clubId, userId) + return CommonResponse.success(ClubResponseCode.MEMBER_FIND_ALL_SUCCESS, members) + } + + @PatchMapping("/members/{clubMemberId}/accept") + @Operation(summary = "멤버 승인") + fun acceptMember( + @Parameter(hidden = true) @CurrentUser userId: Long, + @TsidParam + @TsidPathVariable("clubId") clubId: Long, + @PathVariable clubMemberId: Long, + ): CommonResponse { + adminClubMemberUseCase.accept(clubId, userId, clubMemberId) + return CommonResponse.success(ClubResponseCode.MEMBER_ACCEPTED_SUCCESS) + } + + @DeleteMapping("/members/{clubMemberId}/ban") + @Operation(summary = "멤버 추방") + fun banMember( + @Parameter(hidden = true) @CurrentUser userId: Long, + @TsidParam + @TsidPathVariable("clubId") clubId: Long, + @PathVariable clubMemberId: Long, + ): CommonResponse { + adminClubMemberUseCase.ban(clubId, userId, clubMemberId) + return CommonResponse.success(ClubResponseCode.MEMBER_BANNED_SUCCESS) + } + + @PatchMapping("/members/{clubMemberId}/role") + @Operation(summary = "멤버 권한 변경") + fun updateMemberRole( + @Parameter(hidden = true) @CurrentUser userId: Long, + @TsidParam + @TsidPathVariable("clubId") clubId: Long, + @PathVariable clubMemberId: Long, + @Valid @RequestBody request: ClubMemberRoleUpdateRequest, + ): CommonResponse { + adminClubMemberUseCase.updateMemberRole(clubId, userId, request) + return CommonResponse.success(ClubResponseCode.MEMBER_ROLE_UPDATED_SUCCESS) + } +} diff --git a/src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt b/src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt new file mode 100644 index 00000000..6fcb209f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt @@ -0,0 +1,111 @@ +package com.weeth.domain.club.presentation + +import com.weeth.domain.club.application.dto.request.ClubCreateRequest +import com.weeth.domain.club.application.dto.request.ClubJoinRequest +import com.weeth.domain.club.application.dto.response.ClubInfoResponse +import com.weeth.domain.club.application.dto.response.ClubMemberProfileResponse +import com.weeth.domain.club.application.dto.response.ClubResponse +import com.weeth.domain.club.application.exception.ClubErrorCode +import com.weeth.domain.club.application.usecase.command.ManageClubMemberUsecase +import com.weeth.domain.club.application.usecase.command.ManageClubUseCase +import com.weeth.domain.club.application.usecase.query.GetClubMemberQueryService +import com.weeth.domain.club.application.usecase.query.GetClubQueryService +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "CLUB", description = "동아리 API") +@RestController +@RequestMapping("/api/v4/clubs") +@ApiErrorCodeExample(ClubErrorCode::class) +class ClubController( + private val manageClubUseCase: ManageClubUseCase, + private val manageClubMemberUsecase: ManageClubMemberUsecase, + private val getClubQueryService: GetClubQueryService, + private val getClubMemberQueryService: GetClubMemberQueryService, +) { + @PostMapping + @Operation(summary = "동아리 생성") + @ResponseStatus(HttpStatus.CREATED) + fun create( + @Parameter(hidden = true) @CurrentUser userId: Long, + @Valid @RequestBody request: ClubCreateRequest, + ): CommonResponse { + manageClubUseCase.create(userId, request) + + return CommonResponse.success(ClubResponseCode.CLUB_CREATED_SUCCESS) + } + + @GetMapping + @Operation(summary = "내가 가입한 동아리 목록 조회 (MVP 미사용)", deprecated = true) + fun getMyClubs( + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse> { + val clubs = getClubQueryService.findMyClubs(userId) + + return CommonResponse.success(ClubResponseCode.CLUB_FIND_ALL_SUCCESS, clubs) + } + + @GetMapping("/{clubId}") + @Operation(summary = "동아리 정보 조회 (이름, 소개, 이미지)") + fun getClubPublicInfo( + @Parameter(hidden = true) @CurrentUser userId: Long, + @TsidParam + @TsidPathVariable("clubId") clubId: Long, + ): CommonResponse { + val info = getClubQueryService.findClub(clubId) + + return CommonResponse.success(ClubResponseCode.CLUB_FIND_SUCCESS, info) + } + + @PostMapping("/{clubId}/join") + @Operation(summary = "동아리 가입") + fun join( + @Parameter(hidden = true) @CurrentUser userId: Long, + @TsidParam + @TsidPathVariable("clubId") clubId: Long, + @Valid @RequestBody request: ClubJoinRequest, + ): CommonResponse { + manageClubMemberUsecase.join(clubId, userId, request) + + return CommonResponse.success(ClubResponseCode.CLUB_JOINED_SUCCESS) + } + + @DeleteMapping("/{clubId}/leave") + @Operation(summary = "동아리 탈퇴") + fun leave( + @Parameter(hidden = true) @CurrentUser userId: Long, + @TsidParam + @TsidPathVariable("clubId") clubId: Long, + ): CommonResponse { + manageClubMemberUsecase.leave(clubId, userId) + + return CommonResponse.success(ClubResponseCode.CLUB_LEFT_SUCCESS) + } + + @GetMapping("/{clubId}/members/me") + @Operation(summary = "내 멤버 정보 조회") + fun getMyMemberInfo( + @Parameter(hidden = true) @CurrentUser userId: Long, + @TsidParam + @TsidPathVariable("clubId") clubId: Long, + ): CommonResponse { + val meInfo = getClubMemberQueryService.findMyMemberProfile(clubId, userId) + + return CommonResponse.success(ClubResponseCode.MEMBER_FIND_ME_SUCCESS, meInfo) + } +} diff --git a/src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt b/src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt new file mode 100644 index 00000000..0378fc48 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt @@ -0,0 +1,26 @@ +package com.weeth.domain.club.presentation + +import com.weeth.global.common.response.ResponseCodeInterface +import org.springframework.http.HttpStatus + +enum class ClubResponseCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ResponseCodeInterface { + CLUB_CREATED_SUCCESS(11100, HttpStatus.CREATED, "동아리가 성공적으로 생성되었습니다."), + CLUB_FIND_ALL_SUCCESS(11101, HttpStatus.OK, "동아리 목록을 성공적으로 조회했습니다."), + CLUB_FIND_BY_ID_SUCCESS(11102, HttpStatus.OK, "동아리 정보를 성공적으로 조회했습니다."), + CLUB_UPDATED_SUCCESS(11103, HttpStatus.OK, "동아리 정보가 성공적으로 수정되었습니다."), + CLUB_CODE_REGENERATED_SUCCESS(11104, HttpStatus.OK, "초대 코드가 재생성되었습니다."), + CLUB_JOINED_SUCCESS(11105, HttpStatus.OK, "동아리에 가입했습니다."), + CLUB_LEFT_SUCCESS(11106, HttpStatus.OK, "동아리를 탈퇴했습니다."), + MEMBER_FIND_ALL_SUCCESS(11107, HttpStatus.OK, "동아리 멤버 목록을 성공적으로 조회했습니다."), + MEMBER_FIND_ME_SUCCESS(11108, HttpStatus.OK, "내 멤버 정보를 성공적으로 조회했습니다."), + MEMBER_ACCEPTED_SUCCESS(11109, HttpStatus.OK, "멤버가 승인되었습니다."), + MEMBER_BANNED_SUCCESS(11110, HttpStatus.OK, "멤버가 추방되었습니다."), + MEMBER_ROLE_UPDATED_SUCCESS(11111, HttpStatus.OK, "멤버 권한이 변경되었습니다."), + CLUB_FIND_SUCCESS(11112, HttpStatus.OK, "동아리 공개 정보를 성공적으로 조회했습니다."), + CLUB_PROFILE_IMAGE_DELETED_SUCCESS(11113, HttpStatus.OK, "동아리 프로필 사진이 삭제되었습니다."), + CLUB_BACKGROUND_IMAGE_DELETED_SUCCESS(11114, HttpStatus.OK, "동아리 배경 사진이 삭제되었습니다."), +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserReader.kt b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserReader.kt index ff2a24a9..8c1fb2e4 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserReader.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserReader.kt @@ -7,6 +7,8 @@ import com.weeth.domain.user.domain.enums.Status interface UserReader { fun getById(userId: Long): User + fun getByIdWithLock(userId: Long): User + fun getByEmail(email: String): User fun findByIdOrNull(userId: Long): User? diff --git a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserRepository.kt b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserRepository.kt index 462a9bfd..355f739e 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserRepository.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserRepository.kt @@ -104,6 +104,8 @@ interface UserRepository : override fun getById(userId: Long): User = findById(userId).orElseThrow { UserNotFoundException() } + override fun getByIdWithLock(userId: Long): User = findByIdWithLock(userId).orElseThrow { UserNotFoundException() } + override fun getByEmail(email: String): User = findByEmailValue(email).orElseThrow { UserNotFoundException() } override fun findByIdOrNull(userId: Long): User? = findById(userId).orElse(null) diff --git a/src/main/kotlin/com/weeth/global/common/id/TsidBase62Encoder.kt b/src/main/kotlin/com/weeth/global/common/id/TsidBase62Encoder.kt new file mode 100644 index 00000000..c9f00ae8 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/id/TsidBase62Encoder.kt @@ -0,0 +1,46 @@ +package com.weeth.global.common.id + +/** + * TSID를 Base62로 인코딩/디코딩하는 유틸리티 + * Base62 알파벳: 0-9a-zA-Z (총 62자) + */ +object TsidBase62Encoder { + private const val BASE62_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + private const val BASE = 62 + + /** + * Long을 Base62 String으로 인코딩 + */ + fun encode(id: Long): String { + if (id == 0L) return "0" + + val result = StringBuilder() + var num = id + + while (num > 0) { + result.append(BASE62_ALPHABET[(num % BASE).toInt()]) + num /= BASE + } + + return result.reverse().toString() + } + + /** + * Base62 String을 Long으로 디코딩 + */ + fun decode(encoded: String): Long { + if (encoded.isEmpty()) throw IllegalArgumentException("Base62 인코딩된 문자열은 비어 있을 수 없습니다.") + + var result = 0L + + for (char in encoded) { + val digit = BASE62_ALPHABET.indexOf(char) + if (digit == -1) { + throw IllegalArgumentException("유효하지 않은 Base62 문자: $char") + } + result = result * BASE + digit + } + + return result + } +} diff --git a/src/main/kotlin/com/weeth/global/common/web/TsidParam.kt b/src/main/kotlin/com/weeth/global/common/web/TsidParam.kt new file mode 100644 index 00000000..612f9f4f --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/web/TsidParam.kt @@ -0,0 +1,16 @@ +package com.weeth.global.common.web + +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.media.Schema + +@Target(AnnotationTarget.VALUE_PARAMETER) +@Retention(AnnotationRetention.RUNTIME) +@Parameter( + description = "Base62 인코딩 TSID", + example = "1zA9", + required = true, + `in` = ParameterIn.PATH, + schema = Schema(type = "string"), +) +annotation class TsidParam diff --git a/src/main/kotlin/com/weeth/global/common/web/TsidPathVariable.kt b/src/main/kotlin/com/weeth/global/common/web/TsidPathVariable.kt new file mode 100644 index 00000000..d8ab79d9 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/web/TsidPathVariable.kt @@ -0,0 +1,7 @@ +package com.weeth.global.common.web + +@Target(AnnotationTarget.VALUE_PARAMETER) +@Retention(AnnotationRetention.RUNTIME) +annotation class TsidPathVariable( + val value: String = "", +) diff --git a/src/main/kotlin/com/weeth/global/common/web/TsidPathVariableArgumentResolver.kt b/src/main/kotlin/com/weeth/global/common/web/TsidPathVariableArgumentResolver.kt new file mode 100644 index 00000000..33109468 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/web/TsidPathVariableArgumentResolver.kt @@ -0,0 +1,73 @@ +package com.weeth.global.common.web + +import com.weeth.global.common.id.TsidBase62Encoder +import org.springframework.core.MethodParameter +import org.springframework.web.bind.MissingPathVariableException +import org.springframework.web.bind.ServletRequestBindingException +import org.springframework.web.bind.support.WebDataBinderFactory +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.web.context.request.RequestAttributes +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.method.support.ModelAndViewContainer +import org.springframework.web.servlet.HandlerMapping + +/** + * `@TsidPathVariable`가 선언된 path variable을 Base62 TSID 문자열에서 Long 값으로 변환한다. + * + * 제약: + * - 파라미터 타입은 `Long` 또는 `long`만 지원한다. + * - 어노테이션 `value`가 비어 있으면 파라미터 이름을 path variable 이름으로 사용한다. + * - 디코딩 실패 시 `InvalidTsidPathVariableException`을 던진다. + */ +class TsidPathVariableArgumentResolver : HandlerMethodArgumentResolver { + override fun supportsParameter(parameter: MethodParameter): Boolean { + val hasAnnotation = parameter.hasParameterAnnotation(TsidPathVariable::class.java) + val parameterType = parameter.parameterType + val isLongType = parameterType == Long::class.java || parameterType == Long::class.javaPrimitiveType + return hasAnnotation && isLongType + } + + override fun resolveArgument( + parameter: MethodParameter, + mavContainer: ModelAndViewContainer?, + webRequest: NativeWebRequest, + binderFactory: WebDataBinderFactory?, + ): Any { + val annotation = + parameter.getParameterAnnotation(TsidPathVariable::class.java) + ?: throw IllegalStateException("@TsidPathVariable 어노테이션이 필요합니다.") + + val variableName = + annotation.value.ifBlank { + parameter.parameterName ?: throw IllegalStateException("PathVariable 이름을 해석할 수 없습니다.") + } + + val uriVariables = + webRequest.getAttribute( + HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE, + RequestAttributes.SCOPE_REQUEST, + ) as? Map<*, *> ?: emptyMap() + + val rawValue = + uriVariables[variableName] as? String ?: throw MissingPathVariableException(variableName, parameter) + + return try { + TsidBase62Encoder.decode(rawValue) + } catch (e: IllegalArgumentException) { + throw InvalidTsidPathVariableException( + variableName = variableName, + value = rawValue, + cause = e, + ) + } + } +} + +class InvalidTsidPathVariableException( + val variableName: String, + val value: String, + cause: Throwable? = null, +) : ServletRequestBindingException( + "유효하지 않은 TSID 경로 변수 '$variableName': $value", + cause, + ) diff --git a/src/main/kotlin/com/weeth/global/config/WebMvcConfig.kt b/src/main/kotlin/com/weeth/global/config/WebMvcConfig.kt index 43b02d4a..41baf18b 100644 --- a/src/main/kotlin/com/weeth/global/config/WebMvcConfig.kt +++ b/src/main/kotlin/com/weeth/global/config/WebMvcConfig.kt @@ -2,6 +2,7 @@ package com.weeth.global.config import com.weeth.global.auth.resolver.CurrentUserArgumentResolver import com.weeth.global.auth.resolver.CurrentUserRoleArgumentResolver +import com.weeth.global.common.web.TsidPathVariableArgumentResolver import org.springframework.context.annotation.Configuration import org.springframework.web.method.support.HandlerMethodArgumentResolver import org.springframework.web.servlet.config.annotation.WebMvcConfigurer @@ -11,5 +12,6 @@ class WebMvcConfig : WebMvcConfigurer { override fun addArgumentResolvers(resolvers: MutableList) { resolvers.add(CurrentUserArgumentResolver()) resolvers.add(CurrentUserRoleArgumentResolver()) + resolvers.add(TsidPathVariableArgumentResolver()) } } diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCaseTest.kt new file mode 100644 index 00000000..b581b7d2 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCaseTest.kt @@ -0,0 +1,73 @@ +package com.weeth.domain.club.application.usecase.command + +import com.weeth.domain.club.application.dto.request.ClubMemberRoleUpdateRequest +import com.weeth.domain.club.application.exception.ClubMemberNotInClubException +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.repository.ClubMemberRepository +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.fixture.ClubMemberTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify + +class AdminClubMemberUseCaseTest : + DescribeSpec({ + val clubMemberRepository = mockk() + val clubMemberPolicy = mockk() + val useCase = AdminClubMemberUseCase(clubMemberRepository, clubMemberPolicy) + val adminMember = ClubMemberTestFixture.createAdminMember() + + describe("accept") { + it("같은 동아리 소속 멤버를 승인한다") { + val member = ClubMemberTestFixture.createWaitingMember() + every { clubMemberPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubMemberPolicy.getMemberInClub(1L, 20L) } returns member + + useCase.accept(1L, 10L, 20L) + + member.memberStatus shouldBe MemberStatus.ACTIVE + verify(exactly = 0) { clubMemberRepository.getClubMemberById(any()) } + } + + it("다른 동아리 소속 멤버면 예외가 발생한다") { + every { clubMemberPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubMemberPolicy.getMemberInClub(1L, 20L) } throws ClubMemberNotInClubException() + + shouldThrow { + useCase.accept(1L, 10L, 20L) + } + } + } + + describe("ban") { + it("같은 동아리 소속 멤버를 추방한다") { + val member = ClubMemberTestFixture.createActiveMember() + every { clubMemberPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubMemberPolicy.getMemberInClub(1L, 20L) } returns member + + useCase.ban(1L, 10L, 20L) + + member.memberStatus shouldBe MemberStatus.BANNED + } + } + + describe("updateMemberRole") { + it("같은 동아리 소속 멤버의 권한을 변경한다") { + val member = ClubMemberTestFixture.createActiveMember(memberRole = MemberRole.USER) + every { clubMemberPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubMemberPolicy.getMemberInClub(1L, 20L) } returns member + + useCase.updateMemberRole( + 1L, + 10L, + ClubMemberRoleUpdateRequest(clubMemberId = 20L, memberRole = MemberRole.ADMIN), + ) + + member.memberRole shouldBe MemberRole.ADMIN + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt new file mode 100644 index 00000000..a12f668d --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt @@ -0,0 +1,67 @@ +package com.weeth.domain.club.application.usecase.command + +import com.weeth.domain.club.application.dto.request.ClubJoinRequest +import com.weeth.domain.club.application.exception.ClubCantJoinException +import com.weeth.domain.club.domain.repository.ClubMemberRepository +import com.weeth.domain.club.domain.repository.ClubRepository +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.fixture.ClubTestFixture +import com.weeth.domain.user.domain.repository.UserReader +import com.weeth.domain.user.fixture.UserTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify + +class ManageClubMemberUseCaseTest : + DescribeSpec({ + val clubRepository = mockk() + val clubMemberRepository = mockk() + val userReader = mockk() + val clubMemberPolicy = mockk() + + val useCase = + ManageClubMemberUsecase( + clubRepository = clubRepository, + clubMemberRepository = clubMemberRepository, + userReader = userReader, + clubMemberPolicy = clubMemberPolicy, + ) + + beforeTest { + clearMocks(clubRepository, clubMemberRepository, userReader, clubMemberPolicy) + every { clubMemberRepository.save(any()) } answers { firstArg() } + } + + describe("join") { + context("이미 다른 동아리에서 ACTIVE 상태로 활동 중인 경우") { + it("MVP 단일 동아리 정책에 따라 가입할 수 없다") { + val targetClub = ClubTestFixture.createClub(code = "JOIN-CODE") + val anotherClub = ClubTestFixture.createClub() + val user = UserTestFixture.createActiveUser1() + val anotherClubMember = + ClubTestFixture.createClubMember( + club = anotherClub, + user = user, + ) + + every { clubRepository.getClubById(1L) } returns targetClub + every { userReader.getByIdWithLock(10L) } returns user + every { clubMemberRepository.findByClubIdAndUserId(1L, 10L) } returns null + every { clubMemberRepository.findAllByUserId(10L) } returns listOf(anotherClubMember) + + shouldThrow { + useCase.join( + clubId = 1L, + userId = 10L, + request = ClubJoinRequest(code = "JOIN-CODE"), + ) + } + + verify(exactly = 0) { clubMemberRepository.save(any()) } + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt new file mode 100644 index 00000000..0fe42dcd --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt @@ -0,0 +1,138 @@ +package com.weeth.domain.club.application.usecase.command + +import com.weeth.domain.club.application.dto.request.ClubUpdateRequest +import com.weeth.domain.club.application.mapper.ClubMapper +import com.weeth.domain.club.domain.repository.ClubMemberRepository +import com.weeth.domain.club.domain.repository.ClubRepository +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.vo.ClubContact +import com.weeth.domain.club.fixture.ClubTestFixture +import com.weeth.domain.user.domain.repository.UserReader +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk + +class ManageClubUseCaseTest : + DescribeSpec({ + val clubRepository = mockk() + val clubMemberRepository = mockk() + val userReader = mockk() + val clubMemberPolicy = mockk() + val clubMapper = mockk() + val useCase = ManageClubUseCase(clubRepository, clubMemberRepository, userReader, clubMemberPolicy, clubMapper) + val adminMember = + com.weeth.domain.club.fixture.ClubMemberTestFixture + .createAdminMember() + + describe("update") { + it("null 필드는 유지하고 전달된 필드만 수정한다") { + val club = + ClubTestFixture.createClub( + name = "기존 동아리", + schoolName = "가천대학교", + description = "기존 소개", + clubContact = ClubContact.from(email = "club@example.com", phoneNumber = "010-1111-2222"), + ) + club.update( + null, + null, + null, + null, + null, + "https://example.com/profile.png", + "https://example.com/background.png", + ) + + every { clubMemberPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubRepository.getClubById(1L) } returns club + + useCase.update( + 1L, + 10L, + ClubUpdateRequest( + schoolName = "연세대학교", + contactPhoneNumber = "010-9999-8888", + ), + ) + + club.name shouldBe "기존 동아리" + club.schoolName shouldBe "연세대학교" + club.description shouldBe "기존 소개" + club.clubContact.email shouldBe "club@example.com" + club.clubContact.phoneNumber shouldBe "010-9999-8888" + club.profileImageUrl shouldBe "https://example.com/profile.png" + club.backgroundImageUrl shouldBe "https://example.com/background.png" + } + + it("모든 필드가 null이면 기존 값이 유지된다") { + val club = + ClubTestFixture.createClub( + description = "기존 소개", + clubContact = ClubContact.from(email = "club@example.com", phoneNumber = null), + ) + every { clubMemberPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubRepository.getClubById(1L) } returns club + + useCase.update(1L, 10L, ClubUpdateRequest()) + + club.name shouldBe "테스트 동아리" + club.schoolName shouldBe "가천대학교" + club.description shouldBe "기존 소개" + club.clubContact.email shouldBe "club@example.com" + club.clubContact.phoneNumber shouldBe null + } + } + + describe("deleteProfileImage") { + it("프로필 사진만 삭제하고 배경 사진은 유지한다") { + val club = + ClubTestFixture.createClub( + clubContact = ClubContact.from(email = "club@example.com", phoneNumber = "010-1111-2222"), + ) + club.update( + null, + null, + null, + null, + null, + "https://example.com/profile.png", + "https://example.com/background.png", + ) + + every { clubMemberPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubRepository.getClubById(1L) } returns club + + useCase.deleteProfileImage(1L, 10L) + + club.profileImageUrl shouldBe null + club.backgroundImageUrl shouldBe "https://example.com/background.png" + } + } + + describe("deleteBackgroundImage") { + it("배경 사진만 삭제하고 프로필 사진은 유지한다") { + val club = + ClubTestFixture.createClub( + clubContact = ClubContact.from(email = "club@example.com", phoneNumber = "010-1111-2222"), + ) + club.update( + null, + null, + null, + null, + null, + "https://example.com/profile.png", + "https://example.com/background.png", + ) + + every { clubMemberPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubRepository.getClubById(1L) } returns club + + useCase.deleteBackgroundImage(1L, 10L) + + club.profileImageUrl shouldBe "https://example.com/profile.png" + club.backgroundImageUrl shouldBe null + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryServiceTest.kt new file mode 100644 index 00000000..304a53e6 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryServiceTest.kt @@ -0,0 +1,74 @@ +package com.weeth.domain.club.application.usecase.query + +import com.weeth.domain.cardinal.domain.entity.Cardinal +import com.weeth.domain.club.application.mapper.ClubMapper +import com.weeth.domain.club.domain.entity.ClubMemberCardinal +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.repository.ClubMemberCardinalReader +import com.weeth.domain.club.domain.repository.ClubMemberReader +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.fixture.ClubTestFixture +import com.weeth.domain.user.fixture.UserTestFixture +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify + +class GetClubMemberQueryServiceTest : + DescribeSpec({ + val clubMemberReader = mockk() + val clubMemberCardinalReader = mockk() + val clubMemberPolicy = mockk() + val clubMapper = ClubMapper() + + val service = + GetClubMemberQueryService( + clubMemberReader = clubMemberReader, + clubMemberCardinalReader = clubMemberCardinalReader, + clubMemberPolicy = clubMemberPolicy, + clubMapper = clubMapper, + ) + + describe("findClubMembersForAdmin") { + context("관리자가 멤버 목록을 조회하는 경우") { + it("각 멤버의 소속 기수 정보를 함께 반환한다") { + val club = ClubTestFixture.createClub() + val admin = ClubTestFixture.createClubMember(club = club, memberRole = MemberRole.ADMIN) + val member = + ClubTestFixture.createClubMember(club = club, user = UserTestFixture.createActiveUser1(id = 3L)) + val cardinal7 = Cardinal.create(cardinalNumber = 7) + val cardinal6 = Cardinal.create(cardinalNumber = 6) + val memberCardinals = + listOf( + ClubMemberCardinal.create(member, cardinal7), + ClubMemberCardinal.create(member, cardinal6), + ) + + every { clubMemberPolicy.requireAdmin(1L, 99L) } returns admin + every { clubMemberReader.findAllByClubId(1L) } returns listOf(member) + every { clubMemberCardinalReader.findAllByClubMembers(listOf(member)) } returns memberCardinals + + val result = service.findClubMembersForAdmin(clubId = 1L, userId = 99L) + + result shouldHaveSize 1 + val response = result.first() + response.name shouldBe member.user.name + response.email shouldBe member.user.emailValue + response.studentId shouldBe member.user.studentId + response.tel shouldBe member.user.telValue + response.department shouldBe member.user.department + response.memberStatus shouldBe member.memberStatus + response.memberRole shouldBe member.memberRole + response.attendanceCount shouldBe member.attendanceStats.attendanceCount + response.absenceCount shouldBe member.attendanceStats.absenceCount + response.attendanceRate shouldBe member.attendanceStats.attendanceRate + response.penaltyCount shouldBe member.penaltyCount + response.cardinals shouldBe listOf(6, 7) + verify(exactly = 1) { clubMemberPolicy.requireAdmin(1L, 99L) } + verify(exactly = 1) { clubMemberCardinalReader.findAllByClubMembers(listOf(member)) } + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/club/domain/entity/ClubTest.kt b/src/test/kotlin/com/weeth/domain/club/domain/entity/ClubTest.kt index 20c12cda..32b48066 100644 --- a/src/test/kotlin/com/weeth/domain/club/domain/entity/ClubTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/domain/entity/ClubTest.kt @@ -37,7 +37,15 @@ class ClubTest : "update — 이름과 소개를 수정한다" { val club = Club.create(name = "리츠", code = "LEETS001", schoolName = "가천대학교", clubContact = defaultContact) - club.update(name = "리츠2기", description = "업데이트된 소개") + club.update( + name = "리츠2기", + schoolName = null, + description = "업데이트된 소개", + contactEmail = null, + contactPhoneNumber = null, + profileImageUrl = null, + backgroundImageUrl = null, + ) club.name shouldBe "리츠2기" club.description shouldBe "업데이트된 소개" @@ -47,7 +55,7 @@ class ClubTest : val club = Club.create(name = "리츠", code = "LEETS001", schoolName = "가천대학교", clubContact = defaultContact) shouldThrow { - club.update(name = "", description = null) + club.update("", null, null, null, null, null, null) } } @@ -55,7 +63,7 @@ class ClubTest : val club = Club.create(name = "리츠", code = "LEETS001", schoolName = "가천대학교", clubContact = defaultContact) shouldThrow { - club.update(name = " ", description = null) + club.update(" ", null, null, null, null, null, null) } } diff --git a/src/test/kotlin/com/weeth/domain/club/domain/service/ClubCodePolicyTest.kt b/src/test/kotlin/com/weeth/domain/club/domain/service/ClubCodePolicyTest.kt new file mode 100644 index 00000000..2d0554ee --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/club/domain/service/ClubCodePolicyTest.kt @@ -0,0 +1,62 @@ +package com.weeth.domain.club.domain.service + +import com.weeth.domain.club.application.exception.InvalidClubCodeException +import io.kotest.assertions.throwables.shouldNotThrow +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.string.shouldHaveLength +import java.util.UUID + +class ClubCodePolicyTest : + StringSpec({ + "초대 코드는 UUID로 생성되어야 한다" { + val code = ClubCodePolicy.generateCode() + code shouldHaveLength 36 + + shouldNotThrow { + UUID.fromString(code) + } + } + + "매번 생성되는 코드는 달라야 한다" { + val code1 = ClubCodePolicy.generateCode() + val code2 = ClubCodePolicy.generateCode() + code1 shouldNotBe code2 + } + + "초대 코드가 일치하면 검증 성공" { + val code = ClubCodePolicy.generateCode() + + shouldNotThrow { + ClubCodePolicy.validate(code, code) + } + } + + "초대 코드가 일치하지 않으면 예외 발생" { + val clubCode = ClubCodePolicy.generateCode() + val providedCode = ClubCodePolicy.generateCode() + + shouldThrow { + ClubCodePolicy.validate(clubCode, providedCode) + } + } + + "초대 코드는 대소문자가 달라도 검증 성공" { + val clubCode = "ABCDEF12-3456-7890-ABCD-EF1234567890" + val providedCode = "abcdef12-3456-7890-abcd-ef1234567890" + + shouldNotThrow { + ClubCodePolicy.validate(clubCode, providedCode) + } + } + + "초대 코드는 앞뒤 공백을 제거한 뒤 검증 성공" { + val code = ClubCodePolicy.generateCode().uppercase() + val providedCode = " $code " + + shouldNotThrow { + ClubCodePolicy.validate(code, providedCode) + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicyTest.kt b/src/test/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicyTest.kt new file mode 100644 index 00000000..983f5883 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicyTest.kt @@ -0,0 +1,144 @@ +package com.weeth.domain.club.domain.service + +import com.weeth.domain.club.application.exception.ClubMemberNotFoundException +import com.weeth.domain.club.application.exception.ClubMemberNotInClubException +import com.weeth.domain.club.application.exception.MemberNotActiveException +import com.weeth.domain.club.application.exception.NotClubAdminException +import com.weeth.domain.club.domain.repository.ClubMemberReader +import com.weeth.domain.club.fixture.ClubTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk + +class ClubMemberPolicyTest : + DescribeSpec({ + val clubMemberReader = mockk() + val policy = ClubMemberPolicy(clubMemberReader) + + beforeTest { + clearMocks(clubMemberReader) + } + + describe("getActiveMember") { + context("활성 멤버가 존재하는 경우") { + it("활성 멤버를 반환해야 한다") { + val activeMember = + ClubTestFixture.createClubMember( + memberStatus = com.weeth.domain.club.domain.enums.MemberStatus.ACTIVE, + ) + every { clubMemberReader.findByClubIdAndUserId(1L, 1L) } returns activeMember + + val result = policy.getActiveMember(1L, 1L) + assert(result.id == activeMember.id) + } + } + + context("멤버가 존재하지 않는 경우") { + it("ClubMemberNotFoundException을 발생시켜야 한다") { + every { clubMemberReader.findByClubIdAndUserId(1L, 1L) } returns null + + shouldThrow { + policy.getActiveMember(1L, 1L) + } + } + } + + context("멤버는 존재하지만 비활성 상태인 경우") { + it("MemberNotActiveException을 발생시켜야 한다") { + val inactiveMember = + ClubTestFixture.createClubMember( + memberStatus = com.weeth.domain.club.domain.enums.MemberStatus.WAITING, + ) + every { clubMemberReader.findByClubIdAndUserId(1L, 1L) } returns inactiveMember + + shouldThrow { + policy.getActiveMember(1L, 1L) + } + } + } + } + + describe("requireAdmin") { + context("활성 상태의 관리자인 경우") { + it("멤버를 반환해야 한다") { + val adminMember = + ClubTestFixture.createClubMember( + memberStatus = com.weeth.domain.club.domain.enums.MemberStatus.ACTIVE, + memberRole = com.weeth.domain.club.domain.enums.MemberRole.ADMIN, + ) + every { clubMemberReader.findByClubIdAndUserId(1L, 1L) } returns adminMember + + val result = policy.requireAdmin(1L, 1L) + assert(result.id == adminMember.id) + } + } + + context("활성 상태이지만 관리자가 아닌 경우") { + it("NotClubAdminException을 발생시켜야 한다") { + val userMember = + ClubTestFixture.createClubMember( + memberStatus = com.weeth.domain.club.domain.enums.MemberStatus.ACTIVE, + memberRole = com.weeth.domain.club.domain.enums.MemberRole.USER, + ) + every { clubMemberReader.findByClubIdAndUserId(1L, 1L) } returns userMember + + shouldThrow { + policy.requireAdmin(1L, 1L) + } + } + } + + context("비활성 상태인 경우") { + it("MemberNotActiveException을 발생시켜야 한다") { + val inactiveMember = + ClubTestFixture.createClubMember( + memberStatus = com.weeth.domain.club.domain.enums.MemberStatus.WAITING, + memberRole = com.weeth.domain.club.domain.enums.MemberRole.ADMIN, + ) + every { clubMemberReader.findByClubIdAndUserId(1L, 1L) } returns inactiveMember + + shouldThrow { + policy.requireAdmin(1L, 1L) + } + } + } + } + + describe("getMemberInClub") { + context("해당 동아리에 속한 멤버인 경우") { + it("멤버를 반환해야 한다") { + val member = ClubTestFixture.createClubMember() + every { clubMemberReader.findByIdAndClubId(1L, 1L) } returns member + + val result = policy.getMemberInClub(1L, 1L) + + assert(result == member) + } + } + + context("멤버는 존재하지만 다른 동아리에 속한 경우") { + it("ClubMemberNotInClubException을 발생시켜야 한다") { + val member = ClubTestFixture.createClubMember() + every { clubMemberReader.findByIdAndClubId(2L, 1L) } returns null + every { clubMemberReader.findByIdOrNull(2L) } returns member + + shouldThrow { + policy.getMemberInClub(1L, 2L) + } + } + } + + context("멤버 자체가 존재하지 않는 경우") { + it("ClubMemberNotFoundException을 발생시켜야 한다") { + every { clubMemberReader.findByIdAndClubId(2L, 1L) } returns null + every { clubMemberReader.findByIdOrNull(2L) } returns null + + shouldThrow { + policy.getMemberInClub(1L, 2L) + } + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/club/fixture/ClubTestFixture.kt b/src/test/kotlin/com/weeth/domain/club/fixture/ClubTestFixture.kt index 725072ec..8df0e10b 100644 --- a/src/test/kotlin/com/weeth/domain/club/fixture/ClubTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/club/fixture/ClubTestFixture.kt @@ -1,7 +1,12 @@ package com.weeth.domain.club.fixture import com.weeth.domain.club.domain.entity.Club +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.enums.MemberStatus import com.weeth.domain.club.domain.vo.ClubContact +import com.weeth.domain.user.fixture.UserTestFixture +import org.springframework.test.util.ReflectionTestUtils object ClubTestFixture { fun createClub( @@ -21,4 +26,20 @@ object ClubTestFixture { ) return club } + + fun createClubMember( + club: Club = createClub(), + user: com.weeth.domain.user.domain.entity.User = UserTestFixture.createActiveUser1(), + memberStatus: MemberStatus = MemberStatus.ACTIVE, + memberRole: MemberRole = MemberRole.USER, + ): ClubMember { + val member = + ClubMember( + club = club, + user = user, + memberStatus = memberStatus, + memberRole = memberRole, + ) + return member + } } diff --git a/src/test/kotlin/com/weeth/global/common/id/TsidBase62EncoderTest.kt b/src/test/kotlin/com/weeth/global/common/id/TsidBase62EncoderTest.kt new file mode 100644 index 00000000..22acb7cf --- /dev/null +++ b/src/test/kotlin/com/weeth/global/common/id/TsidBase62EncoderTest.kt @@ -0,0 +1,63 @@ +package com.weeth.global.common.id + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe + +class TsidBase62EncoderTest : + StringSpec({ + "Tsid Long이 Base62 String으로 정상 인코딩 된다." { + val cases = + mapOf( + 0L to "0", + 61L to "Z", + 62L to "10", + 375_109L to "1zA9", + ) + + cases.forEach { (tsid, expected) -> + TsidBase62Encoder.encode(tsid) shouldBe expected + } + } + + "Base62 String이 Tsid Long으로 정상 디코딩 된다." { + val cases = + mapOf( + "0" to 0L, + "Z" to 61L, + "10" to 62L, + "1zA9" to 375_109L, + ) + + cases.forEach { (encoded, expected) -> + TsidBase62Encoder.decode(encoded) shouldBe expected + } + } + + "encode 후 decode 하면 원래 값이 나온다" { + val values = + listOf( + 0L, + 1L, + 10L, + 61L, + 62L, + 999L, + 123456789L, + Long.MAX_VALUE, + ) + + values.forEach { value -> + val encoded = TsidBase62Encoder.encode(value) + val decoded = TsidBase62Encoder.decode(encoded) + + decoded shouldBe value + } + } + + "유효하지 않은 Base62 문자가 들어오면 예외가 발생한다" { + shouldThrow { + TsidBase62Encoder.decode("abc!") + } + } + }) diff --git a/src/test/kotlin/com/weeth/global/common/web/TsidPathVariableArgumentResolverTest.kt b/src/test/kotlin/com/weeth/global/common/web/TsidPathVariableArgumentResolverTest.kt new file mode 100644 index 00000000..7289cb21 --- /dev/null +++ b/src/test/kotlin/com/weeth/global/common/web/TsidPathVariableArgumentResolverTest.kt @@ -0,0 +1,64 @@ +package com.weeth.global.common.web + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import org.springframework.core.MethodParameter +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.web.bind.MissingPathVariableException +import org.springframework.web.context.request.ServletWebRequest +import org.springframework.web.servlet.HandlerMapping + +class TsidPathVariableArgumentResolverTest : + StringSpec({ + val resolver = TsidPathVariableArgumentResolver() + + "@TsidPathVariable Long 파라미터를 지원한다" { + val method = DummyController::class.java.getDeclaredMethod("target", java.lang.Long.TYPE) + val parameter = MethodParameter(method, 0) + + resolver.supportsParameter(parameter) shouldBe true + } + + "Base62 path variable을 Long으로 디코딩한다" { + val method = DummyController::class.java.getDeclaredMethod("target", java.lang.Long.TYPE) + val parameter = MethodParameter(method, 0) + val request = MockHttpServletRequest() + request.setAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE, mapOf("clubId" to "1zA9")) + + val result = resolver.resolveArgument(parameter, null, ServletWebRequest(request), null) + + result shouldBe 375_109L + } + + "유효하지 않은 Base62 값이면 예외가 발생한다" { + val method = DummyController::class.java.getDeclaredMethod("target", java.lang.Long.TYPE) + val parameter = MethodParameter(method, 0) + val request = MockHttpServletRequest() + request.setAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE, mapOf("clubId" to "%%%")) + + shouldThrow { + resolver.resolveArgument(parameter, null, ServletWebRequest(request), null) + } + } + + "path variable이 누락되면 예외가 발생한다" { + val method = DummyController::class.java.getDeclaredMethod("target", java.lang.Long.TYPE) + val parameter = MethodParameter(method, 0) + val request = MockHttpServletRequest() + request.setAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE, emptyMap()) + + shouldThrow { + resolver.resolveArgument(parameter, null, ServletWebRequest(request), null) + } + } + }) { + private class DummyController { + @Suppress("unused") + fun target( + @TsidPathVariable("clubId") clubId: Long, + ) { + clubId.toString() + } + } +} From cb9986fce97b563b5dfa61741aa5b7b93c9efcf9 Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Wed, 11 Mar 2026 10:35:51 +0900 Subject: [PATCH 21/73] =?UTF-8?q?[WTH-183]=20club=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=B6=94=EA=B0=80=20=EC=9E=91=EC=97=85=203=20(#23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: Account 도메인 클럽 추가 * refactor: Account 도메인 클럽 추가 * refactor: Attendance 도메인 사전 작업 * refactor: Board 도메인 Club 추가 작업 * refactor: Board 도메인 Club 추가 작업 * refactor: Cardinal 도메인 Club 추가 작업 * refactor: comment 관련 테스트 반영 * refactor: Penalty 도메인 Club 사전 작업 * refactor: Schedule 도메인 Club 추가 작업 * refactor: Session 도메인 Club 추가 작업 * chore: 컨벤션 수정 * refactor: 계좌 생성 메서드 수정 * refactor: 기존 메서드 성능 개선 * docs: clubId 관련 내용 추가 * test: 테스트 반영 * chore: lint 설정 * refactor: 리뷰 내용 반영 * docs: todo 주석 추가 --- .claude/rules/api-design.md | 20 ++ .../usecase/command/ManageAccountUseCase.kt | 21 +- .../usecase/query/GetAccountQueryService.kt | 13 +- .../domain/account/domain/entity/Account.kt | 9 + .../domain/repository/AccountRepository.kt | 10 + .../presentation/AccountAdminController.kt | 9 +- .../account/presentation/AccountController.kt | 8 +- .../query/GetAttendanceQueryService.kt | 18 +- .../presentation/AttendanceAdminController.kt | 3 +- .../presentation/AttendanceController.kt | 6 +- .../usecase/command/ManageBoardUseCase.kt | 20 +- .../usecase/command/ManagePostUseCase.kt | 16 +- .../usecase/query/GetBoardQueryService.kt | 24 +- .../usecase/query/GetPostQueryService.kt | 16 +- .../weeth/domain/board/domain/entity/Board.kt | 10 + .../domain/repository/BoardRepository.kt | 16 +- .../presentation/BoardAdminController.kt | 33 ++- .../board/presentation/BoardController.kt | 9 +- .../board/presentation/PostController.kt | 34 ++- .../application/mapper/CardinalMapper.kt | 7 +- .../usecase/command/ManageCardinalUseCase.kt | 23 +- .../usecase/query/GetCardinalQueryService.kt | 7 +- .../domain/cardinal/domain/entity/Cardinal.kt | 25 +- .../domain/repository/CardinalReader.kt | 13 ++ .../domain/repository/CardinalRepository.kt | 23 +- .../presentation/CardinalAdminController.kt | 13 +- .../presentation/CardinalController.kt | 12 +- .../weeth/domain/club/domain/entity/Club.kt | 2 + .../club/domain/service/ClubMemberPolicy.kt | 30 +-- .../usecase/command/DeletePenaltyUseCase.kt | 1 + .../usecase/command/SavePenaltyUseCase.kt | 1 + .../usecase/command/UpdatePenaltyUseCase.kt | 1 + .../usecase/query/GetPenaltyQueryService.kt | 12 +- .../presentation/PenaltyAdminController.kt | 3 +- .../presentation/PenaltyUserController.kt | 6 +- .../application/mapper/EventMapper.kt | 3 + .../application/mapper/SessionMapper.kt | 3 + .../usecase/command/ManageEventUseCase.kt | 17 +- .../usecase/query/GetScheduleQueryService.kt | 26 ++- .../domain/schedule/domain/entity/Event.kt | 9 + .../domain/repository/EventRepository.kt | 17 +- .../presentation/EventAdminController.kt | 16 +- .../schedule/presentation/EventController.kt | 3 +- .../presentation/ScheduleController.kt | 13 +- .../usecase/command/ManageSessionUseCase.kt | 18 +- .../usecase/query/GetSessionQueryService.kt | 14 +- .../domain/session/domain/entity/Session.kt | 215 +++++++++--------- .../domain/repository/SessionReader.kt | 25 +- .../domain/repository/SessionRepository.kt | 31 ++- .../presentation/SessionAdminController.kt | 24 +- .../session/presentation/SessionController.kt | 8 +- .../command/ManageAccountUseCaseTest.kt | 22 +- .../query/GetAccountQueryServiceTest.kt | 14 +- .../account/domain/entity/AccountTest.kt | 3 +- .../account/fixture/AccountTestFixture.kt | 4 + .../query/GetAttendanceQueryServiceTest.kt | 9 +- .../repository/AttendanceRepositoryTest.kt | 6 + .../usecase/command/ManageBoardUseCaseTest.kt | 30 ++- .../usecase/command/ManagePostUseCaseTest.kt | 51 +++-- .../usecase/query/GetBoardQueryServiceTest.kt | 54 ++--- .../usecase/query/GetPostQueryServiceTest.kt | 31 +-- .../board/domain/entity/BoardEntityTest.kt | 19 +- .../domain/board/fixture/BoardTestFixture.kt | 10 +- .../usecase/command/CardinalUseCaseTest.kt | 44 ++-- .../cardinal/domain/entity/CardinalTest.kt | 7 +- .../repository/CardinalRepositoryTest.kt | 12 +- .../cardinal/fixture/CardinalTestFixture.kt | 6 + .../query/GetClubMemberQueryServiceTest.kt | 4 +- .../domain/service/ClubMemberPolicyTest.kt | 11 +- .../usecase/command/CommentConcurrencyTest.kt | 28 ++- .../query/CommentQueryPerformanceTest.kt | 10 +- .../schedule/fixture/ScheduleTestFixture.kt | 4 + .../session/fixture/SessionTestFixture.kt | 8 + .../repository/UserCardinalRepositoryTest.kt | 19 +- .../domain/repository/UserRepositoryTest.kt | 8 +- .../domain/user/fixture/SessionTestFixture.kt | 2 + 76 files changed, 932 insertions(+), 400 deletions(-) diff --git a/.claude/rules/api-design.md b/.claude/rules/api-design.md index 2705ffdc..3fb48ce4 100644 --- a/.claude/rules/api-design.md +++ b/.claude/rules/api-design.md @@ -17,6 +17,15 @@ class UserController( } ``` +## Club-scoped API + +Club resources use `/api/v4/clubs/{clubId}/...`. `clubId` is Base62 TSID — use three annotations together: + +```kotlin +@PathVariable @TsidParam // IDE warning suppression + Swagger (type: string) +@TsidPathVariable clubId: Long // decodes Base62 → Long at runtime +``` + ## Required Annotations | Annotation | Purpose | @@ -150,6 +159,17 @@ DELETE /users/{userId} # Delete user POST /users/{userId}/activate # Action on resource ``` +### Admin Endpoints + +`admin` prefix comes **before** `clubs/{clubId}`: `/api/v4/admin/clubs/{clubId}/{resource}` + +``` +/api/v4/clubs/{clubId}/boards # user-facing +/api/v4/admin/clubs/{clubId}/boards # admin +``` + +Enables a single SecurityConfig rule: `.requestMatchers("/api/v4/admin/**").hasRole("ADMIN")` + ## Query & Path Parameters - Query params for filtering: `?page=0&size=10&status=ACTIVE` diff --git a/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCase.kt b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCase.kt index 5e15390d..6af12a3d 100644 --- a/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCase.kt @@ -4,7 +4,10 @@ import com.weeth.domain.account.application.dto.request.AccountSaveRequest import com.weeth.domain.account.application.exception.AccountExistsException import com.weeth.domain.account.domain.entity.Account import com.weeth.domain.account.domain.repository.AccountRepository +import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException import com.weeth.domain.cardinal.domain.repository.CardinalReader +import com.weeth.domain.club.application.exception.ClubNotFoundException +import com.weeth.domain.club.domain.repository.ClubReader import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -12,11 +15,21 @@ import org.springframework.transaction.annotation.Transactional class ManageAccountUseCase( private val accountRepository: AccountRepository, private val cardinalReader: CardinalReader, + private val clubReader: ClubReader, ) { + // TODO(PR4): 해당 클럽 소속 admin인지 검증 필요 @Transactional - fun save(request: AccountSaveRequest) { - if (accountRepository.existsByCardinal(request.cardinal)) throw AccountExistsException() - cardinalReader.getByCardinalNumber(request.cardinal) - accountRepository.save(Account.create(request.description, request.totalAmount, request.cardinal)) + fun save( + clubId: Long, + request: AccountSaveRequest, + ) { + val club = clubReader.getClubById(clubId) + + if (accountRepository.existsByClubIdAndCardinal(clubId, request.cardinal)) throw AccountExistsException() + + cardinalReader.findByClubIdAndCardinalNumber(clubId, request.cardinal) + ?: throw CardinalNotFoundException() + + accountRepository.save(Account.create(club, request.description, request.totalAmount, request.cardinal)) } } diff --git a/src/main/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountQueryService.kt b/src/main/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountQueryService.kt index d67060ad..d2c08acf 100644 --- a/src/main/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountQueryService.kt @@ -12,9 +12,6 @@ import com.weeth.domain.file.domain.repository.FileReader import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -/** - * Todo: 개행을 추가해 가독성 개선 - */ @Service @Transactional(readOnly = true) class GetAccountQueryService( @@ -25,14 +22,20 @@ class GetAccountQueryService( private val receiptMapper: ReceiptMapper, private val fileMapper: FileMapper, ) { - fun findByCardinal(cardinal: Int): AccountResponse { - val account = accountRepository.findByCardinal(cardinal) ?: throw AccountNotFoundException() + // TODO(PR4): 해당 클럽 소속 멤버인지 검증 필요 + fun findByCardinal( + clubId: Long, + cardinal: Int, + ): AccountResponse { + val account = accountRepository.findByClubIdAndCardinal(clubId, cardinal) ?: throw AccountNotFoundException() val receipts = receiptRepository.findAllByAccountIdOrderByCreatedAtDesc(account.id) val receiptIds = receipts.map { it.id } + val filesByReceiptId = fileReader .findAll(FileOwnerType.RECEIPT, receiptIds, null) .groupBy({ it.ownerId }, { fileMapper.toFileResponse(it) }) + return accountMapper.toResponse(account, receiptMapper.toResponses(receipts, filesByReceiptId)) } } diff --git a/src/main/kotlin/com/weeth/domain/account/domain/entity/Account.kt b/src/main/kotlin/com/weeth/domain/account/domain/entity/Account.kt index 98f1b8e8..c2643e83 100644 --- a/src/main/kotlin/com/weeth/domain/account/domain/entity/Account.kt +++ b/src/main/kotlin/com/weeth/domain/account/domain/entity/Account.kt @@ -1,15 +1,22 @@ package com.weeth.domain.account.domain.entity import com.weeth.domain.account.domain.vo.Money +import com.weeth.domain.club.domain.entity.Club import com.weeth.global.common.entity.BaseEntity 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 @Entity class Account( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "club_id", nullable = false) + val club: Club, @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "account_id") @@ -45,12 +52,14 @@ class Account( companion object { fun create( + club: Club, description: String, totalAmount: Int, cardinal: Int, ): Account { require(totalAmount > 0) { "총액은 0보다 커야 합니다: $totalAmount" } return Account( + club = club, description = description, totalAmount = totalAmount, currentAmount = totalAmount, diff --git a/src/main/kotlin/com/weeth/domain/account/domain/repository/AccountRepository.kt b/src/main/kotlin/com/weeth/domain/account/domain/repository/AccountRepository.kt index c7a9daeb..83acdb77 100644 --- a/src/main/kotlin/com/weeth/domain/account/domain/repository/AccountRepository.kt +++ b/src/main/kotlin/com/weeth/domain/account/domain/repository/AccountRepository.kt @@ -7,4 +7,14 @@ interface AccountRepository : JpaRepository { fun findByCardinal(cardinal: Int): Account? fun existsByCardinal(cardinal: Int): Boolean + + fun findByClubIdAndCardinal( + clubId: Long, + cardinal: Int, + ): Account? + + fun existsByClubIdAndCardinal( + clubId: Long, + cardinal: Int, + ): Boolean } diff --git a/src/main/kotlin/com/weeth/domain/account/presentation/AccountAdminController.kt b/src/main/kotlin/com/weeth/domain/account/presentation/AccountAdminController.kt index 92cc5a46..d715ddcf 100644 --- a/src/main/kotlin/com/weeth/domain/account/presentation/AccountAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/account/presentation/AccountAdminController.kt @@ -6,9 +6,12 @@ import com.weeth.domain.account.application.usecase.command.ManageAccountUseCase import com.weeth.domain.account.presentation.AccountResponseCode.ACCOUNT_SAVE_SUCCESS import com.weeth.global.common.exception.ApiErrorCodeExample import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.Valid +import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping @@ -16,7 +19,7 @@ import org.springframework.web.bind.annotation.RestController @Tag(name = "ACCOUNT ADMIN", description = "[ADMIN] 회비 어드민 API") @RestController -@RequestMapping("/api/v1/admin/account") +@RequestMapping("/api/v4/admin/clubs/{clubId}/accounts") @ApiErrorCodeExample(AccountErrorCode::class) class AccountAdminController( private val manageAccountUseCase: ManageAccountUseCase, @@ -24,9 +27,11 @@ class AccountAdminController( @PostMapping @Operation(summary = "회비 총 금액 기입") fun save( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, @RequestBody @Valid dto: AccountSaveRequest, ): CommonResponse { - manageAccountUseCase.save(dto) + manageAccountUseCase.save(clubId, dto) return CommonResponse.success(ACCOUNT_SAVE_SUCCESS) } } diff --git a/src/main/kotlin/com/weeth/domain/account/presentation/AccountController.kt b/src/main/kotlin/com/weeth/domain/account/presentation/AccountController.kt index e4bd6f61..064d2d5e 100644 --- a/src/main/kotlin/com/weeth/domain/account/presentation/AccountController.kt +++ b/src/main/kotlin/com/weeth/domain/account/presentation/AccountController.kt @@ -6,6 +6,8 @@ import com.weeth.domain.account.application.usecase.query.GetAccountQueryService import com.weeth.domain.account.presentation.AccountResponseCode.ACCOUNT_FIND_SUCCESS import com.weeth.global.common.exception.ApiErrorCodeExample import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.web.bind.annotation.GetMapping @@ -15,7 +17,7 @@ import org.springframework.web.bind.annotation.RestController @Tag(name = "ACCOUNT", description = "회비 API") @RestController -@RequestMapping("/api/v1/account") +@RequestMapping("/api/v4/clubs/{clubId}/accounts") @ApiErrorCodeExample(AccountErrorCode::class) class AccountController( private val getAccountQueryService: GetAccountQueryService, @@ -23,7 +25,9 @@ class AccountController( @GetMapping("/{cardinal}") @Operation(summary = "회비 내역 조회") fun find( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, @PathVariable cardinal: Int, ): CommonResponse = - CommonResponse.success(ACCOUNT_FIND_SUCCESS, getAccountQueryService.findByCardinal(cardinal)) + CommonResponse.success(ACCOUNT_FIND_SUCCESS, getAccountQueryService.findByCardinal(clubId, cardinal)) } diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt index 5f63a538..708617ee 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt @@ -23,7 +23,11 @@ class GetAttendanceQueryService( private val attendanceRepository: AttendanceRepository, private val attendanceMapper: AttendanceMapper, ) { - fun findAttendance(userId: Long): AttendanceSummaryResponse { + // TODO: PR4에서 clubMember 기반으로 전환 (현재는 user 기반 유지) + fun findAttendance( + clubId: Long, + userId: Long, + ): AttendanceSummaryResponse { val user = userReader.getById(userId) val today = LocalDate.now() @@ -37,7 +41,11 @@ class GetAttendanceQueryService( return attendanceMapper.toSummaryResponse(user, todayAttendance, isAdmin = user.role == Role.ADMIN) } - fun findAllDetailsByCurrentCardinal(userId: Long): AttendanceDetailResponse { + // TODO: PR4에서 clubMember 기반으로 전환 (현재는 user 기반 유지) + fun findAllDetailsByCurrentCardinal( + clubId: Long, + userId: Long, + ): AttendanceDetailResponse { val user = userReader.getById(userId) val currentCardinal = userCardinalPolicy.getCurrentCardinal(user) @@ -49,7 +57,11 @@ class GetAttendanceQueryService( return attendanceMapper.toDetailResponse(user, responses) } - fun findAllAttendanceBySession(sessionId: Long): List { + // TODO: PR4에서 clubMember 기반으로 전환 (현재는 user 기반 유지) + fun findAllAttendanceBySession( + clubId: Long, + sessionId: Long, + ): List { val session = sessionReader.getById(sessionId) val attendances = attendanceRepository.findAllBySessionAndUserStatus(session, Status.ACTIVE) diff --git a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceAdminController.kt b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceAdminController.kt index 79b5d0f2..753d6975 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceAdminController.kt @@ -49,7 +49,8 @@ class AttendanceAdminController( ): CommonResponse> = CommonResponse.success( AttendanceResponseCode.ATTENDANCE_FIND_DETAIL_SUCCESS, - getAttendanceQueryService.findAllAttendanceBySession(sessionId), + // TODO: PR4에서 clubId 기반으로 전환 + getAttendanceQueryService.findAllAttendanceBySession(0L, sessionId), ) @PatchMapping("/status") diff --git a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt index 98544d27..b95243f3 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt @@ -43,7 +43,8 @@ class AttendanceController( ): CommonResponse = CommonResponse.success( AttendanceResponseCode.ATTENDANCE_FIND_SUCCESS, - getAttendanceQueryService.findAttendance(userId), + // TODO: PR4에서 clubId 기반으로 전환 + getAttendanceQueryService.findAttendance(0L, userId), ) @GetMapping("/detail") @@ -53,6 +54,7 @@ class AttendanceController( ): CommonResponse = CommonResponse.success( AttendanceResponseCode.ATTENDANCE_FIND_ALL_SUCCESS, - getAttendanceQueryService.findAllDetailsByCurrentCardinal(userId), + // TODO: PR4에서 clubId 기반으로 전환 + getAttendanceQueryService.findAllDetailsByCurrentCardinal(0L, userId), ) } diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt index d48485e3..e25b5479 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt @@ -8,6 +8,7 @@ import com.weeth.domain.board.application.mapper.BoardMapper import com.weeth.domain.board.domain.entity.Board import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.board.domain.vo.BoardConfig +import com.weeth.domain.club.domain.repository.ClubReader import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -15,11 +16,18 @@ import org.springframework.transaction.annotation.Transactional class ManageBoardUseCase( private val boardRepository: BoardRepository, private val boardMapper: BoardMapper, + private val clubReader: ClubReader, ) { + // TODO(PR4): 해당 클럽 소속 admin인지 검증 필요 @Transactional - fun create(request: CreateBoardRequest): BoardDetailResponse { + fun create( + clubId: Long, + request: CreateBoardRequest, + ): BoardDetailResponse { + val club = clubReader.getClubById(clubId) val board = Board( + club = club, name = request.name, type = request.type, config = @@ -33,12 +41,15 @@ class ManageBoardUseCase( return boardMapper.toDetailResponse(savedBoard) } + // TODO(PR4): 해당 클럽 소속 admin인지 검증 필요 @Transactional fun update( + clubId: Long, boardId: Long, request: UpdateBoardRequest, ): BoardDetailResponse { val board = findBoard(boardId) + if (board.club.id != clubId) throw BoardNotFoundException() request.name?.let { board.rename(it) } @@ -57,9 +68,14 @@ class ManageBoardUseCase( return boardMapper.toDetailResponse(board) } + // TODO(PR4): 해당 클럽 소속 admin인지 검증 필요 @Transactional - fun delete(boardId: Long) { + fun delete( + clubId: Long, + boardId: Long, + ) { val board = findBoard(boardId) + if (board.club.id != clubId) throw BoardNotFoundException() board.markDeleted() } diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt index 41cc630e..6f17f559 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt @@ -32,14 +32,16 @@ class ManagePostUseCase( private val fileMapper: FileMapper, private val postMapper: PostMapper, ) { + // TODO(PR4): 해당 클럽 소속 멤버인지 검증 필요 @Transactional fun save( + clubId: Long, boardId: Long, request: CreatePostRequest, userId: Long, ): PostSaveResponse { val user = userReader.getById(userId) - val board = findBoard(boardId) + val board = findBoardInClub(boardId, clubId) validateWritePermission(board, user) val post = @@ -56,14 +58,17 @@ class ManagePostUseCase( return postMapper.toSaveResponse(savedPost) } + // TODO(PR4): 해당 클럽 소속 멤버인지 검증 필요 @Transactional fun update( + clubId: Long, postId: Long, request: UpdatePostRequest, userId: Long, ): PostSaveResponse { val user = userReader.getById(userId) val post = findPost(postId) + if (post.board.club.id != clubId) throw PostNotFoundException() validateOwner(post, userId) validateWritePermission(post.board, user) @@ -77,13 +82,16 @@ class ManagePostUseCase( return postMapper.toSaveResponse(post) } + // TODO(PR4): 해당 클럽 소속 멤버인지 검증 필요 @Transactional fun delete( + clubId: Long, postId: Long, userId: Long, ) { val user = userReader.getById(userId) val post = findPost(postId) + if (post.board.club.id != clubId) throw PostNotFoundException() validateOwner(post, userId) validateWritePermission(post.board, user) @@ -91,8 +99,10 @@ class ManagePostUseCase( post.markDeleted() } - private fun findBoard(boardId: Long): Board = - boardRepository.findByIdAndIsDeletedFalse(boardId) ?: throw BoardNotFoundException() + private fun findBoardInClub( + boardId: Long, + clubId: Long, + ): Board = boardRepository.findByIdAndClubIdAndIsDeletedFalse(boardId, clubId) ?: throw BoardNotFoundException() private fun findPost(postId: Long): Post = postRepository.findActivePostById(postId) ?: throw PostNotFoundException() diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt index b4bc2d97..9e63dbd4 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt @@ -6,7 +6,6 @@ import com.weeth.domain.board.application.exception.BoardNotFoundException import com.weeth.domain.board.application.mapper.BoardMapper import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.user.domain.enums.Role -import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -16,19 +15,28 @@ class GetBoardQueryService( private val boardRepository: BoardRepository, private val boardMapper: BoardMapper, ) { - fun findBoards(role: Role): List = + // TODO(PR4): 해당 클럽 소속 멤버인지 검증 필요 + fun findBoards( + clubId: Long, + role: Role, + ): List = boardRepository - .findAllByIsDeletedFalseOrderByIdAsc() - .filter { it.isAccessibleBy(role) } // todo: Club 기반 쿼리로 개선 시 DB 레벨 필터링으로 전환 + .findAllByClubIdAndIsDeletedFalseOrderByIdAsc(clubId) + .filter { it.isAccessibleBy(role) } .map(boardMapper::toListResponse) - fun findBoardDetailForAdmin(boardId: Long): BoardDetailResponse { - val board = boardRepository.findByIdOrNull(boardId) ?: throw BoardNotFoundException() + // TODO(PR4): 해당 클럽 소속 admin인지 검증 필요 + fun findBoardDetailForAdmin( + clubId: Long, + boardId: Long, + ): BoardDetailResponse { + val board = boardRepository.findByIdAndClubId(boardId, clubId) ?: throw BoardNotFoundException() return boardMapper.toDetailResponseForAdmin(board) } - fun findAllBoardsForAdmin(): List = + // TODO(PR4): 해당 클럽 소속 admin인지 검증 필요 + fun findAllBoardsForAdmin(clubId: Long): List = boardRepository - .findAllByOrderByIdAsc() + .findAllByClubIdOrderByIdAsc(clubId) .map(boardMapper::toDetailResponseForAdmin) } diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt index cd533b14..c70bae4f 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt @@ -37,12 +37,14 @@ class GetPostQueryService( private const val MAX_PAGE_SIZE = 50 } + // TODO(PR4): 해당 클럽 소속 멤버인지 검증 필요 fun findPost( + clubId: Long, postId: Long, role: Role, ): PostDetailResponse { val post = postRepository.findByIdAndIsDeletedFalse(postId) ?: throw PostNotFoundException() - if (post.board.isDeleted || !post.board.isAccessibleBy(role)) { + if (post.board.club.id != clubId || post.board.isDeleted || !post.board.isAccessibleBy(role)) { throw PostNotFoundException() } @@ -53,14 +55,16 @@ class GetPostQueryService( return postMapper.toDetailResponse(post, commentTree, files) } + // TODO(PR4): 해당 클럽 소속 멤버인지 검증 필요 fun findPosts( + clubId: Long, boardId: Long, pageNumber: Int, pageSize: Int, role: Role, ): Slice { validatePage(pageNumber, pageSize) - validateBoardVisibility(boardId, role) + validateBoardVisibility(boardId, clubId, role) val pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")) val posts = postRepository.findAllActiveByBoardId(boardId, pageable) @@ -71,7 +75,9 @@ class GetPostQueryService( return posts.map { postMapper.toListResponse(it, fileExistsByPostId[it.id] == true, now) } } + // TODO(PR4): 해당 클럽 소속 멤버인지 검증 필요 fun searchPosts( + clubId: Long, boardId: Long, keyword: String, pageNumber: Int, @@ -79,7 +85,7 @@ class GetPostQueryService( role: Role, ): Slice { validatePage(pageNumber, pageSize) - validateBoardVisibility(boardId, role) + validateBoardVisibility(boardId, clubId, role) val pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")) val posts = postRepository.searchByBoardId(boardId, keyword.trim(), pageable) @@ -113,9 +119,11 @@ class GetPostQueryService( private fun validateBoardVisibility( // todo: 볼 권한이 없는 경우 권한 관련 예외를 던져주는게 나을지 UX 상의 후 결정 boardId: Long, + clubId: Long, role: Role, ) { - val board = boardRepository.findByIdAndIsDeletedFalse(boardId) ?: throw BoardNotFoundException() + val board = + boardRepository.findByIdAndClubIdAndIsDeletedFalse(boardId, clubId) ?: throw BoardNotFoundException() if (!board.isAccessibleBy(role)) { throw BoardNotFoundException() } diff --git a/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt b/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt index 61585ae4..d7fc3a17 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt @@ -3,6 +3,7 @@ package com.weeth.domain.board.domain.entity import com.weeth.domain.board.domain.converter.BoardConfigConverter import com.weeth.domain.board.domain.enums.BoardType import com.weeth.domain.board.domain.vo.BoardConfig +import com.weeth.domain.club.domain.entity.Club import com.weeth.domain.user.domain.enums.Role import com.weeth.global.common.entity.BaseEntity import jakarta.persistence.Column @@ -10,14 +11,18 @@ import jakarta.persistence.Convert import jakarta.persistence.Entity import jakarta.persistence.EnumType import jakarta.persistence.Enumerated +import jakarta.persistence.FetchType import jakarta.persistence.GeneratedValue import jakarta.persistence.GenerationType import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne import jakarta.persistence.Table @Entity @Table(name = "board") class Board( + club: Club, name: String, type: BoardType, config: BoardConfig = BoardConfig(), @@ -26,6 +31,11 @@ class Board( require(name.isNotBlank()) { "게시판 이름은 공백이 될 수 없습니다" } } + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "club_id", nullable = false) + var club: Club = club + private set + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) var id: Long = 0L diff --git a/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardRepository.kt b/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardRepository.kt index 268cd084..6a6bdb99 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardRepository.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardRepository.kt @@ -4,9 +4,19 @@ import com.weeth.domain.board.domain.entity.Board import org.springframework.data.jpa.repository.JpaRepository interface BoardRepository : JpaRepository { - fun findAllByIsDeletedFalseOrderByIdAsc(): List - fun findByIdAndIsDeletedFalse(id: Long): Board? - fun findAllByOrderByIdAsc(): List + fun findByIdAndClubId( + boardId: Long, + clubId: Long, + ): Board? + + fun findAllByClubIdAndIsDeletedFalseOrderByIdAsc(clubId: Long): List + + fun findByIdAndClubIdAndIsDeletedFalse( + boardId: Long, + clubId: Long, + ): Board? + + fun findAllByClubIdOrderByIdAsc(clubId: Long): List } diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt b/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt index b69215ae..b8ebf098 100644 --- a/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt @@ -8,6 +8,8 @@ import com.weeth.domain.board.application.usecase.command.ManageBoardUseCase import com.weeth.domain.board.application.usecase.query.GetBoardQueryService import com.weeth.global.common.exception.ApiErrorCodeExample import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.Valid @@ -23,7 +25,7 @@ import org.springframework.web.bind.annotation.RestController @Tag(name = "Board-Admin", description = "Board Admin API") @RestController -@RequestMapping("/api/v4/admin/board") +@RequestMapping("/api/v4/admin/clubs/{clubId}/boards") @PreAuthorize("hasRole('ADMIN')") @ApiErrorCodeExample(BoardErrorCode::class) class BoardAdminController( @@ -32,40 +34,57 @@ class BoardAdminController( ) { @GetMapping @Operation(summary = "게시판 전체 목록 조회 (삭제/비공개 포함)") - fun findAllBoards(): CommonResponse> = - CommonResponse.success(BoardResponseCode.BOARD_FIND_ALL_SUCCESS, getBoardQueryService.findAllBoardsForAdmin()) + fun findAllBoards( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, + ): CommonResponse> = + CommonResponse.success( + BoardResponseCode.BOARD_FIND_ALL_SUCCESS, + getBoardQueryService.findAllBoardsForAdmin(clubId), + ) @GetMapping("/{boardId}") @Operation(summary = "게시판 상세 조회 (삭제된 게시판 포함)") fun findBoard( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, @PathVariable boardId: Long, ): CommonResponse = CommonResponse.success( BoardResponseCode.BOARD_FIND_BY_ID_SUCCESS, - getBoardQueryService.findBoardDetailForAdmin(boardId), + getBoardQueryService.findBoardDetailForAdmin(clubId, boardId), ) @PostMapping @Operation(summary = "게시판 생성") fun createBoard( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, @RequestBody @Valid request: CreateBoardRequest, ): CommonResponse = - CommonResponse.success(BoardResponseCode.BOARD_CREATED_SUCCESS, manageBoardUseCase.create(request)) + CommonResponse.success(BoardResponseCode.BOARD_CREATED_SUCCESS, manageBoardUseCase.create(clubId, request)) @PatchMapping("/{boardId}") @Operation(summary = "게시판 설정/이름 수정") fun updateBoard( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, @PathVariable boardId: Long, @RequestBody @Valid request: UpdateBoardRequest, ): CommonResponse = - CommonResponse.success(BoardResponseCode.BOARD_UPDATED_SUCCESS, manageBoardUseCase.update(boardId, request)) + CommonResponse.success( + BoardResponseCode.BOARD_UPDATED_SUCCESS, + manageBoardUseCase.update(clubId, boardId, request), + ) @DeleteMapping("/{boardId}") @Operation(summary = "게시판 삭제") fun deleteBoard( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, @PathVariable boardId: Long, ): CommonResponse { - manageBoardUseCase.delete(boardId) + manageBoardUseCase.delete(clubId, boardId) return CommonResponse.success(BoardResponseCode.BOARD_DELETED_SUCCESS) } } diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt b/src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt index 1d4c81ae..74da34dd 100644 --- a/src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt +++ b/src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt @@ -7,16 +7,19 @@ import com.weeth.domain.user.domain.enums.Role import com.weeth.global.auth.annotation.CurrentUserRole import com.weeth.global.common.exception.ApiErrorCodeExample import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @Tag(name = "BOARD", description = "게시판 API") @RestController -@RequestMapping("/api/v4/boards") +@RequestMapping("/api/v4/clubs/{clubId}/boards") @ApiErrorCodeExample(BoardErrorCode::class) class BoardController( private val getBoardQueryService: GetBoardQueryService, @@ -24,7 +27,9 @@ class BoardController( @GetMapping @Operation(summary = "게시판 목록 조회") fun findBoards( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, @Parameter(hidden = true) @CurrentUserRole role: Role, ): CommonResponse> = - CommonResponse.success(BoardResponseCode.BOARD_FIND_ALL_SUCCESS, getBoardQueryService.findBoards(role)) + CommonResponse.success(BoardResponseCode.BOARD_FIND_ALL_SUCCESS, getBoardQueryService.findBoards(clubId, role)) } diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt b/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt index bf4b4f6c..297f06de 100644 --- a/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt +++ b/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt @@ -13,6 +13,8 @@ import com.weeth.global.auth.annotation.CurrentUser import com.weeth.global.auth.annotation.CurrentUserRole import com.weeth.global.common.exception.ApiErrorCodeExample import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag @@ -30,7 +32,7 @@ import org.springframework.web.bind.annotation.RestController @Tag(name = "BOARD", description = "게시글 API") @RestController -@RequestMapping("/api/v4/boards") +@RequestMapping("/api/v4/clubs/{clubId}/boards") @ApiErrorCodeExample(BoardErrorCode::class) class PostController( private val managePostUseCase: ManagePostUseCase, @@ -39,15 +41,22 @@ class PostController( @PostMapping("/{boardId}/posts") @Operation(summary = "게시글 작성") fun save( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, @PathVariable boardId: Long, @RequestBody @Valid request: CreatePostRequest, @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse = - CommonResponse.success(BoardResponseCode.POST_CREATED_SUCCESS, managePostUseCase.save(boardId, request, userId)) + CommonResponse.success( + BoardResponseCode.POST_CREATED_SUCCESS, + managePostUseCase.save(clubId, boardId, request, userId), + ) @GetMapping("/{boardId}/posts") @Operation(summary = "게시글 목록 조회") fun findPosts( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, @PathVariable boardId: Long, @RequestParam(defaultValue = "0") pageNumber: Int, @RequestParam(defaultValue = "10") pageSize: Int, @@ -55,42 +64,53 @@ class PostController( ): CommonResponse> = CommonResponse.success( BoardResponseCode.POST_FIND_ALL_SUCCESS, - getPostQueryService.findPosts(boardId, pageNumber, pageSize, role), + getPostQueryService.findPosts(clubId, boardId, pageNumber, pageSize, role), ) @GetMapping("/posts/{postId}") @Operation(summary = "게시글 상세 조회") fun findPost( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, @PathVariable postId: Long, @Parameter(hidden = true) @CurrentUserRole role: Role, ): CommonResponse = - CommonResponse.success(BoardResponseCode.POST_FIND_BY_ID_SUCCESS, getPostQueryService.findPost(postId, role)) + CommonResponse.success( + BoardResponseCode.POST_FIND_BY_ID_SUCCESS, + getPostQueryService.findPost(clubId, postId, role), + ) @PatchMapping("/posts/{postId}") @Operation(summary = "게시글 수정") fun update( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, @PathVariable postId: Long, @RequestBody @Valid request: UpdatePostRequest, @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse = CommonResponse.success( BoardResponseCode.POST_UPDATED_SUCCESS, - managePostUseCase.update(postId, request, userId), + managePostUseCase.update(clubId, postId, request, userId), ) @DeleteMapping("/posts/{postId}") @Operation(summary = "게시글 삭제") fun delete( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, @PathVariable postId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse { - managePostUseCase.delete(postId, userId) + managePostUseCase.delete(clubId, postId, userId) return CommonResponse.success(BoardResponseCode.POST_DELETED_SUCCESS) } @GetMapping("/{boardId}/posts/search") @Operation(summary = "게시글 검색") fun searchPosts( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, @PathVariable boardId: Long, @RequestParam keyword: String, @RequestParam(defaultValue = "0") pageNumber: Int, @@ -99,7 +119,7 @@ class PostController( ): CommonResponse> = CommonResponse.success( BoardResponseCode.POST_SEARCH_SUCCESS, - getPostQueryService.searchPosts(boardId, keyword, pageNumber, pageSize, role), + getPostQueryService.searchPosts(clubId, boardId, keyword, pageNumber, pageSize, role), ) // todo: 좋아요 관련 API 추가 diff --git a/src/main/kotlin/com/weeth/domain/cardinal/application/mapper/CardinalMapper.kt b/src/main/kotlin/com/weeth/domain/cardinal/application/mapper/CardinalMapper.kt index 536915e7..213b647d 100644 --- a/src/main/kotlin/com/weeth/domain/cardinal/application/mapper/CardinalMapper.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/application/mapper/CardinalMapper.kt @@ -3,12 +3,17 @@ package com.weeth.domain.cardinal.application.mapper import com.weeth.domain.cardinal.application.dto.request.CardinalSaveRequest import com.weeth.domain.cardinal.application.dto.response.CardinalResponse import com.weeth.domain.cardinal.domain.entity.Cardinal +import com.weeth.domain.club.domain.entity.Club import org.springframework.stereotype.Component @Component class CardinalMapper { - fun toEntity(request: CardinalSaveRequest): Cardinal = + fun toEntity( + club: Club, + request: CardinalSaveRequest, + ): Cardinal = Cardinal.create( + club = club, cardinalNumber = request.cardinalNumber, year = request.year, semester = request.semester, diff --git a/src/main/kotlin/com/weeth/domain/cardinal/application/usecase/command/ManageCardinalUseCase.kt b/src/main/kotlin/com/weeth/domain/cardinal/application/usecase/command/ManageCardinalUseCase.kt index 724aea07..a5448184 100644 --- a/src/main/kotlin/com/weeth/domain/cardinal/application/usecase/command/ManageCardinalUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/application/usecase/command/ManageCardinalUseCase.kt @@ -7,6 +7,7 @@ import com.weeth.domain.cardinal.application.exception.DuplicateCardinalExceptio import com.weeth.domain.cardinal.application.mapper.CardinalMapper import com.weeth.domain.cardinal.domain.repository.CardinalRepository import com.weeth.domain.cardinal.domain.service.CardinalStatusPolicy +import com.weeth.domain.club.domain.repository.ClubReader import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -15,22 +16,34 @@ class ManageCardinalUseCase( private val cardinalRepository: CardinalRepository, private val cardinalMapper: CardinalMapper, private val cardinalStatusPolicy: CardinalStatusPolicy, + private val clubReader: ClubReader, ) { + // TODO(PR4): 해당 클럽 소속 admin인지 검증 필요 @Transactional - fun save(request: CardinalSaveRequest) { - if (cardinalRepository.findByCardinalNumber(request.cardinalNumber).isPresent) { + fun save( + clubId: Long, + request: CardinalSaveRequest, + ) { + val club = clubReader.getClubById(clubId) + if (cardinalRepository.findByClubIdAndCardinalNumber(clubId, request.cardinalNumber) != null) { throw DuplicateCardinalException() } - val cardinal = cardinalRepository.save(cardinalMapper.toEntity(request)) + val cardinal = cardinalRepository.save(cardinalMapper.toEntity(club, request)) if (request.inProgress) { cardinalStatusPolicy.activateExclusively(cardinal) } } + // TODO(PR4): 해당 클럽 소속 admin인지 검증 필요 @Transactional - fun update(request: CardinalUpdateRequest) { - val cardinal = cardinalRepository.findById(request.id).orElseThrow { CardinalNotFoundException() } + fun update( + clubId: Long, + request: CardinalUpdateRequest, + ) { + val cardinal = + cardinalRepository.findByIdAndClubId(request.id, clubId) ?: throw CardinalNotFoundException() + cardinal.update(request.year, request.semester) if (request.inProgress) { diff --git a/src/main/kotlin/com/weeth/domain/cardinal/application/usecase/query/GetCardinalQueryService.kt b/src/main/kotlin/com/weeth/domain/cardinal/application/usecase/query/GetCardinalQueryService.kt index 4c5dcacd..b9abf3f9 100644 --- a/src/main/kotlin/com/weeth/domain/cardinal/application/usecase/query/GetCardinalQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/application/usecase/query/GetCardinalQueryService.kt @@ -2,6 +2,7 @@ package com.weeth.domain.cardinal.application.usecase.query import com.weeth.domain.cardinal.application.dto.response.CardinalResponse import com.weeth.domain.cardinal.application.mapper.CardinalMapper +import com.weeth.domain.cardinal.domain.repository.CardinalReader import com.weeth.domain.cardinal.domain.repository.CardinalRepository import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -10,8 +11,10 @@ import org.springframework.transaction.annotation.Transactional @Transactional(readOnly = true) class GetCardinalQueryService( private val cardinalRepository: CardinalRepository, + private val cardinalReader: CardinalReader, private val cardinalMapper: CardinalMapper, ) { - fun findAll(): List = - cardinalRepository.findAllByOrderByCardinalNumberAsc().map(cardinalMapper::toResponse) + // TODO(PR4): 해당 클럽 소속 멤버인지 검증 필요 + fun findAll(clubId: Long): List = + cardinalReader.findAllByClubIdOrderByCardinalNumberAsc(clubId).map(cardinalMapper::toResponse) } diff --git a/src/main/kotlin/com/weeth/domain/cardinal/domain/entity/Cardinal.kt b/src/main/kotlin/com/weeth/domain/cardinal/domain/entity/Cardinal.kt index 0b4dec4f..f828d77a 100644 --- a/src/main/kotlin/com/weeth/domain/cardinal/domain/entity/Cardinal.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/domain/entity/Cardinal.kt @@ -1,19 +1,35 @@ package com.weeth.domain.cardinal.domain.entity import com.weeth.domain.cardinal.domain.enums.CardinalStatus +import com.weeth.domain.club.domain.entity.Club import com.weeth.global.common.entity.BaseEntity import jakarta.persistence.Column import jakarta.persistence.Entity import jakarta.persistence.EnumType import jakarta.persistence.Enumerated +import jakarta.persistence.FetchType import jakarta.persistence.GeneratedValue import jakarta.persistence.GenerationType import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint @Entity +@Table( + name = "cardinal", + uniqueConstraints = [ + UniqueConstraint( + name = "uk_club_id_cardinal_number", + columnNames = ["club_id", "cardinal_number"], + ), + ], +) class Cardinal( + club: Club, id: Long = 0L, - @Column(unique = true, nullable = false) + @Column(nullable = false) val cardinalNumber: Int, year: Int? = null, semester: Int? = null, @@ -25,6 +41,11 @@ class Cardinal( var id: Long = id private set + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "club_id", nullable = false) + var club: Club = club + private set + var year: Int? = year private set @@ -54,6 +75,7 @@ class Cardinal( companion object { fun create( + club: Club, cardinalNumber: Int, year: Int? = null, semester: Int? = null, @@ -63,6 +85,7 @@ class Cardinal( year?.let { require(it > 0) { "연도는 0보다 커야 합니다." } } semester?.let { require(it in 1..2) { "학기는 1 또는 2여야 합니다." } } return Cardinal( + club = club, cardinalNumber = cardinalNumber, year = year, semester = semester, diff --git a/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalReader.kt b/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalReader.kt index bb707701..9fea0fe2 100644 --- a/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalReader.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalReader.kt @@ -1,6 +1,7 @@ package com.weeth.domain.cardinal.domain.repository import com.weeth.domain.cardinal.domain.entity.Cardinal +import com.weeth.domain.cardinal.domain.enums.CardinalStatus interface CardinalReader { fun getByCardinalNumber(cardinalNumber: Int): Cardinal @@ -13,4 +14,16 @@ interface CardinalReader { fun findByIdOrNull(cardinalId: Long): Cardinal? fun findAllByCardinalNumberDesc(): List + + fun findByClubIdAndCardinalNumber( + clubId: Long, + cardinalNumber: Int, + ): Cardinal? + + fun findAllByClubIdAndStatus( + clubId: Long, + status: CardinalStatus, + ): List + + fun findAllByClubIdOrderByCardinalNumberAsc(clubId: Long): List } diff --git a/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalRepository.kt b/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalRepository.kt index 9f20103d..16d156c6 100644 --- a/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalRepository.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalRepository.kt @@ -16,24 +16,35 @@ interface CardinalRepository : CardinalReader { fun findByCardinalNumber(cardinal: Int): Optional - fun findAllByCardinalNumberIn(cardinalNumbers: List): List - fun findByYearAndSemester( year: Int, semester: Int, ): Optional - fun findAllByStatus(cardinalStatus: CardinalStatus): List - @Lock(LockModeType.PESSIMISTIC_WRITE) @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) @Query("SELECT c FROM Cardinal c WHERE c.status = 'IN_PROGRESS'") fun findAllInProgressWithLock(): List - fun findAllByOrderByCardinalNumberAsc(): List - fun findAllByOrderByCardinalNumberDesc(): List + fun findByIdAndClubId( + id: Long, + clubId: Long, + ): Cardinal? + + override fun findByClubIdAndCardinalNumber( + clubId: Long, + cardinalNumber: Int, + ): Cardinal? + + override fun findAllByClubIdAndStatus( + clubId: Long, + status: CardinalStatus, + ): List + + override fun findAllByClubIdOrderByCardinalNumberAsc(clubId: Long): List + override fun getByCardinalNumber(cardinalNumber: Int): Cardinal = findByCardinalNumber(cardinalNumber).orElseThrow { CardinalNotFoundException() } diff --git a/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalAdminController.kt b/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalAdminController.kt index 831f395d..65993cbb 100644 --- a/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalAdminController.kt @@ -7,10 +7,13 @@ import com.weeth.domain.cardinal.application.usecase.command.ManageCardinalUseCa import com.weeth.global.auth.jwt.application.exception.JwtErrorCode import com.weeth.global.common.exception.ApiErrorCodeExample import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.Valid import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping @@ -18,7 +21,7 @@ import org.springframework.web.bind.annotation.RestController @Tag(name = "CARDINAL ADMIN", description = "[ADMIN] 기수 어드민 API") @RestController -@RequestMapping("/api/v4/admin/cardinals") +@RequestMapping("/api/v4/admin/clubs/{clubId}/cardinals") @ApiErrorCodeExample(CardinalErrorCode::class, JwtErrorCode::class) class CardinalAdminController( private val manageCardinalUseCase: ManageCardinalUseCase, @@ -26,18 +29,22 @@ class CardinalAdminController( @PatchMapping @Operation(summary = "기수 정보 수정 API") fun update( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, @RequestBody @Valid request: CardinalUpdateRequest, ): CommonResponse { - manageCardinalUseCase.update(request) + manageCardinalUseCase.update(clubId, request) return CommonResponse.success(CardinalResponseCode.CARDINAL_UPDATE_SUCCESS) } @PostMapping @Operation(summary = "새로운 기수 정보 저장 API") fun save( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, @RequestBody @Valid request: CardinalSaveRequest, ): CommonResponse { - manageCardinalUseCase.save(request) + manageCardinalUseCase.save(clubId, request) return CommonResponse.success(CardinalResponseCode.CARDINAL_SAVE_SUCCESS) } } diff --git a/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalController.kt b/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalController.kt index 60867f03..26c39212 100644 --- a/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalController.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalController.kt @@ -6,21 +6,27 @@ import com.weeth.domain.cardinal.application.usecase.query.GetCardinalQueryServi import com.weeth.global.auth.jwt.application.exception.JwtErrorCode import com.weeth.global.common.exception.ApiErrorCodeExample import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @Tag(name = "CARDINAL") @RestController -@RequestMapping("/api/v4/cardinals") +@RequestMapping("/api/v4/clubs/{clubId}/cardinals") @ApiErrorCodeExample(CardinalErrorCode::class, JwtErrorCode::class) class CardinalController( private val getCardinalQueryService: GetCardinalQueryService, ) { @GetMapping @Operation(summary = "현재 저장된 기수 목록 조회 API") - fun findAllCardinals(): CommonResponse> = - CommonResponse.success(CardinalResponseCode.CARDINAL_FIND_ALL_SUCCESS, getCardinalQueryService.findAll()) + fun findAllCardinals( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, + ): CommonResponse> = + CommonResponse.success(CardinalResponseCode.CARDINAL_FIND_ALL_SUCCESS, getCardinalQueryService.findAll(clubId)) } diff --git a/src/main/kotlin/com/weeth/domain/club/domain/entity/Club.kt b/src/main/kotlin/com/weeth/domain/club/domain/entity/Club.kt index e7af4609..a2b1c35b 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/entity/Club.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/entity/Club.kt @@ -65,6 +65,8 @@ class Club( var backgroundImageUrl: String? = backgroundImageUrl private set + // todo: 동아리 삭제 지원 + fun update( name: String?, schoolName: String?, diff --git a/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt index b603282c..295ba2d8 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt @@ -4,6 +4,7 @@ import com.weeth.domain.club.application.exception.ClubMemberNotFoundException import com.weeth.domain.club.application.exception.ClubMemberNotInClubException import com.weeth.domain.club.application.exception.MemberNotActiveException import com.weeth.domain.club.application.exception.NotClubAdminException +import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.club.domain.repository.ClubMemberReader import org.springframework.stereotype.Service @@ -16,18 +17,18 @@ class ClubMemberPolicy( ) { /** * 동아리의 활성 멤버를 조회 + * 한 번 조회 후 분기하여 불필요한 중복 쿼리를 방지 */ fun getActiveMember( clubId: Long, userId: Long, - ) = clubMemberReader - .findByClubIdAndUserId(clubId, userId) - ?.takeIf { it.isActive() } - ?: throw if (clubMemberReader.findByClubIdAndUserId(clubId, userId) != null) { - MemberNotActiveException() - } else { - ClubMemberNotFoundException() - } + ): ClubMember { + val member = + clubMemberReader.findByClubIdAndUserId(clubId, userId) + ?: throw ClubMemberNotFoundException() + if (!member.isActive()) throw MemberNotActiveException() + return member + } /** * 사용자가 동아리 관리자인지 검증 @@ -45,10 +46,11 @@ class ClubMemberPolicy( fun getMemberInClub( clubId: Long, clubMemberId: Long, - ) = clubMemberReader.findByIdAndClubId(clubMemberId, clubId) - ?: throw if (clubMemberReader.findByIdOrNull(clubMemberId) != null) { - ClubMemberNotInClubException() - } else { - ClubMemberNotFoundException() - } + ): ClubMember { + val member = + clubMemberReader.findByIdOrNull(clubMemberId) + ?: throw ClubMemberNotFoundException() + if (member.club.id != clubId) throw ClubMemberNotInClubException() + return member + } } diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/DeletePenaltyUseCase.kt b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/DeletePenaltyUseCase.kt index 472d9d54..5e90db37 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/DeletePenaltyUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/DeletePenaltyUseCase.kt @@ -10,6 +10,7 @@ import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +// todo: PR4에서 Club 기반으로 수정 @Service class DeletePenaltyUseCase( private val penaltyRepository: PenaltyRepository, diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/SavePenaltyUseCase.kt b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/SavePenaltyUseCase.kt index eeba581f..a146c81c 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/SavePenaltyUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/SavePenaltyUseCase.kt @@ -10,6 +10,7 @@ import com.weeth.domain.user.domain.service.UserCardinalPolicy import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +// todo: PR4에서 Club 기반으로 수정 @Service class SavePenaltyUseCase( private val penaltyRepository: PenaltyRepository, diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/UpdatePenaltyUseCase.kt b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/UpdatePenaltyUseCase.kt index aa55fe2d..d84e77c3 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/UpdatePenaltyUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/UpdatePenaltyUseCase.kt @@ -7,6 +7,7 @@ import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +// todo: PR4에서 Club 기반으로 수정 @Service class UpdatePenaltyUseCase( private val penaltyRepository: PenaltyRepository, diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/query/GetPenaltyQueryService.kt b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/query/GetPenaltyQueryService.kt index 6edee56b..36865c46 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/query/GetPenaltyQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/query/GetPenaltyQueryService.kt @@ -21,7 +21,11 @@ class GetPenaltyQueryService( private val cardinalReader: CardinalReader, private val mapper: PenaltyMapper, ) { - fun findAllByCardinal(cardinalNumber: Int?): List { + // TODO: PR4에서 clubMember 기반으로 전환 (현재는 user 기반 유지) + fun findAllByCardinal( + clubId: Long, + cardinalNumber: Int?, + ): List { val cardinals = if (cardinalNumber == null) { cardinalReader.findAllByCardinalNumberDesc() @@ -50,7 +54,11 @@ class GetPenaltyQueryService( } } - fun findByUser(userId: Long): PenaltyResponse { + // TODO: PR4에서 clubMember 기반으로 전환 (현재는 user 기반 유지) + fun findByUser( + clubId: Long, + userId: Long, + ): PenaltyResponse { val user = userReader.getById(userId) val currentCardinal = userCardinalPolicy.getCurrentCardinal(user) val penalties = penaltyRepository.findByUserIdAndCardinalIdOrderByIdDesc(userId, currentCardinal.id) diff --git a/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyAdminController.kt b/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyAdminController.kt index a60784e0..9a2d38bc 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyAdminController.kt @@ -22,6 +22,7 @@ import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +// todo: PR4에서 Club 기반으로 수정 @Tag(name = "PENALTY ADMIN", description = "[ADMIN] 패널티 어드민 API") @RestController @RequestMapping("/api/v1/admin/penalties") @@ -57,7 +58,7 @@ class PenaltyAdminController( ): CommonResponse> = CommonResponse.success( PenaltyResponseCode.PENALTY_FIND_ALL_SUCCESS, - getPenaltyQueryService.findAllByCardinal(cardinal), + getPenaltyQueryService.findAllByCardinal(0L, cardinal), ) @DeleteMapping diff --git a/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyUserController.kt b/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyUserController.kt index 965fe5f9..7e08f449 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyUserController.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyUserController.kt @@ -13,6 +13,7 @@ import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController +// todo: PR4에서 Club 기반으로 수정 @Tag(name = "PENALTY", description = "패널티 API") @RestController @RequestMapping("/api/v1/penalties") @@ -25,5 +26,8 @@ class PenaltyUserController( fun findAllPenalties( @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse = - CommonResponse.success(PenaltyResponseCode.PENALTY_USER_FIND_SUCCESS, getPenaltyQueryService.findByUser(userId)) + CommonResponse.success( + PenaltyResponseCode.PENALTY_USER_FIND_SUCCESS, + getPenaltyQueryService.findByUser(0L, userId), + ) } diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/mapper/EventMapper.kt b/src/main/kotlin/com/weeth/domain/schedule/application/mapper/EventMapper.kt index 805fa01f..95703137 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/application/mapper/EventMapper.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/application/mapper/EventMapper.kt @@ -1,5 +1,6 @@ package com.weeth.domain.schedule.application.mapper +import com.weeth.domain.club.domain.entity.Club import com.weeth.domain.schedule.application.dto.request.ScheduleSaveRequest import com.weeth.domain.schedule.application.dto.response.EventResponse import com.weeth.domain.schedule.domain.entity.Event @@ -25,10 +26,12 @@ class EventMapper { ) fun toEntity( + club: Club, request: ScheduleSaveRequest, user: User, ): Event = Event.create( + club = club, title = request.title, content = request.content, location = request.location, diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/mapper/SessionMapper.kt b/src/main/kotlin/com/weeth/domain/schedule/application/mapper/SessionMapper.kt index eab3fc73..a65ae5e8 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/application/mapper/SessionMapper.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/application/mapper/SessionMapper.kt @@ -1,5 +1,6 @@ package com.weeth.domain.schedule.application.mapper +import com.weeth.domain.club.domain.entity.Club import com.weeth.domain.schedule.application.dto.request.ScheduleSaveRequest import com.weeth.domain.schedule.application.dto.response.SessionInfoResponse import com.weeth.domain.schedule.application.dto.response.SessionInfosResponse @@ -61,10 +62,12 @@ class SessionMapper { ) fun toEntity( + club: Club, request: ScheduleSaveRequest, user: User, ): Session = Session.create( + club = club, title = request.title, content = request.content, location = request.location, diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/usecase/command/ManageEventUseCase.kt b/src/main/kotlin/com/weeth/domain/schedule/application/usecase/command/ManageEventUseCase.kt index 56ee439f..b799887e 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/application/usecase/command/ManageEventUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/application/usecase/command/ManageEventUseCase.kt @@ -1,6 +1,7 @@ package com.weeth.domain.schedule.application.usecase.command import com.weeth.domain.cardinal.domain.repository.CardinalReader +import com.weeth.domain.club.domain.repository.ClubReader import com.weeth.domain.schedule.application.dto.request.ScheduleSaveRequest import com.weeth.domain.schedule.application.dto.request.ScheduleUpdateRequest import com.weeth.domain.schedule.application.exception.EventNotFoundException @@ -17,31 +18,43 @@ class ManageEventUseCase( private val userReader: UserReader, private val cardinalReader: CardinalReader, private val eventMapper: EventMapper, + private val clubReader: ClubReader, ) { + // TODO(PR4): 해당 클럽 소속 admin인지 검증 필요 @Transactional fun create( + clubId: Long, request: ScheduleSaveRequest, userId: Long, ) { + val club = clubReader.getClubById(clubId) val user = userReader.getById(userId) cardinalReader.getByCardinalNumber(request.cardinal) - eventRepository.save(eventMapper.toEntity(request, user)) + eventRepository.save(eventMapper.toEntity(club, request, user)) } + // TODO(PR4): 해당 클럽 소속 admin인지 검증 필요 @Transactional fun update( + clubId: Long, eventId: Long, request: ScheduleUpdateRequest, userId: Long, ) { val user = userReader.getById(userId) val event = eventRepository.findByIdOrNull(eventId) ?: throw EventNotFoundException() + if (event.club.id != clubId) throw EventNotFoundException() event.update(request.title, request.content, request.location, request.start, request.end, user) } + // TODO(PR4): 해당 클럽 소속 admin인지 검증 필요 @Transactional - fun delete(eventId: Long) { + fun delete( + clubId: Long, + eventId: Long, + ) { val event = eventRepository.findByIdOrNull(eventId) ?: throw EventNotFoundException() + if (event.club.id != clubId) throw EventNotFoundException() eventRepository.delete(event) } } diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt b/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt index e9094924..606a20b0 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt @@ -22,39 +22,47 @@ class GetScheduleQueryService( private val scheduleMapper: ScheduleMapper, private val eventMapper: EventMapper, ) { - fun findEvent(eventId: Long): EventResponse = - eventRepository - .findByIdOrNull(eventId) - ?.let { eventMapper.toResponse(it) } - ?: throw EventNotFoundException() + // TODO(PR4): 해당 클럽 소속 멤버인지 검증 필요 + fun findEvent( + clubId: Long, + eventId: Long, + ): EventResponse { + val event = eventRepository.findByIdOrNull(eventId) ?: throw EventNotFoundException() + if (clubId != 0L && event.club.id != clubId) throw EventNotFoundException() + return eventMapper.toResponse(event) + } + // TODO(PR4): 해당 클럽 소속 멤버인지 검증 필요 fun findMonthly( + clubId: Long, start: LocalDateTime, end: LocalDateTime, ): List { val events = eventRepository - .findByStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc(end, start) + .findByClubIdAndStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc(clubId, end, start) .map { scheduleMapper.toResponse(it, false) } val sessions = sessionReader - .findByStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc(end, start) + .findAllByClubIdAndStartBetween(clubId, start, end) .map { scheduleMapper.toResponse(it, true) } return (events + sessions).sortedBy { it.start } } + // TODO(PR4): 해당 클럽 소속 멤버인지 검증 필요 fun findYearly( + clubId: Long, year: Int, semester: Int, ): Map> { val cardinal = cardinalReader.getByYearAndSemester(year, semester) val events = eventRepository - .findAllByCardinal(cardinal.cardinalNumber) + .findAllByClubIdAndCardinal(clubId, cardinal.cardinalNumber) .map { scheduleMapper.toResponse(it, false) } val sessions = sessionReader - .findAllByCardinal(cardinal.cardinalNumber) + .findAllByClubIdAndCardinalIn(clubId, listOf(cardinal.cardinalNumber)) .map { scheduleMapper.toResponse(it, true) } return (events + sessions) diff --git a/src/main/kotlin/com/weeth/domain/schedule/domain/entity/Event.kt b/src/main/kotlin/com/weeth/domain/schedule/domain/entity/Event.kt index 3bfdd4e6..615356b8 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/domain/entity/Event.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/domain/entity/Event.kt @@ -1,5 +1,6 @@ package com.weeth.domain.schedule.domain.entity +import com.weeth.domain.club.domain.entity.Club import com.weeth.domain.user.domain.entity.User import com.weeth.global.common.entity.BaseEntity import jakarta.persistence.Column @@ -14,6 +15,7 @@ import java.time.LocalDateTime @Entity class Event( + club: Club, var title: String, @Column(length = 500) var content: String, @@ -29,6 +31,11 @@ class Event( @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long = 0 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "club_id", nullable = false) + var club: Club = club + private set + fun update( title: String, content: String, @@ -49,6 +56,7 @@ class Event( companion object { fun create( + club: Club, title: String, content: String, location: String, @@ -60,6 +68,7 @@ class Event( require(title.isNotBlank()) { "제목은 필수입니다" } require(!end.isBefore(start)) { "종료 시간은 시작 시간 이후여야 합니다" } return Event( + club = club, title = title, content = content, location = location, diff --git a/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventRepository.kt b/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventRepository.kt index a24b1804..49d58cad 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventRepository.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventRepository.kt @@ -2,13 +2,22 @@ package com.weeth.domain.schedule.domain.repository import com.weeth.domain.schedule.domain.entity.Event import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param import java.time.LocalDateTime interface EventRepository : JpaRepository { - fun findByStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc( - end: LocalDateTime, - start: LocalDateTime, + fun findAllByClubIdAndCardinal( + clubId: Long, + cardinal: Int, ): List - fun findAllByCardinal(cardinal: Int): List + @Query( + "SELECT e FROM Event e WHERE e.club.id = :clubId AND e.start <= :end AND e.end >= :start ORDER BY e.start ASC", + ) + fun findByClubIdAndStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc( + @Param("clubId") clubId: Long, + @Param("end") end: LocalDateTime, + @Param("start") start: LocalDateTime, + ): List } diff --git a/src/main/kotlin/com/weeth/domain/schedule/presentation/EventAdminController.kt b/src/main/kotlin/com/weeth/domain/schedule/presentation/EventAdminController.kt index bc3c6434..e1cd660b 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/presentation/EventAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/presentation/EventAdminController.kt @@ -7,6 +7,8 @@ import com.weeth.domain.schedule.application.usecase.command.ManageEventUseCase import com.weeth.global.auth.annotation.CurrentUser import com.weeth.global.common.exception.ApiErrorCodeExample import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag @@ -21,7 +23,7 @@ import org.springframework.web.bind.annotation.RestController @Tag(name = "EVENT ADMIN", description = "[ADMIN] 일정 어드민 API") @RestController -@RequestMapping("/api/v4/admin/events") +@RequestMapping("/api/v4/admin/clubs/{clubId}/events") @ApiErrorCodeExample(EventErrorCode::class) class EventAdminController( private val manageEventUseCase: ManageEventUseCase, @@ -29,30 +31,36 @@ class EventAdminController( @PostMapping @Operation(summary = "일정 생성") fun create( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, @Valid @RequestBody dto: ScheduleSaveRequest, @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse { - manageEventUseCase.create(dto, userId) + manageEventUseCase.create(clubId, dto, userId) return CommonResponse.success(ScheduleResponseCode.EVENT_SAVE_SUCCESS) } @PatchMapping("/{eventId}") @Operation(summary = "일정 수정") fun update( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, @PathVariable eventId: Long, @Valid @RequestBody dto: ScheduleUpdateRequest, @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse { - manageEventUseCase.update(eventId, dto, userId) + manageEventUseCase.update(clubId, eventId, dto, userId) return CommonResponse.success(ScheduleResponseCode.EVENT_UPDATE_SUCCESS) } @DeleteMapping("/{eventId}") @Operation(summary = "일정 삭제") fun delete( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, @PathVariable eventId: Long, ): CommonResponse { - manageEventUseCase.delete(eventId) + manageEventUseCase.delete(clubId, eventId) return CommonResponse.success(ScheduleResponseCode.EVENT_DELETE_SUCCESS) } } diff --git a/src/main/kotlin/com/weeth/domain/schedule/presentation/EventController.kt b/src/main/kotlin/com/weeth/domain/schedule/presentation/EventController.kt index ca6d17c1..ba7f0637 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/presentation/EventController.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/presentation/EventController.kt @@ -12,6 +12,7 @@ import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController +// TODO(PR4): /api/v4/clubs/{clubId}/events 경로로 전환 필요 @Tag(name = "EVENT", description = "일정 API") @RestController @RequestMapping("/api/v4/events") @@ -24,5 +25,5 @@ class EventController( fun getEvent( @PathVariable eventId: Long, ): CommonResponse = - CommonResponse.success(ScheduleResponseCode.EVENT_FIND_SUCCESS, getScheduleQueryService.findEvent(eventId)) + CommonResponse.success(ScheduleResponseCode.EVENT_FIND_SUCCESS, getScheduleQueryService.findEvent(0L, eventId)) } diff --git a/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt b/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt index 12e6c130..130e3a21 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt @@ -3,10 +3,13 @@ package com.weeth.domain.schedule.presentation import com.weeth.domain.schedule.application.dto.response.ScheduleResponse import com.weeth.domain.schedule.application.usecase.query.GetScheduleQueryService import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.format.annotation.DateTimeFormat import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController @@ -14,29 +17,33 @@ import java.time.LocalDateTime @Tag(name = "SCHEDULE", description = "캘린더 조회 API") @RestController -@RequestMapping("/api/v4/schedules") +@RequestMapping("/api/v4/clubs/{clubId}/schedules") class ScheduleController( private val getScheduleQueryService: GetScheduleQueryService, ) { @GetMapping("/monthly") @Operation(summary = "월별 일정 조회") fun findByMonthly( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) start: LocalDateTime, @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) end: LocalDateTime, ): CommonResponse> = CommonResponse.success( ScheduleResponseCode.SCHEDULE_MONTHLY_FIND_SUCCESS, - getScheduleQueryService.findMonthly(start, end), + getScheduleQueryService.findMonthly(clubId, start, end), ) @GetMapping("/yearly") @Operation(summary = "연도별 일정 조회") fun findByYearly( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, @RequestParam year: Int, @RequestParam semester: Int, ): CommonResponse>> = CommonResponse.success( ScheduleResponseCode.SCHEDULE_YEARLY_FIND_SUCCESS, - getScheduleQueryService.findYearly(year, semester), + getScheduleQueryService.findYearly(clubId, year, semester), ) } diff --git a/src/main/kotlin/com/weeth/domain/session/application/usecase/command/ManageSessionUseCase.kt b/src/main/kotlin/com/weeth/domain/session/application/usecase/command/ManageSessionUseCase.kt index c47f5af4..eddee7bf 100644 --- a/src/main/kotlin/com/weeth/domain/session/application/usecase/command/ManageSessionUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/session/application/usecase/command/ManageSessionUseCase.kt @@ -4,6 +4,7 @@ import com.weeth.domain.attendance.domain.entity.Attendance import com.weeth.domain.attendance.domain.enums.AttendanceStatus import com.weeth.domain.attendance.domain.repository.AttendanceRepository import com.weeth.domain.cardinal.domain.repository.CardinalReader +import com.weeth.domain.club.domain.repository.ClubReader import com.weeth.domain.schedule.application.dto.request.ScheduleSaveRequest import com.weeth.domain.schedule.application.dto.request.ScheduleUpdateRequest import com.weeth.domain.schedule.application.mapper.SessionMapper @@ -21,37 +22,50 @@ class ManageSessionUseCase( private val userReader: UserReader, private val cardinalReader: CardinalReader, private val sessionMapper: SessionMapper, + private val clubReader: ClubReader, ) { + // TODO(PR4): 해당 클럽 소속 admin인지 검증 필요 @Transactional fun create( + clubId: Long, request: ScheduleSaveRequest, userId: Long, ) { + val club = clubReader.getClubById(clubId) val user = userReader.getById(userId) val cardinal = cardinalReader.getByCardinalNumber(request.cardinal) + // TODO: PR4에서 ClubMember 기반으로 전환 (현재는 user 기반 유지) val users = userReader.findAllByCardinalAndStatus(cardinal, Status.ACTIVE) - val session = sessionMapper.toEntity(request, user) + val session = sessionMapper.toEntity(club, request, user) sessionRepository.save(session) attendanceRepository.saveAll(users.map { Attendance.Companion.create(session, it) }) } + // TODO(PR4): 해당 클럽 소속 admin인지 검증 필요 @Transactional fun update( + clubId: Long, sessionId: Long, request: ScheduleUpdateRequest, userId: Long, ) { val session = sessionRepository.findByIdWithLock(sessionId) ?: throw SessionNotFoundException() + if (session.club.id != clubId) throw SessionNotFoundException() val user = userReader.getById(userId) session.updateInfo(request.title, request.content, request.location, request.start, request.end, user) } + // TODO(PR4): 해당 클럽 소속 admin인지 검증 필요 @Transactional - fun delete(sessionId: Long) { + fun delete( + clubId: Long, + sessionId: Long, + ) { val session = sessionRepository.findByIdWithLock(sessionId) ?: throw SessionNotFoundException() + if (session.club.id != clubId) throw SessionNotFoundException() val attendances = attendanceRepository.findAllBySessionAndUserStatusWithLock(session, Status.ACTIVE) attendances.forEach { a -> diff --git a/src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt b/src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt index be17c0fe..6ca5ffbd 100644 --- a/src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt @@ -22,12 +22,14 @@ class GetSessionQueryService( private val userReader: UserReader, private val sessionMapper: SessionMapper, ) { + // TODO(PR4): 해당 클럽 소속 멤버인지 검증 필요 fun findSession( + clubId: Long, userId: Long, sessionId: Long, ): SessionResponse { val user = userReader.getById(userId) - val session = sessionRepository.findByIdOrNull(sessionId) ?: throw SessionNotFoundException() + val session = sessionRepository.findByIdAndClubId(sessionId, clubId) ?: throw SessionNotFoundException() return if (user.role == Role.ADMIN) { sessionMapper.toAdminResponse(session) @@ -36,12 +38,16 @@ class GetSessionQueryService( } } - fun findSessionInfos(cardinal: Int?): SessionInfosResponse { + // TODO(PR4): 해당 클럽 소속 멤버인지 검증 필요 + fun findSessionInfos( + clubId: Long, + cardinal: Int?, + ): SessionInfosResponse { val sessions = if (cardinal == null) { - sessionRepository.findAllByOrderByStartDesc() + sessionRepository.findAllByClubIdOrderByStartDesc(clubId) } else { - sessionRepository.findAllByCardinalOrderByStartDesc(cardinal) + sessionRepository.findAllByClubIdAndCardinalOrderByStartDesc(clubId, cardinal) } val thisWeek = findThisWeek(sessions) diff --git a/src/main/kotlin/com/weeth/domain/session/domain/entity/Session.kt b/src/main/kotlin/com/weeth/domain/session/domain/entity/Session.kt index f5d03d18..a258ab4b 100644 --- a/src/main/kotlin/com/weeth/domain/session/domain/entity/Session.kt +++ b/src/main/kotlin/com/weeth/domain/session/domain/entity/Session.kt @@ -1,103 +1,112 @@ -package com.weeth.domain.session.domain.entity - -import com.weeth.domain.session.domain.enums.SessionStatus -import com.weeth.domain.user.domain.entity.User -import com.weeth.global.common.entity.BaseEntity -import jakarta.persistence.Column -import jakarta.persistence.Entity -import jakarta.persistence.EnumType -import jakarta.persistence.Enumerated -import jakarta.persistence.FetchType -import jakarta.persistence.GeneratedValue -import jakarta.persistence.GenerationType -import jakarta.persistence.Id -import jakarta.persistence.JoinColumn -import jakarta.persistence.ManyToOne -import jakarta.persistence.Table -import java.security.SecureRandom -import java.time.LocalDateTime -import kotlin.random.asKotlinRandom - -@Entity -@Table(name = "meeting") // 테이블명 Session으로 수정 -class Session( - var title: String, - @Column(length = 500) - var content: String? = null, - var location: String? = null, - var cardinal: Int, - var start: LocalDateTime, - var end: LocalDateTime, - var code: Int, - @Enumerated(EnumType.STRING) - var status: SessionStatus = SessionStatus.OPEN, - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id") - var user: User? = null, -) : BaseEntity() { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - val id: Long = 0 - - fun close() { - check(status == SessionStatus.OPEN) { "이미 종료된 세션입니다" } - status = SessionStatus.CLOSED - } - - fun updateInfo( - title: String, - content: String?, - location: String?, - start: LocalDateTime, - end: LocalDateTime, - user: User?, - ) { - require(title.isNotBlank()) { "제목은 필수입니다" } - require(!end.isBefore(start)) { "종료 시간은 시작 시간 이후여야 합니다" } - this.title = title - this.content = content - this.location = location - this.start = start - this.end = end - this.user = user - } - - fun isCodeMatch(code: Int): Boolean = this.code == code - - fun isInProgress(now: LocalDateTime): Boolean = !now.isBefore(start) && !now.isAfter(end) - - fun isCheckInAllowed(now: LocalDateTime): Boolean { - val from = start.minusMinutes(10) - val to = end.plusMinutes(10) - return !now.isBefore(from) && !now.isAfter(to) - } - - companion object { - private val secureRandom = SecureRandom().asKotlinRandom() - - fun create( - title: String, - content: String?, - location: String?, - cardinal: Int, - start: LocalDateTime, - end: LocalDateTime, - user: User?, - ): Session { - require(title.isNotBlank()) { "제목은 필수입니다" } - require(!end.isBefore(start)) { "종료 시간은 시작 시간 이후여야 합니다" } - return Session( - title = title, - content = content, - location = location, - cardinal = cardinal, - start = start, - end = end, - code = generateCode(), - user = user, - ) - } - - private fun generateCode(): Int = (100000..999999).random(secureRandom) - } -} +package com.weeth.domain.session.domain.entity + +import com.weeth.domain.club.domain.entity.Club +import com.weeth.domain.session.domain.enums.SessionStatus +import com.weeth.domain.user.domain.entity.User +import com.weeth.global.common.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import java.security.SecureRandom +import java.time.LocalDateTime +import kotlin.random.asKotlinRandom + +@Entity +@Table(name = "meeting") // 테이블명 Session으로 수정 +class Session( + club: Club, + var title: String, + @Column(length = 500) + var content: String? = null, + var location: String? = null, + var cardinal: Int, + var start: LocalDateTime, + var end: LocalDateTime, + var code: Int, + @Enumerated(EnumType.STRING) + var status: SessionStatus = SessionStatus.OPEN, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + var user: User? = null, +) : BaseEntity() { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "club_id", nullable = false) + var club: Club = club + private set + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0 + + fun close() { + check(status == SessionStatus.OPEN) { "이미 종료된 세션입니다" } + status = SessionStatus.CLOSED + } + + fun updateInfo( + title: String, + content: String?, + location: String?, + start: LocalDateTime, + end: LocalDateTime, + user: User?, + ) { + require(title.isNotBlank()) { "제목은 필수입니다" } + require(!end.isBefore(start)) { "종료 시간은 시작 시간 이후여야 합니다" } + this.title = title + this.content = content + this.location = location + this.start = start + this.end = end + this.user = user + } + + fun isCodeMatch(code: Int): Boolean = this.code == code + + fun isInProgress(now: LocalDateTime): Boolean = !now.isBefore(start) && !now.isAfter(end) + + fun isCheckInAllowed(now: LocalDateTime): Boolean { + val from = start.minusMinutes(10) + val to = end.plusMinutes(10) + return !now.isBefore(from) && !now.isAfter(to) + } + + companion object { + private val secureRandom = SecureRandom().asKotlinRandom() + + fun create( + club: Club, + title: String, + content: String?, + location: String?, + cardinal: Int, + start: LocalDateTime, + end: LocalDateTime, + user: User?, + ): Session { + require(title.isNotBlank()) { "제목은 필수입니다" } + require(!end.isBefore(start)) { "종료 시간은 시작 시간 이후여야 합니다" } + return Session( + club = club, + title = title, + content = content, + location = location, + cardinal = cardinal, + start = start, + end = end, + code = generateCode(), + user = user, + ) + } + + private fun generateCode(): Int = (100000..999999).random(secureRandom) + } +} diff --git a/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionReader.kt b/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionReader.kt index c11b75d9..a8f0b96a 100644 --- a/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionReader.kt +++ b/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionReader.kt @@ -7,25 +7,24 @@ import java.time.LocalDateTime interface SessionReader { fun getById(sessionId: Long): Session - // TODO: QR 코드 출석 기능 구현 시 사용 예정 (현재 시간 기준 진행 중인 세션 조회) - fun findAllByStartBetween( - start: LocalDateTime, - end: LocalDateTime, - ): List - - fun findByStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc( - end: LocalDateTime, - start: LocalDateTime, - ): List - fun findAllByCardinal(cardinal: Int): List - fun findAllByCardinalIn(cardinals: List): List - fun findAllByCardinalOrderByStartAsc(cardinal: Int): List fun findAllByStatusAndEndBeforeOrderByEndAsc( status: SessionStatus, end: LocalDateTime, ): List + + // TODO: QR 코드 출석 기능 구현 시 사용 예정 (현재 시간 기준 진행 중인 세션 조회) + fun findAllByClubIdAndStartBetween( + clubId: Long, + start: LocalDateTime, + end: LocalDateTime, + ): List + + fun findAllByClubIdAndCardinalIn( + clubId: Long, + cardinals: List, + ): List } diff --git a/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionRepository.kt b/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionRepository.kt index bf06414d..8a65979f 100644 --- a/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionRepository.kt +++ b/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionRepository.kt @@ -20,15 +20,20 @@ interface SessionRepository : @Query("SELECT s FROM Session s WHERE s.id = :id") fun findByIdWithLock(id: Long): Session? - override fun findByStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc( - end: LocalDateTime, - start: LocalDateTime, + fun findByIdAndClubId( + sessionId: Long, + clubId: Long, + ): Session? + + fun findAllByClubIdOrderByStartDesc(clubId: Long): List + + fun findAllByClubIdAndCardinalOrderByStartDesc( + clubId: Long, + cardinal: Int, ): List override fun findAllByCardinalOrderByStartAsc(cardinal: Int): List - fun findAllByCardinalOrderByStartDesc(cardinal: Int): List - override fun findAllByCardinal(cardinal: Int): List override fun findAllByStatusAndEndBeforeOrderByEndAsc( @@ -36,12 +41,18 @@ interface SessionRepository : end: LocalDateTime, ): List - fun findAllByOrderByStartDesc(): List + override fun getById(sessionId: Long): Session = findById(sessionId).orElseThrow { SessionNotFoundException() } - @Query("SELECT s FROM Session s WHERE s.cardinal IN :cardinals") - override fun findAllByCardinalIn( - @Param("cardinals") cardinals: List, + @Query("SELECT s FROM Session s WHERE s.club.id = :clubId AND s.start <= :end AND s.end >= :start") + override fun findAllByClubIdAndStartBetween( + @Param("clubId") clubId: Long, + @Param("start") start: LocalDateTime, + @Param("end") end: LocalDateTime, ): List - override fun getById(sessionId: Long): Session = findById(sessionId).orElseThrow { SessionNotFoundException() } + @Query("SELECT s FROM Session s WHERE s.club.id = :clubId AND s.cardinal IN :cardinals") + override fun findAllByClubIdAndCardinalIn( + @Param("clubId") clubId: Long, + @Param("cardinals") cardinals: List, + ): List } diff --git a/src/main/kotlin/com/weeth/domain/session/presentation/SessionAdminController.kt b/src/main/kotlin/com/weeth/domain/session/presentation/SessionAdminController.kt index 06e2a3ee..42c04604 100644 --- a/src/main/kotlin/com/weeth/domain/session/presentation/SessionAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/session/presentation/SessionAdminController.kt @@ -9,6 +9,8 @@ import com.weeth.domain.session.application.usecase.query.GetSessionQueryService import com.weeth.global.auth.annotation.CurrentUser import com.weeth.global.common.exception.ApiErrorCodeExample import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag @@ -25,7 +27,7 @@ import org.springframework.web.bind.annotation.RestController @Tag(name = "SESSION ADMIN", description = "[ADMIN] 정기모임 어드민 API") @RestController -@RequestMapping("/api/v4/admin/sessions") +@RequestMapping("/api/v4/admin/clubs/{clubId}/sessions") @ApiErrorCodeExample(SessionErrorCode::class) class SessionAdminController( private val manageSessionUseCase: ManageSessionUseCase, @@ -34,40 +36,48 @@ class SessionAdminController( @PostMapping @Operation(summary = "정기모임 생성") fun create( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, @Valid @RequestBody dto: ScheduleSaveRequest, @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse { - manageSessionUseCase.create(dto, userId) + manageSessionUseCase.create(clubId, dto, userId) return CommonResponse.success(SessionResponseCode.SESSION_SAVE_SUCCESS) } @PatchMapping("/{sessionId}") @Operation(summary = "정기모임 수정") fun update( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, @PathVariable sessionId: Long, @Valid @RequestBody dto: ScheduleUpdateRequest, @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse { - manageSessionUseCase.update(sessionId, dto, userId) + manageSessionUseCase.update(clubId, sessionId, dto, userId) return CommonResponse.success(SessionResponseCode.SESSION_UPDATE_SUCCESS) } @DeleteMapping("/{sessionId}") @Operation(summary = "정기모임 삭제") fun delete( - @PathVariable sessionId: Long, + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable sessionId: Long, // todo: userId 받아서 권한 검증 ): CommonResponse { - manageSessionUseCase.delete(sessionId) + manageSessionUseCase.delete(clubId, sessionId) return CommonResponse.success(SessionResponseCode.SESSION_DELETE_SUCCESS) } @GetMapping @Operation(summary = "정기모임 목록 조회") fun getSessionInfos( - @RequestParam(required = false) cardinal: Int?, + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, + @RequestParam(required = false) cardinal: Int?, // todo: userId 받아서 권한 검증 ): CommonResponse = CommonResponse.success( SessionResponseCode.SESSION_INFOS_FIND_SUCCESS, - getSessionQueryService.findSessionInfos(cardinal), + getSessionQueryService.findSessionInfos(clubId, cardinal), ) } diff --git a/src/main/kotlin/com/weeth/domain/session/presentation/SessionController.kt b/src/main/kotlin/com/weeth/domain/session/presentation/SessionController.kt index a9a27de3..b89cd2d3 100644 --- a/src/main/kotlin/com/weeth/domain/session/presentation/SessionController.kt +++ b/src/main/kotlin/com/weeth/domain/session/presentation/SessionController.kt @@ -6,6 +6,8 @@ import com.weeth.domain.session.application.usecase.query.GetSessionQueryService import com.weeth.global.auth.annotation.CurrentUser import com.weeth.global.common.exception.ApiErrorCodeExample import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag @@ -16,7 +18,7 @@ import org.springframework.web.bind.annotation.RestController @Tag(name = "SESSION", description = "정기모임 API") @RestController -@RequestMapping("/api/v4/sessions") +@RequestMapping("/api/v4/clubs/{clubId}/sessions") @ApiErrorCodeExample(SessionErrorCode::class) class SessionController( private val getSessionQueryService: GetSessionQueryService, @@ -24,11 +26,13 @@ class SessionController( @GetMapping("/{sessionId}") @Operation(summary = "정기모임 상세 조회") fun getSession( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, @PathVariable sessionId: Long, ): CommonResponse = CommonResponse.success( SessionResponseCode.SESSION_FIND_SUCCESS, - getSessionQueryService.findSession(userId, sessionId), + getSessionQueryService.findSession(clubId, userId, sessionId), ) } diff --git a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCaseTest.kt index e52e6e92..26c819db 100644 --- a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCaseTest.kt @@ -5,6 +5,8 @@ import com.weeth.domain.account.application.exception.AccountExistsException import com.weeth.domain.account.domain.repository.AccountRepository import com.weeth.domain.cardinal.domain.repository.CardinalReader import com.weeth.domain.cardinal.fixture.CardinalTestFixture +import com.weeth.domain.club.domain.repository.ClubReader +import com.weeth.domain.club.fixture.ClubTestFixture import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.mockk.clearMocks @@ -16,33 +18,37 @@ class ManageAccountUseCaseTest : DescribeSpec({ val accountRepository = mockk(relaxed = true) val cardinalReader = mockk(relaxed = true) - val useCase = ManageAccountUseCase(accountRepository, cardinalReader) + val clubReader = mockk(relaxed = true) + val useCase = ManageAccountUseCase(accountRepository, cardinalReader, clubReader) + + val clubId = 1L + val club = ClubTestFixture.createClub() beforeTest { - clearMocks(accountRepository, cardinalReader) + clearMocks(accountRepository, cardinalReader, clubReader) + every { clubReader.getClubById(clubId) } returns club } describe("save") { context("이미 존재하는 기수로 저장 시") { it("AccountExistsException을 던진다") { val request = AccountSaveRequest("설명", 100_000, 40) - every { accountRepository.existsByCardinal(40) } returns true + every { accountRepository.existsByClubIdAndCardinal(clubId, 40) } returns true - shouldThrow { useCase.save(request) } + shouldThrow { useCase.save(clubId, request) } } } context("정상 저장 시") { it("기수 존재를 보장하고 account를 저장한다") { val request = AccountSaveRequest("설명", 100_000, 40) - every { accountRepository.existsByCardinal(40) } returns false - every { cardinalReader.getByCardinalNumber(40) } returns + every { accountRepository.existsByClubIdAndCardinal(clubId, 40) } returns false + every { cardinalReader.findByClubIdAndCardinalNumber(clubId, 40) } returns CardinalTestFixture.createCardinal(cardinalNumber = 40, year = 2026, semester = 1) every { accountRepository.save(any()) } answers { firstArg() } - useCase.save(request) + useCase.save(clubId, request) - verify(exactly = 1) { cardinalReader.getByCardinalNumber(40) } verify(exactly = 1) { accountRepository.save(any()) } } } diff --git a/src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountQueryServiceTest.kt index 2b8a1d1d..4fd7cd87 100644 --- a/src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountQueryServiceTest.kt @@ -36,6 +36,8 @@ class GetAccountQueryServiceTest : fileMapper, ) + val clubId = 1L + beforeTest { clearMocks(accountRepository, receiptRepository, fileReader, accountMapper, receiptMapper, fileMapper) } @@ -48,7 +50,7 @@ class GetAccountQueryServiceTest : val receipt2 = ReceiptTestFixture.createReceipt(id = 2L, account = account) val accountResponse = mockk() - every { accountRepository.findByCardinal(40) } returns account + every { accountRepository.findByClubIdAndCardinal(clubId, 40) } returns account every { receiptRepository.findAllByAccountIdOrderByCreatedAtDesc(account.id) } returns listOf(receipt1, receipt2) every { fileReader.findAll(FileOwnerType.RECEIPT, listOf(1L, 2L), null) } returns emptyList() @@ -56,7 +58,7 @@ class GetAccountQueryServiceTest : every { receiptMapper.toResponses(any(), any()) } returns emptyList() every { accountMapper.toResponse(account, emptyList()) } returns accountResponse - val result = queryService.findByCardinal(40) + val result = queryService.findByCardinal(clubId, 40) result shouldBe accountResponse verify(exactly = 1) { fileReader.findAll(FileOwnerType.RECEIPT, listOf(1L, 2L), null) } @@ -66,13 +68,13 @@ class GetAccountQueryServiceTest : val account = AccountTestFixture.createAccount(cardinal = 40) val accountResponse = mockk() - every { accountRepository.findByCardinal(40) } returns account + every { accountRepository.findByClubIdAndCardinal(clubId, 40) } returns account every { receiptRepository.findAllByAccountIdOrderByCreatedAtDesc(account.id) } returns emptyList() every { fileReader.findAll(FileOwnerType.RECEIPT, emptyList(), null) } returns emptyList() every { receiptMapper.toResponses(emptyList(), emptyMap()) } returns emptyList() every { accountMapper.toResponse(account, emptyList()) } returns accountResponse - queryService.findByCardinal(40) + queryService.findByCardinal(clubId, 40) verify(exactly = 1) { fileReader.findAll(FileOwnerType.RECEIPT, emptyList(), null) } } @@ -80,9 +82,9 @@ class GetAccountQueryServiceTest : context("존재하지 않는 기수 조회 시") { it("AccountNotFoundException을 던진다") { - every { accountRepository.findByCardinal(99) } returns null + every { accountRepository.findByClubIdAndCardinal(clubId, 99) } returns null - shouldThrow { queryService.findByCardinal(99) } + shouldThrow { queryService.findByCardinal(clubId, 99) } } } } diff --git a/src/test/kotlin/com/weeth/domain/account/domain/entity/AccountTest.kt b/src/test/kotlin/com/weeth/domain/account/domain/entity/AccountTest.kt index 0dc3cdd3..797c7444 100644 --- a/src/test/kotlin/com/weeth/domain/account/domain/entity/AccountTest.kt +++ b/src/test/kotlin/com/weeth/domain/account/domain/entity/AccountTest.kt @@ -2,6 +2,7 @@ package com.weeth.domain.account.domain.entity import com.weeth.domain.account.domain.vo.Money import com.weeth.domain.account.fixture.AccountTestFixture +import com.weeth.domain.club.fixture.ClubTestFixture import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.shouldBe @@ -45,7 +46,7 @@ class AccountTest : } "create는 currentAmount를 totalAmount와 동일하게 초기화한다" { - val account = Account.create("2학기 회비", 200_000, 41) + val account = Account.create(ClubTestFixture.createClub(), "2학기 회비", 200_000, 41) account.currentAmount shouldBe 200_000 account.totalAmount shouldBe 200_000 diff --git a/src/test/kotlin/com/weeth/domain/account/fixture/AccountTestFixture.kt b/src/test/kotlin/com/weeth/domain/account/fixture/AccountTestFixture.kt index 1b515748..e0157e61 100644 --- a/src/test/kotlin/com/weeth/domain/account/fixture/AccountTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/account/fixture/AccountTestFixture.kt @@ -1,16 +1,20 @@ package com.weeth.domain.account.fixture import com.weeth.domain.account.domain.entity.Account +import com.weeth.domain.club.domain.entity.Club +import com.weeth.domain.club.fixture.ClubTestFixture object AccountTestFixture { fun createAccount( id: Long = 1L, + club: Club = ClubTestFixture.createClub(), description: String = "2024년 2학기 회비", totalAmount: Int = 100_000, currentAmount: Int = 100_000, cardinal: Int = 40, ): Account = Account( + club = club, id = id, description = description, totalAmount = totalAmount, diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt index de6cc13f..91f34f5d 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt @@ -38,6 +38,7 @@ class GetAttendanceQueryServiceTest : attendanceMapper, ) + val clubId = 0L // TODO: PR4에서 실제 clubId 기반으로 전환 val userId = 10L describe("find") { @@ -50,7 +51,7 @@ class GetAttendanceQueryServiceTest : every { attendanceRepository.findTodayByUserId(eq(userId), any(), any()) } returns todayAttendance every { attendanceMapper.toSummaryResponse(eq(user), eq(todayAttendance), eq(false)) } returns mapped - val actual = queryService.findAttendance(userId) + val actual = queryService.findAttendance(clubId, userId) actual shouldBe mapped verify { attendanceMapper.toSummaryResponse(eq(user), eq(todayAttendance), eq(false)) } @@ -64,7 +65,7 @@ class GetAttendanceQueryServiceTest : every { attendanceRepository.findTodayByUserId(eq(userId), any(), any()) } returns null every { attendanceMapper.toSummaryResponse(user, null, false) } returns mapped - val actual = queryService.findAttendance(userId) + val actual = queryService.findAttendance(clubId, userId) actual shouldBe mapped verify { attendanceMapper.toSummaryResponse(user, null, false) } @@ -92,7 +93,7 @@ class GetAttendanceQueryServiceTest : val expectedDetail = mockk() every { attendanceMapper.toDetailResponse(eq(user), any()) } returns expectedDetail - val actualDetail = queryService.findAllDetailsByCurrentCardinal(userId) + val actualDetail = queryService.findAllDetailsByCurrentCardinal(clubId, userId) actualDetail shouldBe expectedDetail verify { @@ -120,7 +121,7 @@ class GetAttendanceQueryServiceTest : every { attendanceMapper.toInfoResponse(attendance1) } returns response1 every { attendanceMapper.toInfoResponse(attendance2) } returns response2 - val result = queryService.findAllAttendanceBySession(sessionId) + val result = queryService.findAllAttendanceBySession(clubId, sessionId) result shouldBe listOf(response1, response2) } diff --git a/src/test/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepositoryTest.kt b/src/test/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepositoryTest.kt index 09e97a23..f0e16f14 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepositoryTest.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepositoryTest.kt @@ -2,6 +2,8 @@ package com.weeth.domain.attendance.domain.repository import com.weeth.config.TestContainersConfig import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.club.domain.repository.ClubRepository +import com.weeth.domain.club.fixture.ClubTestFixture import com.weeth.domain.session.domain.entity.Session import com.weeth.domain.session.domain.enums.SessionStatus import com.weeth.domain.session.domain.repository.SessionRepository @@ -24,6 +26,7 @@ class AttendanceRepositoryTest( private val attendanceRepository: AttendanceRepository, private val sessionRepository: SessionRepository, private val userRepository: UserRepository, + private val clubRepository: ClubRepository, ) : DescribeSpec({ lateinit var session: Session @@ -31,8 +34,11 @@ class AttendanceRepositoryTest( lateinit var activeUser2: User beforeEach { + val club = clubRepository.save(ClubTestFixture.createClub()) + session = Session( + club = club, title = "1차 정기모임", start = LocalDateTime.now().minusHours(1), end = LocalDateTime.now().plusHours(1), diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt index 8e5dbf4e..8658eba3 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt @@ -4,13 +4,17 @@ import com.weeth.domain.board.application.dto.request.CreateBoardRequest import com.weeth.domain.board.application.dto.request.UpdateBoardRequest import com.weeth.domain.board.application.exception.BoardNotFoundException import com.weeth.domain.board.application.mapper.BoardMapper -import com.weeth.domain.board.domain.entity.Board import com.weeth.domain.board.domain.enums.BoardType import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.board.domain.vo.BoardConfig +import com.weeth.domain.board.fixture.BoardTestFixture +import com.weeth.domain.club.domain.repository.ClubReader +import com.weeth.domain.club.fixture.ClubTestFixture import com.weeth.domain.user.domain.enums.Role import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe +import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -19,10 +23,16 @@ class ManageBoardUseCaseTest : DescribeSpec({ val boardRepository = mockk() val boardMapper = BoardMapper() - val useCase = ManageBoardUseCase(boardRepository, boardMapper) + val clubReader = mockk() + val useCase = ManageBoardUseCase(boardRepository, boardMapper, clubReader) + + val club = ClubTestFixture.createClub() + val clubId = club.id beforeTest { + clearMocks(boardRepository, clubReader) every { boardRepository.save(any()) } answers { firstArg() } + every { clubReader.getClubById(clubId) } returns club } describe("create") { @@ -36,7 +46,7 @@ class ManageBoardUseCaseTest : isPrivate = true, ) - val result = useCase.create(request) + val result = useCase.create(clubId, request) result.name shouldBe "운영공지" result.type shouldBe BoardType.NOTICE @@ -48,10 +58,10 @@ class ManageBoardUseCaseTest : describe("update") { it("일부 필드만 전달되면 해당 필드만 갱신한다") { - val board = Board(name = "기존", type = BoardType.GENERAL) + val board = BoardTestFixture.create(club = club, name = "기존", type = BoardType.GENERAL) every { boardRepository.findByIdAndIsDeletedFalse(1L) } returns board - val result = useCase.update(1L, UpdateBoardRequest(name = "변경", isPrivate = true)) + val result = useCase.update(clubId, 1L, UpdateBoardRequest(name = "변경", isPrivate = true)) result.name shouldBe "변경" result.commentEnabled shouldBe true @@ -60,10 +70,10 @@ class ManageBoardUseCaseTest : } it("아무 필드도 전달되지 않으면 기존 값이 그대로 유지된다") { - val board = Board(name = "기존", type = BoardType.GENERAL) + val board = BoardTestFixture.create(club = club, name = "기존", type = BoardType.GENERAL) every { boardRepository.findByIdAndIsDeletedFalse(1L) } returns board - val result = useCase.update(1L, UpdateBoardRequest()) + val result = useCase.update(clubId, 1L, UpdateBoardRequest()) result.name shouldBe "기존" result.commentEnabled shouldBe true @@ -75,17 +85,17 @@ class ManageBoardUseCaseTest : every { boardRepository.findByIdAndIsDeletedFalse(999L) } returns null shouldThrow { - useCase.update(999L, UpdateBoardRequest(name = "변경")) + useCase.update(clubId, 999L, UpdateBoardRequest(name = "변경")) } } } describe("delete") { it("게시판을 soft delete 처리한다") { - val board = Board(name = "일반", type = BoardType.GENERAL) + val board = BoardTestFixture.create(club = club, name = "일반", type = BoardType.GENERAL) every { boardRepository.findByIdAndIsDeletedFalse(1L) } returns board - useCase.delete(1L) + useCase.delete(clubId, 1L) board.isDeleted shouldBe true verify(exactly = 0) { boardRepository.delete(any()) } diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt index 0ffda1dc..687d3ba4 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt @@ -102,9 +102,9 @@ class ManagePostUseCaseTest : val request = CreatePostRequest(title = "제목", content = "내용") every { userReader.getById(1L) } returns user - every { boardRepository.findByIdAndIsDeletedFalse(10L) } returns board + every { boardRepository.findByIdAndClubIdAndIsDeletedFalse(10L, 1L) } returns board - val result = useCase.save(10L, request, 1L) + val result = useCase.save(1L, 10L, request, 1L) result.id shouldBe 1L verify(exactly = 1) { postRepository.save(any()) } @@ -121,10 +121,10 @@ class ManagePostUseCaseTest : val request = CreatePostRequest(title = "제목", content = "내용") every { userReader.getById(1L) } returns user - every { boardRepository.findByIdAndIsDeletedFalse(20L) } returns board + every { boardRepository.findByIdAndClubIdAndIsDeletedFalse(20L, 1L) } returns board shouldThrow { - useCase.save(20L, request, 1L) + useCase.save(1L, 20L, request, 1L) } verify(exactly = 0) { postRepository.save(any()) } @@ -141,10 +141,10 @@ class ManagePostUseCaseTest : val request = CreatePostRequest(title = "제목", content = "내용") every { userReader.getById(1L) } returns user - every { boardRepository.findByIdAndIsDeletedFalse(21L) } returns board + every { boardRepository.findByIdAndClubIdAndIsDeletedFalse(21L, 1L) } returns board shouldThrow { - useCase.save(21L, request, 1L) + useCase.save(1L, 21L, request, 1L) } verify(exactly = 0) { postRepository.save(any()) } @@ -161,9 +161,9 @@ class ManagePostUseCaseTest : ) every { userReader.getById(1L) } returns user - every { boardRepository.findByIdAndIsDeletedFalse(11L) } returns board + every { boardRepository.findByIdAndClubIdAndIsDeletedFalse(11L, 1L) } returns board - useCase.save(11L, request, 1L) + useCase.save(1L, 11L, request, 1L) verify { postRepository.save( @@ -177,10 +177,10 @@ class ManagePostUseCaseTest : val request = CreatePostRequest(title = "제목", content = "내용") every { userReader.getById(1L) } returns user - every { boardRepository.findByIdAndIsDeletedFalse(999L) } returns null + every { boardRepository.findByIdAndClubIdAndIsDeletedFalse(999L, 1L) } returns null shouldThrow { - useCase.save(999L, request, 1L) + useCase.save(1L, 999L, request, 1L) } } } @@ -189,13 +189,14 @@ class ManagePostUseCaseTest : it("files가 null이면 기존 파일을 유지한다") { val user = createUser(1L, Role.USER) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val clubId = board.club.id val post = Post.create("제목", "내용", user, board) val request = UpdatePostRequest(title = "수정", content = "수정") every { userReader.getById(1L) } returns user every { postRepository.findActivePostById(1L) } returns post - useCase.update(1L, request, 1L) + useCase.update(clubId, 1L, request, 1L) verify(exactly = 0) { fileReader.findAll(any(), any(), any()) } verify(exactly = 0) { fileRepository.saveAll(any>()) } @@ -204,6 +205,7 @@ class ManagePostUseCaseTest : it("files가 있으면 기존 파일을 soft delete 후 교체한다") { val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val clubId = board.club.id val post = PostTestFixture.create(title = "제목", content = "내용", user = user, board = board) val oldFile = createUploadedPostFile("old.png") val newFiles = listOf(createUploadedPostFile("new.png")) @@ -228,7 +230,7 @@ class ManagePostUseCaseTest : every { fileMapper.toFileList(request.files, FileOwnerType.POST, any()) } returns newFiles every { fileRepository.saveAll(newFiles) } returns newFiles - useCase.update(1L, request, 1L) + useCase.update(clubId, 1L, request, 1L) oldFile.status.name shouldBe "DELETED" post.title shouldBe "수정" @@ -239,13 +241,14 @@ class ManagePostUseCaseTest : it("title이 null이면 기존 제목을 유지한다") { val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val clubId = board.club.id val post = Post.create("원래 제목", "원래 내용", user, board) val request = UpdatePostRequest(content = "수정된 내용") every { userReader.getById(1L) } returns user every { postRepository.findActivePostById(1L) } returns post - useCase.update(1L, request, 1L) + useCase.update(clubId, 1L, request, 1L) post.title shouldBe "원래 제목" post.content shouldBe "수정된 내용" @@ -254,13 +257,14 @@ class ManagePostUseCaseTest : it("content가 null이면 기존 내용을 유지한다") { val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val clubId = board.club.id val post = Post.create("원래 제목", "원래 내용", user, board) val request = UpdatePostRequest(title = "수정된 제목") every { userReader.getById(1L) } returns user every { postRepository.findActivePostById(1L) } returns post - useCase.update(1L, request, 1L) + useCase.update(clubId, 1L, request, 1L) post.title shouldBe "수정된 제목" post.content shouldBe "원래 내용" @@ -270,7 +274,7 @@ class ManagePostUseCaseTest : every { postRepository.findActivePostById(1L) } returns null shouldThrow { - useCase.update(1L, UpdatePostRequest(title = "수정"), 1L) + useCase.update(1L, 1L, UpdatePostRequest(title = "수정"), 1L) } } @@ -282,13 +286,14 @@ class ManagePostUseCaseTest : type = BoardType.NOTICE, config = BoardConfig(writePermission = Role.ADMIN), ) + val clubId = board.club.id val post = PostTestFixture.create(title = "제목", content = "내용", user = user, board = board) every { userReader.getById(1L) } returns user every { postRepository.findActivePostById(1L) } returns post shouldThrow { - useCase.update(1L, UpdatePostRequest(title = "수정"), 1L) + useCase.update(clubId, 1L, UpdatePostRequest(title = "수정"), 1L) } } @@ -300,13 +305,14 @@ class ManagePostUseCaseTest : type = BoardType.GENERAL, config = BoardConfig(isPrivate = true), ) + val clubId = board.club.id val post = PostTestFixture.create(title = "제목", content = "내용", user = user, board = board) every { userReader.getById(1L) } returns user every { postRepository.findActivePostById(1L) } returns post shouldThrow { - useCase.update(1L, UpdatePostRequest(title = "수정"), 1L) + useCase.update(clubId, 1L, UpdatePostRequest(title = "수정"), 1L) } } } @@ -315,6 +321,7 @@ class ManagePostUseCaseTest : it("삭제 시 첨부 파일과 게시글을 soft delete한다") { val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val clubId = board.club.id val post = PostTestFixture.create(title = "제목", content = "내용", user = user, board = board) val oldFile = createUploadedPostFile("old.png") @@ -322,7 +329,7 @@ class ManagePostUseCaseTest : every { postRepository.findActivePostById(1L) } returns post every { fileReader.findAll(FileOwnerType.POST, any(), any()) } returns listOf(oldFile) - useCase.delete(1L, 1L) + useCase.delete(clubId, 1L, 1L) oldFile.status.name shouldBe "DELETED" post.isDeleted shouldBe true @@ -333,7 +340,7 @@ class ManagePostUseCaseTest : every { postRepository.findActivePostById(1L) } returns null shouldThrow { - useCase.delete(1L, 1L) + useCase.delete(1L, 1L, 1L) } } @@ -345,13 +352,14 @@ class ManagePostUseCaseTest : type = BoardType.NOTICE, config = BoardConfig(writePermission = Role.ADMIN), ) + val clubId = board.club.id val post = PostTestFixture.create(title = "제목", content = "내용", user = user, board = board) every { userReader.getById(1L) } returns user every { postRepository.findActivePostById(1L) } returns post shouldThrow { - useCase.delete(1L, 1L) + useCase.delete(clubId, 1L, 1L) } } } @@ -361,6 +369,7 @@ class ManagePostUseCaseTest : val owner = UserTestFixture.createActiveUser1(1L) val otherUser = createUser(2L, Role.USER) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val clubId = board.club.id val post = PostTestFixture.create(title = "제목", content = "내용", user = owner, board = board) val request = UpdatePostRequest(title = "수정", content = "수정") @@ -368,7 +377,7 @@ class ManagePostUseCaseTest : every { postRepository.findActivePostById(1L) } returns post shouldThrow { - useCase.update(1L, request, 2L) + useCase.update(clubId, 1L, request, 2L) } } } diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt index ce786458..3162295b 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt @@ -2,18 +2,16 @@ package com.weeth.domain.board.application.usecase.query import com.weeth.domain.board.application.exception.BoardNotFoundException import com.weeth.domain.board.application.mapper.BoardMapper -import com.weeth.domain.board.domain.entity.Board import com.weeth.domain.board.domain.enums.BoardType import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.board.fixture.BoardTestFixture import com.weeth.domain.user.domain.enums.Role import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec -import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe import io.mockk.every import io.mockk.mockk -import org.springframework.data.repository.findByIdOrNull class GetBoardQueryServiceTest : DescribeSpec({ @@ -21,34 +19,36 @@ class GetBoardQueryServiceTest : val boardMapper = BoardMapper() val queryService = GetBoardQueryService(boardRepository, boardMapper) + val clubId = 1L + describe("findBoards") { it("일반 사용자에게는 공개 게시판만 반환한다") { - val publicBoard = Board(name = "일반", type = BoardType.GENERAL) + val publicBoard = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val privateBoard = - Board(name = "운영", type = BoardType.NOTICE).apply { + BoardTestFixture.create(name = "운영", type = BoardType.NOTICE).apply { updateConfig(config.copy(isPrivate = true)) } - every { boardRepository.findAllByIsDeletedFalseOrderByIdAsc() } returns + every { boardRepository.findAllByClubIdAndIsDeletedFalseOrderByIdAsc(clubId) } returns listOf(publicBoard, privateBoard) - val result = queryService.findBoards(Role.USER) + val result = queryService.findBoards(clubId, Role.USER) result shouldHaveSize 1 result.first().name shouldBe "일반" } it("관리자에게는 비공개 게시판도 포함해 반환한다") { - val publicBoard = Board(name = "일반", type = BoardType.GENERAL) + val publicBoard = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val privateBoard = - Board(name = "운영", type = BoardType.NOTICE).apply { + BoardTestFixture.create(name = "운영", type = BoardType.NOTICE).apply { updateConfig(config.copy(isPrivate = true)) } - every { boardRepository.findAllByIsDeletedFalseOrderByIdAsc() } returns + every { boardRepository.findAllByClubIdAndIsDeletedFalseOrderByIdAsc(clubId) } returns listOf(publicBoard, privateBoard) - val result = queryService.findBoards(Role.ADMIN) + val result = queryService.findBoards(clubId, Role.ADMIN) result shouldHaveSize 2 result.map { it.name } shouldBe listOf("일반", "운영") @@ -57,30 +57,30 @@ class GetBoardQueryServiceTest : describe("findAllBoardsForAdmin") { it("삭제된 게시판을 포함해 전체 목록을 반환한다") { - val activeBoard = Board(name = "일반", type = BoardType.GENERAL) + val activeBoard = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val deletedBoard = - Board(name = "삭제됨", type = BoardType.GENERAL).apply { + BoardTestFixture.create(name = "삭제됨", type = BoardType.GENERAL).apply { markDeleted() } - every { boardRepository.findAllByOrderByIdAsc() } returns listOf(activeBoard, deletedBoard) + every { boardRepository.findAllByClubIdOrderByIdAsc(clubId) } returns listOf(activeBoard, deletedBoard) - val result = queryService.findAllBoardsForAdmin() + val result = queryService.findAllBoardsForAdmin(clubId) result shouldHaveSize 2 result.map { it.name } shouldBe listOf("일반", "삭제됨") } it("활성 게시판과 비공개 게시판도 모두 포함해 반환한다") { - val publicBoard = Board(name = "일반", type = BoardType.GENERAL) + val publicBoard = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val privateBoard = - Board(name = "운영", type = BoardType.NOTICE).apply { + BoardTestFixture.create(name = "운영", type = BoardType.NOTICE).apply { updateConfig(config.copy(isPrivate = true)) } - every { boardRepository.findAllByOrderByIdAsc() } returns listOf(publicBoard, privateBoard) + every { boardRepository.findAllByClubIdOrderByIdAsc(clubId) } returns listOf(publicBoard, privateBoard) - val result = queryService.findAllBoardsForAdmin() + val result = queryService.findAllBoardsForAdmin(clubId) result shouldHaveSize 2 result.map { it.name } shouldBe listOf("일반", "운영") @@ -90,33 +90,33 @@ class GetBoardQueryServiceTest : describe("findBoardDetailForAdmin") { it("삭제된 게시판도 조회할 수 있다") { val deletedBoard = - Board(name = "삭제됨", type = BoardType.GENERAL).apply { + BoardTestFixture.create(name = "삭제됨", type = BoardType.GENERAL).apply { markDeleted() } - every { boardRepository.findByIdOrNull(3L) } returns deletedBoard + every { boardRepository.findByIdAndClubId(3L, clubId) } returns deletedBoard - val result = queryService.findBoardDetailForAdmin(3L) + val result = queryService.findBoardDetailForAdmin(clubId, 3L) result.isDeleted shouldBe true } it("비공개 게시판도 조회할 수 있다") { val privateBoard = - Board(name = "운영", type = BoardType.NOTICE).apply { + BoardTestFixture.create(name = "운영", type = BoardType.NOTICE).apply { updateConfig(config.copy(isPrivate = true)) } - every { boardRepository.findByIdOrNull(2L) } returns privateBoard + every { boardRepository.findByIdAndClubId(2L, clubId) } returns privateBoard - val result = queryService.findBoardDetailForAdmin(2L) + val result = queryService.findBoardDetailForAdmin(clubId, 2L) result.isPrivate shouldBe true } it("존재하지 않는 boardId면 예외를 던진다") { - every { boardRepository.findByIdOrNull(999L) } returns null + every { boardRepository.findByIdAndClubId(999L, clubId) } returns null shouldThrow { - queryService.findBoardDetailForAdmin(999L) + queryService.findBoardDetailForAdmin(clubId, 999L) } } } diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt index c8ef9c3d..741ab569 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt @@ -53,6 +53,8 @@ class GetPostQueryServiceTest : postMapper, ) + val clubId = 1L // findPosts/searchPosts 테스트에서 boardRepository mock 인자로 사용 + beforeTest { clearMocks( postRepository, @@ -70,13 +72,14 @@ class GetPostQueryServiceTest : every { postRepository.findByIdAndIsDeletedFalse(1L) } returns null shouldThrow { - queryService.findPost(1L, Role.USER) + queryService.findPost(clubId, 1L, Role.USER) } } it("댓글/파일을 포함한 상세 응답을 반환한다") { val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val actualClubId = board.club.id // TSID 생성된 실제 club id 사용 val post = PostTestFixture.create( title = "제목", @@ -128,7 +131,7 @@ class GetPostQueryServiceTest : every { postMapper.toDetailResponse(post, comments, fileResponses) } returns detail every { fileMapper.toFileResponse(files.first()) } returns fileResponses.first() - val result = queryService.findPost(1L, Role.USER) + val result = queryService.findPost(actualClubId, 1L, Role.USER) result.id shouldBe 1L result.comments.size shouldBe 1 @@ -138,6 +141,7 @@ class GetPostQueryServiceTest : it("비공개 게시판 게시글은 일반/익명에게 노출하지 않는다") { val user = UserTestFixture.createActiveUser1(1L) val privateBoard = BoardTestFixture.create(name = "비공개", type = BoardType.GENERAL) + val actualClubId = privateBoard.club.id privateBoard.updateConfig(privateBoard.config.copy(isPrivate = true)) val post = PostTestFixture.create( @@ -150,7 +154,7 @@ class GetPostQueryServiceTest : every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post shouldThrow { - queryService.findPost(1L, Role.USER) + queryService.findPost(actualClubId, 1L, Role.USER) } } @@ -162,6 +166,7 @@ class GetPostQueryServiceTest : name = "삭제", type = BoardType.GENERAL, ).also { it.markDeleted() } + val actualClubId = deletedBoard.club.id val post = PostTestFixture.create( title = "제목", @@ -173,7 +178,7 @@ class GetPostQueryServiceTest : every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post shouldThrow { - queryService.findPost(1L, Role.USER) + queryService.findPost(actualClubId, 1L, Role.USER) } } } @@ -182,22 +187,22 @@ class GetPostQueryServiceTest : it("검색 결과가 없으면 예외를 던진다") { val pageable = PageRequest.of(0, 10) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) - every { boardRepository.findByIdAndIsDeletedFalse(1L) } returns board + every { boardRepository.findByIdAndClubIdAndIsDeletedFalse(1L, clubId) } returns board every { postRepository.searchByBoardId(1L, "키워드", any()) } returns SliceImpl(emptyList(), pageable, false) shouldThrow { - queryService.searchPosts(1L, "키워드", 0, 10, Role.USER) + queryService.searchPosts(clubId, 1L, "키워드", 0, 10, Role.USER) } } it("비공개 게시판은 일반/익명이 검색할 수 없다") { val privateBoard = BoardTestFixture.create(name = "비공개", type = BoardType.GENERAL) privateBoard.updateConfig(privateBoard.config.copy(isPrivate = true)) - every { boardRepository.findByIdAndIsDeletedFalse(1L) } returns privateBoard + every { boardRepository.findByIdAndClubIdAndIsDeletedFalse(1L, clubId) } returns privateBoard shouldThrow { - queryService.searchPosts(1L, "키워드", 0, 10, Role.USER) + queryService.searchPosts(clubId, 1L, "키워드", 0, 10, Role.USER) } } } @@ -205,19 +210,19 @@ class GetPostQueryServiceTest : describe("validatePage") { it("음수 페이지면 예외를 던진다") { shouldThrow { - queryService.findPosts(1L, -1, 10, Role.USER) + queryService.findPosts(clubId, 1L, -1, 10, Role.USER) } } it("pageSize가 0이면 예외를 던진다") { shouldThrow { - queryService.findPosts(1L, 0, 0, Role.USER) + queryService.findPosts(clubId, 1L, 0, 0, Role.USER) } } it("pageSize가 최대값을 초과하면 예외를 던진다") { shouldThrow { - queryService.findPosts(1L, 0, 51, Role.USER) + queryService.findPosts(clubId, 1L, 0, 51, Role.USER) } } } @@ -248,12 +253,12 @@ class GetPostQueryServiceTest : isNew = false, ) - every { boardRepository.findByIdAndIsDeletedFalse(1L) } returns board + every { boardRepository.findByIdAndClubIdAndIsDeletedFalse(1L, clubId) } returns board every { postRepository.findAllActiveByBoardId(1L, any()) } returns postSlice every { fileReader.findAll(FileOwnerType.POST, any>(), any()) } returns emptyList() every { postMapper.toListResponse(any(), any(), any()) } returns response - val result = queryService.findPosts(1L, 0, 10, Role.USER) + val result = queryService.findPosts(clubId, 1L, 0, 10, Role.USER) result.content.size shouldBe 1 verify(exactly = 1) { fileReader.findAll(FileOwnerType.POST, any>(), any()) } diff --git a/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt b/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt index f212a85e..750db876 100644 --- a/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt @@ -2,6 +2,7 @@ package com.weeth.domain.board.domain.entity import com.weeth.domain.board.domain.enums.BoardType import com.weeth.domain.board.domain.vo.BoardConfig +import com.weeth.domain.board.fixture.BoardTestFixture import com.weeth.domain.user.domain.enums.Role import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.StringSpec @@ -11,7 +12,7 @@ class BoardEntityTest : StringSpec({ "isCommentEnabled는 config 값을 반영한다" { val board = - Board( + BoardTestFixture.create( name = "공지사항", type = BoardType.NOTICE, config = BoardConfig(commentEnabled = false), @@ -21,7 +22,7 @@ class BoardEntityTest : } "rename은 빈 이름이면 예외를 던진다" { - val board = Board(name = "게시판", type = BoardType.GENERAL) + val board = BoardTestFixture.create(name = "게시판", type = BoardType.GENERAL) shouldThrow { board.rename(" ") @@ -30,7 +31,7 @@ class BoardEntityTest : "isAdminOnly는 writePermission이 ADMIN일 때 true를 반환한다" { val board = - Board( + BoardTestFixture.create( name = "공지", type = BoardType.NOTICE, config = BoardConfig(writePermission = Role.ADMIN), @@ -41,7 +42,7 @@ class BoardEntityTest : "isAccessibleBy는 비공개 게시판을 ADMIN에게만 허용한다" { val privateBoard = - Board( + BoardTestFixture.create( name = "운영", type = BoardType.NOTICE, config = BoardConfig(isPrivate = true), @@ -53,14 +54,14 @@ class BoardEntityTest : "canWriteBy는 비공개/관리자 전용 설정을 모두 고려한다" { val privateBoard = - Board(name = "비공개", type = BoardType.GENERAL, config = BoardConfig(isPrivate = true)) + BoardTestFixture.create(name = "비공개", type = BoardType.GENERAL, config = BoardConfig(isPrivate = true)) val adminOnlyBoard = - Board( + BoardTestFixture.create( name = "공지", type = BoardType.NOTICE, config = BoardConfig(writePermission = Role.ADMIN), ) - val publicBoard = Board(name = "일반", type = BoardType.GENERAL, config = BoardConfig()) + val publicBoard = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL, config = BoardConfig()) privateBoard.canWriteBy(Role.USER) shouldBe false privateBoard.canWriteBy(Role.ADMIN) shouldBe true @@ -70,7 +71,7 @@ class BoardEntityTest : } "updateConfig는 config를 교체한다" { - val board = Board(name = "일반", type = BoardType.GENERAL) + val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val newConfig = BoardConfig(commentEnabled = false, isPrivate = true) board.updateConfig(newConfig) @@ -79,7 +80,7 @@ class BoardEntityTest : } "markDeleted와 restore는 삭제 상태를 토글한다" { - val board = Board(name = "운영", type = BoardType.GENERAL) + val board = BoardTestFixture.create(name = "운영", type = BoardType.GENERAL) board.markDeleted() board.isDeleted shouldBe true diff --git a/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt b/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt index 80178524..1e4bf65f 100644 --- a/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt @@ -3,22 +3,30 @@ package com.weeth.domain.board.fixture import com.weeth.domain.board.domain.entity.Board import com.weeth.domain.board.domain.enums.BoardType import com.weeth.domain.board.domain.vo.BoardConfig +import com.weeth.domain.club.domain.entity.Club +import com.weeth.domain.club.fixture.ClubTestFixture import com.weeth.domain.user.domain.enums.Role object BoardTestFixture { fun create( + club: Club = ClubTestFixture.createClub(), name: String = "일반 게시판", type: BoardType = BoardType.GENERAL, config: BoardConfig = BoardConfig(), ): Board = Board( + club = club, name = name, type = type, config = config, ) - fun createNoticeBoard(name: String = "공지사항"): Board = + fun createNoticeBoard( + club: Club = ClubTestFixture.createClub(), + name: String = "공지사항", + ): Board = create( + club = club, name = name, type = BoardType.NOTICE, config = BoardConfig(writePermission = Role.ADMIN), diff --git a/src/test/kotlin/com/weeth/domain/cardinal/application/usecase/command/CardinalUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/cardinal/application/usecase/command/CardinalUseCaseTest.kt index 5817c021..38a0b381 100644 --- a/src/test/kotlin/com/weeth/domain/cardinal/application/usecase/command/CardinalUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/cardinal/application/usecase/command/CardinalUseCaseTest.kt @@ -7,25 +7,39 @@ import com.weeth.domain.cardinal.application.mapper.CardinalMapper import com.weeth.domain.cardinal.application.usecase.query.GetCardinalQueryService import com.weeth.domain.cardinal.domain.entity.Cardinal import com.weeth.domain.cardinal.domain.enums.CardinalStatus +import com.weeth.domain.cardinal.domain.repository.CardinalReader import com.weeth.domain.cardinal.domain.repository.CardinalRepository import com.weeth.domain.cardinal.domain.service.CardinalStatusPolicy import com.weeth.domain.cardinal.fixture.CardinalTestFixture +import com.weeth.domain.club.domain.repository.ClubReader +import com.weeth.domain.club.fixture.ClubTestFixture import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe +import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk import io.mockk.verify import java.time.LocalDateTime -import java.util.Optional class CardinalUseCaseTest : DescribeSpec({ val cardinalRepository = mockk() + val cardinalReader = mockk() val cardinalMapper = mockk() + val clubReader = mockk() val cardinalStatusPolicy = CardinalStatusPolicy(cardinalRepository) - val manageCardinalUseCase = ManageCardinalUseCase(cardinalRepository, cardinalMapper, cardinalStatusPolicy) - val getCardinalQueryService = GetCardinalQueryService(cardinalRepository, cardinalMapper) + val manageCardinalUseCase = + ManageCardinalUseCase(cardinalRepository, cardinalMapper, cardinalStatusPolicy, clubReader) + val getCardinalQueryService = GetCardinalQueryService(cardinalRepository, cardinalReader, cardinalMapper) + + val clubId = 1L + val club = ClubTestFixture.createClub() + + beforeTest { + clearMocks(cardinalRepository, cardinalReader, cardinalMapper, clubReader) + every { clubReader.getClubById(clubId) } returns club + } describe("save") { context("진행중이 아닌 기수라면") { @@ -34,13 +48,13 @@ class CardinalUseCaseTest : val toSave = CardinalTestFixture.createCardinal(cardinalNumber = 7, year = 2025, semester = 1) val saved = CardinalTestFixture.createCardinal(cardinalNumber = 7, year = 2025, semester = 1) - every { cardinalRepository.findByCardinalNumber(7) } returns Optional.empty() - every { cardinalMapper.toEntity(request) } returns toSave + every { cardinalRepository.findByClubIdAndCardinalNumber(clubId, 7) } returns null + every { cardinalMapper.toEntity(club, request) } returns toSave every { cardinalRepository.save(toSave) } returns saved - manageCardinalUseCase.save(request) + manageCardinalUseCase.save(clubId, request) - verify { cardinalRepository.findByCardinalNumber(7) } + verify { cardinalRepository.findByClubIdAndCardinalNumber(clubId, 7) } verify { cardinalRepository.save(toSave) } verify(exactly = 0) { cardinalRepository.findAllInProgressWithLock() } } @@ -56,12 +70,12 @@ class CardinalUseCaseTest : val newCardinalAfterSave = CardinalTestFixture.createCardinal(cardinalNumber = 7, year = 2025, semester = 1) - every { cardinalRepository.findByCardinalNumber(7) } returns Optional.empty() + every { cardinalRepository.findByClubIdAndCardinalNumber(clubId, 7) } returns null every { cardinalRepository.findAllInProgressWithLock() } returns listOf(oldCardinal) - every { cardinalMapper.toEntity(request) } returns newCardinalBeforeSave + every { cardinalMapper.toEntity(club, request) } returns newCardinalBeforeSave every { cardinalRepository.save(newCardinalBeforeSave) } returns newCardinalAfterSave - manageCardinalUseCase.save(request) + manageCardinalUseCase.save(clubId, request) verify { cardinalRepository.findAllInProgressWithLock() } verify { cardinalRepository.save(newCardinalBeforeSave) } @@ -75,9 +89,9 @@ class CardinalUseCaseTest : describe("update") { it("연도와 학기를 변경한다") { val cardinal = CardinalTestFixture.createCardinal(cardinalNumber = 6, year = 2024, semester = 2) - every { cardinalRepository.findById(1L) } returns Optional.of(cardinal) + every { cardinalRepository.findByIdAndClubId(1L, clubId) } returns cardinal - manageCardinalUseCase.update(CardinalUpdateRequest(1L, 2025, 1, false)) + manageCardinalUseCase.update(clubId, CardinalUpdateRequest(1L, 2025, 1, false)) cardinal.year shouldBe 2025 cardinal.semester shouldBe 1 @@ -98,13 +112,13 @@ class CardinalUseCaseTest : val response2 = CardinalResponse(2L, 7, 2025, 1, CardinalStatus.IN_PROGRESS, now.minusDays(2), now) - every { cardinalRepository.findAllByOrderByCardinalNumberAsc() } returns cardinals + every { cardinalReader.findAllByClubIdOrderByCardinalNumberAsc(clubId) } returns cardinals every { cardinalMapper.toResponse(cardinal1) } returns response1 every { cardinalMapper.toResponse(cardinal2) } returns response2 - val responses = getCardinalQueryService.findAll() + val responses = getCardinalQueryService.findAll(clubId) - verify { cardinalRepository.findAllByOrderByCardinalNumberAsc() } + verify { cardinalReader.findAllByClubIdOrderByCardinalNumberAsc(clubId) } verify(exactly = 2) { cardinalMapper.toResponse(any()) } responses shouldHaveSize 2 diff --git a/src/test/kotlin/com/weeth/domain/cardinal/domain/entity/CardinalTest.kt b/src/test/kotlin/com/weeth/domain/cardinal/domain/entity/CardinalTest.kt index 9cc75cad..371a454a 100644 --- a/src/test/kotlin/com/weeth/domain/cardinal/domain/entity/CardinalTest.kt +++ b/src/test/kotlin/com/weeth/domain/cardinal/domain/entity/CardinalTest.kt @@ -1,13 +1,16 @@ package com.weeth.domain.cardinal.domain.entity import com.weeth.domain.cardinal.domain.enums.CardinalStatus +import com.weeth.domain.club.fixture.ClubTestFixture import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.shouldBe class CardinalTest : StringSpec({ + val club = ClubTestFixture.createClub() + "inProgress/done 상태 전환" { - val cardinal = Cardinal(cardinalNumber = 10, year = 2026, semester = 1) + val cardinal = Cardinal(club = club, cardinalNumber = 10, year = 2026, semester = 1) cardinal.inProgress() cardinal.status shouldBe CardinalStatus.IN_PROGRESS @@ -17,7 +20,7 @@ class CardinalTest : } "update는 year/semester를 변경한다" { - val cardinal = Cardinal(cardinalNumber = 9, year = 2025, semester = 2) + val cardinal = Cardinal(club = club, cardinalNumber = 9, year = 2025, semester = 2) cardinal.update(2026, 1) cardinal.year shouldBe 2026 diff --git a/src/test/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalRepositoryTest.kt b/src/test/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalRepositoryTest.kt index 43663356..3c02c706 100644 --- a/src/test/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalRepositoryTest.kt +++ b/src/test/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalRepositoryTest.kt @@ -2,6 +2,8 @@ package com.weeth.domain.cardinal.domain.repository import com.weeth.config.TestContainersConfig import com.weeth.domain.cardinal.fixture.CardinalTestFixture +import com.weeth.domain.club.domain.repository.ClubRepository +import com.weeth.domain.club.fixture.ClubTestFixture import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.optional.shouldBePresent import io.kotest.matchers.shouldBe @@ -14,10 +16,18 @@ import org.springframework.context.annotation.Import @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) class CardinalRepositoryTest( private val cardinalRepository: CardinalRepository, + private val clubRepository: ClubRepository, ) : StringSpec({ "기수번호로 조회된다" { - val cardinal = CardinalTestFixture.createCardinal(cardinalNumber = 7, year = 2025, semester = 1) + val club = clubRepository.save(ClubTestFixture.createClub()) + val cardinal = + CardinalTestFixture.createCardinal( + club = club, + cardinalNumber = 7, + year = 2025, + semester = 1, + ) cardinalRepository.save(cardinal) val result = cardinalRepository.findByCardinalNumber(7) diff --git a/src/test/kotlin/com/weeth/domain/cardinal/fixture/CardinalTestFixture.kt b/src/test/kotlin/com/weeth/domain/cardinal/fixture/CardinalTestFixture.kt index 305c2a2e..5a2f540d 100644 --- a/src/test/kotlin/com/weeth/domain/cardinal/fixture/CardinalTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/cardinal/fixture/CardinalTestFixture.kt @@ -2,15 +2,19 @@ package com.weeth.domain.cardinal.fixture import com.weeth.domain.cardinal.domain.entity.Cardinal import com.weeth.domain.cardinal.domain.enums.CardinalStatus +import com.weeth.domain.club.domain.entity.Club +import com.weeth.domain.club.fixture.ClubTestFixture object CardinalTestFixture { fun createCardinal( id: Long? = null, + club: Club = ClubTestFixture.createClub(), cardinalNumber: Int, year: Int, semester: Int, ): Cardinal = Cardinal( + club = club, id = id ?: 0L, cardinalNumber = cardinalNumber, year = year, @@ -20,11 +24,13 @@ object CardinalTestFixture { fun createCardinalInProgress( id: Long? = null, + club: Club = ClubTestFixture.createClub(), cardinalNumber: Int, year: Int, semester: Int, ): Cardinal = Cardinal( + club = club, id = id ?: 0L, cardinalNumber = cardinalNumber, year = year, diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryServiceTest.kt index 304a53e6..1959362b 100644 --- a/src/test/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryServiceTest.kt @@ -38,8 +38,8 @@ class GetClubMemberQueryServiceTest : val admin = ClubTestFixture.createClubMember(club = club, memberRole = MemberRole.ADMIN) val member = ClubTestFixture.createClubMember(club = club, user = UserTestFixture.createActiveUser1(id = 3L)) - val cardinal7 = Cardinal.create(cardinalNumber = 7) - val cardinal6 = Cardinal.create(cardinalNumber = 6) + val cardinal7 = Cardinal.create(club = club, cardinalNumber = 7) + val cardinal6 = Cardinal.create(club = club, cardinalNumber = 6) val memberCardinals = listOf( ClubMemberCardinal.create(member, cardinal7), diff --git a/src/test/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicyTest.kt b/src/test/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicyTest.kt index 983f5883..658e75d3 100644 --- a/src/test/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicyTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicyTest.kt @@ -110,9 +110,9 @@ class ClubMemberPolicyTest : context("해당 동아리에 속한 멤버인 경우") { it("멤버를 반환해야 한다") { val member = ClubTestFixture.createClubMember() - every { clubMemberReader.findByIdAndClubId(1L, 1L) } returns member + every { clubMemberReader.findByIdOrNull(1L) } returns member - val result = policy.getMemberInClub(1L, 1L) + val result = policy.getMemberInClub(member.club.id, 1L) assert(result == member) } @@ -121,18 +121,17 @@ class ClubMemberPolicyTest : context("멤버는 존재하지만 다른 동아리에 속한 경우") { it("ClubMemberNotInClubException을 발생시켜야 한다") { val member = ClubTestFixture.createClubMember() - every { clubMemberReader.findByIdAndClubId(2L, 1L) } returns null - every { clubMemberReader.findByIdOrNull(2L) } returns member + every { clubMemberReader.findByIdOrNull(1L) } returns member shouldThrow { - policy.getMemberInClub(1L, 2L) + // member.club.id와 다른 clubId를 전달하여 다른 동아리 시나리오를 재현 + policy.getMemberInClub(member.club.id + 999L, 1L) } } } context("멤버 자체가 존재하지 않는 경우") { it("ClubMemberNotFoundException을 발생시켜야 한다") { - every { clubMemberReader.findByIdAndClubId(2L, 1L) } returns null every { clubMemberReader.findByIdOrNull(2L) } returns null shouldThrow { diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt index c96d9ef8..1ff692bb 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt @@ -7,6 +7,8 @@ import com.weeth.domain.board.domain.entity.Post import com.weeth.domain.board.domain.enums.BoardType import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.board.domain.repository.PostRepository +import com.weeth.domain.club.domain.repository.ClubRepository +import com.weeth.domain.club.fixture.ClubTestFixture import com.weeth.domain.comment.application.dto.request.CommentSaveRequest import com.weeth.domain.comment.domain.entity.Comment import com.weeth.domain.comment.domain.repository.CommentRepository @@ -25,6 +27,7 @@ import org.springframework.context.annotation.Import import org.springframework.test.context.ActiveProfiles import org.springframework.transaction.PlatformTransactionManager import org.springframework.transaction.support.TransactionTemplate +import java.util.UUID import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors import java.util.concurrent.ThreadLocalRandom @@ -42,6 +45,7 @@ class CommentConcurrencyTest( private val postRepository: PostRepository, private val userRepository: UserRepository, private val commentRepository: CommentRepository, + private val clubRepository: ClubRepository, private val entityManager: EntityManager, private val atomicCommentCountCommand: AtomicCommentCountCommand, ) : DescribeSpec({ @@ -64,12 +68,15 @@ class CommentConcurrencyTest( val allElapsedMs: List, ) - fun createUsers(size: Int): List = + fun createUsers( + size: Int, + runId: String, + ): List = (1..size).map { i -> userRepository.save( User( name = "user$i", - email = Email.from("user$i@test.com"), + email = Email.from("user-$runId-$i@test.com"), status = Status.ACTIVE, ), ) @@ -78,11 +85,20 @@ class CommentConcurrencyTest( fun createPost( title: String, user: User, + runId: String, ): Post { + val club = + clubRepository.save( + ClubTestFixture.createClub( + name = "테스트 동아리-$runId", + code = "TEST-$runId", + ), + ) val board = boardRepository.save( Board( - name = "concurrency-board", + club = club, + name = "concurrency-board-$runId", type = BoardType.GENERAL, ), ) @@ -100,8 +116,9 @@ class CommentConcurrencyTest( threadCount: Int, saveAction: (postId: Long, userId: Long, index: Int) -> Unit, ): ConcurrencyResult { - val users = createUsers(threadCount) - val post = createPost("동시성 테스트 게시글", users.first()) + val runId = UUID.randomUUID().toString().take(8) + val users = createUsers(threadCount, runId) + val post = createPost("동시성 테스트 게시글-$runId", users.first(), runId) val executor = Executors.newFixedThreadPool(threadCount) val latch = CountDownLatch(threadCount) val successCount = AtomicInteger(0) @@ -189,6 +206,7 @@ class CommentConcurrencyTest( commentRepository.deleteAllInBatch() postRepository.deleteAllInBatch() boardRepository.deleteAllInBatch() + clubRepository.deleteAllInBatch() userRepository.deleteAllInBatch() } diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt index 82f560d1..5fa31592 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt @@ -7,6 +7,8 @@ import com.weeth.domain.board.domain.entity.Post import com.weeth.domain.board.domain.enums.BoardType import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.board.domain.repository.PostRepository +import com.weeth.domain.club.domain.repository.ClubRepository +import com.weeth.domain.club.fixture.ClubTestFixture import com.weeth.domain.comment.application.dto.response.CommentResponse import com.weeth.domain.comment.application.mapper.CommentMapper import com.weeth.domain.comment.domain.entity.Comment @@ -41,6 +43,7 @@ class CommentQueryPerformanceTest( private val postRepository: PostRepository, private val commentRepository: CommentRepository, private val fileRepository: FileRepository, + private val clubRepository: ClubRepository, private val entityManager: EntityManager, ) : DescribeSpec({ val runPerformanceTests = System.getProperty("runPerformanceTests")?.toBoolean() ?: false @@ -56,13 +59,16 @@ class CommentQueryPerformanceTest( ), ) - fun createBoard(): Board = - boardRepository.save( + fun createBoard(): Board { + val club = clubRepository.save(ClubTestFixture.createClub()) + return boardRepository.save( Board( + club = club, name = "perf-board", type = BoardType.GENERAL, ), ) + } fun createPost( user: User, diff --git a/src/test/kotlin/com/weeth/domain/schedule/fixture/ScheduleTestFixture.kt b/src/test/kotlin/com/weeth/domain/schedule/fixture/ScheduleTestFixture.kt index 39fb7995..f68798ce 100644 --- a/src/test/kotlin/com/weeth/domain/schedule/fixture/ScheduleTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/schedule/fixture/ScheduleTestFixture.kt @@ -1,5 +1,7 @@ package com.weeth.domain.schedule.fixture +import com.weeth.domain.club.domain.entity.Club +import com.weeth.domain.club.fixture.ClubTestFixture import com.weeth.domain.schedule.domain.entity.Event import org.springframework.test.util.ReflectionTestUtils import java.time.LocalDateTime @@ -7,6 +9,7 @@ import java.time.LocalDateTime object ScheduleTestFixture { fun createEvent( id: Long = 0L, + club: Club = ClubTestFixture.createClub(), title: String = "Test Event", content: String = "Test Content", location: String = "Test Location", @@ -16,6 +19,7 @@ object ScheduleTestFixture { ): Event { val event = Event.create( + club = club, title = title, content = content, location = location, diff --git a/src/test/kotlin/com/weeth/domain/session/fixture/SessionTestFixture.kt b/src/test/kotlin/com/weeth/domain/session/fixture/SessionTestFixture.kt index d3216b78..11f73243 100644 --- a/src/test/kotlin/com/weeth/domain/session/fixture/SessionTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/session/fixture/SessionTestFixture.kt @@ -1,5 +1,7 @@ package com.weeth.domain.session.fixture +import com.weeth.domain.club.domain.entity.Club +import com.weeth.domain.club.fixture.ClubTestFixture import com.weeth.domain.session.domain.entity.Session import com.weeth.domain.session.domain.enums.SessionStatus import org.springframework.test.util.ReflectionTestUtils @@ -9,6 +11,7 @@ import java.time.LocalDateTime object SessionTestFixture { fun createSession( id: Long = 0L, + club: Club = ClubTestFixture.createClub(), title: String = "Test Session", content: String = "Test Content", location: String = "Test Location", @@ -20,6 +23,7 @@ object SessionTestFixture { ): Session { val session = Session( + club = club, title = title, content = content, location = location, @@ -38,8 +42,10 @@ object SessionTestFixture { cardinal: Int, code: Int, title: String, + club: Club = ClubTestFixture.createClub(), ): Session = Session( + club = club, title = title, location = "Test Location", start = date.atTime(10, 0), @@ -52,8 +58,10 @@ object SessionTestFixture { cardinal: Int, code: Int, title: String, + club: Club = ClubTestFixture.createClub(), ): Session = Session( + club = club, title = title, location = "Test Location", start = LocalDateTime.now().minusMinutes(5), diff --git a/src/test/kotlin/com/weeth/domain/user/domain/repository/UserCardinalRepositoryTest.kt b/src/test/kotlin/com/weeth/domain/user/domain/repository/UserCardinalRepositoryTest.kt index 9433bc03..ee37a7a2 100644 --- a/src/test/kotlin/com/weeth/domain/user/domain/repository/UserCardinalRepositoryTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/domain/repository/UserCardinalRepositoryTest.kt @@ -3,6 +3,8 @@ package com.weeth.domain.user.domain.repository import com.weeth.config.TestContainersConfig import com.weeth.domain.cardinal.domain.repository.CardinalRepository import com.weeth.domain.cardinal.fixture.CardinalTestFixture +import com.weeth.domain.club.domain.repository.ClubRepository +import com.weeth.domain.club.fixture.ClubTestFixture import com.weeth.domain.user.domain.entity.UserCardinal import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.core.spec.style.DescribeSpec @@ -19,24 +21,26 @@ class UserCardinalRepositoryTest( private val userRepository: UserRepository, private val cardinalRepository: CardinalRepository, private val userCardinalRepository: UserCardinalRepository, + private val clubRepository: ClubRepository, ) : DescribeSpec({ describe("findAllByUserOrderByCardinalCardinalNumberDesc") { it("유저별 기수가 내림차순으로 조회된다") { val user = UserTestFixture.createActiveUser1() userRepository.save(user) + val club = clubRepository.save(ClubTestFixture.createClub()) val cardinal1 = cardinalRepository.save( - CardinalTestFixture.createCardinal(cardinalNumber = 5, year = 2023, semester = 1), + CardinalTestFixture.createCardinal(club = club, cardinalNumber = 5, year = 2023, semester = 1), ) val cardinal2 = cardinalRepository.save( - CardinalTestFixture.createCardinal(cardinalNumber = 6, year = 2023, semester = 2), + CardinalTestFixture.createCardinal(club = club, cardinalNumber = 6, year = 2023, semester = 2), ) val cardinal3 = cardinalRepository.save( - CardinalTestFixture.createCardinal(cardinalNumber = 7, year = 2024, semester = 1), + CardinalTestFixture.createCardinal(club = club, cardinalNumber = 7, year = 2024, semester = 1), ) userCardinalRepository.saveAll( @@ -62,22 +66,23 @@ class UserCardinalRepositoryTest( val user2 = UserTestFixture.createActiveUser2() userRepository.save(user1) userRepository.save(user2) + val club = clubRepository.save(ClubTestFixture.createClub()) val c1 = cardinalRepository.save( - CardinalTestFixture.createCardinal(cardinalNumber = 5, year = 2023, semester = 1), + CardinalTestFixture.createCardinal(club = club, cardinalNumber = 5, year = 2023, semester = 1), ) val c2 = cardinalRepository.save( - CardinalTestFixture.createCardinal(cardinalNumber = 6, year = 2023, semester = 2), + CardinalTestFixture.createCardinal(club = club, cardinalNumber = 6, year = 2023, semester = 2), ) val c3 = cardinalRepository.save( - CardinalTestFixture.createCardinal(cardinalNumber = 7, year = 2024, semester = 1), + CardinalTestFixture.createCardinal(club = club, cardinalNumber = 7, year = 2024, semester = 1), ) val c4 = cardinalRepository.save( - CardinalTestFixture.createCardinal(cardinalNumber = 8, year = 2024, semester = 2), + CardinalTestFixture.createCardinal(club = club, cardinalNumber = 8, year = 2024, semester = 2), ) userCardinalRepository.saveAll( diff --git a/src/test/kotlin/com/weeth/domain/user/domain/repository/UserRepositoryTest.kt b/src/test/kotlin/com/weeth/domain/user/domain/repository/UserRepositoryTest.kt index b25a4540..c407d2da 100644 --- a/src/test/kotlin/com/weeth/domain/user/domain/repository/UserRepositoryTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/domain/repository/UserRepositoryTest.kt @@ -3,6 +3,8 @@ package com.weeth.domain.user.domain.repository import com.weeth.config.TestContainersConfig import com.weeth.domain.cardinal.domain.repository.CardinalRepository import com.weeth.domain.cardinal.fixture.CardinalTestFixture +import com.weeth.domain.club.domain.repository.ClubRepository +import com.weeth.domain.club.fixture.ClubTestFixture import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.fixture.UserCardinalTestFixture import com.weeth.domain.user.fixture.UserTestFixture @@ -21,19 +23,21 @@ class UserRepositoryTest( private val userRepository: UserRepository, private val userCardinalRepository: UserCardinalRepository, private val cardinalRepository: CardinalRepository, + private val clubRepository: ClubRepository, ) : DescribeSpec({ lateinit var cardinal7: com.weeth.domain.cardinal.domain.entity.Cardinal lateinit var cardinal8: com.weeth.domain.cardinal.domain.entity.Cardinal beforeEach { + val club = clubRepository.save(ClubTestFixture.createClub()) cardinal7 = cardinalRepository.save( - CardinalTestFixture.createCardinal(cardinalNumber = 7, year = 2026, semester = 1), + CardinalTestFixture.createCardinal(club = club, cardinalNumber = 7, year = 2026, semester = 1), ) cardinal8 = cardinalRepository.save( - CardinalTestFixture.createCardinal(cardinalNumber = 8, year = 2026, semester = 2), + CardinalTestFixture.createCardinal(club = club, cardinalNumber = 8, year = 2026, semester = 2), ) val user1 = userRepository.save(UserTestFixture.createActiveUser1()) diff --git a/src/test/kotlin/com/weeth/domain/user/fixture/SessionTestFixture.kt b/src/test/kotlin/com/weeth/domain/user/fixture/SessionTestFixture.kt index 9c87c018..1471d9f2 100644 --- a/src/test/kotlin/com/weeth/domain/user/fixture/SessionTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/user/fixture/SessionTestFixture.kt @@ -1,5 +1,6 @@ package com.weeth.domain.user.fixture +import com.weeth.domain.club.fixture.ClubTestFixture import com.weeth.domain.session.domain.entity.Session import java.time.LocalDateTime @@ -12,6 +13,7 @@ object SessionTestFixture { code: Int = 1234, ): Session = Session( + club = ClubTestFixture.createClub(), title = title, cardinal = cardinalNumber, start = start, From dac0a80fad9226b2ffe09d8423f5c75b164a7576 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=84=EC=88=98=ED=98=84?= <128474444+soo0711@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:07:08 +0900 Subject: [PATCH 22/73] =?UTF-8?q?[WTH-180]=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20api=20=EA=B5=AC=ED=98=84=20(#24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: NoticeRead 엔티티 및 Repository 추가 * feat: 공지 읽음 처리 유스케이스 및 API 추가 * feat: 대시보드용 Reader 인터페이스 추가 * feat: 대시보드 DTO, Mapper, Exception 추가 * feat: 대시보드 QueryService 구현 * feat: 대시보드 Controller 구현 * test: 대시보드 및 공지 읽음 처리 테스트 추가 * docs: 대시보드 도메인 ID API 설계 문서 추가 * docs: Swagger 도메인 코드 범위에 Club, Dashboard 추가 * style: 불필요한 주석 제거 * style: 불필요한 import문 삭제 * refactor: 공지 읽음 ID 조회 시 기간 제한 추가 * refactor: EventRepository findByDateRange @Query 제거 * refactor: 사용하지 않는 메서드 제거 * style: 작성자 프로필 이미지 미구현 TODO 주석 추가 * refactor: 미사용 메서드 제거 * fix: 공지 읽음 처리 동시 요청 시 중복 저장 예외 처리 * fix: PostRepository 쿼리 보조 정렬 키 추가 * refactor: 공지 읽음 처리 API에 boardId 범위 제한 추가 * refactor: myClubs 조회 시 활성 멤버십만 반환하도록 수정 * refactor: validateMembership에서 비활성 멤버 접근 차단 * refactor: clubId api 지침에 맞게 수정 * style: 린트 적용 * refactor: NoticeRead 방식을 LastNoticeRead 방식으로 수정 * refactor: clubInfo DTO 초대코드 필드 추가 * refactor: 스케줄 일정 enum으로 변경 * refactor: 최신공지 응답 구조 수정 * refactor: MVP 미지원 홈 응답 필드 임시 제외 * refactor: home 대시보드에 내활동 응답 추가 * refactor: 대시보드 게시글/공지 조회 시 해당 클럽 검증 및 클럽 범위 필터링 적용 * style: 린트 적용 * refactor: 공지 읽음 처리 club 검증 추가 * fix: 대시보드 공지 시간 필드를 정렬 기준인 createdAt으로 통일 * test: 공지 읽음 갱신 시 lastReadAt 변경 여부 단언 추가 * refactor: SessionReader에 findByDateRange API로 통일 * refactor: 불필요한 쿼리문 삭제 --- .claude/rules/api-design.md | 5 + .../application/exception/BoardErrorCode.kt | 6 + .../exception/BoardNotInClubException.kt | 5 + .../exception/BoardTypeMismatchException.kt | 5 + .../usecase/command/MarkNoticeReadUseCase.kt | 48 ++++ .../board/domain/entity/LastNoticeRead.kt | 54 ++++ .../domain/repository/LastNoticeReadReader.kt | 10 + .../repository/LastNoticeReadRepository.kt | 13 + .../board/domain/repository/PostReader.kt | 42 +++ .../board/domain/repository/PostRepository.kt | 110 ++++++- .../board/presentation/BoardResponseCode.kt | 1 + .../board/presentation/PostController.kt | 17 +- .../club/application/mapper/ClubMapper.kt | 1 - .../domain/repository/ClubMemberReader.kt | 4 + .../domain/repository/ClubMemberRepository.kt | 24 ++ .../dto/response/DashboardClubInfoResponse.kt | 22 ++ .../dto/response/DashboardHomeResponse.kt | 17 ++ .../dto/response/DashboardMyClubResponse.kt | 16 ++ .../dto/response/DashboardMyInfoResponse.kt | 12 + .../dto/response/DashboardNoticeResponse.kt | 17 ++ .../dto/response/DashboardPostResponse.kt | 28 ++ .../dto/response/DashboardScheduleResponse.kt | 18 ++ .../response/DashboardUnreadNoticeResponse.kt | 12 + .../exception/DashboardErrorCode.kt | 14 + .../DashboardNotClubMemberException.kt | 5 + .../application/mapper/DashboardMapper.kt | 131 +++++++++ .../usecase/query/GetDashboardQueryService.kt | 134 +++++++++ .../dashboard/domain/enums/ScheduleType.kt | 6 + .../presentation/DashboardController.kt | 96 +++++++ .../presentation/DashboardResponseCode.kt | 16 ++ .../schedule/domain/repository/EventReader.kt | 13 + .../domain/repository/EventRepository.kt | 16 +- .../domain/repository/SessionReader.kt | 5 + .../domain/repository/SessionRepository.kt | 15 +- .../com/weeth/global/config/SwaggerConfig.kt | 2 + .../command/MarkNoticeReadUseCaseTest.kt | 122 ++++++++ .../query/GetDashboardQueryServiceTest.kt | 271 ++++++++++++++++++ 37 files changed, 1326 insertions(+), 7 deletions(-) create mode 100644 src/main/kotlin/com/weeth/domain/board/application/exception/BoardNotInClubException.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/exception/BoardTypeMismatchException.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/usecase/command/MarkNoticeReadUseCase.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/domain/entity/LastNoticeRead.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/domain/repository/LastNoticeReadReader.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/domain/repository/LastNoticeReadRepository.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/domain/repository/PostReader.kt create mode 100644 src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardClubInfoResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardHomeResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardMyClubResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardMyInfoResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardNoticeResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardPostResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardScheduleResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardUnreadNoticeResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/dashboard/application/exception/DashboardErrorCode.kt create mode 100644 src/main/kotlin/com/weeth/domain/dashboard/application/exception/DashboardNotClubMemberException.kt create mode 100644 src/main/kotlin/com/weeth/domain/dashboard/application/mapper/DashboardMapper.kt create mode 100644 src/main/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryService.kt create mode 100644 src/main/kotlin/com/weeth/domain/dashboard/domain/enums/ScheduleType.kt create mode 100644 src/main/kotlin/com/weeth/domain/dashboard/presentation/DashboardController.kt create mode 100644 src/main/kotlin/com/weeth/domain/dashboard/presentation/DashboardResponseCode.kt create mode 100644 src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventReader.kt create mode 100644 src/test/kotlin/com/weeth/domain/board/application/usecase/command/MarkNoticeReadUseCaseTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryServiceTest.kt diff --git a/.claude/rules/api-design.md b/.claude/rules/api-design.md index 3fb48ce4..66445fb5 100644 --- a/.claude/rules/api-design.md +++ b/.claude/rules/api-design.md @@ -104,6 +104,7 @@ enum class UserResponseCode( | 09 | user | 10900~ | 20900~ | — | | 10 | cardinal | 11000~ | 21000~ | — | | 11 | club | 11100~ | 21100~ | — | +| 12 | dashboard | 11200~ | 21200~ | — | | 90 | jwt/auth | — | 29000~ | — | | 99 | common | — | — | 39900~ | @@ -121,6 +122,8 @@ enum class UserResponseCode( | Schedule | `ScheduleResponseCode` | `108xx` | `domain/schedule/presentation/` | | User | `UserResponseCode` | `109xx` | `domain/user/presentation/` | | Cardinal | `CardinalResponseCode` | `110xx` | `domain/cardinal/presentation/` | +| Club | `ClubResponseCode` | `111xx` | `domain/club/presentation/` | +| Dashboard | `DashboardResponseCode` | `112xx` | `domain/dashboard/presentation/` | ## Domain Error Codes @@ -136,6 +139,8 @@ enum class UserResponseCode( | Schedule | `EventErrorCode` | `208xx` | `domain/schedule/application/exception/` | | User | `UserErrorCode` | `209xx` | `domain/user/application/exception/` | | Cardinal | `CardinalErrorCode` | `210xx` | `domain/cardinal/application/exception/` | +| Club | `ClubErrorCode` | `211xx` | `domain/club/application/exception/` | +| Dashboard | `DashboardErrorCode` | `212xx` | `domain/dashboard/application/exception/` | | JWT (Global) | `JwtErrorCode` | `290xx` | `global/auth/jwt/application/exception/` | ## HTTP Methods diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt index 4dca1f23..57733dfb 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt @@ -26,4 +26,10 @@ enum class BoardErrorCode( @ExplainError("게시글 작성자가 아닌 사용자가 수정/삭제를 시도할 때 발생합니다.") POST_NOT_OWNED(20405, HttpStatus.FORBIDDEN, "게시글 작성자만 수정/삭제할 수 있습니다."), + + @ExplainError("공지 게시판이 아닌 게시판에 읽음 처리를 시도할 때 발생합니다.") + BOARD_TYPE_MISMATCH(20406, HttpStatus.BAD_REQUEST, "공지 게시판이 아닙니다."), + + @ExplainError("경로의 clubId와 게시판의 소속 클럽이 일치하지 않을 때 발생합니다.") + BOARD_NOT_IN_CLUB(20407, HttpStatus.FORBIDDEN, "해당 클럽에 속한 게시판이 아닙니다."), } diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/BoardNotInClubException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardNotInClubException.kt new file mode 100644 index 00000000..fb91f82e --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardNotInClubException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class BoardNotInClubException : BaseException(BoardErrorCode.BOARD_NOT_IN_CLUB) diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/BoardTypeMismatchException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardTypeMismatchException.kt new file mode 100644 index 00000000..1b6042df --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardTypeMismatchException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class BoardTypeMismatchException : BaseException(BoardErrorCode.BOARD_TYPE_MISMATCH) diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/MarkNoticeReadUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/MarkNoticeReadUseCase.kt new file mode 100644 index 00000000..1f41eebd --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/MarkNoticeReadUseCase.kt @@ -0,0 +1,48 @@ +package com.weeth.domain.board.application.usecase.command + +import com.weeth.domain.board.application.exception.BoardNotFoundException +import com.weeth.domain.board.application.exception.BoardNotInClubException +import com.weeth.domain.board.application.exception.BoardTypeMismatchException +import com.weeth.domain.board.domain.entity.LastNoticeRead +import com.weeth.domain.board.domain.enums.BoardType +import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.board.domain.repository.LastNoticeReadReader +import com.weeth.domain.board.domain.repository.LastNoticeReadRepository +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.user.domain.repository.UserReader +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +class MarkNoticeReadUseCase( + private val boardRepository: BoardRepository, + private val lastNoticeReadReader: LastNoticeReadReader, + private val lastNoticeReadRepository: LastNoticeReadRepository, + private val userReader: UserReader, + private val clubMemberPolicy: ClubMemberPolicy, +) { + @Transactional + fun execute( + userId: Long, + clubId: Long, + boardId: Long, + ) { + clubMemberPolicy.getActiveMember(clubId, userId) + + val board = + boardRepository.findByIdAndIsDeletedFalse(boardId) + ?: throw BoardNotFoundException() + if (board.club.id != clubId) throw BoardNotInClubException() + if (board.type != BoardType.NOTICE) throw BoardTypeMismatchException() + + val existing = lastNoticeReadReader.findByUserIdAndBoardId(userId, boardId) + if (existing != null) { + existing.updateLastReadAt(LocalDateTime.now()) + return + } + + val user = userReader.getById(userId) + lastNoticeReadRepository.save(LastNoticeRead.create(user = user, board = board)) + } +} diff --git a/src/main/kotlin/com/weeth/domain/board/domain/entity/LastNoticeRead.kt b/src/main/kotlin/com/weeth/domain/board/domain/entity/LastNoticeRead.kt new file mode 100644 index 00000000..cb052c3f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/entity/LastNoticeRead.kt @@ -0,0 +1,54 @@ +package com.weeth.domain.board.domain.entity + +import com.weeth.domain.user.domain.entity.User +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint +import java.time.LocalDateTime + +@Entity +@Table( + name = "last_notice_read", + uniqueConstraints = [UniqueConstraint(columnNames = ["user_id", "board_id"])], +) +class LastNoticeRead( + user: User, + board: Board, +) { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long = 0L + private set + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + var user: User = user + private set + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "board_id", nullable = false) + var board: Board = board + private set + + @Column(nullable = false) + var lastReadAt: LocalDateTime = LocalDateTime.now() + private set + + fun updateLastReadAt(time: LocalDateTime) { + lastReadAt = time + } + + companion object { + fun create( + user: User, + board: Board, + ) = LastNoticeRead(user = user, board = board) + } +} diff --git a/src/main/kotlin/com/weeth/domain/board/domain/repository/LastNoticeReadReader.kt b/src/main/kotlin/com/weeth/domain/board/domain/repository/LastNoticeReadReader.kt new file mode 100644 index 00000000..edd3fac0 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/repository/LastNoticeReadReader.kt @@ -0,0 +1,10 @@ +package com.weeth.domain.board.domain.repository + +import com.weeth.domain.board.domain.entity.LastNoticeRead + +interface LastNoticeReadReader { + fun findByUserIdAndBoardId( + userId: Long, + boardId: Long, + ): LastNoticeRead? +} diff --git a/src/main/kotlin/com/weeth/domain/board/domain/repository/LastNoticeReadRepository.kt b/src/main/kotlin/com/weeth/domain/board/domain/repository/LastNoticeReadRepository.kt new file mode 100644 index 00000000..bbb241c8 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/repository/LastNoticeReadRepository.kt @@ -0,0 +1,13 @@ +package com.weeth.domain.board.domain.repository + +import com.weeth.domain.board.domain.entity.LastNoticeRead +import org.springframework.data.jpa.repository.JpaRepository + +interface LastNoticeReadRepository : + JpaRepository, + LastNoticeReadReader { + override fun findByUserIdAndBoardId( + userId: Long, + boardId: Long, + ): LastNoticeRead? +} diff --git a/src/main/kotlin/com/weeth/domain/board/domain/repository/PostReader.kt b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostReader.kt new file mode 100644 index 00000000..6c000ef9 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostReader.kt @@ -0,0 +1,42 @@ +package com.weeth.domain.board.domain.repository + +import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.board.domain.enums.BoardType +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Slice +import java.time.LocalDateTime + +interface PostReader { + fun getById(postId: Long): Post + + fun findActiveById(postId: Long): Post? + + fun findRecentByBoardType( + boardType: BoardType, + pageable: Pageable, + ): Slice + + fun findRecentExcludingBoardType( + excludedType: BoardType, + pageable: Pageable, + ): Slice + + fun findRecentByClubIdAndBoardType( + clubId: Long, + boardType: BoardType, + pageable: Pageable, + ): Slice + + fun findRecentByClubIdExcludingBoardType( + clubId: Long, + excludedType: BoardType, + pageable: Pageable, + ): Slice + + fun findFirstUnreadNoticeSince( + clubId: Long, + userId: Long, + boardType: BoardType, + since: LocalDateTime, + ): Post? +} diff --git a/src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt index 297acba0..13b15464 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt @@ -1,8 +1,11 @@ package com.weeth.domain.board.domain.repository +import com.weeth.domain.board.application.exception.PostNotFoundException import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.board.domain.enums.BoardType import jakarta.persistence.LockModeType import jakarta.persistence.QueryHint +import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Pageable import org.springframework.data.domain.Slice import org.springframework.data.jpa.repository.EntityGraph @@ -11,8 +14,11 @@ import org.springframework.data.jpa.repository.Lock import org.springframework.data.jpa.repository.Query import org.springframework.data.jpa.repository.QueryHints import org.springframework.data.repository.query.Param +import java.time.LocalDateTime -interface PostRepository : JpaRepository { +interface PostRepository : + JpaRepository, + PostReader { @EntityGraph(attributePaths = ["user"]) @Query( """ @@ -74,4 +80,106 @@ interface PostRepository : JpaRepository { @Param("keyword") keyword: String, pageable: Pageable, ): Slice + + override fun getById(postId: Long): Post = findActivePostById(postId) ?: throw PostNotFoundException() + + override fun findActiveById(postId: Long): Post? = findActivePostById(postId) + + @EntityGraph(attributePaths = ["user"]) + @Query( + """ + SELECT p + FROM Post p + WHERE p.board.type = :boardType + AND p.isDeleted = false + AND p.board.isDeleted = false + ORDER BY p.createdAt DESC, p.id DESC + """, + ) + override fun findRecentByBoardType( + @Param("boardType") boardType: BoardType, + pageable: Pageable, + ): Slice + + @EntityGraph(attributePaths = ["user"]) + @Query( + """ + SELECT p + FROM Post p + WHERE p.board.type <> :excludedType + AND p.isDeleted = false + AND p.board.isDeleted = false + ORDER BY p.createdAt DESC, p.id DESC + """, + ) + override fun findRecentExcludingBoardType( + @Param("excludedType") excludedType: BoardType, + pageable: Pageable, + ): Slice + + @EntityGraph(attributePaths = ["user"]) + @Query( + """ + SELECT p + FROM Post p + WHERE p.board.club.id = :clubId + AND p.board.type = :boardType + AND p.isDeleted = false + AND p.board.isDeleted = false + ORDER BY p.createdAt DESC, p.id DESC + """, + ) + override fun findRecentByClubIdAndBoardType( + @Param("clubId") clubId: Long, + @Param("boardType") boardType: BoardType, + pageable: Pageable, + ): Slice + + @EntityGraph(attributePaths = ["user"]) + @Query( + """ + SELECT p + FROM Post p + WHERE p.board.club.id = :clubId + AND p.board.type <> :excludedType + AND p.isDeleted = false + AND p.board.isDeleted = false + ORDER BY p.createdAt DESC, p.id DESC + """, + ) + override fun findRecentByClubIdExcludingBoardType( + @Param("clubId") clubId: Long, + @Param("excludedType") excludedType: BoardType, + pageable: Pageable, + ): Slice + + @EntityGraph(attributePaths = ["user"]) + @Query( + """ + SELECT p + FROM Post p + LEFT JOIN LastNoticeRead lr ON lr.user.id = :userId AND lr.board.id = p.board.id + WHERE p.board.club.id = :clubId + AND p.board.type = :boardType + AND p.isDeleted = false + AND p.board.isDeleted = false + AND p.createdAt >= :since + AND (lr IS NULL OR p.createdAt > lr.lastReadAt) + ORDER BY p.createdAt DESC, p.id DESC + """, + ) + fun findUnreadNoticeSince( + @Param("clubId") clubId: Long, + @Param("userId") userId: Long, + @Param("boardType") boardType: BoardType, + @Param("since") since: LocalDateTime, + pageable: Pageable, + ): List + + override fun findFirstUnreadNoticeSince( + clubId: Long, + userId: Long, + boardType: BoardType, + since: LocalDateTime, + ): Post? = findUnreadNoticeSince(clubId, userId, boardType, since, PageRequest.of(0, 1)).firstOrNull() } diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt b/src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt index ae3bf958..16ac57c6 100644 --- a/src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt @@ -19,4 +19,5 @@ enum class BoardResponseCode( BOARD_DELETED_SUCCESS(10408, HttpStatus.OK, "게시판이 성공적으로 삭제되었습니다."), BOARD_FIND_ALL_SUCCESS(10409, HttpStatus.OK, "게시판 목록이 성공적으로 조회되었습니다."), BOARD_FIND_BY_ID_SUCCESS(10410, HttpStatus.OK, "게시판이 성공적으로 조회되었습니다."), + BOARD_NOTICE_READ_SUCCESS(10411, HttpStatus.OK, "공지를 읽음 처리했습니다."), } diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt b/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt index 297f06de..f3a82957 100644 --- a/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt +++ b/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt @@ -7,10 +7,12 @@ import com.weeth.domain.board.application.dto.response.PostListResponse import com.weeth.domain.board.application.dto.response.PostSaveResponse import com.weeth.domain.board.application.exception.BoardErrorCode import com.weeth.domain.board.application.usecase.command.ManagePostUseCase +import com.weeth.domain.board.application.usecase.command.MarkNoticeReadUseCase import com.weeth.domain.board.application.usecase.query.GetPostQueryService import com.weeth.domain.user.domain.enums.Role import com.weeth.global.auth.annotation.CurrentUser import com.weeth.global.auth.annotation.CurrentUserRole +import com.weeth.global.auth.jwt.application.exception.JwtErrorCode import com.weeth.global.common.exception.ApiErrorCodeExample import com.weeth.global.common.response.CommonResponse import com.weeth.global.common.web.TsidParam @@ -33,10 +35,11 @@ import org.springframework.web.bind.annotation.RestController @Tag(name = "BOARD", description = "게시글 API") @RestController @RequestMapping("/api/v4/clubs/{clubId}/boards") -@ApiErrorCodeExample(BoardErrorCode::class) +@ApiErrorCodeExample(BoardErrorCode::class, JwtErrorCode::class) class PostController( private val managePostUseCase: ManagePostUseCase, private val getPostQueryService: GetPostQueryService, + private val markNoticeReadUseCase: MarkNoticeReadUseCase, ) { @PostMapping("/{boardId}/posts") @Operation(summary = "게시글 작성") @@ -122,5 +125,17 @@ class PostController( getPostQueryService.searchPosts(clubId, boardId, keyword, pageNumber, pageSize, role), ) + @PostMapping("/{boardId}/notices/read-all") + @Operation(summary = "공지 읽음 처리", description = "공지 게시판 진입 시 마지막 읽음 시간을 현재 시각으로 갱신합니다.") + fun markAllNoticesRead( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable boardId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + markNoticeReadUseCase.execute(userId, clubId, boardId) + return CommonResponse.success(BoardResponseCode.BOARD_NOTICE_READ_SUCCESS) + } + // todo: 좋아요 관련 API 추가 } diff --git a/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt b/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt index a9ed2660..9a6f9737 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt @@ -9,7 +9,6 @@ import com.weeth.domain.club.domain.entity.Club import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.club.domain.entity.ClubMemberCardinal import com.weeth.global.common.id.TsidBase62Encoder -import org.hibernate.metamodel.model.domain.internal.MapMember import org.springframework.stereotype.Component @Component diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt index b855d82d..e742eeb4 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt @@ -20,4 +20,8 @@ interface ClubMemberReader { fun findAllByClubId(clubId: Long): List fun findAllByUserId(userId: Long): List + + fun findActiveByUserId(userId: Long): List + + fun countActiveByClubId(clubId: Long): Long } diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt index f13ec47c..b665d7d5 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt @@ -37,4 +37,28 @@ interface ClubMemberRepository : ): List override fun findAllByUserId(userId: Long): List + + @Query( + """ + SELECT cm + FROM ClubMember cm + WHERE cm.user.id = :userId + AND cm.memberStatus = com.weeth.domain.club.domain.enums.MemberStatus.ACTIVE + """, + ) + override fun findActiveByUserId( + @Param("userId") userId: Long, + ): List + + @Query( + """ + SELECT COUNT(cm) + FROM ClubMember cm + WHERE cm.club.id = :clubId + AND cm.memberStatus = com.weeth.domain.club.domain.enums.MemberStatus.ACTIVE + """, + ) + override fun countActiveByClubId( + @Param("clubId") clubId: Long, + ): Long } diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardClubInfoResponse.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardClubInfoResponse.kt new file mode 100644 index 00000000..bf0a7519 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardClubInfoResponse.kt @@ -0,0 +1,22 @@ +package com.weeth.domain.dashboard.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class DashboardClubInfoResponse( + @field:Schema(description = "동아리 ID (Base62)", example = "1A2b3C") + val id: String, + @field:Schema(description = "동아리 이름", example = "Leets") + val name: String, + @field:Schema(description = "초대 코드", example = "550e8400-e29b-41d4-a716-446655440000") + val code: String, + @field:Schema(description = "학교 이름", example = "가천대학교") + val schoolName: String, + @field:Schema(description = "동아리 설명", example = "IT 동아리") + val description: String?, + @field:Schema(description = "활성 멤버 수", example = "70") + val memberCount: Long, + @field:Schema(description = "프로필 이미지 URL") + val profileImageUrl: String?, + @field:Schema(description = "배경 이미지 URL") + val backgroundImageUrl: String?, +) diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardHomeResponse.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardHomeResponse.kt new file mode 100644 index 00000000..1ae57883 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardHomeResponse.kt @@ -0,0 +1,17 @@ +package com.weeth.domain.dashboard.application.dto.response + +import com.fasterxml.jackson.annotation.JsonIgnore +import io.swagger.v3.oas.annotations.media.Schema + +data class DashboardHomeResponse( + @field:Schema(description = "현재 동아리 정보") + val club: DashboardClubInfoResponse, + @field:Schema(description = "내 활동 정보") + val myInfo: DashboardMyInfoResponse, + // MVP 제외 (이후 개발 시 @field:Schema(description = "오늘의 일정") 추가) + @JsonIgnore + val todaySchedules: List, + // MVP 제외 (이후 개발 시 @field:Schema(description = "가입한 동아리 목록") 추가) + @JsonIgnore + val myClubs: List, +) diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardMyClubResponse.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardMyClubResponse.kt new file mode 100644 index 00000000..9d0fad29 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardMyClubResponse.kt @@ -0,0 +1,16 @@ +package com.weeth.domain.dashboard.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class DashboardMyClubResponse( + @field:Schema(description = "동아리 ID (Base62)", example = "1A2b3C") + val id: String, + @field:Schema(description = "동아리 이름", example = "Leets") + val name: String, + @field:Schema(description = "학교 이름", example = "가천대학교") + val schoolName: String, + @field:Schema(description = "동아리 설명", example = "IT 동아리") + val description: String?, + @field:Schema(description = "프로필 이미지 URL") + val profileImageUrl: String?, +) diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardMyInfoResponse.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardMyInfoResponse.kt new file mode 100644 index 00000000..f59dc57a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardMyInfoResponse.kt @@ -0,0 +1,12 @@ +package com.weeth.domain.dashboard.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class DashboardMyInfoResponse( + @field:Schema(description = "이름", example = "홍길동") + val name: String, + @field:Schema(description = "프로필 이미지 URL") + val profileImageUrl: String?, + @field:Schema(description = "자기소개") + val bio: String?, +) diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardNoticeResponse.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardNoticeResponse.kt new file mode 100644 index 00000000..207b19b2 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardNoticeResponse.kt @@ -0,0 +1,17 @@ +package com.weeth.domain.dashboard.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class DashboardNoticeResponse( + @field:Schema(description = "게시글 ID", example = "1") + val id: Long, + @field:Schema(description = "공지 제목", example = "중간고사 기간 공지") + val title: String, + @field:Schema(description = "공지 내용", example = "이번 주 정기 모임은 중간고사 기간으로 인해 쉬어갑니다.") + val content: String, + @field:Schema(description = "최종 수정 일시") + val time: LocalDateTime, + @field:Schema(description = "24시간 내 새 공지 여부", example = "true") + val isNew: Boolean, +) diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardPostResponse.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardPostResponse.kt new file mode 100644 index 00000000..b65cc05d --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardPostResponse.kt @@ -0,0 +1,28 @@ +package com.weeth.domain.dashboard.application.dto.response + +import com.weeth.domain.file.application.dto.response.FileResponse +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class DashboardPostResponse( + @field:Schema(description = "게시글 ID", example = "1") + val id: Long, + @field:Schema(description = "작성자 이름", example = "홍길동") + val name: String, + @field:Schema(description = "작성자 프로필 이미지 URL") + val authorProfileImageUrl: String?, + @field:Schema(description = "제목", example = "안녕하세요") + val title: String, + @field:Schema(description = "내용", example = "오늘은 날씨가 좋네요") + val content: String, + @field:Schema(description = "작성일") + val time: LocalDateTime, + @field:Schema(description = "댓글 수", example = "5") + val commentCount: Int, + @field:Schema(description = "좋아요 수", example = "3") + val likeCount: Int, + @field:Schema(description = "첨부 파일 목록") + val fileUrls: List, + @field:Schema(description = "24시간 내 새 게시글 여부", example = "true") + val isNew: Boolean, +) diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardScheduleResponse.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardScheduleResponse.kt new file mode 100644 index 00000000..74f6d7d0 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardScheduleResponse.kt @@ -0,0 +1,18 @@ +package com.weeth.domain.dashboard.application.dto.response + +import com.weeth.domain.dashboard.domain.enums.ScheduleType +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class DashboardScheduleResponse( + @field:Schema(description = "일정 ID", example = "1") + val id: Long, + @field:Schema(description = "일정 제목", example = "Spring 스터디") + val title: String, + @field:Schema(description = "시작 일시", example = "2026-03-09T14:00:00") + val start: LocalDateTime, + @field:Schema(description = "종료 일시", example = "2026-03-09T16:00:00") + val end: LocalDateTime, + @field:Schema(description = "일정 유형", example = "EVENT") + val type: ScheduleType, +) diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardUnreadNoticeResponse.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardUnreadNoticeResponse.kt new file mode 100644 index 00000000..6111e0ef --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardUnreadNoticeResponse.kt @@ -0,0 +1,12 @@ +package com.weeth.domain.dashboard.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class DashboardUnreadNoticeResponse( + @field:Schema(description = "게시글 ID", example = "1") + val id: Long, + @field:Schema(description = "공지 제목", example = "중간고사 기간 공지") + val title: String, + @field:Schema(description = "공지 내용", example = "이번 주 정기 모임은 중간고사 기간으로 인해 쉬어갑니다.") + val content: String, +) diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/exception/DashboardErrorCode.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/exception/DashboardErrorCode.kt new file mode 100644 index 00000000..48909f8a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/exception/DashboardErrorCode.kt @@ -0,0 +1,14 @@ +package com.weeth.domain.dashboard.application.exception + +import com.weeth.global.common.exception.ErrorCodeInterface +import com.weeth.global.common.exception.ExplainError +import org.springframework.http.HttpStatus + +enum class DashboardErrorCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ErrorCodeInterface { + @ExplainError("사용자가 해당 동아리의 활성 멤버가 아닐 때 발생합니다.") + NOT_CLUB_MEMBER(21200, HttpStatus.FORBIDDEN, "해당 동아리의 멤버가 아닙니다."), +} diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/exception/DashboardNotClubMemberException.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/exception/DashboardNotClubMemberException.kt new file mode 100644 index 00000000..1e6ebb72 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/exception/DashboardNotClubMemberException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.dashboard.application.exception + +import com.weeth.global.common.exception.BaseException + +class DashboardNotClubMemberException : BaseException(DashboardErrorCode.NOT_CLUB_MEMBER) diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/mapper/DashboardMapper.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/mapper/DashboardMapper.kt new file mode 100644 index 00000000..77e7c140 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/mapper/DashboardMapper.kt @@ -0,0 +1,131 @@ +package com.weeth.domain.dashboard.application.mapper + +import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.club.domain.entity.Club +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.dashboard.application.dto.response.DashboardClubInfoResponse +import com.weeth.domain.dashboard.application.dto.response.DashboardHomeResponse +import com.weeth.domain.dashboard.application.dto.response.DashboardMyClubResponse +import com.weeth.domain.dashboard.application.dto.response.DashboardMyInfoResponse +import com.weeth.domain.dashboard.application.dto.response.DashboardNoticeResponse +import com.weeth.domain.dashboard.application.dto.response.DashboardPostResponse +import com.weeth.domain.dashboard.application.dto.response.DashboardScheduleResponse +import com.weeth.domain.dashboard.application.dto.response.DashboardUnreadNoticeResponse +import com.weeth.domain.dashboard.domain.enums.ScheduleType +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.entity.File +import com.weeth.domain.schedule.domain.entity.Event +import com.weeth.domain.session.domain.entity.Session +import com.weeth.domain.user.domain.entity.User +import com.weeth.global.common.id.TsidBase62Encoder +import org.springframework.stereotype.Component +import java.time.LocalDateTime + +@Component +class DashboardMapper( + private val fileMapper: FileMapper, +) { + fun toClubInfoResponse( + club: Club, + memberCount: Long, + ) = DashboardClubInfoResponse( + id = TsidBase62Encoder.encode(club.id), + name = club.name, + schoolName = club.schoolName, + description = club.description, + memberCount = memberCount, + profileImageUrl = club.profileImageUrl, + backgroundImageUrl = club.backgroundImageUrl, + code = club.code, + ) + + fun toMyInfoResponse(user: User) = + DashboardMyInfoResponse( + name = user.name, + profileImageUrl = null, // TODO: 프로필 이미지 기능 구현 후 연동 + bio = null, // TODO: 자기소개 기능 구현 후 연동 + ) + + fun toHomeResponse( + club: Club, + memberCount: Long, + myInfo: DashboardMyInfoResponse, + todaySchedules: List, + myClubs: List, + ) = DashboardHomeResponse( + club = toClubInfoResponse(club, memberCount), + myInfo = myInfo, + todaySchedules = todaySchedules, + myClubs = myClubs, + ) + + fun toMyClubResponse(cm: ClubMember) = + DashboardMyClubResponse( + id = TsidBase62Encoder.encode(cm.club.id), + name = cm.club.name, + schoolName = cm.club.schoolName, + description = cm.club.description, + profileImageUrl = cm.club.profileImageUrl, + ) + + fun toScheduleResponses( + events: List, + sessions: List, + ): List = + (events.map(::toScheduleResponse) + sessions.map(::toScheduleResponse)) + .sortedBy { it.start } + + fun toScheduleResponse(event: Event) = + DashboardScheduleResponse( + id = event.id, + title = event.title, + start = event.start, + end = event.end, + type = ScheduleType.EVENT, + ) + + fun toScheduleResponse(session: Session) = + DashboardScheduleResponse( + id = session.id, + title = session.title, + start = session.start, + end = session.end, + type = ScheduleType.SESSION, + ) + + fun toPostResponse( + post: Post, + authorProfileImage: File?, + files: List, + now: LocalDateTime, + ) = DashboardPostResponse( + id = post.id, + name = post.user.name, + authorProfileImageUrl = authorProfileImage?.let { fileMapper.toFileResponse(it).fileUrl }, + title = post.title, + content = post.content, + time = post.createdAt, + commentCount = post.commentCount, + likeCount = post.likeCount, + fileUrls = files.map(fileMapper::toFileResponse), + isNew = post.createdAt.isAfter(now.minusHours(24)), + ) + + fun toNoticeResponse( + post: Post, + now: LocalDateTime, + ) = DashboardNoticeResponse( + id = post.id, + title = post.title, + content = post.content, + time = post.createdAt, + isNew = post.createdAt.isAfter(now.minusHours(24)), + ) + + fun toUnreadNoticeResponse(post: Post) = + DashboardUnreadNoticeResponse( + id = post.id, + title = post.title, + content = post.content, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryService.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryService.kt new file mode 100644 index 00000000..9f9e470e --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryService.kt @@ -0,0 +1,134 @@ +package com.weeth.domain.dashboard.application.usecase.query + +import com.weeth.domain.board.domain.enums.BoardType +import com.weeth.domain.board.domain.repository.PostReader +import com.weeth.domain.club.domain.repository.ClubMemberReader +import com.weeth.domain.club.domain.repository.ClubReader +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.dashboard.application.dto.response.DashboardHomeResponse +import com.weeth.domain.dashboard.application.dto.response.DashboardNoticeResponse +import com.weeth.domain.dashboard.application.dto.response.DashboardPostResponse +import com.weeth.domain.dashboard.application.dto.response.DashboardScheduleResponse +import com.weeth.domain.dashboard.application.dto.response.DashboardUnreadNoticeResponse +import com.weeth.domain.dashboard.application.mapper.DashboardMapper +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.repository.FileReader +import com.weeth.domain.schedule.domain.repository.EventReader +import com.weeth.domain.session.domain.repository.SessionReader +import com.weeth.domain.user.domain.repository.UserReader +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Slice +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate +import java.time.LocalDateTime + +@Service +@Transactional(readOnly = true) +class GetDashboardQueryService( + private val clubReader: ClubReader, + private val clubMemberReader: ClubMemberReader, + private val clubMemberPolicy: ClubMemberPolicy, + private val eventReader: EventReader, + private val sessionReader: SessionReader, + private val postReader: PostReader, + private val fileReader: FileReader, + private val userReader: UserReader, + private val dashboardMapper: DashboardMapper, +) { + fun getHome( + clubId: Long, + userId: Long, + ): DashboardHomeResponse { + clubMemberPolicy.getActiveMember(clubId, userId) + + val club = clubReader.getClubById(clubId) + val memberCount = clubMemberReader.countActiveByClubId(clubId) + + // TODO: 해당 클럽 회원인지 검증 후 클럽의 오늘 일정만 조회 + val todayStart = LocalDate.now().atStartOfDay() + val todayEnd = todayStart.plusDays(1).minusNanos(1) + val todayEvents = eventReader.findByDateRange(todayStart, todayEnd) + val todaySessions = sessionReader.findByDateRange(todayStart, todayEnd) + + val myClubs = clubMemberReader.findActiveByUserId(userId).map(dashboardMapper::toMyClubResponse) + val myInfo = dashboardMapper.toMyInfoResponse(userReader.getById(userId)) + + return dashboardMapper.toHomeResponse( + club = club, + memberCount = memberCount, + myInfo = myInfo, + todaySchedules = dashboardMapper.toScheduleResponses(todayEvents, todaySessions), + myClubs = myClubs, + ) + } + + fun getRecentPosts( + clubId: Long, + userId: Long, + pageNumber: Int, + pageSize: Int, + ): Slice { + clubMemberPolicy.getActiveMember(clubId, userId) + + val posts = + postReader.findRecentByClubIdExcludingBoardType( + clubId, + BoardType.NOTICE, + PageRequest.of(pageNumber, pageSize), + ) + val now = LocalDateTime.now() + val postIds = posts.content.map { it.id } + val filesByPostId = fileReader.findAll(FileOwnerType.POST, postIds).groupBy { it.ownerId } + + return posts.map { post -> + dashboardMapper.toPostResponse( + post = post, + authorProfileImage = null, // TODO: 유저 프로필 이미지 기능 구현 후 연동 + files = filesByPostId[post.id] ?: emptyList(), + now = now, + ) + } + } + + fun getRecentNotices( + clubId: Long, + userId: Long, + size: Int, + ): List { + clubMemberPolicy.getActiveMember(clubId, userId) + + val notices = postReader.findRecentByClubIdAndBoardType(clubId, BoardType.NOTICE, PageRequest.of(0, size)) + val now = LocalDateTime.now() + + return notices.content.map { dashboardMapper.toNoticeResponse(it, now) } + } + + fun getMonthlySchedules( + clubId: Long, + userId: Long, + ): List { + clubMemberPolicy.getActiveMember(clubId, userId) + + // TODO: 해당 클럽 회원인지 검증 후 클럽의 일정만 조회 + val monthStart = LocalDate.now().withDayOfMonth(1).atStartOfDay() + val monthEnd = monthStart.plusMonths(1).minusNanos(1) + + val events = eventReader.findByDateRange(monthStart, monthEnd) + val sessions = sessionReader.findByDateRange(monthStart, monthEnd) + + return dashboardMapper.toScheduleResponses(events, sessions) + } + + fun getUnreadNotice( + clubId: Long, + userId: Long, + ): DashboardUnreadNoticeResponse? { + clubMemberPolicy.getActiveMember(clubId, userId) + + val since = LocalDateTime.now().minusWeeks(2) + return postReader + .findFirstUnreadNoticeSince(clubId, userId, BoardType.NOTICE, since) + ?.let(dashboardMapper::toUnreadNoticeResponse) + } +} diff --git a/src/main/kotlin/com/weeth/domain/dashboard/domain/enums/ScheduleType.kt b/src/main/kotlin/com/weeth/domain/dashboard/domain/enums/ScheduleType.kt new file mode 100644 index 00000000..262ad4aa --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/dashboard/domain/enums/ScheduleType.kt @@ -0,0 +1,6 @@ +package com.weeth.domain.dashboard.domain.enums + +enum class ScheduleType { + SESSION, + EVENT, +} diff --git a/src/main/kotlin/com/weeth/domain/dashboard/presentation/DashboardController.kt b/src/main/kotlin/com/weeth/domain/dashboard/presentation/DashboardController.kt new file mode 100644 index 00000000..09e2dad6 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/dashboard/presentation/DashboardController.kt @@ -0,0 +1,96 @@ +package com.weeth.domain.dashboard.presentation + +import com.weeth.domain.club.application.exception.ClubErrorCode +import com.weeth.domain.dashboard.application.dto.response.DashboardHomeResponse +import com.weeth.domain.dashboard.application.dto.response.DashboardNoticeResponse +import com.weeth.domain.dashboard.application.dto.response.DashboardPostResponse +import com.weeth.domain.dashboard.application.dto.response.DashboardScheduleResponse +import com.weeth.domain.dashboard.application.dto.response.DashboardUnreadNoticeResponse +import com.weeth.domain.dashboard.application.exception.DashboardErrorCode +import com.weeth.domain.dashboard.application.usecase.query.GetDashboardQueryService +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.auth.jwt.application.exception.JwtErrorCode +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.data.domain.Slice +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "DASHBOARD", description = "대시보드 API") +@RestController +@RequestMapping("/api/v4/clubs/{clubId}/dashboard") +@ApiErrorCodeExample(DashboardErrorCode::class, ClubErrorCode::class, JwtErrorCode::class) +class DashboardController( + private val getDashboardQueryService: GetDashboardQueryService, +) { + @GetMapping("/home") + @Operation(summary = "홈 조회") + fun getHome( + @PathVariable @TsidParam + @TsidPathVariable("clubId") clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success( + DashboardResponseCode.DASHBOARD_HOME_SUCCESS, + getDashboardQueryService.getHome(clubId, userId), + ) + + @GetMapping("/recent-posts") + @Operation(summary = "최신 게시글 조회") + fun getRecentPosts( + @PathVariable @TsidParam + @TsidPathVariable("clubId") clubId: Long, + @RequestParam(defaultValue = "0") pageNumber: Int, + @RequestParam(defaultValue = "10") pageSize: Int, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse> = + CommonResponse.success( + DashboardResponseCode.DASHBOARD_RECENT_POSTS_SUCCESS, + getDashboardQueryService.getRecentPosts(clubId, userId, pageNumber, pageSize), + ) + + @GetMapping("/recent-notices") + @Operation(summary = "최신 공지 조회") + fun getRecentNotices( + @PathVariable @TsidParam + @TsidPathVariable("clubId") clubId: Long, + @RequestParam(defaultValue = "5") size: Int, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse> = + CommonResponse.success( + DashboardResponseCode.DASHBOARD_RECENT_NOTICES_SUCCESS, + getDashboardQueryService.getRecentNotices(clubId, userId, size), + ) + + @GetMapping("/monthly-schedules") + @Operation(summary = "월간 일정 조회") + fun getMonthlySchedules( + @PathVariable @TsidParam + @TsidPathVariable("clubId") clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse> = + CommonResponse.success( + DashboardResponseCode.DASHBOARD_MONTHLY_SCHEDULES_SUCCESS, + getDashboardQueryService.getMonthlySchedules(clubId, userId), + ) + + @GetMapping("/unread-notice") + @Operation(summary = "2주 이내 읽지 않은 공지 조회") + fun getUnreadNotice( + @PathVariable @TsidParam + @TsidPathVariable("clubId") clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success( + DashboardResponseCode.DASHBOARD_UNREAD_NOTICE_SUCCESS, + getDashboardQueryService.getUnreadNotice(clubId, userId), + ) +} diff --git a/src/main/kotlin/com/weeth/domain/dashboard/presentation/DashboardResponseCode.kt b/src/main/kotlin/com/weeth/domain/dashboard/presentation/DashboardResponseCode.kt new file mode 100644 index 00000000..de2164a6 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/dashboard/presentation/DashboardResponseCode.kt @@ -0,0 +1,16 @@ +package com.weeth.domain.dashboard.presentation + +import com.weeth.global.common.response.ResponseCodeInterface +import org.springframework.http.HttpStatus + +enum class DashboardResponseCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ResponseCodeInterface { + DASHBOARD_HOME_SUCCESS(11200, HttpStatus.OK, "홈 정보를 성공적으로 조회했습니다."), + DASHBOARD_RECENT_POSTS_SUCCESS(11201, HttpStatus.OK, "최신 게시글을 성공적으로 조회했습니다."), + DASHBOARD_RECENT_NOTICES_SUCCESS(11202, HttpStatus.OK, "최신 공지를 성공적으로 조회했습니다."), + DASHBOARD_MONTHLY_SCHEDULES_SUCCESS(11203, HttpStatus.OK, "월간 일정을 성공적으로 조회했습니다."), + DASHBOARD_UNREAD_NOTICE_SUCCESS(11204, HttpStatus.OK, "읽지 않은 공지를 성공적으로 조회했습니다."), +} diff --git a/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventReader.kt b/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventReader.kt new file mode 100644 index 00000000..79e6ea5c --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventReader.kt @@ -0,0 +1,13 @@ +package com.weeth.domain.schedule.domain.repository + +import com.weeth.domain.schedule.domain.entity.Event +import java.time.LocalDateTime + +interface EventReader { + fun findByDateRange( + start: LocalDateTime, + end: LocalDateTime, + ): List + + fun findAllByCardinal(cardinal: Int): List +} diff --git a/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventRepository.kt b/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventRepository.kt index 49d58cad..2f3aec04 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventRepository.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventRepository.kt @@ -6,7 +6,21 @@ import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param import java.time.LocalDateTime -interface EventRepository : JpaRepository { +interface EventRepository : + JpaRepository, + EventReader { + fun findByStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc( + end: LocalDateTime, + start: LocalDateTime, + ): List + + override fun findByDateRange( + start: LocalDateTime, + end: LocalDateTime, + ): List = findByStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc(end, start) + + override fun findAllByCardinal(cardinal: Int): List + fun findAllByClubIdAndCardinal( clubId: Long, cardinal: Int, diff --git a/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionReader.kt b/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionReader.kt index a8f0b96a..ec7a0367 100644 --- a/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionReader.kt +++ b/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionReader.kt @@ -7,6 +7,11 @@ import java.time.LocalDateTime interface SessionReader { fun getById(sessionId: Long): Session + fun findByDateRange( + start: LocalDateTime, + end: LocalDateTime, + ): List + fun findAllByCardinal(cardinal: Int): List fun findAllByCardinalOrderByStartAsc(cardinal: Int): List diff --git a/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionRepository.kt b/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionRepository.kt index 8a65979f..91b0664c 100644 --- a/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionRepository.kt +++ b/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionRepository.kt @@ -50,9 +50,18 @@ interface SessionRepository : @Param("end") end: LocalDateTime, ): List - @Query("SELECT s FROM Session s WHERE s.club.id = :clubId AND s.cardinal IN :cardinals") + fun findByStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc( + end: LocalDateTime, + start: LocalDateTime, + ): List + + override fun findByDateRange( + start: LocalDateTime, + end: LocalDateTime, + ): List = findByStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc(end, start) + override fun findAllByClubIdAndCardinalIn( - @Param("clubId") clubId: Long, - @Param("cardinals") cardinals: List, + clubId: Long, + cardinals: List, ): List } diff --git a/src/main/kotlin/com/weeth/global/config/SwaggerConfig.kt b/src/main/kotlin/com/weeth/global/config/SwaggerConfig.kt index 96603b35..68274790 100644 --- a/src/main/kotlin/com/weeth/global/config/SwaggerConfig.kt +++ b/src/main/kotlin/com/weeth/global/config/SwaggerConfig.kt @@ -41,6 +41,8 @@ private const val SWAGGER_DESCRIPTION = "| Schedule | 08 | 108xx | 208xx | — |\n" + "| User | 09 | 109xx | 209xx | — |\n" + "| Cardinal | 10 | 110xx | 210xx | — |\n" + + "| Club | 11 | 111xx | 211xx | — |\n" + + "| Dashboard | 12 | 112xx | 212xx | — |\n" + "| Auth/JWT | 90 | — | 290xx | — |\n\n" + "> 각 API의 상세 응답 예시는 Swagger의 **Responses** 섹션에서 확인하세요." diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/MarkNoticeReadUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/MarkNoticeReadUseCaseTest.kt new file mode 100644 index 00000000..54fc525b --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/MarkNoticeReadUseCaseTest.kt @@ -0,0 +1,122 @@ +package com.weeth.domain.board.application.usecase.command + +import com.weeth.domain.board.application.exception.BoardNotInClubException +import com.weeth.domain.board.application.exception.BoardTypeMismatchException +import com.weeth.domain.board.domain.entity.LastNoticeRead +import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.board.domain.repository.LastNoticeReadReader +import com.weeth.domain.board.domain.repository.LastNoticeReadRepository +import com.weeth.domain.board.fixture.BoardTestFixture +import com.weeth.domain.club.domain.repository.ClubMemberReader +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.fixture.ClubTestFixture +import com.weeth.domain.user.domain.repository.UserReader +import com.weeth.domain.user.fixture.UserTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.date.shouldBeAfter +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.springframework.test.util.ReflectionTestUtils + +class MarkNoticeReadUseCaseTest : + DescribeSpec({ + val boardRepository = mockk() + val lastNoticeReadReader = mockk() + val lastNoticeReadRepository = mockk() + val userReader = mockk() + val clubMemberReader = mockk() + val clubMemberPolicy = ClubMemberPolicy(clubMemberReader) + + val useCase = + MarkNoticeReadUseCase( + boardRepository = boardRepository, + lastNoticeReadReader = lastNoticeReadReader, + lastNoticeReadRepository = lastNoticeReadRepository, + userReader = userReader, + clubMemberPolicy = clubMemberPolicy, + ) + + beforeTest { + clearMocks(boardRepository, lastNoticeReadReader, lastNoticeReadRepository, userReader, clubMemberReader) + } + + describe("execute") { + val userId = 1L + val clubId = 1L + val boardId = 1L + val user = UserTestFixture.createActiveUser1(userId) + val club = ClubTestFixture.createClub().also { ReflectionTestUtils.setField(it, "id", clubId) } + val clubMember = ClubTestFixture.createClubMember(club = club, user = user) + val noticeBoard = BoardTestFixture.createNoticeBoard(club = club) + + context("클럽 멤버가 아닌 경우") { + it("ClubMemberNotFoundException을 던진다") { + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns null + + shouldThrow { + useCase.execute(userId, clubId, boardId) + } + } + } + + context("다른 클럽의 게시판인 경우") { + it("BoardNotInClubException을 던진다") { + val otherClub = ClubTestFixture.createClub().also { ReflectionTestUtils.setField(it, "id", 99L) } + val boardInOtherClub = BoardTestFixture.createNoticeBoard(club = otherClub) + + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns clubMember + every { boardRepository.findByIdAndIsDeletedFalse(boardId) } returns boardInOtherClub + + shouldThrow { + useCase.execute(userId, clubId, boardId) + } + } + } + + context("공지 타입이 아닌 게시판인 경우") { + it("BoardTypeMismatchException을 던진다") { + val generalBoard = BoardTestFixture.create(club = club) + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns clubMember + every { boardRepository.findByIdAndIsDeletedFalse(boardId) } returns generalBoard + + shouldThrow { + useCase.execute(userId, clubId, boardId) + } + } + } + + context("이미 읽은 기록이 있는 경우") { + it("lastReadAt을 현재 시각으로 갱신하고 새 레코드를 저장하지 않는다") { + val existing = LastNoticeRead.create(user = user, board = noticeBoard) + val beforeExecute = existing.lastReadAt + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns clubMember + every { boardRepository.findByIdAndIsDeletedFalse(boardId) } returns noticeBoard + every { lastNoticeReadReader.findByUserIdAndBoardId(userId, boardId) } returns existing + + useCase.execute(userId, clubId, boardId) + + existing.lastReadAt shouldBeAfter beforeExecute + verify(exactly = 0) { userReader.getById(any()) } + verify(exactly = 0) { lastNoticeReadRepository.save(any()) } + } + } + + context("처음 읽는 경우") { + it("새 LastNoticeRead 레코드를 저장한다") { + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns clubMember + every { boardRepository.findByIdAndIsDeletedFalse(boardId) } returns noticeBoard + every { lastNoticeReadReader.findByUserIdAndBoardId(userId, boardId) } returns null + every { userReader.getById(userId) } returns user + every { lastNoticeReadRepository.save(any()) } answers { firstArg() } + + useCase.execute(userId, clubId, boardId) + + verify(exactly = 1) { userReader.getById(userId) } + verify(exactly = 1) { lastNoticeReadRepository.save(any()) } + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryServiceTest.kt new file mode 100644 index 00000000..3a05bc3d --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryServiceTest.kt @@ -0,0 +1,271 @@ +package com.weeth.domain.dashboard.application.usecase.query + +import com.weeth.domain.board.domain.enums.BoardType +import com.weeth.domain.board.domain.repository.PostReader +import com.weeth.domain.board.fixture.BoardTestFixture +import com.weeth.domain.board.fixture.PostTestFixture +import com.weeth.domain.club.application.exception.ClubMemberNotFoundException +import com.weeth.domain.club.application.exception.MemberNotActiveException +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.repository.ClubMemberReader +import com.weeth.domain.club.domain.repository.ClubReader +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.fixture.ClubTestFixture +import com.weeth.domain.dashboard.application.mapper.DashboardMapper +import com.weeth.domain.dashboard.domain.enums.ScheduleType +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.repository.FileReader +import com.weeth.domain.schedule.domain.repository.EventReader +import com.weeth.domain.schedule.fixture.ScheduleTestFixture +import com.weeth.domain.session.domain.repository.SessionReader +import com.weeth.domain.session.fixture.SessionTestFixture +import com.weeth.domain.user.domain.repository.UserReader +import com.weeth.domain.user.fixture.UserTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.SliceImpl +import java.time.LocalDateTime + +class GetDashboardQueryServiceTest : + DescribeSpec({ + val clubReader = mockk() + val clubMemberReader = mockk() + val clubMemberPolicy = ClubMemberPolicy(clubMemberReader) + val eventReader = mockk() + val sessionReader = mockk() + val postReader = mockk() + val fileReader = mockk() + val userReader = mockk() + val fileMapper = mockk() + val dashboardMapper = DashboardMapper(fileMapper) + + val queryService = + GetDashboardQueryService( + clubReader = clubReader, + clubMemberReader = clubMemberReader, + clubMemberPolicy = clubMemberPolicy, + eventReader = eventReader, + sessionReader = sessionReader, + postReader = postReader, + fileReader = fileReader, + userReader = userReader, + dashboardMapper = dashboardMapper, + ) + + val clubId = 1L + val userId = 1L + val club = ClubTestFixture.createClub() + val clubMember = ClubTestFixture.createClubMember(club = club) + val user = UserTestFixture.createActiveUser1(userId) + + beforeTest { + clearMocks( + clubReader, + clubMemberReader, + eventReader, + sessionReader, + postReader, + fileReader, + userReader, + fileMapper, + ) + } + + describe("getHome") { + context("활성 멤버인 경우") { + it("홈 정보를 반환한다") { + every { clubReader.getClubById(clubId) } returns club + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns clubMember + every { clubMemberReader.countActiveByClubId(clubId) } returns 10L + every { eventReader.findByDateRange(any(), any()) } returns emptyList() + every { + sessionReader.findByDateRange(any(), any()) + } returns emptyList() + every { clubMemberReader.findActiveByUserId(userId) } returns listOf(clubMember) + every { userReader.getById(userId) } returns user + + val result = queryService.getHome(clubId, userId) + + result shouldNotBe null + result.club.memberCount shouldBe 10L + result.myClubs.size shouldBe 1 + } + } + + context("멤버가 아닌 경우") { + it("ClubMemberNotFoundException을 던진다") { + every { clubReader.getClubById(clubId) } returns club + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns null + + shouldThrow { + queryService.getHome(clubId, userId) + } + } + } + + context("비활성 멤버인 경우") { + it("MemberNotActiveException을 던진다") { + val inactiveMember = + ClubTestFixture.createClubMember( + club = club, + memberStatus = MemberStatus.BANNED, + ) + every { clubReader.getClubById(clubId) } returns club + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns inactiveMember + + shouldThrow { + queryService.getHome(clubId, userId) + } + } + } + + context("오늘 일정이 있는 경우") { + it("이벤트와 세션을 시작 시간순으로 정렬하여 반환한다") { + val event = + ScheduleTestFixture.createEvent( + id = 1L, + start = LocalDateTime.now().withHour(10).withMinute(0), + end = LocalDateTime.now().withHour(12).withMinute(0), + ) + val session = + SessionTestFixture.createSession( + id = 2L, + start = LocalDateTime.now().withHour(14).withMinute(0), + end = LocalDateTime.now().withHour(16).withMinute(0), + ) + + every { clubReader.getClubById(clubId) } returns club + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns clubMember + every { clubMemberReader.countActiveByClubId(clubId) } returns 5L + every { eventReader.findByDateRange(any(), any()) } returns listOf(event) + every { + sessionReader.findByDateRange(any(), any()) + } returns listOf(session) + every { clubMemberReader.findActiveByUserId(userId) } returns listOf(clubMember) + every { userReader.getById(userId) } returns user + + val result = queryService.getHome(clubId, userId) + + result.todaySchedules.size shouldBe 2 + result.todaySchedules[0].type shouldBe ScheduleType.EVENT + result.todaySchedules[1].type shouldBe ScheduleType.SESSION + } + } + } + + describe("getRecentPosts") { + context("멤버인 경우") { + it("공지 제외한 최신 게시글을 반환한다") { + val board = BoardTestFixture.create(type = BoardType.GENERAL) + val post = PostTestFixture.create(board = board) + val pageable = PageRequest.of(0, 10) + val slice = SliceImpl(listOf(post), pageable, false) + + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns clubMember + every { postReader.findRecentByClubIdExcludingBoardType(clubId, BoardType.NOTICE, any()) } returns + slice + every { fileReader.findAll(FileOwnerType.POST, any>()) } returns emptyList() + + val result = queryService.getRecentPosts(clubId, userId, 0, 10) + + result.content.size shouldBe 1 + result.content[0].fileUrls.isEmpty() shouldBe true + } + } + + context("멤버가 아닌 경우") { + it("ClubMemberNotFoundException을 던진다") { + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns null + + shouldThrow { + queryService.getRecentPosts(clubId, userId, 0, 10) + } + } + } + } + + describe("getRecentNotices") { + context("멤버인 경우") { + it("최신 공지 목록을 반환한다") { + val noticeBoard = BoardTestFixture.create(type = BoardType.NOTICE) + val notice = PostTestFixture.create(board = noticeBoard) + val pageable = PageRequest.of(0, 5) + val slice = SliceImpl(listOf(notice), pageable, false) + + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns clubMember + every { postReader.findRecentByClubIdAndBoardType(clubId, BoardType.NOTICE, any()) } returns slice + + val result = queryService.getRecentNotices(clubId, userId, 5) + + result.size shouldBe 1 + } + } + } + + describe("getMonthlySchedules") { + context("멤버인 경우") { + it("월간 일정 목록을 시작 시간순으로 반환한다") { + val event = ScheduleTestFixture.createEvent(id = 1L) + + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns clubMember + every { eventReader.findByDateRange(any(), any()) } returns listOf(event) + every { + sessionReader.findByDateRange(any(), any()) + } returns emptyList() + + val result = queryService.getMonthlySchedules(clubId, userId) + + result.size shouldBe 1 + result[0].type shouldBe ScheduleType.EVENT + } + } + } + + describe("getUnreadNotice") { + context("읽지 않은 공지가 있는 경우") { + it("읽지 않은 최신 공지 1건을 반환한다") { + val noticeBoard = BoardTestFixture.create(type = BoardType.NOTICE) + val notice = PostTestFixture.create(board = noticeBoard) + + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns clubMember + every { postReader.findFirstUnreadNoticeSince(clubId, userId, BoardType.NOTICE, any()) } returns + notice + + val result = queryService.getUnreadNotice(clubId, userId) + + result shouldNotBe null + } + } + + context("모든 공지를 읽은 경우") { + it("null을 반환한다") { + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns clubMember + every { postReader.findFirstUnreadNoticeSince(clubId, userId, BoardType.NOTICE, any()) } returns + null + + val result = queryService.getUnreadNotice(clubId, userId) + + result shouldBe null + } + } + + context("2주 내 공지가 없는 경우") { + it("null을 반환한다") { + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns clubMember + every { postReader.findFirstUnreadNoticeSince(clubId, userId, BoardType.NOTICE, any()) } returns + null + + val result = queryService.getUnreadNotice(clubId, userId) + + result shouldBe null + } + } + } + }) From 5d3d4f4281b73c0deae38bb2d6188bed291f025b Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:11:39 +0900 Subject: [PATCH 23/73] =?UTF-8?q?[WTH-189]=20club=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=A7=88=EB=AC=B4=EB=A6=AC=20=EC=9E=91=EC=97=85=20(#25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: Account 도메인 ClubId 추가 및 접근 제어 * refactor: Board 도메인 ClubId 추가 및 접근 제어 * refactor: Cardinal 도메인 ClubId 추가 및 접근 제어 * refactor: CLub 도메인 리팩토링 * refactor: CLub 도메인 리팩토링 * refactor: schedule 도메인 club 추가 및 접근 제어 * refactor: session 도메인 club 추가 및 접근 제어 * refactor: ManageCardinalUseCase.kt 접근제어 추가 * refactor: applyOb 메서드 ClubMember로 이전 * refactor: attendance 도메인 clubMember 추가 * refactor: apply-ob api 추가 * refactor: apply-ob dto 추가 * refactor: ClubMember 조회시 clubId 추가 * refactor: Penalty clubMember 추가 * refactor: 접근 제어 추가 * refactor: 기수 정책 추가 * refactor: 미사용 API 제거 * refactor: 미사용 코드 제거 * docs: 주석 추가 * refactor: response code 추가 * refactor: mock 제거 * refactor: lint 설정 * test: ClubMemberCardinalPolicyTest 추가 * refactor: 에러코드 수정 * docs: 주석 추가 * refactor: 코드 정리 * refactor: 테스트 오류 수정 * docs: TODO 주석 추가 * refactor: isCurrent 메서드명 변경 및 주석 추가 * refactor: 리뷰 내용 반영 --- .../application/exception/AccountErrorCode.kt | 2 +- .../usecase/command/ManageAccountUseCase.kt | 6 +- .../usecase/command/ManageReceiptUseCase.kt | 47 +++- .../usecase/query/GetAccountQueryService.kt | 5 +- .../domain/repository/AccountRepository.kt | 4 - .../presentation/AccountAdminController.kt | 5 +- .../account/presentation/AccountController.kt | 5 +- .../presentation/ReceiptAdminController.kt | 21 +- .../application/mapper/AttendanceMapper.kt | 20 +- .../usecase/command/GenerateQrTokenUseCase.kt | 10 +- .../command/ManageAttendanceUseCase.kt | 61 ++++-- .../query/GetAttendanceQueryService.kt | 40 ++-- .../attendance/domain/entity/Attendance.kt | 13 +- .../domain/repository/AttendanceRepository.kt | 51 ++--- .../presentation/AttendanceAdminController.kt | 27 ++- .../presentation/AttendanceController.kt | 19 +- .../application/exception/BoardErrorCode.kt | 2 +- .../usecase/command/ManageBoardUseCase.kt | 18 +- .../usecase/command/ManagePostUseCase.kt | 11 +- .../usecase/query/GetBoardQueryService.kt | 26 ++- .../usecase/query/GetPostQueryService.kt | 19 +- .../presentation/BoardAdminController.kt | 20 +- .../board/presentation/BoardController.kt | 9 +- .../board/presentation/PostController.kt | 15 +- .../usecase/command/ManageCardinalUseCase.kt | 10 +- .../usecase/query/GetCardinalQueryService.kt | 15 +- .../domain/repository/CardinalReader.kt | 6 + .../domain/repository/CardinalRepository.kt | 12 ++ .../domain/service/CardinalStatusPolicy.kt | 1 + .../presentation/CardinalAdminController.kt | 8 +- .../presentation/CardinalController.kt | 8 +- .../dto/request/ClubMemberApplyObRequest.kt | 10 + .../usecase/command/AdminClubMemberUseCase.kt | 62 +++++- .../command/ManageClubMemberUsecase.kt | 1 + .../usecase/command/ManageClubUseCase.kt | 2 - .../domain/club/domain/enums/MemberRole.kt | 2 + .../domain/repository/ClubMemberReader.kt | 13 +- .../domain/repository/ClubMemberRepository.kt | 24 ++- .../service/ClubMemberCardinalPolicy.kt | 37 ++++ .../club/domain/service/ClubMemberPolicy.kt | 2 + .../club/presentation/ClubAdminController.kt | 49 +++-- .../club/presentation/ClubController.kt | 21 +- .../club/presentation/ClubResponseCode.kt | 1 + .../application/mapper/PenaltyMapper.kt | 24 +-- .../usecase/command/DeletePenaltyUseCase.kt | 41 ++-- .../usecase/command/SavePenaltyUseCase.kt | 40 ++-- .../usecase/command/UpdatePenaltyUseCase.kt | 12 +- .../usecase/query/GetPenaltyQueryService.kt | 56 ++--- .../domain/penalty/domain/entity/Penalty.kt | 6 +- .../domain/repository/PenaltyRepository.kt | 40 ++-- .../presentation/PenaltyAdminController.kt | 28 ++- .../presentation/PenaltyUserController.kt | 10 +- .../application/exception/EventErrorCode.kt | 2 +- .../usecase/command/ManageEventUseCase.kt | 10 +- .../usecase/query/GetScheduleQueryService.kt | 22 +- .../presentation/EventAdminController.kt | 3 +- .../schedule/presentation/EventController.kt | 15 +- .../presentation/ScheduleController.kt | 8 +- .../usecase/command/ManageSessionUseCase.kt | 38 +++- .../usecase/query/GetSessionQueryService.kt | 8 +- .../presentation/SessionAdminController.kt | 10 +- .../dto/request/UserApplyObRequest.kt | 11 - .../dto/response/AdminUserResponse.kt | 39 ---- .../dto/response/UserDetailsResponse.kt | 21 -- .../UserCardinalNotFoundException.kt | 5 - .../application/exception/UserErrorCode.kt | 9 +- .../user/application/mapper/UserMapper.kt | 60 +----- .../usecase/command/AdminUserUseCase.kt | 109 ---------- .../usecase/query/GetUserQueryService.kt | 93 +------- .../weeth/domain/user/domain/entity/User.kt | 53 ----- .../domain/user/domain/entity/UserCardinal.kt | 48 ----- .../domain/repository/UserCardinalReader.kt | 12 -- .../repository/UserCardinalRepository.kt | 58 ----- .../user/domain/repository/UserReader.kt | 7 - .../user/domain/repository/UserRepository.kt | 48 ----- .../user/domain/service/UserCardinalPolicy.kt | 28 --- .../domain/user/domain/vo/AttendanceStats.kt | 58 ----- .../user/presentation/UserAdminController.kt | 75 ------- .../user/presentation/UserController.kt | 38 ---- .../com/weeth/global/config/SecurityConfig.kt | 1 + .../command/ManageAccountUseCaseTest.kt | 12 +- .../command/ManageReceiptUseCaseTest.kt | 67 ++++-- .../query/GetAccountQueryServiceTest.kt | 20 +- .../mapper/AttendanceMapperTest.kt | 57 +++-- .../command/GenerateQrTokenUseCaseTest.kt | 11 +- .../command/ManageAttendanceUseCaseTest.kt | 189 ++++++++-------- .../query/GetAttendanceQueryServiceTest.kt | 187 ++++++++-------- .../domain/entity/AttendanceTest.kt | 37 +++- .../repository/AttendanceRepositoryTest.kt | 40 +++- .../fixture/AttendanceTestFixture.kt | 30 +-- .../usecase/command/ManageBoardUseCaseTest.kt | 17 +- .../usecase/command/ManagePostUseCaseTest.kt | 14 +- .../usecase/query/GetBoardQueryServiceTest.kt | 19 +- .../usecase/query/GetPostQueryServiceTest.kt | 25 ++- .../usecase/command/CardinalUseCaseTest.kt | 30 ++- .../command/AdminClubMemberUseCaseTest.kt | 160 +++++++++++++- .../usecase/command/ManageClubUseCaseTest.kt | 4 +- .../service/ClubMemberCardinalPolicyTest.kt | 180 ++++++++++++++++ .../usecase/command/AdminUserUseCaseTest.kt | 204 ------------------ .../usecase/query/GetUserQueryServiceTest.kt | 128 ----------- .../domain/user/domain/entity/UserTest.kt | 20 -- .../repository/UserCardinalRepositoryTest.kt | 113 ---------- .../domain/repository/UserRepositoryTest.kt | 88 -------- .../domain/service/UserCardinalPolicyTest.kt | 76 ------- .../user/fixture/UserCardinalTestFixture.kt | 12 -- 105 files changed, 1551 insertions(+), 2075 deletions(-) create mode 100644 src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubMemberApplyObRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberCardinalPolicy.kt delete mode 100644 src/main/kotlin/com/weeth/domain/user/application/dto/request/UserApplyObRequest.kt delete mode 100644 src/main/kotlin/com/weeth/domain/user/application/dto/response/AdminUserResponse.kt delete mode 100644 src/main/kotlin/com/weeth/domain/user/application/dto/response/UserDetailsResponse.kt delete mode 100644 src/main/kotlin/com/weeth/domain/user/application/exception/UserCardinalNotFoundException.kt delete mode 100644 src/main/kotlin/com/weeth/domain/user/application/usecase/command/AdminUserUseCase.kt delete mode 100644 src/main/kotlin/com/weeth/domain/user/domain/entity/UserCardinal.kt delete mode 100644 src/main/kotlin/com/weeth/domain/user/domain/repository/UserCardinalReader.kt delete mode 100644 src/main/kotlin/com/weeth/domain/user/domain/repository/UserCardinalRepository.kt delete mode 100644 src/main/kotlin/com/weeth/domain/user/domain/service/UserCardinalPolicy.kt delete mode 100644 src/main/kotlin/com/weeth/domain/user/domain/vo/AttendanceStats.kt delete mode 100644 src/main/kotlin/com/weeth/domain/user/presentation/UserAdminController.kt create mode 100644 src/test/kotlin/com/weeth/domain/club/domain/service/ClubMemberCardinalPolicyTest.kt delete mode 100644 src/test/kotlin/com/weeth/domain/user/application/usecase/command/AdminUserUseCaseTest.kt delete mode 100644 src/test/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryServiceTest.kt delete mode 100644 src/test/kotlin/com/weeth/domain/user/domain/repository/UserCardinalRepositoryTest.kt delete mode 100644 src/test/kotlin/com/weeth/domain/user/domain/repository/UserRepositoryTest.kt delete mode 100644 src/test/kotlin/com/weeth/domain/user/domain/service/UserCardinalPolicyTest.kt delete mode 100644 src/test/kotlin/com/weeth/domain/user/fixture/UserCardinalTestFixture.kt diff --git a/src/main/kotlin/com/weeth/domain/account/application/exception/AccountErrorCode.kt b/src/main/kotlin/com/weeth/domain/account/application/exception/AccountErrorCode.kt index 1318ca1b..2f48de19 100644 --- a/src/main/kotlin/com/weeth/domain/account/application/exception/AccountErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/account/application/exception/AccountErrorCode.kt @@ -18,6 +18,6 @@ enum class AccountErrorCode( @ExplainError("요청한 영수증 내역이 존재하지 않을 때 발생합니다.") RECEIPT_NOT_FOUND(20102, HttpStatus.NOT_FOUND, "존재하지 않는 내역입니다."), - @ExplainError("영수증이 요청한 기수의 장부에 속하지 않을 때 발생합니다.") + @ExplainError("영수증이 요청한 기수의 장부에 속하지 않거나 동아리에 속하지 않는 경우에 발생합니다.") RECEIPT_ACCOUNT_MISMATCH(20103, HttpStatus.BAD_REQUEST, "영수증이 해당 기수의 장부에 속하지 않습니다."), } diff --git a/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCase.kt b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCase.kt index 6af12a3d..96088a25 100644 --- a/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCase.kt @@ -6,8 +6,8 @@ import com.weeth.domain.account.domain.entity.Account import com.weeth.domain.account.domain.repository.AccountRepository import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException import com.weeth.domain.cardinal.domain.repository.CardinalReader -import com.weeth.domain.club.application.exception.ClubNotFoundException import com.weeth.domain.club.domain.repository.ClubReader +import com.weeth.domain.club.domain.service.ClubMemberPolicy import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -16,13 +16,15 @@ class ManageAccountUseCase( private val accountRepository: AccountRepository, private val cardinalReader: CardinalReader, private val clubReader: ClubReader, + private val clubMemberPolicy: ClubMemberPolicy, ) { - // TODO(PR4): 해당 클럽 소속 admin인지 검증 필요 @Transactional fun save( clubId: Long, request: AccountSaveRequest, + userId: Long, ) { + clubMemberPolicy.requireAdmin(clubId, userId) val club = clubReader.getClubById(clubId) if (accountRepository.existsByClubIdAndCardinal(clubId, request.cardinal)) throw AccountExistsException() diff --git a/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCase.kt b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCase.kt index 9c99aa72..3fa1150a 100644 --- a/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCase.kt @@ -10,6 +10,7 @@ import com.weeth.domain.account.domain.repository.AccountRepository import com.weeth.domain.account.domain.repository.ReceiptRepository import com.weeth.domain.account.domain.vo.Money import com.weeth.domain.cardinal.domain.repository.CardinalReader +import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.repository.FileReader @@ -18,9 +19,6 @@ import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -/** - * Todo: 개행을 추가해 가독성 개선 - */ @Service class ManageReceiptUseCase( private val receiptRepository: ReceiptRepository, @@ -28,41 +26,70 @@ class ManageReceiptUseCase( private val fileReader: FileReader, private val fileRepository: FileRepository, private val cardinalReader: CardinalReader, + private val clubMemberPolicy: ClubMemberPolicy, private val fileMapper: FileMapper, ) { @Transactional - fun save(request: ReceiptSaveRequest) { - cardinalReader.getByCardinalNumber(request.cardinal) - val account = accountRepository.findByCardinal(request.cardinal) ?: throw AccountNotFoundException() + fun save( + clubId: Long, + userId: Long, + request: ReceiptSaveRequest, + ) { + clubMemberPolicy.requireAdmin(clubId, userId) + cardinalReader.findByClubIdAndCardinalNumber(clubId, request.cardinal) ?: throw AccountNotFoundException() + val account = + accountRepository.findByClubIdAndCardinal(clubId, request.cardinal) ?: throw AccountNotFoundException() + val receipt = receiptRepository.save( Receipt.create(request.description, request.source, request.amount, request.date, account), ) + account.spend(Money.of(request.amount)) + fileRepository.saveAll(fileMapper.toFileList(request.files, FileOwnerType.RECEIPT, receipt.id)) } @Transactional fun update( + clubId: Long, + userId: Long, receiptId: Long, request: ReceiptUpdateRequest, ) { - cardinalReader.getByCardinalNumber(request.cardinal) - val account = accountRepository.findByCardinal(request.cardinal) ?: throw AccountNotFoundException() + clubMemberPolicy.requireAdmin(clubId, userId) + cardinalReader.findByClubIdAndCardinalNumber(clubId, request.cardinal) ?: throw AccountNotFoundException() + val account = + accountRepository.findByClubIdAndCardinal(clubId, request.cardinal) ?: throw AccountNotFoundException() val receipt = receiptRepository.findByIdOrNull(receiptId) ?: throw ReceiptNotFoundException() - if (receipt.account.id != account.id) throw ReceiptAccountMismatchException() + + if (receipt.account.club.id != clubId || receipt.account.id != account.id) { + throw ReceiptAccountMismatchException() + } + account.adjustSpend(Money.of(receipt.amount), Money.of(request.amount)) + if (request.files != null) { fileRepository.deleteAll(fileReader.findAll(FileOwnerType.RECEIPT, receiptId, null)) fileRepository.saveAll(fileMapper.toFileList(request.files, FileOwnerType.RECEIPT, receiptId)) } + receipt.update(request.description, request.source, request.amount, request.date) } @Transactional - fun delete(receiptId: Long) { + fun delete( + clubId: Long, + userId: Long, + receiptId: Long, + ) { + clubMemberPolicy.requireAdmin(clubId, userId) val receipt = receiptRepository.findByIdOrNull(receiptId) ?: throw ReceiptNotFoundException() + + if (receipt.account.club.id != clubId) throw ReceiptAccountMismatchException() + receipt.account.cancelSpend(Money.of(receipt.amount)) + fileRepository.deleteAll(fileReader.findAll(FileOwnerType.RECEIPT, receiptId, null)) receiptRepository.delete(receipt) } diff --git a/src/main/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountQueryService.kt b/src/main/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountQueryService.kt index d2c08acf..53b5e57a 100644 --- a/src/main/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountQueryService.kt @@ -6,6 +6,7 @@ import com.weeth.domain.account.application.mapper.AccountMapper import com.weeth.domain.account.application.mapper.ReceiptMapper import com.weeth.domain.account.domain.repository.AccountRepository import com.weeth.domain.account.domain.repository.ReceiptRepository +import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.repository.FileReader @@ -18,15 +19,17 @@ class GetAccountQueryService( private val accountRepository: AccountRepository, private val receiptRepository: ReceiptRepository, private val fileReader: FileReader, + private val clubMemberPolicy: ClubMemberPolicy, private val accountMapper: AccountMapper, private val receiptMapper: ReceiptMapper, private val fileMapper: FileMapper, ) { - // TODO(PR4): 해당 클럽 소속 멤버인지 검증 필요 fun findByCardinal( clubId: Long, + userId: Long, cardinal: Int, ): AccountResponse { + clubMemberPolicy.getActiveMember(clubId, userId) val account = accountRepository.findByClubIdAndCardinal(clubId, cardinal) ?: throw AccountNotFoundException() val receipts = receiptRepository.findAllByAccountIdOrderByCreatedAtDesc(account.id) val receiptIds = receipts.map { it.id } diff --git a/src/main/kotlin/com/weeth/domain/account/domain/repository/AccountRepository.kt b/src/main/kotlin/com/weeth/domain/account/domain/repository/AccountRepository.kt index 83acdb77..21ccaaf3 100644 --- a/src/main/kotlin/com/weeth/domain/account/domain/repository/AccountRepository.kt +++ b/src/main/kotlin/com/weeth/domain/account/domain/repository/AccountRepository.kt @@ -4,10 +4,6 @@ import com.weeth.domain.account.domain.entity.Account import org.springframework.data.jpa.repository.JpaRepository interface AccountRepository : JpaRepository { - fun findByCardinal(cardinal: Int): Account? - - fun existsByCardinal(cardinal: Int): Boolean - fun findByClubIdAndCardinal( clubId: Long, cardinal: Int, diff --git a/src/main/kotlin/com/weeth/domain/account/presentation/AccountAdminController.kt b/src/main/kotlin/com/weeth/domain/account/presentation/AccountAdminController.kt index d715ddcf..eccb9b5d 100644 --- a/src/main/kotlin/com/weeth/domain/account/presentation/AccountAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/account/presentation/AccountAdminController.kt @@ -4,11 +4,13 @@ import com.weeth.domain.account.application.dto.request.AccountSaveRequest import com.weeth.domain.account.application.exception.AccountErrorCode import com.weeth.domain.account.application.usecase.command.ManageAccountUseCase import com.weeth.domain.account.presentation.AccountResponseCode.ACCOUNT_SAVE_SUCCESS +import com.weeth.global.auth.annotation.CurrentUser import com.weeth.global.common.exception.ApiErrorCodeExample import com.weeth.global.common.response.CommonResponse import com.weeth.global.common.web.TsidParam import com.weeth.global.common.web.TsidPathVariable import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.Valid import org.springframework.web.bind.annotation.PathVariable @@ -30,8 +32,9 @@ class AccountAdminController( @PathVariable @TsidParam @TsidPathVariable clubId: Long, @RequestBody @Valid dto: AccountSaveRequest, + @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse { - manageAccountUseCase.save(clubId, dto) + manageAccountUseCase.save(clubId, dto, userId) return CommonResponse.success(ACCOUNT_SAVE_SUCCESS) } } diff --git a/src/main/kotlin/com/weeth/domain/account/presentation/AccountController.kt b/src/main/kotlin/com/weeth/domain/account/presentation/AccountController.kt index 064d2d5e..2c1ccb36 100644 --- a/src/main/kotlin/com/weeth/domain/account/presentation/AccountController.kt +++ b/src/main/kotlin/com/weeth/domain/account/presentation/AccountController.kt @@ -4,11 +4,13 @@ import com.weeth.domain.account.application.dto.response.AccountResponse import com.weeth.domain.account.application.exception.AccountErrorCode import com.weeth.domain.account.application.usecase.query.GetAccountQueryService import com.weeth.domain.account.presentation.AccountResponseCode.ACCOUNT_FIND_SUCCESS +import com.weeth.global.auth.annotation.CurrentUser import com.weeth.global.common.exception.ApiErrorCodeExample import com.weeth.global.common.response.CommonResponse import com.weeth.global.common.web.TsidParam import com.weeth.global.common.web.TsidPathVariable import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable @@ -27,7 +29,8 @@ class AccountController( fun find( @PathVariable @TsidParam @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, @PathVariable cardinal: Int, ): CommonResponse = - CommonResponse.success(ACCOUNT_FIND_SUCCESS, getAccountQueryService.findByCardinal(clubId, cardinal)) + CommonResponse.success(ACCOUNT_FIND_SUCCESS, getAccountQueryService.findByCardinal(clubId, userId, cardinal)) } diff --git a/src/main/kotlin/com/weeth/domain/account/presentation/ReceiptAdminController.kt b/src/main/kotlin/com/weeth/domain/account/presentation/ReceiptAdminController.kt index 3df7b96f..355aa9d8 100644 --- a/src/main/kotlin/com/weeth/domain/account/presentation/ReceiptAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/account/presentation/ReceiptAdminController.kt @@ -7,9 +7,13 @@ import com.weeth.domain.account.application.usecase.command.ManageReceiptUseCase import com.weeth.domain.account.presentation.AccountResponseCode.RECEIPT_DELETE_SUCCESS import com.weeth.domain.account.presentation.AccountResponseCode.RECEIPT_SAVE_SUCCESS import com.weeth.domain.account.presentation.AccountResponseCode.RECEIPT_UPDATE_SUCCESS +import com.weeth.global.auth.annotation.CurrentUser import com.weeth.global.common.exception.ApiErrorCodeExample import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.Valid import org.springframework.web.bind.annotation.DeleteMapping @@ -22,7 +26,7 @@ import org.springframework.web.bind.annotation.RestController @Tag(name = "RECEIPT ADMIN", description = "[ADMIN] 회비 어드민 API") @RestController -@RequestMapping("/api/v1/admin/receipts") +@RequestMapping("/api/v4/admin/clubs/{clubId}/receipts") @ApiErrorCodeExample(AccountErrorCode::class) class ReceiptAdminController( private val manageReceiptUseCase: ManageReceiptUseCase, @@ -30,28 +34,37 @@ class ReceiptAdminController( @PostMapping @Operation(summary = "회비 사용 내역 기입") fun save( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, @RequestBody @Valid dto: ReceiptSaveRequest, ): CommonResponse { - manageReceiptUseCase.save(dto) + manageReceiptUseCase.save(clubId, userId, dto) return CommonResponse.success(RECEIPT_SAVE_SUCCESS) } @DeleteMapping("/{receiptId}") @Operation(summary = "회비 사용 내역 취소") fun delete( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, @PathVariable receiptId: Long, ): CommonResponse { - manageReceiptUseCase.delete(receiptId) + manageReceiptUseCase.delete(clubId, userId, receiptId) return CommonResponse.success(RECEIPT_DELETE_SUCCESS) } @PatchMapping("/{receiptId}") @Operation(summary = "회비 사용 내역 수정") fun update( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, @PathVariable receiptId: Long, @RequestBody @Valid dto: ReceiptUpdateRequest, ): CommonResponse { - manageReceiptUseCase.update(receiptId, dto) + manageReceiptUseCase.update(clubId, userId, receiptId, dto) return CommonResponse.success(RECEIPT_UPDATE_SUCCESS) } } diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapper.kt b/src/main/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapper.kt index b9bf9f88..b3447878 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapper.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapper.kt @@ -6,20 +6,20 @@ import com.weeth.domain.attendance.application.dto.response.AttendanceResponse import com.weeth.domain.attendance.application.dto.response.AttendanceSummaryResponse import com.weeth.domain.attendance.application.dto.response.QrTokenResponse import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.session.domain.entity.Session -import com.weeth.domain.user.domain.entity.User import org.springframework.stereotype.Component import java.time.LocalDateTime @Component class AttendanceMapper { fun toSummaryResponse( - user: User, + clubMember: ClubMember, attendance: Attendance?, isAdmin: Boolean = false, ): AttendanceSummaryResponse = AttendanceSummaryResponse( - attendanceRate = user.attendanceRate, + attendanceRate = clubMember.attendanceStats.attendanceRate, title = attendance?.session?.title, status = attendance?.status, code = if (isAdmin) attendance?.session?.code else null, @@ -29,13 +29,13 @@ class AttendanceMapper { ) fun toDetailResponse( - user: User, + clubMember: ClubMember, attendances: List, ): AttendanceDetailResponse = AttendanceDetailResponse( - attendanceCount = user.attendanceCount, - total = user.attendanceCount + user.absenceCount, - absenceCount = user.absenceCount, + attendanceCount = clubMember.attendanceStats.attendanceCount, + total = clubMember.attendanceStats.attendanceCount + clubMember.attendanceStats.absenceCount, + absenceCount = clubMember.attendanceStats.absenceCount, attendances = attendances, ) @@ -53,9 +53,9 @@ class AttendanceMapper { AttendanceInfoResponse( id = attendance.id, status = attendance.status, - name = attendance.user.name, - department = attendance.user.department, - studentId = attendance.user.studentId, + name = attendance.clubMember.user.name, + department = attendance.clubMember.user.department, + studentId = attendance.clubMember.user.studentId, ) fun toQrTokenResponse( diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCase.kt b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCase.kt index 56e3cd2a..1a1d86ea 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCase.kt @@ -3,6 +3,7 @@ package com.weeth.domain.attendance.application.usecase.command import com.weeth.domain.attendance.application.dto.response.QrTokenResponse import com.weeth.domain.attendance.application.mapper.AttendanceMapper import com.weeth.domain.attendance.domain.port.QrAttendancePort +import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.session.domain.repository.SessionReader import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -13,8 +14,15 @@ class GenerateQrTokenUseCase( private val sessionReader: SessionReader, private val qrAttendancePort: QrAttendancePort, private val attendanceMapper: AttendanceMapper, + private val clubMemberPolicy: ClubMemberPolicy, ) { - fun execute(sessionId: Long): QrTokenResponse { + fun execute( + sessionId: Long, + clubId: Long, + userId: Long, + ): QrTokenResponse { + clubMemberPolicy.requireAdmin(clubId, userId) + val session = sessionReader.getById(sessionId) val expiredAt = LocalDateTime.now().plusSeconds(QrAttendancePort.TTL_SECONDS) diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCase.kt b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCase.kt index c6e1b4c5..cf70fc17 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCase.kt @@ -9,12 +9,12 @@ import com.weeth.domain.attendance.domain.entity.Attendance import com.weeth.domain.attendance.domain.enums.AttendanceStatus import com.weeth.domain.attendance.domain.port.QrAttendancePort import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.session.application.exception.SessionNotFoundException import com.weeth.domain.session.application.exception.SessionNotInProgressException import com.weeth.domain.session.domain.enums.SessionStatus import com.weeth.domain.session.domain.repository.SessionReader -import com.weeth.domain.user.domain.enums.Status -import com.weeth.domain.user.domain.repository.UserReader import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.LocalDate @@ -22,51 +22,57 @@ import java.time.LocalDateTime @Service class ManageAttendanceUseCase( - private val userReader: UserReader, + private val clubMemberPolicy: ClubMemberPolicy, private val sessionReader: SessionReader, private val attendanceRepository: AttendanceRepository, private val qrAttendancePort: QrAttendancePort, ) { @Transactional fun checkIn( + clubId: Long, userId: Long, sessionId: Long, code: Int, ) { - val storedCode = qrAttendancePort.getCode(sessionId) ?: throw QrTokenExpiredException() - if (storedCode != code) throw AttendanceCodeMismatchException() + val clubMember = clubMemberPolicy.getActiveMember(clubId, userId) val session = sessionReader.getById(sessionId) - + if (session.club.id != clubId) throw AttendanceNotFoundException() if (!session.isCheckInAllowed(LocalDateTime.now())) throw SessionNotInProgressException() - val user = userReader.getById(userId) + val storedCode = qrAttendancePort.getCode(sessionId) ?: throw QrTokenExpiredException() + if (storedCode != code) throw AttendanceCodeMismatchException() val lockedAttendance = - attendanceRepository.findBySessionAndUserWithLock(session, user) + attendanceRepository.findBySessionAndClubMemberWithLock(session, clubMember) ?: throw AttendanceNotFoundException() if (lockedAttendance.status == AttendanceStatus.ATTEND) throw AlreadyAttendedException() lockedAttendance.attend() - user.attend() + clubMember.attend() } @Transactional fun close( + clubId: Long, + userId: Long, now: LocalDate, cardinal: Int, ) { + clubMemberPolicy.requireAdmin(clubId, userId) val targetSession = sessionReader - .findAllByCardinalOrderByStartAsc(cardinal) + .findAllByClubIdAndCardinalIn(clubId, listOf(cardinal)) .firstOrNull { session -> session.start.toLocalDate().isEqual(now) && session.end.toLocalDate().isEqual(now) } ?: throw SessionNotFoundException() - val attendances = attendanceRepository.findAllBySessionAndUserStatus(targetSession, Status.ACTIVE) + targetSession.close() + val attendances = + attendanceRepository.findAllBySessionAndClubMemberMemberStatus(targetSession, MemberStatus.ACTIVE) closePendingAttendances(attendances) } @@ -76,21 +82,27 @@ class ManageAttendanceUseCase( sessions.forEach { session -> session.close() - val attendances = attendanceRepository.findAllBySessionAndUserStatus(session, Status.ACTIVE) + val attendances = + attendanceRepository.findAllBySessionAndClubMemberMemberStatus(session, MemberStatus.ACTIVE) closePendingAttendances(attendances) } } @Transactional - fun updateStatus(attendanceUpdates: List) { + fun updateStatus( + clubId: Long, + userId: Long, + attendanceUpdates: List, + ) { + clubMemberPolicy.requireAdmin(clubId, userId) attendanceUpdates.forEach { update -> val attendance = - attendanceRepository.findByIdWithUser(update.attendanceId) + attendanceRepository.findByIdWithClubMember(update.attendanceId) ?: throw AttendanceNotFoundException() + if (attendance.clubMember.club.id != clubId) throw AttendanceNotFoundException() - val user = attendance.user + val member = attendance.clubMember val newStatus = AttendanceStatus.valueOf(update.status) - if (attendance.status == newStatus) return@forEach val prevStatus = attendance.status @@ -98,13 +110,18 @@ class ManageAttendanceUseCase( when (newStatus) { AttendanceStatus.ABSENT -> { - if (prevStatus == AttendanceStatus.ATTEND) user.removeAttend() - user.absent() + if (prevStatus == AttendanceStatus.ATTEND) member.removeAttend() + member.absent() + } + + AttendanceStatus.ATTEND -> { + if (prevStatus == AttendanceStatus.ABSENT) member.removeAbsent() + member.attend() } - else -> { - if (prevStatus == AttendanceStatus.ABSENT) user.removeAbsent() - user.attend() + AttendanceStatus.PENDING -> { + if (prevStatus == AttendanceStatus.ATTEND) member.removeAttend() + if (prevStatus == AttendanceStatus.ABSENT) member.removeAbsent() } } } @@ -115,7 +132,7 @@ class ManageAttendanceUseCase( .filter { it.isPending() } .forEach { attendance -> attendance.close() - attendance.user.absent() + attendance.clubMember.absent() } } } diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt index 708617ee..0b2e9600 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt @@ -3,13 +3,13 @@ package com.weeth.domain.attendance.application.usecase.query import com.weeth.domain.attendance.application.dto.response.AttendanceDetailResponse import com.weeth.domain.attendance.application.dto.response.AttendanceInfoResponse import com.weeth.domain.attendance.application.dto.response.AttendanceSummaryResponse +import com.weeth.domain.attendance.application.exception.AttendanceNotFoundException import com.weeth.domain.attendance.application.mapper.AttendanceMapper import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.service.ClubMemberCardinalPolicy +import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.session.domain.repository.SessionReader -import com.weeth.domain.user.domain.enums.Role -import com.weeth.domain.user.domain.enums.Status -import com.weeth.domain.user.domain.repository.UserReader -import com.weeth.domain.user.domain.service.UserCardinalPolicy import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.LocalDate @@ -17,54 +17,56 @@ import java.time.LocalDate @Service @Transactional(readOnly = true) class GetAttendanceQueryService( - private val userReader: UserReader, - private val userCardinalPolicy: UserCardinalPolicy, + private val clubMemberPolicy: ClubMemberPolicy, + private val clubMemberCardinalPolicy: ClubMemberCardinalPolicy, private val sessionReader: SessionReader, private val attendanceRepository: AttendanceRepository, private val attendanceMapper: AttendanceMapper, ) { - // TODO: PR4에서 clubMember 기반으로 전환 (현재는 user 기반 유지) fun findAttendance( clubId: Long, userId: Long, ): AttendanceSummaryResponse { - val user = userReader.getById(userId) + val clubMember = clubMemberPolicy.getActiveMember(clubId, userId) val today = LocalDate.now() val todayAttendance = - attendanceRepository.findTodayByUserId( - userId, + attendanceRepository.findTodayByClubMemberId( + clubMember.id, today.atStartOfDay(), today.plusDays(1).atStartOfDay(), ) - return attendanceMapper.toSummaryResponse(user, todayAttendance, isAdmin = user.role == Role.ADMIN) + return attendanceMapper.toSummaryResponse(clubMember, todayAttendance, isAdmin = clubMember.isAdmin()) } - // TODO: PR4에서 clubMember 기반으로 전환 (현재는 user 기반 유지) fun findAllDetailsByCurrentCardinal( clubId: Long, userId: Long, ): AttendanceDetailResponse { - val user = userReader.getById(userId) - val currentCardinal = userCardinalPolicy.getCurrentCardinal(user) - + val clubMember = clubMemberPolicy.getActiveMember(clubId, userId) + val currentCardinal = clubMemberCardinalPolicy.getCurrentCardinal(clubMember) val responses = attendanceRepository - .findAllByUserIdAndCardinal(userId, currentCardinal.cardinalNumber) + .findAllByClubMemberIdAndCardinal(clubMember.id, currentCardinal.cardinalNumber) .map(attendanceMapper::toResponse) - return attendanceMapper.toDetailResponse(user, responses) + return attendanceMapper.toDetailResponse(clubMember, responses) } - // TODO: PR4에서 clubMember 기반으로 전환 (현재는 user 기반 유지) fun findAllAttendanceBySession( clubId: Long, + userId: Long, sessionId: Long, ): List { + clubMemberPolicy.requireAdmin(clubId, userId) val session = sessionReader.getById(sessionId) - val attendances = attendanceRepository.findAllBySessionAndUserStatus(session, Status.ACTIVE) + if (session.club.id != clubId) { + throw AttendanceNotFoundException() + } + + val attendances = attendanceRepository.findAllBySessionAndClubMemberMemberStatus(session, MemberStatus.ACTIVE) return attendances.map(attendanceMapper::toInfoResponse) } } diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/entity/Attendance.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/entity/Attendance.kt index 9b1d2ab9..0e991b0b 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/domain/entity/Attendance.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/entity/Attendance.kt @@ -1,8 +1,8 @@ package com.weeth.domain.attendance.domain.entity import com.weeth.domain.attendance.domain.enums.AttendanceStatus +import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.session.domain.entity.Session -import com.weeth.domain.user.domain.entity.User import com.weeth.global.common.entity.BaseEntity import jakarta.persistence.Column import jakarta.persistence.Entity @@ -23,9 +23,9 @@ class Attendance( @JoinColumn(name = "meeting_id") val session: Session, @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id") + @JoinColumn(name = "club_member_id") @OnDelete(action = OnDeleteAction.CASCADE) - val user: User, + val clubMember: ClubMember, status: AttendanceStatus = AttendanceStatus.PENDING, ) : BaseEntity() { @Id @@ -61,7 +61,10 @@ class Attendance( companion object { fun create( session: Session, - user: User, - ): Attendance = Attendance(session = session, user = user) + clubMember: ClubMember, + ): Attendance { + require(session.club.id == clubMember.club.id) { "세션과 멤버의 동아리가 일치하지 않습니다" } + return Attendance(session = session, clubMember = clubMember) + } } } diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt index 307d7b4b..881a93c8 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt @@ -1,9 +1,9 @@ package com.weeth.domain.attendance.domain.repository import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.enums.MemberStatus import com.weeth.domain.session.domain.entity.Session -import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.enums.Status import jakarta.persistence.LockModeType import jakarta.persistence.QueryHint import org.springframework.data.jpa.repository.EntityGraph @@ -18,40 +18,44 @@ import java.time.LocalDateTime interface AttendanceRepository : JpaRepository { @Lock(LockModeType.PESSIMISTIC_WRITE) @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) - @Query("SELECT a FROM Attendance a JOIN FETCH a.user WHERE a.session = :session AND a.user = :user") - fun findBySessionAndUserWithLock( + @Query( + "SELECT a FROM Attendance a JOIN FETCH a.clubMember cm JOIN FETCH cm.user WHERE a.session = :session AND a.clubMember = :clubMember", + ) + fun findBySessionAndClubMemberWithLock( @Param("session") session: Session, - @Param("user") user: User, + @Param("clubMember") clubMember: ClubMember, ): Attendance? - @EntityGraph(attributePaths = ["user"]) - fun findAllBySessionAndUserStatus( + @EntityGraph(attributePaths = ["clubMember", "clubMember.user"]) + fun findAllBySessionAndClubMemberMemberStatus( session: Session, - status: Status, + memberStatus: MemberStatus, ): List @Lock(LockModeType.PESSIMISTIC_WRITE) @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) - @Query("SELECT a FROM Attendance a JOIN FETCH a.user WHERE a.session = :session AND a.user.status = :status") - fun findAllBySessionAndUserStatusWithLock( + @Query( + "SELECT a FROM Attendance a JOIN FETCH a.clubMember cm JOIN FETCH cm.user WHERE a.session = :session AND cm.memberStatus = :status", + ) + fun findAllBySessionAndClubMemberMemberStatusWithLock( @Param("session") session: Session, - @Param("status") status: Status, + @Param("status") status: MemberStatus, ): List - @Query("SELECT a FROM Attendance a JOIN FETCH a.user WHERE a.id = :id") - fun findByIdWithUser(id: Long): Attendance? + @Query("SELECT a FROM Attendance a JOIN FETCH a.clubMember cm JOIN FETCH cm.user WHERE a.id = :id") + fun findByIdWithClubMember(id: Long): Attendance? @Query( """ SELECT a FROM Attendance a JOIN FETCH a.session s - WHERE a.user.id = :userId + WHERE a.clubMember.id = :clubMemberId AND s.start <= :checkInEnd AND s.end > :now """, ) - fun findCurrentByUserId( - @Param("userId") userId: Long, + fun findCurrentByClubMemberId( + @Param("clubMemberId") clubMemberId: Long, @Param("now") now: LocalDateTime, @Param("checkInEnd") checkInEnd: LocalDateTime, ): Attendance? @@ -60,13 +64,13 @@ interface AttendanceRepository : JpaRepository { """ SELECT a FROM Attendance a JOIN FETCH a.session s - WHERE a.user.id = :userId + WHERE a.clubMember.id = :clubMemberId AND s.start >= :dayStart AND s.end < :dayEnd """, ) - fun findTodayByUserId( - @Param("userId") userId: Long, + fun findTodayByClubMemberId( + @Param("clubMemberId") clubMemberId: Long, @Param("dayStart") dayStart: LocalDateTime, @Param("dayEnd") dayEnd: LocalDateTime, ): Attendance? @@ -75,18 +79,17 @@ interface AttendanceRepository : JpaRepository { """ SELECT a FROM Attendance a JOIN FETCH a.session s - WHERE a.user.id = :userId + WHERE a.clubMember.id = :clubMemberId AND s.cardinal = :cardinal ORDER BY s.start """, ) - fun findAllByUserIdAndCardinal( - @Param("userId") userId: Long, + fun findAllByClubMemberIdAndCardinal( + @Param("clubMemberId") clubMemberId: Long, @Param("cardinal") cardinal: Int, ): List - // TODO: QR 코드 출석 기능 구현 시 사용 예정 (여러 세션의 출석자 배치 조회) - @Query("SELECT a FROM Attendance a JOIN FETCH a.user WHERE a.session IN :sessions") + @Query("SELECT a FROM Attendance a JOIN FETCH a.clubMember cm JOIN FETCH cm.user WHERE a.session IN :sessions") fun findAllBySessionIn( @Param("sessions") sessions: List, ): List diff --git a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceAdminController.kt b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceAdminController.kt index 753d6975..f567b2ae 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceAdminController.kt @@ -8,9 +8,13 @@ import com.weeth.domain.attendance.application.usecase.command.GenerateQrTokenUs import com.weeth.domain.attendance.application.usecase.command.ManageAttendanceUseCase import com.weeth.domain.attendance.application.usecase.query.GetAttendanceQueryService import com.weeth.domain.session.application.exception.SessionErrorCode +import com.weeth.global.auth.annotation.CurrentUser import com.weeth.global.common.exception.ApiErrorCodeExample import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.Valid import org.springframework.web.bind.annotation.GetMapping @@ -25,7 +29,7 @@ import java.time.LocalDate @Tag(name = "ATTENDANCE ADMIN", description = "[ADMIN] 출석 어드민 API") @RestController -@RequestMapping("/api/v4/admin/attendances") +@RequestMapping("/api/v4/admin/clubs/{clubId}/attendances") @ApiErrorCodeExample(AttendanceErrorCode::class, SessionErrorCode::class) class AttendanceAdminController( private val manageAttendanceUseCase: ManageAttendanceUseCase, @@ -35,40 +39,51 @@ class AttendanceAdminController( @PatchMapping("/close") @Operation(summary = "출석 마감") fun close( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, @RequestParam now: LocalDate, @RequestParam cardinal: Int, ): CommonResponse { - manageAttendanceUseCase.close(now, cardinal) + manageAttendanceUseCase.close(clubId, userId, now, cardinal) return CommonResponse.success(AttendanceResponseCode.ATTENDANCE_CLOSE_SUCCESS) } @GetMapping("/{sessionId}") @Operation(summary = "모든 인원 정기모임 출석 정보 조회") fun getAllAttendance( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, @PathVariable sessionId: Long, ): CommonResponse> = CommonResponse.success( AttendanceResponseCode.ATTENDANCE_FIND_DETAIL_SUCCESS, - // TODO: PR4에서 clubId 기반으로 전환 - getAttendanceQueryService.findAllAttendanceBySession(0L, sessionId), + getAttendanceQueryService.findAllAttendanceBySession(clubId, userId, sessionId), ) @PatchMapping("/status") @Operation(summary = "모든 인원 정기모임 개별 출석 상태 수정") fun updateAttendanceStatus( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, @RequestBody @Valid attendanceUpdates: List, ): CommonResponse { - manageAttendanceUseCase.updateStatus(attendanceUpdates) + manageAttendanceUseCase.updateStatus(clubId, userId, attendanceUpdates) return CommonResponse.success(AttendanceResponseCode.ATTENDANCE_UPDATED_SUCCESS) } @PostMapping("/{sessionId}/qr") @Operation(summary = "QR 코드 생성") fun generateQr( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, @PathVariable sessionId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse = CommonResponse.success( AttendanceResponseCode.QR_TOKEN_GENERATE_SUCCESS, - generateQrTokenUseCase.execute(sessionId), + generateQrTokenUseCase.execute(sessionId, clubId, userId), ) } diff --git a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt index b95243f3..e623b669 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt @@ -9,10 +9,13 @@ import com.weeth.domain.attendance.application.usecase.query.GetAttendanceQueryS import com.weeth.global.auth.annotation.CurrentUser import com.weeth.global.common.exception.ApiErrorCodeExample import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping @@ -20,7 +23,7 @@ import org.springframework.web.bind.annotation.RestController @Tag(name = "ATTENDANCE", description = "출석 API") @RestController -@RequestMapping("/api/v4/attendances") +@RequestMapping("/api/v4/clubs/{clubId}/attendances") @ApiErrorCodeExample(AttendanceErrorCode::class) class AttendanceController( private val manageAttendanceUseCase: ManageAttendanceUseCase, @@ -29,32 +32,36 @@ class AttendanceController( @PostMapping("/check-in") @Operation(summary = "출석체크") fun checkIn( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, @RequestBody checkIn: CheckInRequest, ): CommonResponse { - manageAttendanceUseCase.checkIn(userId, checkIn.sessionId, checkIn.code) + manageAttendanceUseCase.checkIn(clubId, userId, checkIn.sessionId, checkIn.code) return CommonResponse.success(AttendanceResponseCode.ATTENDANCE_CHECKIN_SUCCESS) } @GetMapping @Operation(summary = "출석 메인페이지") fun find( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse = CommonResponse.success( AttendanceResponseCode.ATTENDANCE_FIND_SUCCESS, - // TODO: PR4에서 clubId 기반으로 전환 - getAttendanceQueryService.findAttendance(0L, userId), + getAttendanceQueryService.findAttendance(clubId, userId), ) @GetMapping("/detail") @Operation(summary = "출석 내역 상세조회") fun findAll( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse = CommonResponse.success( AttendanceResponseCode.ATTENDANCE_FIND_ALL_SUCCESS, - // TODO: PR4에서 clubId 기반으로 전환 - getAttendanceQueryService.findAllDetailsByCurrentCardinal(0L, userId), + getAttendanceQueryService.findAllDetailsByCurrentCardinal(clubId, userId), ) } diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt index 57733dfb..ee46e804 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt @@ -18,7 +18,7 @@ enum class BoardErrorCode( @ExplainError("ADMIN 전용 게시판에 일반 사용자가 글을 작성할 때 발생합니다.") CATEGORY_ACCESS_DENIED(20402, HttpStatus.FORBIDDEN, "해당 카테고리에 대한 권한이 없습니다."), - @ExplainError("게시판 ID로 조회했으나 해당 게시판이 존재하지 않을 때 발생합니다.") + @ExplainError("게시판 ID로 조회했으나 해당 게시판이 존재하지 않거나 동아리에 속하지 않는 경우에 발생합니다.") BOARD_NOT_FOUND(20403, HttpStatus.NOT_FOUND, "존재하지 않는 게시판입니다."), @ExplainError("게시글 ID로 조회했으나 해당 게시글이 존재하지 않을 때 발생합니다.") diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt index e25b5479..8beb8c96 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt @@ -9,6 +9,7 @@ import com.weeth.domain.board.domain.entity.Board import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.board.domain.vo.BoardConfig import com.weeth.domain.club.domain.repository.ClubReader +import com.weeth.domain.club.domain.service.ClubMemberPolicy import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -17,14 +18,21 @@ class ManageBoardUseCase( private val boardRepository: BoardRepository, private val boardMapper: BoardMapper, private val clubReader: ClubReader, + private val clubMemberPolicy: ClubMemberPolicy, ) { - // TODO(PR4): 해당 클럽 소속 admin인지 검증 필요 + /** + * 게시판 생성 API, 커스텀한 게시판 생성 가능 + * TODO: MVP, 무료의 경우엔 개수 제한. 공지사항 제외 + */ @Transactional fun create( clubId: Long, request: CreateBoardRequest, + userId: Long, ): BoardDetailResponse { + clubMemberPolicy.requireAdmin(clubId, userId) val club = clubReader.getClubById(clubId) + val board = Board( club = club, @@ -37,17 +45,19 @@ class ManageBoardUseCase( isPrivate = request.isPrivate, ), ) + val savedBoard = boardRepository.save(board) return boardMapper.toDetailResponse(savedBoard) } - // TODO(PR4): 해당 클럽 소속 admin인지 검증 필요 @Transactional fun update( clubId: Long, boardId: Long, request: UpdateBoardRequest, + userId: Long, ): BoardDetailResponse { + clubMemberPolicy.requireAdmin(clubId, userId) val board = findBoard(boardId) if (board.club.id != clubId) throw BoardNotFoundException() @@ -68,13 +78,15 @@ class ManageBoardUseCase( return boardMapper.toDetailResponse(board) } - // TODO(PR4): 해당 클럽 소속 admin인지 검증 필요 @Transactional fun delete( clubId: Long, boardId: Long, + userId: Long, ) { + clubMemberPolicy.requireAdmin(clubId, userId) val board = findBoard(boardId) + if (board.club.id != clubId) throw BoardNotFoundException() board.markDeleted() } diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt index 6f17f559..6b37d967 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt @@ -12,6 +12,7 @@ import com.weeth.domain.board.domain.entity.Board import com.weeth.domain.board.domain.entity.Post import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.board.domain.repository.PostRepository +import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.file.application.dto.request.FileSaveRequest import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.enums.FileOwnerType @@ -27,12 +28,12 @@ class ManagePostUseCase( private val postRepository: PostRepository, private val boardRepository: BoardRepository, private val userReader: UserReader, + private val clubMemberPolicy: ClubMemberPolicy, private val fileRepository: FileRepository, private val fileReader: FileReader, private val fileMapper: FileMapper, private val postMapper: PostMapper, ) { - // TODO(PR4): 해당 클럽 소속 멤버인지 검증 필요 @Transactional fun save( clubId: Long, @@ -40,6 +41,7 @@ class ManagePostUseCase( request: CreatePostRequest, userId: Long, ): PostSaveResponse { + clubMemberPolicy.getActiveMember(clubId, userId) val user = userReader.getById(userId) val board = findBoardInClub(boardId, clubId) validateWritePermission(board, user) @@ -58,7 +60,6 @@ class ManagePostUseCase( return postMapper.toSaveResponse(savedPost) } - // TODO(PR4): 해당 클럽 소속 멤버인지 검증 필요 @Transactional fun update( clubId: Long, @@ -66,6 +67,7 @@ class ManagePostUseCase( request: UpdatePostRequest, userId: Long, ): PostSaveResponse { + clubMemberPolicy.getActiveMember(clubId, userId) val user = userReader.getById(userId) val post = findPost(postId) if (post.board.club.id != clubId) throw PostNotFoundException() @@ -82,13 +84,13 @@ class ManagePostUseCase( return postMapper.toSaveResponse(post) } - // TODO(PR4): 해당 클럽 소속 멤버인지 검증 필요 @Transactional fun delete( clubId: Long, postId: Long, userId: Long, ) { + clubMemberPolicy.getActiveMember(clubId, userId) val user = userReader.getById(userId) val post = findPost(postId) if (post.board.club.id != clubId) throw PostNotFoundException() @@ -120,8 +122,7 @@ class ManagePostUseCase( board: Board, user: User, ) { - val userRole = user.role ?: throw CategoryAccessDeniedException() - if (!board.canWriteBy(userRole)) { + if (!board.canWriteBy(user.role)) { throw CategoryAccessDeniedException() } } diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt index 9e63dbd4..3b2cb92c 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt @@ -5,6 +5,7 @@ import com.weeth.domain.board.application.dto.response.BoardListResponse import com.weeth.domain.board.application.exception.BoardNotFoundException import com.weeth.domain.board.application.mapper.BoardMapper import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.user.domain.enums.Role import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -13,30 +14,41 @@ import org.springframework.transaction.annotation.Transactional @Transactional(readOnly = true) class GetBoardQueryService( private val boardRepository: BoardRepository, + private val clubMemberPolicy: ClubMemberPolicy, private val boardMapper: BoardMapper, ) { - // TODO(PR4): 해당 클럽 소속 멤버인지 검증 필요 fun findBoards( clubId: Long, + userId: Long, role: Role, - ): List = - boardRepository + ): List { + clubMemberPolicy.getActiveMember(clubId, userId) + + return boardRepository .findAllByClubIdAndIsDeletedFalseOrderByIdAsc(clubId) .filter { it.isAccessibleBy(role) } .map(boardMapper::toListResponse) + } - // TODO(PR4): 해당 클럽 소속 admin인지 검증 필요 fun findBoardDetailForAdmin( clubId: Long, + userId: Long, boardId: Long, ): BoardDetailResponse { + clubMemberPolicy.requireAdmin(clubId, userId) val board = boardRepository.findByIdAndClubId(boardId, clubId) ?: throw BoardNotFoundException() + return boardMapper.toDetailResponseForAdmin(board) } - // TODO(PR4): 해당 클럽 소속 admin인지 검증 필요 - fun findAllBoardsForAdmin(clubId: Long): List = - boardRepository + fun findAllBoardsForAdmin( + clubId: Long, + userId: Long, + ): List { + clubMemberPolicy.requireAdmin(clubId, userId) + + return boardRepository .findAllByClubIdOrderByIdAsc(clubId) .map(boardMapper::toDetailResponseForAdmin) + } } diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt index c70bae4f..3350c303 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt @@ -9,6 +9,7 @@ import com.weeth.domain.board.application.exception.PostNotFoundException import com.weeth.domain.board.application.mapper.PostMapper import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.board.domain.repository.PostRepository +import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.comment.application.usecase.query.GetCommentQueryService import com.weeth.domain.comment.domain.repository.CommentReader import com.weeth.domain.file.application.mapper.FileMapper @@ -27,6 +28,7 @@ import java.time.LocalDateTime class GetPostQueryService( private val postRepository: PostRepository, private val boardRepository: BoardRepository, + private val clubMemberPolicy: ClubMemberPolicy, private val commentReader: CommentReader, private val getCommentQueryService: GetCommentQueryService, private val fileReader: FileReader, @@ -37,13 +39,15 @@ class GetPostQueryService( private const val MAX_PAGE_SIZE = 50 } - // TODO(PR4): 해당 클럽 소속 멤버인지 검증 필요 fun findPost( clubId: Long, + userId: Long, postId: Long, - role: Role, + role: Role, // TODO: 멀티 테넨시 지원으로 Jwt에 포함한 Role은 삭제 예정 ): PostDetailResponse { + clubMemberPolicy.getActiveMember(clubId, userId) val post = postRepository.findByIdAndIsDeletedFalse(postId) ?: throw PostNotFoundException() + if (post.board.club.id != clubId || post.board.isDeleted || !post.board.isAccessibleBy(role)) { throw PostNotFoundException() } @@ -55,16 +59,18 @@ class GetPostQueryService( return postMapper.toDetailResponse(post, commentTree, files) } - // TODO(PR4): 해당 클럽 소속 멤버인지 검증 필요 fun findPosts( clubId: Long, + userId: Long, boardId: Long, pageNumber: Int, pageSize: Int, - role: Role, + role: Role, // TODO: 멀티 테넨시 지원으로 Jwt에 포함한 Role은 삭제 예정 ): Slice { + clubMemberPolicy.getActiveMember(clubId, userId) validatePage(pageNumber, pageSize) validateBoardVisibility(boardId, clubId, role) + val pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")) val posts = postRepository.findAllActiveByBoardId(boardId, pageable) @@ -75,15 +81,16 @@ class GetPostQueryService( return posts.map { postMapper.toListResponse(it, fileExistsByPostId[it.id] == true, now) } } - // TODO(PR4): 해당 클럽 소속 멤버인지 검증 필요 fun searchPosts( clubId: Long, + userId: Long, boardId: Long, keyword: String, pageNumber: Int, pageSize: Int, - role: Role, + role: Role, // TODO: 멀티 테넨시 지원으로 Jwt에 포함한 Role은 삭제 예정 ): Slice { + clubMemberPolicy.getActiveMember(clubId, userId) validatePage(pageNumber, pageSize) validateBoardVisibility(boardId, clubId, role) val pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")) diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt b/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt index b8ebf098..a389bb87 100644 --- a/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt @@ -6,11 +6,13 @@ import com.weeth.domain.board.application.dto.response.BoardDetailResponse import com.weeth.domain.board.application.exception.BoardErrorCode import com.weeth.domain.board.application.usecase.command.ManageBoardUseCase import com.weeth.domain.board.application.usecase.query.GetBoardQueryService +import com.weeth.global.auth.annotation.CurrentUser import com.weeth.global.common.exception.ApiErrorCodeExample import com.weeth.global.common.response.CommonResponse import com.weeth.global.common.web.TsidParam import com.weeth.global.common.web.TsidPathVariable import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.Valid import org.springframework.security.access.prepost.PreAuthorize @@ -37,10 +39,11 @@ class BoardAdminController( fun findAllBoards( @PathVariable @TsidParam @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse> = CommonResponse.success( BoardResponseCode.BOARD_FIND_ALL_SUCCESS, - getBoardQueryService.findAllBoardsForAdmin(clubId), + getBoardQueryService.findAllBoardsForAdmin(clubId, userId), ) @GetMapping("/{boardId}") @@ -49,10 +52,11 @@ class BoardAdminController( @PathVariable @TsidParam @TsidPathVariable clubId: Long, @PathVariable boardId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse = CommonResponse.success( BoardResponseCode.BOARD_FIND_BY_ID_SUCCESS, - getBoardQueryService.findBoardDetailForAdmin(clubId, boardId), + getBoardQueryService.findBoardDetailForAdmin(clubId, userId, boardId), ) @PostMapping @@ -61,8 +65,12 @@ class BoardAdminController( @PathVariable @TsidParam @TsidPathVariable clubId: Long, @RequestBody @Valid request: CreateBoardRequest, + @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse = - CommonResponse.success(BoardResponseCode.BOARD_CREATED_SUCCESS, manageBoardUseCase.create(clubId, request)) + CommonResponse.success( + BoardResponseCode.BOARD_CREATED_SUCCESS, + manageBoardUseCase.create(clubId, request, userId), + ) @PatchMapping("/{boardId}") @Operation(summary = "게시판 설정/이름 수정") @@ -71,10 +79,11 @@ class BoardAdminController( @TsidPathVariable clubId: Long, @PathVariable boardId: Long, @RequestBody @Valid request: UpdateBoardRequest, + @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse = CommonResponse.success( BoardResponseCode.BOARD_UPDATED_SUCCESS, - manageBoardUseCase.update(clubId, boardId, request), + manageBoardUseCase.update(clubId, boardId, request, userId), ) @DeleteMapping("/{boardId}") @@ -83,8 +92,9 @@ class BoardAdminController( @PathVariable @TsidParam @TsidPathVariable clubId: Long, @PathVariable boardId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse { - manageBoardUseCase.delete(clubId, boardId) + manageBoardUseCase.delete(clubId, boardId, userId) return CommonResponse.success(BoardResponseCode.BOARD_DELETED_SUCCESS) } } diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt b/src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt index 74da34dd..d95c0bf0 100644 --- a/src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt +++ b/src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt @@ -4,6 +4,7 @@ import com.weeth.domain.board.application.dto.response.BoardListResponse import com.weeth.domain.board.application.exception.BoardErrorCode import com.weeth.domain.board.application.usecase.query.GetBoardQueryService import com.weeth.domain.user.domain.enums.Role +import com.weeth.global.auth.annotation.CurrentUser import com.weeth.global.auth.annotation.CurrentUserRole import com.weeth.global.common.exception.ApiErrorCodeExample import com.weeth.global.common.response.CommonResponse @@ -29,7 +30,11 @@ class BoardController( fun findBoards( @PathVariable @TsidParam @TsidPathVariable clubId: Long, - @Parameter(hidden = true) @CurrentUserRole role: Role, + @Parameter(hidden = true) @CurrentUser userId: Long, + @Parameter(hidden = true) @CurrentUserRole role: Role, // TODO: 멀티 테넨시 지원으로 Jwt에 포함한 Role은 삭제 예정 ): CommonResponse> = - CommonResponse.success(BoardResponseCode.BOARD_FIND_ALL_SUCCESS, getBoardQueryService.findBoards(clubId, role)) + CommonResponse.success( + BoardResponseCode.BOARD_FIND_ALL_SUCCESS, + getBoardQueryService.findBoards(clubId, userId, role), + ) } diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt b/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt index f3a82957..a7e8b048 100644 --- a/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt +++ b/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt @@ -63,11 +63,12 @@ class PostController( @PathVariable boardId: Long, @RequestParam(defaultValue = "0") pageNumber: Int, @RequestParam(defaultValue = "10") pageSize: Int, - @Parameter(hidden = true) @CurrentUserRole role: Role, + @Parameter(hidden = true) @CurrentUser userId: Long, + @Parameter(hidden = true) @CurrentUserRole role: Role, // TODO: 멀티 테넨시 지원으로 Jwt에 포함한 Role은 삭제 예정 ): CommonResponse> = CommonResponse.success( BoardResponseCode.POST_FIND_ALL_SUCCESS, - getPostQueryService.findPosts(clubId, boardId, pageNumber, pageSize, role), + getPostQueryService.findPosts(clubId, userId, boardId, pageNumber, pageSize, role), ) @GetMapping("/posts/{postId}") @@ -76,11 +77,12 @@ class PostController( @PathVariable @TsidParam @TsidPathVariable clubId: Long, @PathVariable postId: Long, - @Parameter(hidden = true) @CurrentUserRole role: Role, + @Parameter(hidden = true) @CurrentUser userId: Long, + @Parameter(hidden = true) @CurrentUserRole role: Role, // TODO: 멀티 테넨시 지원으로 Jwt에 포함한 Role은 삭제 예정 ): CommonResponse = CommonResponse.success( BoardResponseCode.POST_FIND_BY_ID_SUCCESS, - getPostQueryService.findPost(clubId, postId, role), + getPostQueryService.findPost(clubId, userId, postId, role), ) @PatchMapping("/posts/{postId}") @@ -118,11 +120,12 @@ class PostController( @RequestParam keyword: String, @RequestParam(defaultValue = "0") pageNumber: Int, @RequestParam(defaultValue = "10") pageSize: Int, - @Parameter(hidden = true) @CurrentUserRole role: Role, + @Parameter(hidden = true) @CurrentUser userId: Long, + @Parameter(hidden = true) @CurrentUserRole role: Role, // TODO: 멀티 테넨시 지원으로 Jwt에 포함한 Role은 삭제 예정 ): CommonResponse> = CommonResponse.success( BoardResponseCode.POST_SEARCH_SUCCESS, - getPostQueryService.searchPosts(clubId, boardId, keyword, pageNumber, pageSize, role), + getPostQueryService.searchPosts(clubId, userId, boardId, keyword, pageNumber, pageSize, role), ) @PostMapping("/{boardId}/notices/read-all") diff --git a/src/main/kotlin/com/weeth/domain/cardinal/application/usecase/command/ManageCardinalUseCase.kt b/src/main/kotlin/com/weeth/domain/cardinal/application/usecase/command/ManageCardinalUseCase.kt index a5448184..98286bac 100644 --- a/src/main/kotlin/com/weeth/domain/cardinal/application/usecase/command/ManageCardinalUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/application/usecase/command/ManageCardinalUseCase.kt @@ -8,6 +8,7 @@ import com.weeth.domain.cardinal.application.mapper.CardinalMapper import com.weeth.domain.cardinal.domain.repository.CardinalRepository import com.weeth.domain.cardinal.domain.service.CardinalStatusPolicy import com.weeth.domain.club.domain.repository.ClubReader +import com.weeth.domain.club.domain.service.ClubMemberPolicy import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -17,30 +18,35 @@ class ManageCardinalUseCase( private val cardinalMapper: CardinalMapper, private val cardinalStatusPolicy: CardinalStatusPolicy, private val clubReader: ClubReader, + private val clubMemberPolicy: ClubMemberPolicy, ) { - // TODO(PR4): 해당 클럽 소속 admin인지 검증 필요 @Transactional fun save( clubId: Long, request: CardinalSaveRequest, + userId: Long, ) { + clubMemberPolicy.requireAdmin(clubId, userId) val club = clubReader.getClubById(clubId) + if (cardinalRepository.findByClubIdAndCardinalNumber(clubId, request.cardinalNumber) != null) { throw DuplicateCardinalException() } val cardinal = cardinalRepository.save(cardinalMapper.toEntity(club, request)) + if (request.inProgress) { cardinalStatusPolicy.activateExclusively(cardinal) } } - // TODO(PR4): 해당 클럽 소속 admin인지 검증 필요 @Transactional fun update( clubId: Long, request: CardinalUpdateRequest, + userId: Long, ) { + clubMemberPolicy.requireAdmin(clubId, userId) val cardinal = cardinalRepository.findByIdAndClubId(request.id, clubId) ?: throw CardinalNotFoundException() diff --git a/src/main/kotlin/com/weeth/domain/cardinal/application/usecase/query/GetCardinalQueryService.kt b/src/main/kotlin/com/weeth/domain/cardinal/application/usecase/query/GetCardinalQueryService.kt index b9abf3f9..82a961c3 100644 --- a/src/main/kotlin/com/weeth/domain/cardinal/application/usecase/query/GetCardinalQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/application/usecase/query/GetCardinalQueryService.kt @@ -3,18 +3,23 @@ package com.weeth.domain.cardinal.application.usecase.query import com.weeth.domain.cardinal.application.dto.response.CardinalResponse import com.weeth.domain.cardinal.application.mapper.CardinalMapper import com.weeth.domain.cardinal.domain.repository.CardinalReader -import com.weeth.domain.cardinal.domain.repository.CardinalRepository +import com.weeth.domain.club.domain.service.ClubMemberPolicy import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @Service @Transactional(readOnly = true) class GetCardinalQueryService( - private val cardinalRepository: CardinalRepository, private val cardinalReader: CardinalReader, + private val clubMemberPolicy: ClubMemberPolicy, private val cardinalMapper: CardinalMapper, ) { - // TODO(PR4): 해당 클럽 소속 멤버인지 검증 필요 - fun findAll(clubId: Long): List = - cardinalReader.findAllByClubIdOrderByCardinalNumberAsc(clubId).map(cardinalMapper::toResponse) + fun findAll( + clubId: Long, + userId: Long, + ): List { + clubMemberPolicy.getActiveMember(clubId, userId) + + return cardinalReader.findAllByClubIdOrderByCardinalNumberAsc(clubId).map(cardinalMapper::toResponse) + } } diff --git a/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalReader.kt b/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalReader.kt index 9fea0fe2..e6feeef7 100644 --- a/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalReader.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalReader.kt @@ -11,6 +11,12 @@ interface CardinalReader { semester: Int, ): Cardinal + fun getByClubIdAndYearAndSemester( + clubId: Long, + year: Int, + semester: Int, + ): Cardinal + fun findByIdOrNull(cardinalId: Long): Cardinal? fun findAllByCardinalNumberDesc(): List diff --git a/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalRepository.kt b/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalRepository.kt index 16d156c6..4429a4df 100644 --- a/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalRepository.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalRepository.kt @@ -21,6 +21,12 @@ interface CardinalRepository : semester: Int, ): Optional + fun findByClubIdAndYearAndSemester( + clubId: Long, + year: Int, + semester: Int, + ): Optional + @Lock(LockModeType.PESSIMISTIC_WRITE) @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) @Query("SELECT c FROM Cardinal c WHERE c.status = 'IN_PROGRESS'") @@ -53,6 +59,12 @@ interface CardinalRepository : semester: Int, ): Cardinal = findByYearAndSemester(year, semester).orElseThrow { CardinalNotFoundException() } + override fun getByClubIdAndYearAndSemester( + clubId: Long, + year: Int, + semester: Int, + ): Cardinal = findByClubIdAndYearAndSemester(clubId, year, semester).orElseThrow { CardinalNotFoundException() } + override fun findByIdOrNull(cardinalId: Long): Cardinal? = findById(cardinalId).orElse(null) override fun findAllByCardinalNumberDesc(): List = findAllByOrderByCardinalNumberDesc() diff --git a/src/main/kotlin/com/weeth/domain/cardinal/domain/service/CardinalStatusPolicy.kt b/src/main/kotlin/com/weeth/domain/cardinal/domain/service/CardinalStatusPolicy.kt index 1d25d375..75b06867 100644 --- a/src/main/kotlin/com/weeth/domain/cardinal/domain/service/CardinalStatusPolicy.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/domain/service/CardinalStatusPolicy.kt @@ -9,6 +9,7 @@ class CardinalStatusPolicy( private val cardinalRepository: CardinalRepository, ) { fun activateExclusively(cardinal: Cardinal) { + // TODO: 현재는 전역 IN_PROGRESS cardinal을 모두 종료한다. clubId 기준으로 범위를 제한해야 동아리 간 격리가 유지된다. val inProgressCardinals = cardinalRepository.findAllInProgressWithLock() inProgressCardinals.forEach(Cardinal::done) cardinal.inProgress() diff --git a/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalAdminController.kt b/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalAdminController.kt index 65993cbb..866d5b0c 100644 --- a/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalAdminController.kt @@ -4,12 +4,14 @@ import com.weeth.domain.cardinal.application.dto.request.CardinalSaveRequest import com.weeth.domain.cardinal.application.dto.request.CardinalUpdateRequest import com.weeth.domain.cardinal.application.exception.CardinalErrorCode import com.weeth.domain.cardinal.application.usecase.command.ManageCardinalUseCase +import com.weeth.global.auth.annotation.CurrentUser import com.weeth.global.auth.jwt.application.exception.JwtErrorCode import com.weeth.global.common.exception.ApiErrorCodeExample import com.weeth.global.common.response.CommonResponse import com.weeth.global.common.web.TsidParam import com.weeth.global.common.web.TsidPathVariable import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.Valid import org.springframework.web.bind.annotation.PatchMapping @@ -32,8 +34,9 @@ class CardinalAdminController( @PathVariable @TsidParam @TsidPathVariable clubId: Long, @RequestBody @Valid request: CardinalUpdateRequest, + @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse { - manageCardinalUseCase.update(clubId, request) + manageCardinalUseCase.update(clubId, request, userId) return CommonResponse.success(CardinalResponseCode.CARDINAL_UPDATE_SUCCESS) } @@ -43,8 +46,9 @@ class CardinalAdminController( @PathVariable @TsidParam @TsidPathVariable clubId: Long, @RequestBody @Valid request: CardinalSaveRequest, + @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse { - manageCardinalUseCase.save(clubId, request) + manageCardinalUseCase.save(clubId, request, userId) return CommonResponse.success(CardinalResponseCode.CARDINAL_SAVE_SUCCESS) } } diff --git a/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalController.kt b/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalController.kt index 26c39212..f579d4fb 100644 --- a/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalController.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalController.kt @@ -3,12 +3,14 @@ package com.weeth.domain.cardinal.presentation import com.weeth.domain.cardinal.application.dto.response.CardinalResponse import com.weeth.domain.cardinal.application.exception.CardinalErrorCode import com.weeth.domain.cardinal.application.usecase.query.GetCardinalQueryService +import com.weeth.global.auth.annotation.CurrentUser import com.weeth.global.auth.jwt.application.exception.JwtErrorCode import com.weeth.global.common.exception.ApiErrorCodeExample import com.weeth.global.common.response.CommonResponse import com.weeth.global.common.web.TsidParam import com.weeth.global.common.web.TsidPathVariable import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable @@ -27,6 +29,10 @@ class CardinalController( fun findAllCardinals( @PathVariable @TsidParam @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse> = - CommonResponse.success(CardinalResponseCode.CARDINAL_FIND_ALL_SUCCESS, getCardinalQueryService.findAll(clubId)) + CommonResponse.success( + CardinalResponseCode.CARDINAL_FIND_ALL_SUCCESS, + getCardinalQueryService.findAll(clubId, userId), + ) } diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubMemberApplyObRequest.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubMemberApplyObRequest.kt new file mode 100644 index 00000000..7568c641 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubMemberApplyObRequest.kt @@ -0,0 +1,10 @@ +package com.weeth.domain.club.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema + +data class ClubMemberApplyObRequest( + @field:Schema(description = "대상 멤버 ID", example = "1") + val clubMemberId: Long, + @field:Schema(description = "적용할 기수", example = "8") + val cardinal: Int, +) diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt index 7dfd6f9d..9ef2f24f 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt @@ -1,8 +1,18 @@ package com.weeth.domain.club.application.usecase.command +import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException +import com.weeth.domain.cardinal.domain.entity.Cardinal +import com.weeth.domain.cardinal.domain.repository.CardinalReader +import com.weeth.domain.club.application.dto.request.ClubMemberApplyObRequest import com.weeth.domain.club.application.dto.request.ClubMemberRoleUpdateRequest -import com.weeth.domain.club.domain.repository.ClubMemberRepository +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.entity.ClubMemberCardinal +import com.weeth.domain.club.domain.repository.ClubMemberCardinalRepository +import com.weeth.domain.club.domain.service.ClubMemberCardinalPolicy import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.session.domain.repository.SessionReader import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -11,8 +21,12 @@ import org.springframework.transaction.annotation.Transactional */ @Service class AdminClubMemberUseCase( - private val clubMemberRepository: ClubMemberRepository, private val clubMemberPolicy: ClubMemberPolicy, + private val clubMemberCardinalPolicy: ClubMemberCardinalPolicy, + private val cardinalReader: CardinalReader, + private val sessionReader: SessionReader, + private val attendanceRepository: AttendanceRepository, + private val clubMemberCardinalRepository: ClubMemberCardinalRepository, ) { @Transactional fun accept( @@ -49,4 +63,48 @@ class AdminClubMemberUseCase( val member = clubMemberPolicy.getMemberInClub(clubId, request.clubMemberId) member.updateRole(request.memberRole) } + + @Transactional + fun applyOb( + clubId: Long, + userId: Long, + requests: List, + ) { + clubMemberPolicy.requireAdmin(clubId, userId) + + val uniqueRequests = requests.distinctBy { it.clubMemberId to it.cardinal } + if (uniqueRequests.isEmpty()) return + + val cardinalByNumber = mutableMapOf() + + uniqueRequests.forEach { request -> + val member = clubMemberPolicy.getMemberInClub(clubId, request.clubMemberId) + val nextCardinal = + cardinalByNumber.getOrPut(request.cardinal) { + cardinalReader.findByClubIdAndCardinalNumber(clubId, request.cardinal) + ?: throw CardinalNotFoundException() + } + + if (clubMemberCardinalPolicy.notContains(member, nextCardinal)) { + if (clubMemberCardinalPolicy.isLatestOrFirstCardinal(member, nextCardinal)) { + member.resetAttendanceStats() + initializeAttendances(clubId, member, nextCardinal) + } + + clubMemberCardinalRepository.save(ClubMemberCardinal.create(member, nextCardinal)) + } + } + } + + private fun initializeAttendances( + clubId: Long, + member: ClubMember, + cardinal: Cardinal, + ) { + val sessions = sessionReader.findAllByClubIdAndCardinalIn(clubId, listOf(cardinal.cardinalNumber)) + if (sessions.isEmpty()) return + + val attendances = sessions.map { Attendance.create(session = it, clubMember = member) } + attendanceRepository.saveAll(attendances) + } } diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt index 6d840612..9d477294 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt @@ -27,6 +27,7 @@ class ManageClubMemberUsecase( /** * 초대 코드가 일치하면 자동으로 활성 상태로 가입됨 * MVP에서는 단일 동아리 지원만 가능 + * TODO: 출석 초기화 */ @Transactional fun join( diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt index 6eff19e5..453231af 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt @@ -2,7 +2,6 @@ package com.weeth.domain.club.application.usecase.command import com.weeth.domain.club.application.dto.request.ClubCreateRequest import com.weeth.domain.club.application.dto.request.ClubUpdateRequest -import com.weeth.domain.club.application.mapper.ClubMapper import com.weeth.domain.club.domain.entity.Club import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.club.domain.enums.MemberRole @@ -25,7 +24,6 @@ class ManageClubUseCase( private val clubMemberRepository: ClubMemberRepository, private val userReader: UserReader, private val clubMemberPolicy: ClubMemberPolicy, - private val clubMapper: ClubMapper, ) { /** * 새로운 동아리를 생성 diff --git a/src/main/kotlin/com/weeth/domain/club/domain/enums/MemberRole.kt b/src/main/kotlin/com/weeth/domain/club/domain/enums/MemberRole.kt index 139a8ffc..c750d77d 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/enums/MemberRole.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/enums/MemberRole.kt @@ -4,4 +4,6 @@ enum class MemberRole { USER, ADMIN, LEAD, // 동아리 개설한 인원의 역할. 추후 LEAD 권한 이양 API도 추가 + // TODO: ADMIN, LEAD 권한 관련 JWT, Filter + // 다른 동아리의 ADMIN인 경우는 JWT로 검증이 안되니까 JWT에서 Role을 빼야할 수도 있음 } diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt index e742eeb4..c57bb7d8 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt @@ -1,17 +1,13 @@ package com.weeth.domain.club.domain.repository import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.enums.MemberStatus interface ClubMemberReader { - fun getClubMemberById(clubMemberId: Long): ClubMember + fun findByIdWithLock(clubMemberId: Long): ClubMember? fun findByIdOrNull(clubMemberId: Long): ClubMember? - fun findByIdAndClubId( - clubMemberId: Long, - clubId: Long, - ): ClubMember? - fun findByClubIdAndUserId( clubId: Long, userId: Long, @@ -24,4 +20,9 @@ interface ClubMemberReader { fun findActiveByUserId(userId: Long): List fun countActiveByClubId(clubId: Long): Long + + fun findAllByClubIdAndMemberStatus( + clubId: Long, + memberStatus: MemberStatus, + ): List } diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt index b665d7d5..1621b568 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt @@ -1,23 +1,31 @@ package com.weeth.domain.club.domain.repository -import com.weeth.domain.club.application.exception.ClubMemberNotFoundException import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.enums.MemberStatus +import jakarta.persistence.LockModeType +import jakarta.persistence.QueryHint import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Lock import org.springframework.data.jpa.repository.Query +import org.springframework.data.jpa.repository.QueryHints import org.springframework.data.repository.query.Param interface ClubMemberRepository : JpaRepository, ClubMemberReader { - override fun getClubMemberById(clubMemberId: Long): ClubMember = - findById(clubMemberId).orElseThrow { ClubMemberNotFoundException() } - - override fun findByIdOrNull(clubMemberId: Long): ClubMember? = findById(clubMemberId).orElse(null) + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query("SELECT cm FROM ClubMember cm JOIN FETCH cm.user WHERE cm.id = :clubMemberId") + override fun findByIdWithLock( + @Param("clubMemberId") clubMemberId: Long, + ): ClubMember? - override fun findByIdAndClubId( - clubMemberId: Long, + override fun findAllByClubIdAndMemberStatus( clubId: Long, - ): ClubMember? + memberStatus: MemberStatus, + ): List + + override fun findByIdOrNull(clubMemberId: Long): ClubMember? = findById(clubMemberId).orElse(null) override fun findByClubIdAndUserId( clubId: Long, diff --git a/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberCardinalPolicy.kt b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberCardinalPolicy.kt new file mode 100644 index 00000000..7dc653f6 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberCardinalPolicy.kt @@ -0,0 +1,37 @@ +package com.weeth.domain.club.domain.service + +import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException +import com.weeth.domain.cardinal.domain.entity.Cardinal +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.repository.ClubMemberCardinalReader +import org.springframework.stereotype.Service + +@Service +class ClubMemberCardinalPolicy( + private val clubMemberCardinalReader: ClubMemberCardinalReader, +) { + fun getCurrentCardinal(clubMember: ClubMember): Cardinal { + val latest = + clubMemberCardinalReader.findLatestCardinalByClubMember(clubMember) + ?: throw CardinalNotFoundException() + return latest.cardinal + } + + fun notContains( + clubMember: ClubMember, + cardinal: Cardinal, + ): Boolean = !clubMemberCardinalReader.existsByClubMemberAndCardinalId(clubMember, cardinal.id) + + /** + * applyOb에서 다음 기수로 등록하기 위해 사용하는 메서드 + * 하위호환을 위해 기수가 없는 경우라도 다음 기수 활동이 가능하도록 지원 + * TODO: 앞 단에서 기수가 필수로 저장됨을 보장해야함. (가입, 기수 추가 등) + */ + fun isLatestOrFirstCardinal( + clubMember: ClubMember, + cardinal: Cardinal, + ): Boolean { + val latest = clubMemberCardinalReader.findLatestCardinalByClubMember(clubMember) + return latest == null || cardinal.cardinalNumber > latest.cardinal.cardinalNumber + } +} diff --git a/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt index 295ba2d8..3fac4d2e 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt @@ -10,6 +10,7 @@ import org.springframework.stereotype.Service /** * 동아리 멤버 관련 비즈니스 규칙 및 권한 검증 + * TODO: 캐싱 도입 */ @Service class ClubMemberPolicy( @@ -38,6 +39,7 @@ class ClubMemberPolicy( clubId: Long, userId: Long, ) = getActiveMember(clubId, userId).also { + // TODO: 동아리 생성자를 LEAD로 저장하고 있어 LEAD도 관리자 권한으로 취급할지 정책 정리가 필요하다. if (!it.isAdmin()) { throw NotClubAdminException() } diff --git a/src/main/kotlin/com/weeth/domain/club/presentation/ClubAdminController.kt b/src/main/kotlin/com/weeth/domain/club/presentation/ClubAdminController.kt index 5403724f..63567718 100644 --- a/src/main/kotlin/com/weeth/domain/club/presentation/ClubAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/club/presentation/ClubAdminController.kt @@ -1,5 +1,6 @@ package com.weeth.domain.club.presentation +import com.weeth.domain.club.application.dto.request.ClubMemberApplyObRequest import com.weeth.domain.club.application.dto.request.ClubMemberRoleUpdateRequest import com.weeth.domain.club.application.dto.request.ClubUpdateRequest import com.weeth.domain.club.application.dto.response.ClubDetailResponse @@ -41,8 +42,8 @@ class ClubAdminController( @Operation(summary = "동아리 상세 정보 조회") fun getClubDetail( @Parameter(hidden = true) @CurrentUser userId: Long, - @TsidParam - @TsidPathVariable("clubId") clubId: Long, + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, ): CommonResponse { val detail = getClubQueryService.findClubDetailForAdmin(clubId, userId) return CommonResponse.success(ClubResponseCode.CLUB_FIND_BY_ID_SUCCESS, detail) @@ -52,8 +53,8 @@ class ClubAdminController( @Operation(summary = "동아리 정보 수정") fun update( @Parameter(hidden = true) @CurrentUser userId: Long, - @TsidParam - @TsidPathVariable("clubId") clubId: Long, + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, @Valid @RequestBody request: ClubUpdateRequest, ): CommonResponse { manageClubUseCase.update(clubId, userId, request) @@ -64,8 +65,8 @@ class ClubAdminController( @Operation(summary = "동아리 프로필 사진 삭제") fun deleteProfileImage( @Parameter(hidden = true) @CurrentUser userId: Long, - @TsidParam - @TsidPathVariable("clubId") clubId: Long, + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, ): CommonResponse { manageClubUseCase.deleteProfileImage(clubId, userId) return CommonResponse.success(ClubResponseCode.CLUB_PROFILE_IMAGE_DELETED_SUCCESS) @@ -75,8 +76,8 @@ class ClubAdminController( @Operation(summary = "동아리 배경 사진 삭제") fun deleteBackgroundImage( @Parameter(hidden = true) @CurrentUser userId: Long, - @TsidParam - @TsidPathVariable("clubId") clubId: Long, + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, ): CommonResponse { manageClubUseCase.deleteBackgroundImage(clubId, userId) return CommonResponse.success(ClubResponseCode.CLUB_BACKGROUND_IMAGE_DELETED_SUCCESS) @@ -86,8 +87,8 @@ class ClubAdminController( @Operation(summary = "초대 코드 재생성 (MVP 미사용)", deprecated = true) fun regenerateCode( @Parameter(hidden = true) @CurrentUser userId: Long, - @TsidParam - @TsidPathVariable("clubId") clubId: Long, + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, ): CommonResponse { manageClubUseCase.regenerateCode(clubId, userId) return CommonResponse.success(ClubResponseCode.CLUB_CODE_REGENERATED_SUCCESS) @@ -97,8 +98,8 @@ class ClubAdminController( @Operation(summary = "동아리 멤버 목록 조회") fun getClubMembers( @Parameter(hidden = true) @CurrentUser userId: Long, - @TsidParam - @TsidPathVariable("clubId") clubId: Long, + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, ): CommonResponse> { val members = getClubMemberQueryService.findClubMembersForAdmin(clubId, userId) return CommonResponse.success(ClubResponseCode.MEMBER_FIND_ALL_SUCCESS, members) @@ -108,8 +109,8 @@ class ClubAdminController( @Operation(summary = "멤버 승인") fun acceptMember( @Parameter(hidden = true) @CurrentUser userId: Long, - @TsidParam - @TsidPathVariable("clubId") clubId: Long, + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, @PathVariable clubMemberId: Long, ): CommonResponse { adminClubMemberUseCase.accept(clubId, userId, clubMemberId) @@ -120,8 +121,8 @@ class ClubAdminController( @Operation(summary = "멤버 추방") fun banMember( @Parameter(hidden = true) @CurrentUser userId: Long, - @TsidParam - @TsidPathVariable("clubId") clubId: Long, + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, @PathVariable clubMemberId: Long, ): CommonResponse { adminClubMemberUseCase.ban(clubId, userId, clubMemberId) @@ -132,12 +133,24 @@ class ClubAdminController( @Operation(summary = "멤버 권한 변경") fun updateMemberRole( @Parameter(hidden = true) @CurrentUser userId: Long, - @TsidParam - @TsidPathVariable("clubId") clubId: Long, + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, @PathVariable clubMemberId: Long, @Valid @RequestBody request: ClubMemberRoleUpdateRequest, ): CommonResponse { adminClubMemberUseCase.updateMemberRole(clubId, userId, request) return CommonResponse.success(ClubResponseCode.MEMBER_ROLE_UPDATED_SUCCESS) } + + @PatchMapping("/members/apply-ob") + @Operation(summary = "멤버 OB 기수 등록") + fun applyOb( + @Parameter(hidden = true) @CurrentUser userId: Long, + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, + @Valid @RequestBody requests: List, + ): CommonResponse { + adminClubMemberUseCase.applyOb(clubId, userId, requests) + return CommonResponse.success(ClubResponseCode.MEMBER_APPLY_OB_SUCCESS) + } } diff --git a/src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt b/src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt index 6fcb209f..5270f2e8 100644 --- a/src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt +++ b/src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt @@ -4,6 +4,7 @@ import com.weeth.domain.club.application.dto.request.ClubCreateRequest import com.weeth.domain.club.application.dto.request.ClubJoinRequest import com.weeth.domain.club.application.dto.response.ClubInfoResponse import com.weeth.domain.club.application.dto.response.ClubMemberProfileResponse +import com.weeth.domain.club.application.dto.response.ClubMemberResponse import com.weeth.domain.club.application.dto.response.ClubResponse import com.weeth.domain.club.application.exception.ClubErrorCode import com.weeth.domain.club.application.usecase.command.ManageClubMemberUsecase @@ -22,9 +23,11 @@ import jakarta.validation.Valid import org.springframework.http.HttpStatus import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController @@ -64,8 +67,8 @@ class ClubController( @Operation(summary = "동아리 정보 조회 (이름, 소개, 이미지)") fun getClubPublicInfo( @Parameter(hidden = true) @CurrentUser userId: Long, - @TsidParam - @TsidPathVariable("clubId") clubId: Long, + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, ): CommonResponse { val info = getClubQueryService.findClub(clubId) @@ -76,8 +79,8 @@ class ClubController( @Operation(summary = "동아리 가입") fun join( @Parameter(hidden = true) @CurrentUser userId: Long, - @TsidParam - @TsidPathVariable("clubId") clubId: Long, + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, @Valid @RequestBody request: ClubJoinRequest, ): CommonResponse { manageClubMemberUsecase.join(clubId, userId, request) @@ -89,8 +92,8 @@ class ClubController( @Operation(summary = "동아리 탈퇴") fun leave( @Parameter(hidden = true) @CurrentUser userId: Long, - @TsidParam - @TsidPathVariable("clubId") clubId: Long, + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, ): CommonResponse { manageClubMemberUsecase.leave(clubId, userId) @@ -101,11 +104,13 @@ class ClubController( @Operation(summary = "내 멤버 정보 조회") fun getMyMemberInfo( @Parameter(hidden = true) @CurrentUser userId: Long, - @TsidParam - @TsidPathVariable("clubId") clubId: Long, + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, ): CommonResponse { val meInfo = getClubMemberQueryService.findMyMemberProfile(clubId, userId) return CommonResponse.success(ClubResponseCode.MEMBER_FIND_ME_SUCCESS, meInfo) } + + // TODO: MVP 후 동아리 멤버 조회 기능 구현 } diff --git a/src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt b/src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt index 0378fc48..ddd31a04 100644 --- a/src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt @@ -23,4 +23,5 @@ enum class ClubResponseCode( CLUB_FIND_SUCCESS(11112, HttpStatus.OK, "동아리 공개 정보를 성공적으로 조회했습니다."), CLUB_PROFILE_IMAGE_DELETED_SUCCESS(11113, HttpStatus.OK, "동아리 프로필 사진이 삭제되었습니다."), CLUB_BACKGROUND_IMAGE_DELETED_SUCCESS(11114, HttpStatus.OK, "동아리 배경 사진이 삭제되었습니다."), + MEMBER_APPLY_OB_SUCCESS(11115, HttpStatus.OK, "멤버의 OB 기수 등록이 완료되었습니다."), } diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/mapper/PenaltyMapper.kt b/src/main/kotlin/com/weeth/domain/penalty/application/mapper/PenaltyMapper.kt index 224118f3..b8d387fd 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/application/mapper/PenaltyMapper.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/application/mapper/PenaltyMapper.kt @@ -1,25 +1,25 @@ package com.weeth.domain.penalty.application.mapper import com.weeth.domain.cardinal.domain.entity.Cardinal +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.entity.ClubMemberCardinal import com.weeth.domain.penalty.application.dto.request.SavePenaltyRequest import com.weeth.domain.penalty.application.dto.response.PenaltyByCardinalResponse import com.weeth.domain.penalty.application.dto.response.PenaltyDetailResponse import com.weeth.domain.penalty.application.dto.response.PenaltyResponse import com.weeth.domain.penalty.domain.entity.Penalty import com.weeth.domain.penalty.domain.enums.PenaltyType -import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.entity.UserCardinal import org.springframework.stereotype.Component @Component class PenaltyMapper { fun toEntity( request: SavePenaltyRequest, - user: User, + clubMember: ClubMember, cardinal: Cardinal, ): Penalty = Penalty( - user = user, + clubMember = clubMember, cardinal = cardinal, penaltyType = request.penaltyType, penaltyDescription = request.penaltyDescription ?: "", @@ -27,26 +27,26 @@ class PenaltyMapper { fun toAutoPenalty( penaltyDescription: String, - user: User, + clubMember: ClubMember, cardinal: Cardinal, ): Penalty = Penalty( - user = user, + clubMember = clubMember, cardinal = cardinal, penaltyType = PenaltyType.AUTO_PENALTY, penaltyDescription = penaltyDescription, ) fun toResponse( - user: User, + clubMember: ClubMember, penalties: List, - userCardinals: List, + clubMemberCardinals: List, ): PenaltyResponse = PenaltyResponse( - userId = user.id, - name = user.name, - penaltyCount = user.penaltyCount, - cardinals = userCardinals.map { it.cardinal.cardinalNumber }, + userId = clubMember.user.id, + name = clubMember.user.name, + penaltyCount = clubMember.penaltyCount, + cardinals = clubMemberCardinals.map { it.cardinal.cardinalNumber }, penalties = penalties.map(::toDetailResponse), ) diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/DeletePenaltyUseCase.kt b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/DeletePenaltyUseCase.kt index 5e90db37..2b02ed5f 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/DeletePenaltyUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/DeletePenaltyUseCase.kt @@ -1,47 +1,44 @@ package com.weeth.domain.penalty.application.usecase.command +import com.weeth.domain.club.domain.repository.ClubMemberRepository +import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.penalty.application.exception.AutoPenaltyDeleteNotAllowedException import com.weeth.domain.penalty.application.exception.PenaltyNotFoundException import com.weeth.domain.penalty.domain.enums.PenaltyType import com.weeth.domain.penalty.domain.repository.PenaltyRepository -import com.weeth.domain.user.application.exception.UserNotFoundException -import com.weeth.domain.user.domain.repository.UserRepository -import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -// todo: PR4에서 Club 기반으로 수정 @Service class DeletePenaltyUseCase( private val penaltyRepository: PenaltyRepository, - private val userRepository: UserRepository, // 타 도메인이므로 Reader 사용 검토 (조회 시에는 Reader, 업데이트 시에는 Repository) + private val clubMemberRepository: ClubMemberRepository, + private val clubMemberPolicy: ClubMemberPolicy, ) { - /** - * Todo: 코드 가독성 개선 및 트랜잭션 범위 축소 - */ @Transactional - fun delete(penaltyId: Long) { + fun delete( + clubId: Long, + userId: Long, + penaltyId: Long, + ) { + clubMemberPolicy.requireAdmin(clubId, userId) + val penalty = - penaltyRepository.findByIdOrNull(penaltyId) + penaltyRepository.findByIdWithLock(penaltyId) ?: throw PenaltyNotFoundException() + if (penalty.clubMember.club.id != clubId) throw PenaltyNotFoundException() if (penalty.penaltyType == PenaltyType.AUTO_PENALTY) { throw AutoPenaltyDeleteNotAllowedException() } - val user = - userRepository - .findByIdWithLock(penalty.user.id) - .orElseThrow { UserNotFoundException() } - - when (penalty.penaltyType) { - PenaltyType.PENALTY -> { - user.decrementPenaltyCount() - } - - else -> {} + if (penalty.penaltyType == PenaltyType.PENALTY) { + val lockedMember = + clubMemberRepository.findByIdWithLock(penalty.clubMember.id) + ?: throw PenaltyNotFoundException() + lockedMember.decrementPenaltyCount() } - penaltyRepository.deleteById(penaltyId) + penaltyRepository.delete(penalty) } } diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/SavePenaltyUseCase.kt b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/SavePenaltyUseCase.kt index a146c81c..379f236a 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/SavePenaltyUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/SavePenaltyUseCase.kt @@ -1,40 +1,42 @@ package com.weeth.domain.penalty.application.usecase.command +import com.weeth.domain.club.domain.repository.ClubMemberRepository +import com.weeth.domain.club.domain.service.ClubMemberCardinalPolicy +import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.penalty.application.dto.request.SavePenaltyRequest +import com.weeth.domain.penalty.application.exception.PenaltyNotFoundException import com.weeth.domain.penalty.application.mapper.PenaltyMapper import com.weeth.domain.penalty.domain.enums.PenaltyType import com.weeth.domain.penalty.domain.repository.PenaltyRepository -import com.weeth.domain.user.application.exception.UserNotFoundException -import com.weeth.domain.user.domain.repository.UserRepository -import com.weeth.domain.user.domain.service.UserCardinalPolicy import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -// todo: PR4에서 Club 기반으로 수정 @Service class SavePenaltyUseCase( private val penaltyRepository: PenaltyRepository, - private val userRepository: UserRepository, // 타 도메인이므로 Reader 사용 검토 - private val userCardinalPolicy: UserCardinalPolicy, + private val clubMemberRepository: ClubMemberRepository, + private val clubMemberPolicy: ClubMemberPolicy, + private val clubMemberCardinalPolicy: ClubMemberCardinalPolicy, private val mapper: PenaltyMapper, ) { @Transactional - fun save(request: SavePenaltyRequest) { - val user = - userRepository - .findByIdWithLock(request.userId) - .orElseThrow { UserNotFoundException() } - val cardinal = userCardinalPolicy.getCurrentCardinal(user) + fun save( + clubId: Long, + userId: Long, + request: SavePenaltyRequest, + ) { + clubMemberPolicy.requireAdmin(clubId, userId) + val clubMember = clubMemberPolicy.getActiveMember(clubId, request.userId) + val cardinal = clubMemberCardinalPolicy.getCurrentCardinal(clubMember) - val penalty = mapper.toEntity(request, user, cardinal) + val penalty = mapper.toEntity(request, clubMember, cardinal) penaltyRepository.save(penalty) - when (penalty.penaltyType) { - PenaltyType.PENALTY -> { - user.incrementPenaltyCount() - } - - else -> {} // BONUS 등 다른 유형은 카운트 변경 없음 + if (penalty.penaltyType == PenaltyType.PENALTY) { + val lockedMember = + clubMemberRepository.findByIdWithLock(clubMember.id) + ?: throw PenaltyNotFoundException() + lockedMember.incrementPenaltyCount() } } } diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/UpdatePenaltyUseCase.kt b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/UpdatePenaltyUseCase.kt index d84e77c3..f53fea62 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/UpdatePenaltyUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/UpdatePenaltyUseCase.kt @@ -1,5 +1,6 @@ package com.weeth.domain.penalty.application.usecase.command +import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.penalty.application.dto.request.UpdatePenaltyRequest import com.weeth.domain.penalty.application.exception.PenaltyNotFoundException import com.weeth.domain.penalty.domain.repository.PenaltyRepository @@ -7,16 +8,23 @@ import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -// todo: PR4에서 Club 기반으로 수정 @Service class UpdatePenaltyUseCase( private val penaltyRepository: PenaltyRepository, + private val clubMemberPolicy: ClubMemberPolicy, ) { @Transactional - fun update(request: UpdatePenaltyRequest) { + fun update( + clubId: Long, + userId: Long, + request: UpdatePenaltyRequest, + ) { + clubMemberPolicy.requireAdmin(clubId, userId) + val penalty = penaltyRepository.findByIdOrNull(request.penaltyId) ?: throw PenaltyNotFoundException() + if (penalty.clubMember.club.id != clubId) throw PenaltyNotFoundException() if (!request.penaltyDescription.isNullOrBlank()) { penalty.update(request.penaltyDescription) diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/query/GetPenaltyQueryService.kt b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/query/GetPenaltyQueryService.kt index 36865c46..24647228 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/query/GetPenaltyQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/query/GetPenaltyQueryService.kt @@ -1,13 +1,13 @@ package com.weeth.domain.penalty.application.usecase.query import com.weeth.domain.cardinal.domain.repository.CardinalReader +import com.weeth.domain.club.domain.repository.ClubMemberCardinalReader +import com.weeth.domain.club.domain.service.ClubMemberCardinalPolicy +import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.penalty.application.dto.response.PenaltyByCardinalResponse import com.weeth.domain.penalty.application.dto.response.PenaltyResponse import com.weeth.domain.penalty.application.mapper.PenaltyMapper import com.weeth.domain.penalty.domain.repository.PenaltyRepository -import com.weeth.domain.user.domain.repository.UserCardinalReader -import com.weeth.domain.user.domain.repository.UserReader -import com.weeth.domain.user.domain.service.UserCardinalPolicy import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -15,55 +15,61 @@ import org.springframework.transaction.annotation.Transactional @Transactional(readOnly = true) class GetPenaltyQueryService( private val penaltyRepository: PenaltyRepository, - private val userReader: UserReader, - private val userCardinalReader: UserCardinalReader, - private val userCardinalPolicy: UserCardinalPolicy, + private val clubMemberCardinalReader: ClubMemberCardinalReader, + private val clubMemberPolicy: ClubMemberPolicy, + private val clubMemberCardinalPolicy: ClubMemberCardinalPolicy, private val cardinalReader: CardinalReader, private val mapper: PenaltyMapper, ) { - // TODO: PR4에서 clubMember 기반으로 전환 (현재는 user 기반 유지) fun findAllByCardinal( clubId: Long, + userId: Long, cardinalNumber: Int?, ): List { + clubMemberPolicy.requireAdmin(clubId, userId) val cardinals = if (cardinalNumber == null) { - cardinalReader.findAllByCardinalNumberDesc() + cardinalReader.findAllByClubIdOrderByCardinalNumberAsc(clubId) } else { - listOf(cardinalReader.getByCardinalNumber(cardinalNumber)) + listOf(cardinalReader.findByClubIdAndCardinalNumber(clubId, cardinalNumber) ?: return emptyList()) } return cardinals.map { cardinal -> - val penalties = penaltyRepository.findByCardinalIdOrderByIdDesc(cardinal.id) - val users = penalties.map { it.user }.distinct() - val userCardinalsMap = - userCardinalReader - .findAllByUsersOrderByCardinalDesc(users) - .groupBy { it.user.id } + val penalties = penaltyRepository.findByClubIdAndCardinalIdOrderByIdDesc(clubId, cardinal.id) + val clubMembers = penalties.map { it.clubMember }.distinct() + val memberCardinalsMap = + clubMemberCardinalReader + .findAllByClubMembers( + clubMembers, + ).groupBy { it.clubMember.id } val responses = penalties - .groupBy { it.user.id } + .groupBy { it.clubMember.id } .entries - .map { (userId, userPenalties) -> - val userCardinals = userCardinalsMap[userId] ?: emptyList() - mapper.toResponse(userPenalties.first().user, userPenalties, userCardinals) + .map { (clubMemberId, memberPenalties) -> + val clubMember = memberPenalties.first().clubMember + val memberCardinals = memberCardinalsMap[clubMemberId] ?: emptyList() + mapper.toResponse(clubMember, memberPenalties, memberCardinals) }.sortedBy { it.userId } mapper.toByCardinalResponse(cardinal.cardinalNumber, responses) } } - // TODO: PR4에서 clubMember 기반으로 전환 (현재는 user 기반 유지) fun findByUser( clubId: Long, userId: Long, ): PenaltyResponse { - val user = userReader.getById(userId) - val currentCardinal = userCardinalPolicy.getCurrentCardinal(user) - val penalties = penaltyRepository.findByUserIdAndCardinalIdOrderByIdDesc(userId, currentCardinal.id) - val userCardinals = userCardinalReader.findAllByUser(user) + val clubMember = clubMemberPolicy.getActiveMember(clubId, userId) + val currentCardinal = clubMemberCardinalPolicy.getCurrentCardinal(clubMember) + val penalties = + penaltyRepository.findByClubMemberIdAndCardinalIdOrderByIdDesc( + clubMember.id, + currentCardinal.id, + ) + val clubMemberCardinals = clubMemberCardinalReader.findAllByClubMember(clubMember) - return mapper.toResponse(user, penalties, userCardinals) + return mapper.toResponse(clubMember, penalties, clubMemberCardinals) } } diff --git a/src/main/kotlin/com/weeth/domain/penalty/domain/entity/Penalty.kt b/src/main/kotlin/com/weeth/domain/penalty/domain/entity/Penalty.kt index 093339cb..b86327b7 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/domain/entity/Penalty.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/domain/entity/Penalty.kt @@ -1,8 +1,8 @@ package com.weeth.domain.penalty.domain.entity import com.weeth.domain.cardinal.domain.entity.Cardinal +import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.penalty.domain.enums.PenaltyType -import com.weeth.domain.user.domain.entity.User import com.weeth.global.common.entity.BaseEntity import jakarta.persistence.Column import jakarta.persistence.Entity @@ -18,8 +18,8 @@ import jakarta.persistence.ManyToOne @Entity class Penalty( @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id") - val user: User, + @JoinColumn(name = "club_member_id") + val clubMember: ClubMember, @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "cardinal_id") val cardinal: Cardinal, diff --git a/src/main/kotlin/com/weeth/domain/penalty/domain/repository/PenaltyRepository.kt b/src/main/kotlin/com/weeth/domain/penalty/domain/repository/PenaltyRepository.kt index ab79eaa1..dbb205a9 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/domain/repository/PenaltyRepository.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/domain/repository/PenaltyRepository.kt @@ -1,39 +1,35 @@ package com.weeth.domain.penalty.domain.repository import com.weeth.domain.penalty.domain.entity.Penalty -import com.weeth.domain.penalty.domain.enums.PenaltyType -import org.springframework.data.domain.Pageable +import jakarta.persistence.LockModeType +import jakarta.persistence.QueryHint import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Lock import org.springframework.data.jpa.repository.Query -import java.time.LocalDateTime +import org.springframework.data.jpa.repository.QueryHints +import org.springframework.data.repository.query.Param interface PenaltyRepository : JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query("SELECT p FROM Penalty p WHERE p.id = :penaltyId") + fun findByIdWithLock( + @Param("penaltyId") penaltyId: Long, + ): Penalty? + @Query( - "SELECT p FROM Penalty p JOIN FETCH p.cardinal WHERE p.user.id = :userId AND p.cardinal.id = :cardinalId ORDER BY p.id DESC", + "SELECT p FROM Penalty p JOIN FETCH p.clubMember cm JOIN FETCH cm.user JOIN FETCH p.cardinal WHERE cm.id = :clubMemberId AND p.cardinal.id = :cardinalId ORDER BY p.id DESC", ) - fun findByUserIdAndCardinalIdOrderByIdDesc( - userId: Long, + fun findByClubMemberIdAndCardinalIdOrderByIdDesc( + clubMemberId: Long, cardinalId: Long, ): List @Query( - """ - SELECT p FROM Penalty p - WHERE p.user.id = :userId AND p.cardinal.id = :cardinalId - AND p.penaltyType = :penaltyType AND p.createdAt > :createdAt - ORDER BY p.createdAt ASC - """, + "SELECT p FROM Penalty p JOIN FETCH p.clubMember cm JOIN FETCH cm.user JOIN FETCH p.cardinal WHERE cm.club.id = :clubId AND p.cardinal.id = :cardinalId ORDER BY p.id DESC", ) - fun findFirstAutoPenaltyAfter( - userId: Long, + fun findByClubIdAndCardinalIdOrderByIdDesc( + clubId: Long, cardinalId: Long, - penaltyType: PenaltyType, - createdAt: LocalDateTime, - pageable: Pageable, ): List - - @Query( - "SELECT p FROM Penalty p JOIN FETCH p.user JOIN FETCH p.cardinal WHERE p.cardinal.id = :cardinalId ORDER BY p.id DESC", - ) - fun findByCardinalIdOrderByIdDesc(cardinalId: Long): List } diff --git a/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyAdminController.kt b/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyAdminController.kt index 9a2d38bc..d1c44c99 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyAdminController.kt @@ -8,24 +8,28 @@ import com.weeth.domain.penalty.application.usecase.command.DeletePenaltyUseCase import com.weeth.domain.penalty.application.usecase.command.SavePenaltyUseCase import com.weeth.domain.penalty.application.usecase.command.UpdatePenaltyUseCase import com.weeth.domain.penalty.application.usecase.query.GetPenaltyQueryService +import com.weeth.global.auth.annotation.CurrentUser import com.weeth.global.common.exception.ApiErrorCodeExample import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.Valid import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController -// todo: PR4에서 Club 기반으로 수정 @Tag(name = "PENALTY ADMIN", description = "[ADMIN] 패널티 어드민 API") @RestController -@RequestMapping("/api/v1/admin/penalties") +@RequestMapping("/api/v4/admin/clubs/{clubId}/penalties") @ApiErrorCodeExample(PenaltyErrorCode::class) class PenaltyAdminController( private val savePenaltyUseCase: SavePenaltyUseCase, @@ -36,37 +40,49 @@ class PenaltyAdminController( @PostMapping @Operation(summary = "패널티 부여") fun assignPenalty( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, @Valid @RequestBody request: SavePenaltyRequest, ): CommonResponse { - savePenaltyUseCase.save(request) + savePenaltyUseCase.save(clubId, userId, request) return CommonResponse.success(PenaltyResponseCode.PENALTY_ASSIGN_SUCCESS) } @PatchMapping @Operation(summary = "패널티 수정") fun update( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, @Valid @RequestBody request: UpdatePenaltyRequest, ): CommonResponse { - updatePenaltyUseCase.update(request) + updatePenaltyUseCase.update(clubId, userId, request) return CommonResponse.success(PenaltyResponseCode.PENALTY_UPDATE_SUCCESS) } @GetMapping @Operation(summary = "전체 패널티 조회") fun findAll( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, @RequestParam(required = false) cardinal: Int?, ): CommonResponse> = CommonResponse.success( PenaltyResponseCode.PENALTY_FIND_ALL_SUCCESS, - getPenaltyQueryService.findAllByCardinal(0L, cardinal), + getPenaltyQueryService.findAllByCardinal(clubId, userId, cardinal), ) @DeleteMapping @Operation(summary = "패널티 삭제") fun delete( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, @RequestParam penaltyId: Long, ): CommonResponse { - deletePenaltyUseCase.delete(penaltyId) + deletePenaltyUseCase.delete(clubId, userId, penaltyId) return CommonResponse.success(PenaltyResponseCode.PENALTY_DELETE_SUCCESS) } } diff --git a/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyUserController.kt b/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyUserController.kt index 7e08f449..00cb4daa 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyUserController.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyUserController.kt @@ -6,17 +6,19 @@ import com.weeth.domain.penalty.application.usecase.query.GetPenaltyQueryService import com.weeth.global.auth.annotation.CurrentUser import com.weeth.global.common.exception.ApiErrorCodeExample import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController -// todo: PR4에서 Club 기반으로 수정 @Tag(name = "PENALTY", description = "패널티 API") @RestController -@RequestMapping("/api/v1/penalties") +@RequestMapping("/api/v4/clubs/{clubId}/penalties") @ApiErrorCodeExample(PenaltyErrorCode::class) class PenaltyUserController( private val getPenaltyQueryService: GetPenaltyQueryService, @@ -24,10 +26,12 @@ class PenaltyUserController( @GetMapping @Operation(summary = "본인 패널티 조회") fun findAllPenalties( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse = CommonResponse.success( PenaltyResponseCode.PENALTY_USER_FIND_SUCCESS, - getPenaltyQueryService.findByUser(0L, userId), + getPenaltyQueryService.findByUser(clubId, userId), ) } diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/exception/EventErrorCode.kt b/src/main/kotlin/com/weeth/domain/schedule/application/exception/EventErrorCode.kt index c0cfbb38..0848ded0 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/application/exception/EventErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/application/exception/EventErrorCode.kt @@ -9,6 +9,6 @@ enum class EventErrorCode( override val status: HttpStatus, override val message: String, ) : ErrorCodeInterface { - @ExplainError("요청한 일정 ID에 해당하는 일정이 존재하지 않을 때 발생합니다.") + @ExplainError("요청한 일정 ID에 해당하는 일정이 존재하지 않거나 동아리에 속하지 않은 경우에 발생합니다.") EVENT_NOT_FOUND(20800, HttpStatus.NOT_FOUND, "존재하지 않는 일정입니다."), } diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/usecase/command/ManageEventUseCase.kt b/src/main/kotlin/com/weeth/domain/schedule/application/usecase/command/ManageEventUseCase.kt index b799887e..3c264953 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/application/usecase/command/ManageEventUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/application/usecase/command/ManageEventUseCase.kt @@ -2,6 +2,7 @@ package com.weeth.domain.schedule.application.usecase.command import com.weeth.domain.cardinal.domain.repository.CardinalReader import com.weeth.domain.club.domain.repository.ClubReader +import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.schedule.application.dto.request.ScheduleSaveRequest import com.weeth.domain.schedule.application.dto.request.ScheduleUpdateRequest import com.weeth.domain.schedule.application.exception.EventNotFoundException @@ -19,21 +20,22 @@ class ManageEventUseCase( private val cardinalReader: CardinalReader, private val eventMapper: EventMapper, private val clubReader: ClubReader, + private val clubMemberPolicy: ClubMemberPolicy, ) { - // TODO(PR4): 해당 클럽 소속 admin인지 검증 필요 @Transactional fun create( clubId: Long, request: ScheduleSaveRequest, userId: Long, ) { + clubMemberPolicy.requireAdmin(clubId, userId) val club = clubReader.getClubById(clubId) val user = userReader.getById(userId) + // TODO: 전역 cardinal 조회 대신 clubId 기준 조회를 사용해야 다른 동아리 기수로 검증이 통과하지 않는다. cardinalReader.getByCardinalNumber(request.cardinal) eventRepository.save(eventMapper.toEntity(club, request, user)) } - // TODO(PR4): 해당 클럽 소속 admin인지 검증 필요 @Transactional fun update( clubId: Long, @@ -41,18 +43,20 @@ class ManageEventUseCase( request: ScheduleUpdateRequest, userId: Long, ) { + clubMemberPolicy.requireAdmin(clubId, userId) val user = userReader.getById(userId) val event = eventRepository.findByIdOrNull(eventId) ?: throw EventNotFoundException() if (event.club.id != clubId) throw EventNotFoundException() event.update(request.title, request.content, request.location, request.start, request.end, user) } - // TODO(PR4): 해당 클럽 소속 admin인지 검증 필요 @Transactional fun delete( clubId: Long, eventId: Long, + userId: Long, ) { + clubMemberPolicy.requireAdmin(clubId, userId) val event = eventRepository.findByIdOrNull(eventId) ?: throw EventNotFoundException() if (event.club.id != clubId) throw EventNotFoundException() eventRepository.delete(event) diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt b/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt index 606a20b0..5b0ef000 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt @@ -1,6 +1,7 @@ package com.weeth.domain.schedule.application.usecase.query import com.weeth.domain.cardinal.domain.repository.CardinalReader +import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.schedule.application.dto.response.EventResponse import com.weeth.domain.schedule.application.dto.response.ScheduleResponse import com.weeth.domain.schedule.application.exception.EventNotFoundException @@ -19,47 +20,58 @@ class GetScheduleQueryService( private val eventRepository: EventRepository, private val sessionReader: SessionReader, private val cardinalReader: CardinalReader, + private val clubMemberPolicy: ClubMemberPolicy, private val scheduleMapper: ScheduleMapper, private val eventMapper: EventMapper, ) { - // TODO(PR4): 해당 클럽 소속 멤버인지 검증 필요 fun findEvent( clubId: Long, + userId: Long, eventId: Long, ): EventResponse { + clubMemberPolicy.getActiveMember(clubId, userId) val event = eventRepository.findByIdOrNull(eventId) ?: throw EventNotFoundException() - if (clubId != 0L && event.club.id != clubId) throw EventNotFoundException() + + if (event.club.id != clubId) throw EventNotFoundException() + return eventMapper.toResponse(event) } - // TODO(PR4): 해당 클럽 소속 멤버인지 검증 필요 fun findMonthly( clubId: Long, + userId: Long, start: LocalDateTime, end: LocalDateTime, ): List { + clubMemberPolicy.getActiveMember(clubId, userId) + val events = eventRepository .findByClubIdAndStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc(clubId, end, start) .map { scheduleMapper.toResponse(it, false) } + val sessions = sessionReader .findAllByClubIdAndStartBetween(clubId, start, end) .map { scheduleMapper.toResponse(it, true) } + return (events + sessions).sortedBy { it.start } } - // TODO(PR4): 해당 클럽 소속 멤버인지 검증 필요 fun findYearly( clubId: Long, + userId: Long, year: Int, semester: Int, ): Map> { - val cardinal = cardinalReader.getByYearAndSemester(year, semester) + clubMemberPolicy.getActiveMember(clubId, userId) + val cardinal = cardinalReader.getByClubIdAndYearAndSemester(clubId, year, semester) + val events = eventRepository .findAllByClubIdAndCardinal(clubId, cardinal.cardinalNumber) .map { scheduleMapper.toResponse(it, false) } + val sessions = sessionReader .findAllByClubIdAndCardinalIn(clubId, listOf(cardinal.cardinalNumber)) diff --git a/src/main/kotlin/com/weeth/domain/schedule/presentation/EventAdminController.kt b/src/main/kotlin/com/weeth/domain/schedule/presentation/EventAdminController.kt index e1cd660b..51e81678 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/presentation/EventAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/presentation/EventAdminController.kt @@ -59,8 +59,9 @@ class EventAdminController( @PathVariable @TsidParam @TsidPathVariable clubId: Long, @PathVariable eventId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse { - manageEventUseCase.delete(clubId, eventId) + manageEventUseCase.delete(clubId, eventId, userId) return CommonResponse.success(ScheduleResponseCode.EVENT_DELETE_SUCCESS) } } diff --git a/src/main/kotlin/com/weeth/domain/schedule/presentation/EventController.kt b/src/main/kotlin/com/weeth/domain/schedule/presentation/EventController.kt index ba7f0637..7f660cab 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/presentation/EventController.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/presentation/EventController.kt @@ -3,19 +3,22 @@ package com.weeth.domain.schedule.presentation import com.weeth.domain.schedule.application.dto.response.EventResponse import com.weeth.domain.schedule.application.exception.EventErrorCode import com.weeth.domain.schedule.application.usecase.query.GetScheduleQueryService +import com.weeth.global.auth.annotation.CurrentUser import com.weeth.global.common.exception.ApiErrorCodeExample import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController -// TODO(PR4): /api/v4/clubs/{clubId}/events 경로로 전환 필요 @Tag(name = "EVENT", description = "일정 API") @RestController -@RequestMapping("/api/v4/events") +@RequestMapping("/api/v4/clubs/{clubId}/events") @ApiErrorCodeExample(EventErrorCode::class) class EventController( private val getScheduleQueryService: GetScheduleQueryService, @@ -23,7 +26,13 @@ class EventController( @GetMapping("/{eventId}") @Operation(summary = "일정 상세 조회") fun getEvent( + @PathVariable @TsidParam + @TsidPathVariable clubId: Long, @PathVariable eventId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse = - CommonResponse.success(ScheduleResponseCode.EVENT_FIND_SUCCESS, getScheduleQueryService.findEvent(0L, eventId)) + CommonResponse.success( + ScheduleResponseCode.EVENT_FIND_SUCCESS, + getScheduleQueryService.findEvent(clubId, userId, eventId), + ) } diff --git a/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt b/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt index 130e3a21..05f34247 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt @@ -2,10 +2,12 @@ package com.weeth.domain.schedule.presentation import com.weeth.domain.schedule.application.dto.response.ScheduleResponse import com.weeth.domain.schedule.application.usecase.query.GetScheduleQueryService +import com.weeth.global.auth.annotation.CurrentUser import com.weeth.global.common.response.CommonResponse import com.weeth.global.common.web.TsidParam import com.weeth.global.common.web.TsidPathVariable import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.format.annotation.DateTimeFormat import org.springframework.web.bind.annotation.GetMapping @@ -26,12 +28,13 @@ class ScheduleController( fun findByMonthly( @PathVariable @TsidParam @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) start: LocalDateTime, @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) end: LocalDateTime, ): CommonResponse> = CommonResponse.success( ScheduleResponseCode.SCHEDULE_MONTHLY_FIND_SUCCESS, - getScheduleQueryService.findMonthly(clubId, start, end), + getScheduleQueryService.findMonthly(clubId, userId, start, end), ) @GetMapping("/yearly") @@ -39,11 +42,12 @@ class ScheduleController( fun findByYearly( @PathVariable @TsidParam @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, @RequestParam year: Int, @RequestParam semester: Int, ): CommonResponse>> = CommonResponse.success( ScheduleResponseCode.SCHEDULE_YEARLY_FIND_SUCCESS, - getScheduleQueryService.findYearly(clubId, year, semester), + getScheduleQueryService.findYearly(clubId, userId, year, semester), ) } diff --git a/src/main/kotlin/com/weeth/domain/session/application/usecase/command/ManageSessionUseCase.kt b/src/main/kotlin/com/weeth/domain/session/application/usecase/command/ManageSessionUseCase.kt index eddee7bf..e58e821a 100644 --- a/src/main/kotlin/com/weeth/domain/session/application/usecase/command/ManageSessionUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/session/application/usecase/command/ManageSessionUseCase.kt @@ -4,17 +4,22 @@ import com.weeth.domain.attendance.domain.entity.Attendance import com.weeth.domain.attendance.domain.enums.AttendanceStatus import com.weeth.domain.attendance.domain.repository.AttendanceRepository import com.weeth.domain.cardinal.domain.repository.CardinalReader +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.repository.ClubMemberReader import com.weeth.domain.club.domain.repository.ClubReader +import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.schedule.application.dto.request.ScheduleSaveRequest import com.weeth.domain.schedule.application.dto.request.ScheduleUpdateRequest import com.weeth.domain.schedule.application.mapper.SessionMapper import com.weeth.domain.session.application.exception.SessionNotFoundException import com.weeth.domain.session.domain.repository.SessionRepository -import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.repository.UserReader import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +/** + * TODO: 출석 생성/삭제 관련해서 출석 초기화 로직과 함께 엣지 케이스나 개선 사항 점검 (join, applyOb) + */ @Service class ManageSessionUseCase( private val sessionRepository: SessionRepository, @@ -23,27 +28,28 @@ class ManageSessionUseCase( private val cardinalReader: CardinalReader, private val sessionMapper: SessionMapper, private val clubReader: ClubReader, + private val clubMemberReader: ClubMemberReader, + private val clubMemberPolicy: ClubMemberPolicy, ) { - // TODO(PR4): 해당 클럽 소속 admin인지 검증 필요 @Transactional fun create( clubId: Long, request: ScheduleSaveRequest, userId: Long, ) { + clubMemberPolicy.requireAdmin(clubId, userId) val club = clubReader.getClubById(clubId) val user = userReader.getById(userId) - val cardinal = cardinalReader.getByCardinalNumber(request.cardinal) - // TODO: PR4에서 ClubMember 기반으로 전환 (현재는 user 기반 유지) - val users = userReader.findAllByCardinalAndStatus(cardinal, Status.ACTIVE) + cardinalReader.findByClubIdAndCardinalNumber(clubId, request.cardinal) ?: throw SessionNotFoundException() + // TODO: 현재는 동아리 전체 ACTIVE 멤버에게 출석을 만든다. clubMemberCardinal 기준으로 좁히지 않으면 applyOb 초기화와 중복될 수 있다. + val clubMembers = clubMemberReader.findAllByClubIdAndMemberStatus(clubId, MemberStatus.ACTIVE) val session = sessionMapper.toEntity(club, request, user) sessionRepository.save(session) - attendanceRepository.saveAll(users.map { Attendance.Companion.create(session, it) }) + attendanceRepository.saveAll(clubMembers.map { Attendance.create(session, it) }) } - // TODO(PR4): 해당 클럽 소속 admin인지 검증 필요 @Transactional fun update( clubId: Long, @@ -51,6 +57,7 @@ class ManageSessionUseCase( request: ScheduleUpdateRequest, userId: Long, ) { + clubMemberPolicy.requireAdmin(clubId, userId) val session = sessionRepository.findByIdWithLock(sessionId) ?: throw SessionNotFoundException() if (session.club.id != clubId) throw SessionNotFoundException() val user = userReader.getById(userId) @@ -58,20 +65,29 @@ class ManageSessionUseCase( session.updateInfo(request.title, request.content, request.location, request.start, request.end, user) } - // TODO(PR4): 해당 클럽 소속 admin인지 검증 필요 @Transactional fun delete( clubId: Long, sessionId: Long, + userId: Long, ) { + clubMemberPolicy.requireAdmin(clubId, userId) val session = sessionRepository.findByIdWithLock(sessionId) ?: throw SessionNotFoundException() if (session.club.id != clubId) throw SessionNotFoundException() - val attendances = attendanceRepository.findAllBySessionAndUserStatusWithLock(session, Status.ACTIVE) + val attendances = + attendanceRepository.findAllBySessionAndClubMemberMemberStatusWithLock( + session, + MemberStatus.ACTIVE, + ) attendances.forEach { a -> when (a.status) { - AttendanceStatus.ATTEND -> a.user.removeAttend() - AttendanceStatus.ABSENT -> a.user.removeAbsent() + AttendanceStatus.ATTEND -> a.clubMember.removeAttend() + + // 출석률 재계산은 내부에 + AttendanceStatus.ABSENT -> a.clubMember.removeAbsent() + + // 출석률 재계산은 내부에 else -> Unit } } diff --git a/src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt b/src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt index 6ca5ffbd..a8622a01 100644 --- a/src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt @@ -1,5 +1,6 @@ package com.weeth.domain.session.application.usecase.query +import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.schedule.application.dto.response.SessionInfosResponse import com.weeth.domain.schedule.application.dto.response.SessionResponse import com.weeth.domain.schedule.application.mapper.SessionMapper @@ -8,7 +9,6 @@ import com.weeth.domain.session.domain.entity.Session import com.weeth.domain.session.domain.repository.SessionRepository import com.weeth.domain.user.domain.enums.Role import com.weeth.domain.user.domain.repository.UserReader -import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.DayOfWeek @@ -20,14 +20,15 @@ import java.time.temporal.TemporalAdjusters class GetSessionQueryService( private val sessionRepository: SessionRepository, private val userReader: UserReader, + private val clubMemberPolicy: ClubMemberPolicy, private val sessionMapper: SessionMapper, ) { - // TODO(PR4): 해당 클럽 소속 멤버인지 검증 필요 fun findSession( clubId: Long, userId: Long, sessionId: Long, ): SessionResponse { + clubMemberPolicy.getActiveMember(clubId, userId) val user = userReader.getById(userId) val session = sessionRepository.findByIdAndClubId(sessionId, clubId) ?: throw SessionNotFoundException() @@ -38,11 +39,12 @@ class GetSessionQueryService( } } - // TODO(PR4): 해당 클럽 소속 멤버인지 검증 필요 fun findSessionInfos( clubId: Long, + userId: Long, cardinal: Int?, ): SessionInfosResponse { + clubMemberPolicy.requireAdmin(clubId, userId) val sessions = if (cardinal == null) { sessionRepository.findAllByClubIdOrderByStartDesc(clubId) diff --git a/src/main/kotlin/com/weeth/domain/session/presentation/SessionAdminController.kt b/src/main/kotlin/com/weeth/domain/session/presentation/SessionAdminController.kt index 42c04604..68c1b661 100644 --- a/src/main/kotlin/com/weeth/domain/session/presentation/SessionAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/session/presentation/SessionAdminController.kt @@ -63,9 +63,10 @@ class SessionAdminController( fun delete( @PathVariable @TsidParam @TsidPathVariable clubId: Long, - @PathVariable sessionId: Long, // todo: userId 받아서 권한 검증 + @PathVariable sessionId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse { - manageSessionUseCase.delete(clubId, sessionId) + manageSessionUseCase.delete(clubId, sessionId, userId) return CommonResponse.success(SessionResponseCode.SESSION_DELETE_SUCCESS) } @@ -74,10 +75,11 @@ class SessionAdminController( fun getSessionInfos( @PathVariable @TsidParam @TsidPathVariable clubId: Long, - @RequestParam(required = false) cardinal: Int?, // todo: userId 받아서 권한 검증 + @RequestParam(required = false) cardinal: Int?, + @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse = CommonResponse.success( SessionResponseCode.SESSION_INFOS_FIND_SUCCESS, - getSessionQueryService.findSessionInfos(clubId, cardinal), + getSessionQueryService.findSessionInfos(clubId, userId, cardinal), ) } diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/request/UserApplyObRequest.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/request/UserApplyObRequest.kt deleted file mode 100644 index 7b68d0d6..00000000 --- a/src/main/kotlin/com/weeth/domain/user/application/dto/request/UserApplyObRequest.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.weeth.domain.user.application.dto.request - -import io.swagger.v3.oas.annotations.media.Schema -import jakarta.validation.constraints.NotNull - -data class UserApplyObRequest( - @field:Schema(description = "대상 사용자 ID", example = "1") - val userId: Long, - @field:Schema(description = "적용할 기수", example = "8") - val cardinal: Int, -) diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/response/AdminUserResponse.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/response/AdminUserResponse.kt deleted file mode 100644 index 4a039c23..00000000 --- a/src/main/kotlin/com/weeth/domain/user/application/dto/response/AdminUserResponse.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.weeth.domain.user.application.dto.response - -import com.weeth.domain.user.domain.enums.Role -import com.weeth.domain.user.domain.enums.Status -import io.swagger.v3.oas.annotations.media.Schema -import java.time.LocalDateTime - -data class AdminUserResponse( - @field:Schema(description = "사용자 ID", example = "1") - val id: Long, - @field:Schema(description = "이름", example = "홍길동") - val name: String, - @field:Schema(description = "이메일", example = "hong@example.com") - val email: String, - @field:Schema(description = "학번", example = "20201234") - val studentId: String, - @field:Schema(description = "전화번호", example = "01012345678") - val tel: String, - @field:Schema(description = "학과", example = "컴퓨터공학과") - val department: String, - @field:Schema(description = "소속 기수 목록", example = "[6, 7]") - val cardinals: List, - @field:Schema(description = "회원 상태", example = "ACTIVE") - val status: Status, - @field:Schema(description = "권한", example = "USER", nullable = true) - val role: Role?, - @field:Schema(description = "출석 횟수", example = "8") - val attendanceCount: Int, - @field:Schema(description = "결석 횟수", example = "2") - val absenceCount: Int, - @field:Schema(description = "출석률", example = "80") - val attendanceRate: Int, - @field:Schema(description = "패널티 횟수", example = "1") - val penaltyCount: Int, - @field:Schema(description = "생성 시각") - val createdAt: LocalDateTime?, - @field:Schema(description = "수정 시각") - val modifiedAt: LocalDateTime?, -) diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserDetailsResponse.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserDetailsResponse.kt deleted file mode 100644 index f3409bca..00000000 --- a/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserDetailsResponse.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.weeth.domain.user.application.dto.response - -import com.weeth.domain.user.domain.enums.Role -import io.swagger.v3.oas.annotations.media.Schema - -data class UserDetailsResponse( - @field:Schema(description = "사용자 ID", example = "1") - val id: Long, - @field:Schema(description = "이름", example = "홍길동") - val name: String, - @field:Schema(description = "이메일", example = "hong@example.com") - val email: String, - @field:Schema(description = "학번", example = "20201234") - val studentId: String, - @field:Schema(description = "학과", example = "컴퓨터공학과") - val department: String, - @field:Schema(description = "소속 기수 목록", example = "[6, 7]") - val cardinals: List, - @field:Schema(description = "권한", example = "USER", nullable = true) - val role: Role?, -) diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/UserCardinalNotFoundException.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/UserCardinalNotFoundException.kt deleted file mode 100644 index da49afec..00000000 --- a/src/main/kotlin/com/weeth/domain/user/application/exception/UserCardinalNotFoundException.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.weeth.domain.user.application.exception - -import com.weeth.global.common.exception.BaseException - -class UserCardinalNotFoundException : BaseException(UserErrorCode.USER_CARDINAL_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/UserErrorCode.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/UserErrorCode.kt index 246444b3..074ce475 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/exception/UserErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/UserErrorCode.kt @@ -36,15 +36,12 @@ enum class UserErrorCode( @ExplainError("이미 등록된 전화번호로 회원가입을 시도할 때 발생합니다.") TEL_EXISTS(20908, HttpStatus.BAD_REQUEST, "이미 존재하는 전화번호입니다."), - @ExplainError("사용자와 기수 간의 연결 정보를 찾을 수 없을 때 발생합니다.") - USER_CARDINAL_NOT_FOUND(20909, HttpStatus.NOT_FOUND, "사용자 기수 정보를 찾을 수 없습니다."), - @ExplainError("잘못된 권한 값이 입력되었을 때 발생합니다.") - ROLE_NOT_FOUND(20911, HttpStatus.BAD_REQUEST, "권한을 찾을 수 없습니다."), + ROLE_NOT_FOUND(20909, HttpStatus.BAD_REQUEST, "권한을 찾을 수 없습니다."), @ExplainError("잘못된 상태 값이 입력되었을 때 발생합니다.") - STATUS_NOT_FOUND(20912, HttpStatus.BAD_REQUEST, "상태를 찾을 수 없습니다."), + STATUS_NOT_FOUND(20910, HttpStatus.BAD_REQUEST, "상태를 찾을 수 없습니다."), @ExplainError("사용자 순서 지정 시 잘못된 값이 입력되었을 때 발생합니다.") - INVALID_USER_ORDER(20913, HttpStatus.BAD_REQUEST, "잘못된 사용자 순서입니다."), + INVALID_USER_ORDER(20911, HttpStatus.BAD_REQUEST, "잘못된 사용자 순서입니다."), } diff --git a/src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt b/src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt index 572721af..25b0540f 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt @@ -1,12 +1,9 @@ package com.weeth.domain.user.application.mapper -import com.weeth.domain.user.application.dto.response.AdminUserResponse import com.weeth.domain.user.application.dto.response.SocialLoginResponse -import com.weeth.domain.user.application.dto.response.UserDetailsResponse import com.weeth.domain.user.application.dto.response.UserProfileResponse import com.weeth.domain.user.application.dto.response.UserSummaryResponse import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.entity.UserCardinal import com.weeth.global.auth.jwt.application.dto.JwtDto import org.springframework.stereotype.Component @@ -22,10 +19,7 @@ class UserMapper { isNewUser = isNewUser, ) - fun toUserProfileResponse( - user: User, - userCardinals: List, - ): UserProfileResponse = + fun toUserProfileResponse(user: User): UserProfileResponse = UserProfileResponse( user.id, user.name, @@ -33,61 +27,15 @@ class UserMapper { user.studentId, user.telValue, user.department, - toCardinalNumbers(userCardinals), + emptyList(), user.role, ) - fun toAdminUserResponse( - user: User, - userCardinals: List, - ): AdminUserResponse = - AdminUserResponse( - user.id, - user.name, - user.emailValue, - user.studentId, - user.telValue, - user.department, - toCardinalNumbers(userCardinals), - user.status, - user.role, - user.attendanceCount, - user.absenceCount, - user.attendanceRate, - user.penaltyCount, - user.createdAt, - user.modifiedAt, - ) - - fun toUserSummaryResponse( - user: User, - userCardinals: List, - ): UserSummaryResponse = + fun toUserSummaryResponse(user: User): UserSummaryResponse = UserSummaryResponse( user.id, user.name, - toCardinalNumbers(userCardinals), + emptyList(), user.role, ) - - fun toUserDetailsResponse( - user: User, - userCardinals: List, - ): UserDetailsResponse = - UserDetailsResponse( - user.id, - user.name, - user.emailValue, - user.studentId, - user.department, - toCardinalNumbers(userCardinals), - user.role, - ) - - private fun toCardinalNumbers(userCardinals: List): List { - if (userCardinals.isEmpty()) { - return emptyList() - } - return userCardinals.map { it.cardinal.cardinalNumber } - } } diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AdminUserUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AdminUserUseCase.kt deleted file mode 100644 index 4ef6571e..00000000 --- a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AdminUserUseCase.kt +++ /dev/null @@ -1,109 +0,0 @@ -package com.weeth.domain.user.application.usecase.command - -import com.weeth.domain.attendance.domain.entity.Attendance -import com.weeth.domain.attendance.domain.repository.AttendanceRepository -import com.weeth.domain.cardinal.domain.entity.Cardinal -import com.weeth.domain.cardinal.domain.repository.CardinalReader -import com.weeth.domain.session.domain.repository.SessionReader -import com.weeth.domain.user.application.dto.request.UserApplyObRequest -import com.weeth.domain.user.application.dto.request.UserIdsRequest -import com.weeth.domain.user.application.dto.request.UserRoleUpdateRequest -import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.entity.UserCardinal -import com.weeth.domain.user.domain.repository.UserCardinalRepository -import com.weeth.domain.user.domain.repository.UserReader -import com.weeth.domain.user.domain.service.UserCardinalPolicy -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional - -@Service -class AdminUserUseCase( - private val userReader: UserReader, - private val userCardinalPolicy: UserCardinalPolicy, - private val cardinalReader: CardinalReader, - private val sessionReader: SessionReader, - private val attendanceRepository: AttendanceRepository, - private val userCardinalRepository: UserCardinalRepository, -) { - @Transactional - fun accept(request: UserIdsRequest) { - val users = userReader.findAllByIds(request.userId) - users.forEach { user -> - val cardinal = userCardinalPolicy.getCurrentCardinal(user) - - if (user.isInactive()) { - user.accept() - initializeAttendances(listOf(user), cardinal) - } - } - } - - @Transactional - fun updateRole(request: List) { - request.forEach { req -> - val user = userReader.getById(req.userId) - user.updateRole(req.role) - } - } - - @Transactional - fun ban(request: UserIdsRequest) { - val users = userReader.findAllByIds(request.userId) - users.forEach { user -> - user.ban() - } - } - - /** - * 이전 기수의 인원들을 다음 기수로 한 번에 등록하는 메서드. - * N+1을 해소하는 비용이 코드 가독성에 비해 지나치게 커서 배치 조회 + 캐싱 방식으로 절충하였다. - */ - @Transactional - fun applyOb(requests: List) { - // 동일한 (userId, cardinal) 요청은 한 번만 처리한다. - val uniqueRequests = requests.distinctBy { it.userId to it.cardinal } - if (uniqueRequests.isEmpty()) return - - // 유저는 한 번에 조회해 요청 수만큼 getById가 반복되는 것을 줄인다. - val usersById = - userReader - .findAllByIds(uniqueRequests.map { it.userId }.distinct()) - .associateBy { it.id } - - // 같은 기수 번호 조회가 반복되지 않도록 메모리 캐시를 사용한다. - val cardinalByNumber = mutableMapOf() - - uniqueRequests.forEach { req -> - // 배치 조회에서 누락된 id는 기존과 동일하게 getById로 예외를 발생시킨다. - val user = usersById[req.userId] ?: userReader.getById(req.userId) - val nextCardinal = - cardinalByNumber.getOrPut(req.cardinal) { - cardinalReader.getByCardinalNumber(req.cardinal) - } - - if (userCardinalPolicy.notContains(user, nextCardinal)) { - if (userCardinalPolicy.isCurrent(user, nextCardinal)) { - user.resetAttendanceStats() - initializeAttendances(listOf(user), nextCardinal) - } - - userCardinalRepository.save(UserCardinal.create(user, nextCardinal)) - } - } - } - - private fun initializeAttendances( - users: List, - cardinal: Cardinal, - ) { - if (users.isEmpty()) return - val sessions = sessionReader.findAllByCardinal(cardinal.cardinalNumber) - if (sessions.isEmpty()) return - - attendanceRepository.saveAll( - users.flatMap { user -> - sessions.map { Attendance.create(it, user) } - }, - ) - } -} diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryService.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryService.kt index 9d28e149..d4b41510 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryService.kt @@ -1,114 +1,27 @@ package com.weeth.domain.user.application.usecase.query -import com.weeth.domain.cardinal.domain.repository.CardinalReader -import com.weeth.domain.user.application.dto.response.AdminUserResponse -import com.weeth.domain.user.application.dto.response.UserDetailsResponse import com.weeth.domain.user.application.dto.response.UserProfileResponse import com.weeth.domain.user.application.dto.response.UserSummaryResponse import com.weeth.domain.user.application.mapper.UserMapper -import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.entity.UserCardinal -import com.weeth.domain.user.domain.enums.Status -import com.weeth.domain.user.domain.enums.StatusPriority -import com.weeth.domain.user.domain.enums.UsersOrderBy -import com.weeth.domain.user.domain.repository.UserCardinalRepository import com.weeth.domain.user.domain.repository.UserRepository -import org.springframework.data.domain.PageRequest -import org.springframework.data.domain.Slice import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import java.util.LinkedHashMap @Service @Transactional(readOnly = true) class GetUserQueryService( private val userRepository: UserRepository, - private val cardinalReader: CardinalReader, - private val userCardinalRepository: UserCardinalRepository, private val mapper: UserMapper, ) { fun existsByEmail(email: String): Boolean = userRepository.existsByEmailValue(email) - fun findAllUser( - pageNumber: Int, - pageSize: Int, - cardinal: Int?, - ): Slice { - val pageable = PageRequest.of(pageNumber, pageSize) - val users: Slice = - if (cardinal == null) { - userRepository.findAllByStatusOrderedByCardinalAndName(Status.ACTIVE, pageable) - } else { - val inputCardinal = cardinalReader.getByCardinalNumber(cardinal) - userRepository.findAllByCardinalOrderByNameAsc(Status.ACTIVE, inputCardinal, pageable) - } - - val allUserCardinals = userCardinalRepository.findAllByUsers(users.content) - val userCardinalMap = allUserCardinals.groupBy { it.user.id } - return users.map { user -> - val userCardinals = userCardinalMap[user.id] ?: emptyList() - mapper.toUserSummaryResponse(user, userCardinals) - } - } - - fun searchUser(keyword: String): List { - val users = userRepository.findAllByNameContainingAndStatus(keyword, Status.ACTIVE) - val allUserCardinals = userCardinalRepository.findAllByUsers(users) - val userCardinalMap = allUserCardinals.groupBy { it.user.id } - return users.map { user -> - val userCardinals = userCardinalMap[user.id] ?: emptyList() - mapper.toUserSummaryResponse(user, userCardinals) - } - } - - fun findUserDetails(userId: Long): UserDetailsResponse { - val user = userRepository.getById(userId) - val userCardinals = userCardinalRepository.findAllByUser(user) - return mapper.toUserDetailsResponse(user, userCardinals) - } - - fun findMyProfile(userId: Long): UserProfileResponse { + fun findMyProfile(userId: Long): UserProfileResponse { // todo: 동아리별 정보 추가 val user = userRepository.getById(userId) - val userCardinals = userCardinalRepository.findAllByUser(user) - return mapper.toUserProfileResponse(user, userCardinals) + return mapper.toUserProfileResponse(user) } fun findMyInfo(userId: Long): UserSummaryResponse { val user = userRepository.getById(userId) - val userCardinals = userCardinalRepository.findAllByUser(user) - return mapper.toUserSummaryResponse(user, userCardinals) - } - - fun findAllByAdmin(orderBy: UsersOrderBy): List { - val userCardinalMap: LinkedHashMap> = - LinkedHashMap( - userCardinalRepository.findAllWithUserAndCardinal().groupBy { it.user }, - ) - - return when (orderBy) { - UsersOrderBy.NAME_ASCENDING -> { - userCardinalMap.entries - .sortedBy { StatusPriority.fromStatus(it.key.status).priority } - .map { entry -> - mapper.toAdminUserResponse(entry.key, entry.value) - } - } - - UsersOrderBy.CARDINAL_DESCENDING -> { - userCardinalMap.entries - .sortedWith( - compareBy>> { - StatusPriority - .fromStatus( - it.key.status, - ).priority - }.thenByDescending { entry -> - entry.value.maxOfOrNull { it.cardinal.cardinalNumber } ?: -1 - }, - ).map { entry -> - mapper.toAdminUserResponse(entry.key, entry.value) - } - } - } + return mapper.toUserSummaryResponse(user) } } diff --git a/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt b/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt index 0d664c2d..786dfbfd 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt @@ -4,13 +4,11 @@ import com.weeth.domain.user.domain.converter.EmailConverter import com.weeth.domain.user.domain.converter.PhoneNumberConverter import com.weeth.domain.user.domain.enums.Role import com.weeth.domain.user.domain.enums.Status -import com.weeth.domain.user.domain.vo.AttendanceStats import com.weeth.domain.user.domain.vo.Email import com.weeth.domain.user.domain.vo.PhoneNumber import com.weeth.global.common.entity.BaseEntity import jakarta.persistence.Column import jakarta.persistence.Convert -import jakarta.persistence.Embedded import jakarta.persistence.Entity import jakarta.persistence.EnumType import jakarta.persistence.Enumerated @@ -60,14 +58,6 @@ class User protected constructor() : BaseEntity() { // todo: 엔티티 정리 ( var role: Role = Role.USER private set - @Embedded - var attendanceStats: AttendanceStats = AttendanceStats() - private set - - @Column(nullable = false) - var penaltyCount: Int = 0 - private set - constructor( id: Long = 0L, name: String, @@ -77,8 +67,6 @@ class User protected constructor() : BaseEntity() { // todo: 엔티티 정리 ( department: String = "", status: Status = Status.WAITING, role: Role = Role.USER, - attendanceStats: AttendanceStats = AttendanceStats(), - penaltyCount: Int = 0, ) : this() { this.id = id this.name = name.trim() @@ -88,8 +76,6 @@ class User protected constructor() : BaseEntity() { // todo: 엔티티 정리 ( this.department = department this.status = status this.role = role - this.attendanceStats = attendanceStats - this.penaltyCount = penaltyCount } val emailValue: String @@ -98,15 +84,6 @@ class User protected constructor() : BaseEntity() { // todo: 엔티티 정리 ( val telValue: String get() = tel.value - val attendanceCount: Int - get() = attendanceStats.attendanceCount - - val absenceCount: Int - get() = attendanceStats.absenceCount - - val attendanceRate: Int - get() = attendanceStats.attendanceRate - fun leave() { status = Status.LEFT } @@ -150,36 +127,6 @@ class User protected constructor() : BaseEntity() { // todo: 엔티티 정리 ( this.role = role } - fun resetAttendanceStats() { - attendanceStats.reset() - } - - fun attend() { - attendanceStats.attend() - } - - fun removeAttend() { - attendanceStats.removeAttend() - } - - fun absent() { - attendanceStats.absent() - } - - fun removeAbsent() { - attendanceStats.removeAbsent() - } - - fun incrementPenaltyCount() { - penaltyCount++ - } - - fun decrementPenaltyCount() { - if (penaltyCount > 0) { - penaltyCount-- - } - } - fun hasRole(role: Role): Boolean = this.role == role companion object { diff --git a/src/main/kotlin/com/weeth/domain/user/domain/entity/UserCardinal.kt b/src/main/kotlin/com/weeth/domain/user/domain/entity/UserCardinal.kt deleted file mode 100644 index 7d0c3a94..00000000 --- a/src/main/kotlin/com/weeth/domain/user/domain/entity/UserCardinal.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.weeth.domain.user.domain.entity - -import com.weeth.domain.cardinal.domain.entity.Cardinal -import com.weeth.global.common.entity.BaseEntity -import jakarta.persistence.Column -import jakarta.persistence.Entity -import jakarta.persistence.FetchType -import jakarta.persistence.GeneratedValue -import jakarta.persistence.GenerationType -import jakarta.persistence.Id -import jakarta.persistence.JoinColumn -import jakarta.persistence.ManyToOne -import jakarta.persistence.Table -import jakarta.persistence.UniqueConstraint - -@Entity -@Table( - name = "user_cardinal", - uniqueConstraints = [ - UniqueConstraint( - name = "uk_user_id_cardinal_id", - columnNames = ["user_id", "cardinal_id"], - ), - ], -) -class UserCardinal( - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "user_id", nullable = false) - val user: User, - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "cardinal_id", nullable = false) - val cardinal: Cardinal, -) : BaseEntity() { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "user_cardinal_id") - val id: Long = 0L - - companion object { - fun create( - user: User, - cardinal: Cardinal, - ) = UserCardinal( - user = user, - cardinal = cardinal, - ) - } -} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserCardinalReader.kt b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserCardinalReader.kt deleted file mode 100644 index 0e4c25b4..00000000 --- a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserCardinalReader.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.weeth.domain.user.domain.repository - -import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.entity.UserCardinal - -interface UserCardinalReader { - fun findAllByUser(user: User): List - - fun findAllByUsersOrderByCardinalDesc(users: List): List - - fun findTopByUserOrderByCardinalNumberDesc(user: User): UserCardinal? -} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserCardinalRepository.kt b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserCardinalRepository.kt deleted file mode 100644 index 8622449d..00000000 --- a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserCardinalRepository.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.weeth.domain.user.domain.repository - -import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.entity.UserCardinal -import org.springframework.data.jpa.repository.JpaRepository -import org.springframework.data.jpa.repository.Query -import org.springframework.data.repository.query.Param - -interface UserCardinalRepository : - JpaRepository, - UserCardinalReader { - fun findAllByUserOrderByCardinalCardinalNumberDesc(user: User): List - - fun findTopByUserOrderByCardinalCardinalNumberDesc(user: User): UserCardinal? - - @Query( - """ - SELECT uc - FROM UserCardinal uc - JOIN FETCH uc.cardinal - WHERE uc.user IN :users - ORDER BY uc.user.id, uc.cardinal.cardinalNumber DESC - """, - ) - fun findAllByUsers( - @Param("users") users: List, - ): List - - @Query( - """ - SELECT uc - FROM UserCardinal uc - JOIN FETCH uc.user - JOIN FETCH uc.cardinal - ORDER BY uc.user.name ASC - """, - ) - fun findAllWithUserAndCardinal(): List - - @Query( - """ - select uc.cardinal.cardinalNumber - from UserCardinal uc - where uc.user = :user - order by uc.cardinal.cardinalNumber desc - """, - ) - fun findCardinalNumbersByUser( - @Param("user") user: User, - ): List - - override fun findAllByUser(user: User): List = findAllByUserOrderByCardinalCardinalNumberDesc(user) - - override fun findAllByUsersOrderByCardinalDesc(users: List): List = findAllByUsers(users) - - override fun findTopByUserOrderByCardinalNumberDesc(user: User): UserCardinal? = - findTopByUserOrderByCardinalCardinalNumberDesc(user) -} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserReader.kt b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserReader.kt index 8c1fb2e4..057ea739 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserReader.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserReader.kt @@ -1,8 +1,6 @@ package com.weeth.domain.user.domain.repository -import com.weeth.domain.cardinal.domain.entity.Cardinal import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.enums.Status interface UserReader { fun getById(userId: Long): User @@ -14,9 +12,4 @@ interface UserReader { fun findByIdOrNull(userId: Long): User? fun findAllByIds(userIds: List): List - - fun findAllByCardinalAndStatus( - cardinal: Cardinal, - status: Status, - ): List } diff --git a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserRepository.kt b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserRepository.kt index 355f739e..f51d40e8 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserRepository.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserRepository.kt @@ -1,15 +1,11 @@ package com.weeth.domain.user.domain.repository -import com.weeth.domain.cardinal.domain.entity.Cardinal import com.weeth.domain.user.application.exception.UserNotFoundException import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.vo.Email import com.weeth.domain.user.domain.vo.PhoneNumber import jakarta.persistence.LockModeType import jakarta.persistence.QueryHint -import org.springframework.data.domain.Pageable -import org.springframework.data.domain.Slice import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Lock import org.springframework.data.jpa.repository.Query @@ -29,11 +25,6 @@ interface UserRepository : fun findByEmail(email: Email): Optional - fun findAllByNameContainingAndStatus( - name: String, - status: Status, - ): List - fun existsByEmail(email: Email): Boolean fun existsByStudentId(studentId: String): Boolean @@ -50,47 +41,8 @@ interface UserRepository : id: Long, ): Boolean - fun findAllByStatusOrderByName(status: Status): List - fun findAllByOrderByNameAsc(): List - @Query("SELECT uc.user FROM UserCardinal uc WHERE uc.cardinal = :cardinal AND uc.user.status = :status") - override fun findAllByCardinalAndStatus( - @Param("cardinal") cardinal: Cardinal, - @Param("status") status: Status, - ): List - - @Query( - """ - SELECT u - FROM User u - JOIN UserCardinal uc ON u.id = uc.user.id - JOIN uc.cardinal c - WHERE u.status = :status - GROUP BY u.id - ORDER BY MAX(c.cardinalNumber) DESC, u.name ASC - """, - ) - fun findAllByStatusOrderedByCardinalAndName( - @Param("status") status: Status, - pageable: Pageable, - ): Slice - - @Query( - """ - SELECT u FROM User u - JOIN UserCardinal uc ON uc.user.id = u.id - WHERE u.status = :status - AND uc.cardinal = :cardinal - ORDER BY u.name ASC - """, - ) - fun findAllByCardinalOrderByNameAsc( - @Param("status") status: Status, - @Param("cardinal") cardinal: Cardinal, - pageable: Pageable, - ): Slice - fun findByEmailValue(email: String): Optional = findByEmail(Email.from(email)) fun existsByEmailValue(email: String): Boolean = existsByEmail(Email.from(email)) diff --git a/src/main/kotlin/com/weeth/domain/user/domain/service/UserCardinalPolicy.kt b/src/main/kotlin/com/weeth/domain/user/domain/service/UserCardinalPolicy.kt deleted file mode 100644 index 0c565c33..00000000 --- a/src/main/kotlin/com/weeth/domain/user/domain/service/UserCardinalPolicy.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.weeth.domain.user.domain.service - -import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException -import com.weeth.domain.cardinal.domain.entity.Cardinal -import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.repository.UserCardinalReader -import org.springframework.stereotype.Service - -@Service -class UserCardinalPolicy( - private val userCardinalReader: UserCardinalReader, -) { - fun getCurrentCardinal(user: User): Cardinal = - userCardinalReader - .findTopByUserOrderByCardinalNumberDesc(user) - ?.cardinal - ?: throw CardinalNotFoundException() - - fun notContains( - user: User, - cardinal: Cardinal, - ): Boolean = userCardinalReader.findAllByUser(user).none { it.cardinal.id == cardinal.id } - - fun isCurrent( - user: User, - cardinal: Cardinal, - ): Boolean = getCurrentCardinal(user).cardinalNumber < cardinal.cardinalNumber -} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/vo/AttendanceStats.kt b/src/main/kotlin/com/weeth/domain/user/domain/vo/AttendanceStats.kt deleted file mode 100644 index ddbfa3f9..00000000 --- a/src/main/kotlin/com/weeth/domain/user/domain/vo/AttendanceStats.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.weeth.domain.user.domain.vo - -import jakarta.persistence.Column -import jakarta.persistence.Embeddable - -@Embeddable -class AttendanceStats( - attendanceCount: Int = 0, - absenceCount: Int = 0, - attendanceRate: Int = 0, -) { - @Column(name = "attendance_count") - var attendanceCount: Int = attendanceCount - private set - - @Column(name = "absence_count") - var absenceCount: Int = absenceCount - private set - - @Column(name = "attendance_rate") - var attendanceRate: Int = attendanceRate - private set - - fun reset() { - attendanceCount = 0 - absenceCount = 0 - attendanceRate = 0 - } - - fun attend() { - attendanceCount++ - recalculateRate() - } - - fun removeAttend() { - if (attendanceCount > 0) { - attendanceCount-- - recalculateRate() - } - } - - fun absent() { - absenceCount++ - recalculateRate() - } - - fun removeAbsent() { - if (absenceCount > 0) { - absenceCount-- - recalculateRate() - } - } - - private fun recalculateRate() { - val total = attendanceCount + absenceCount - attendanceRate = if (total > 0) (attendanceCount * 100) / total else 0 - } -} diff --git a/src/main/kotlin/com/weeth/domain/user/presentation/UserAdminController.kt b/src/main/kotlin/com/weeth/domain/user/presentation/UserAdminController.kt deleted file mode 100644 index 25d4a0b9..00000000 --- a/src/main/kotlin/com/weeth/domain/user/presentation/UserAdminController.kt +++ /dev/null @@ -1,75 +0,0 @@ -package com.weeth.domain.user.presentation - -import com.weeth.domain.user.application.dto.request.UserApplyObRequest -import com.weeth.domain.user.application.dto.request.UserIdsRequest -import com.weeth.domain.user.application.dto.request.UserRoleUpdateRequest -import com.weeth.domain.user.application.dto.response.AdminUserResponse -import com.weeth.domain.user.application.exception.UserErrorCode -import com.weeth.domain.user.application.usecase.command.AdminUserUseCase -import com.weeth.domain.user.application.usecase.query.GetUserQueryService -import com.weeth.domain.user.domain.enums.UsersOrderBy -import com.weeth.global.auth.jwt.application.exception.JwtErrorCode -import com.weeth.global.common.exception.ApiErrorCodeExample -import com.weeth.global.common.response.CommonResponse -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.tags.Tag -import jakarta.validation.Valid -import org.springframework.web.bind.annotation.DeleteMapping -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PatchMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RequestParam -import org.springframework.web.bind.annotation.RestController - -@Tag(name = "USER ADMIN", description = "[ADMIN] 사용자 어드민 API") -@RestController -@RequestMapping("/api/v4/admin/users") -@ApiErrorCodeExample(UserErrorCode::class, JwtErrorCode::class) -class UserAdminController( - private val adminUserUseCase: AdminUserUseCase, - private val getUserQueryService: GetUserQueryService, -) { - @GetMapping("/all") - @Operation(summary = "어드민용 회원 조회") - fun findAll( - @RequestParam orderBy: UsersOrderBy, - ): CommonResponse> = - CommonResponse.success(UserResponseCode.USER_FIND_ALL_SUCCESS, getUserQueryService.findAllByAdmin(orderBy)) - - @PatchMapping - @Operation(summary = "가입 신청 승인") - fun accept( - @RequestBody @Valid request: UserIdsRequest, - ): CommonResponse { - adminUserUseCase.accept(request) - return CommonResponse.success(UserResponseCode.USER_ACCEPT_SUCCESS) - } - - @DeleteMapping - @Operation(summary = "유저 추방") - fun ban( - @RequestBody @Valid request: UserIdsRequest, - ): CommonResponse { - adminUserUseCase.ban(request) - return CommonResponse.success(UserResponseCode.USER_BAN_SUCCESS) - } - - @PatchMapping("/role") - @Operation(summary = "관리자로 승격/강등") - fun update( - @RequestBody request: List, - ): CommonResponse { - adminUserUseCase.updateRole(request) - return CommonResponse.success(UserResponseCode.USER_ROLE_UPDATE_SUCCESS) - } - - @PatchMapping("/apply") - @Operation(summary = "다음 기수도 이어서 진행") - fun applyOb( - @RequestBody request: List, - ): CommonResponse { - adminUserUseCase.applyOb(request) - return CommonResponse.success(UserResponseCode.USER_APPLY_OB_SUCCESS) - } -} diff --git a/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt b/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt index 3ac82c1b..37190c4b 100644 --- a/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt +++ b/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt @@ -3,7 +3,6 @@ package com.weeth.domain.user.presentation import com.weeth.domain.user.application.dto.request.SocialLoginRequest import com.weeth.domain.user.application.dto.request.UpdateUserProfileRequest import com.weeth.domain.user.application.dto.response.SocialLoginResponse -import com.weeth.domain.user.application.dto.response.UserDetailsResponse import com.weeth.domain.user.application.dto.response.UserProfileResponse import com.weeth.domain.user.application.dto.response.UserSummaryResponse import com.weeth.domain.user.application.exception.UserErrorCode @@ -21,8 +20,6 @@ import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag import jakarta.servlet.http.HttpServletRequest import jakarta.validation.Valid -import org.springframework.data.domain.Slice -import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PatchMapping import org.springframework.web.bind.annotation.PostMapping @@ -67,32 +64,6 @@ class UserController( ): CommonResponse = CommonResponse.success(UserResponseCode.USER_EMAIL_CHECK_SUCCESS, !getUserQueryService.existsByEmail(email)) - @GetMapping("/all") - @Operation(summary = "동아리 멤버 전체 조회(전체/기수별)") - fun findAllUser( - @RequestParam("pageNumber") pageNumber: Int, - @RequestParam("pageSize") pageSize: Int, - @RequestParam(required = false) cardinal: Int?, - ): CommonResponse> = - CommonResponse.success( - UserResponseCode.USER_FIND_ALL_SUCCESS, - getUserQueryService.findAllUser(pageNumber, pageSize, cardinal), - ) - - @GetMapping("/search") - @Operation(summary = "동아리 멤버 검색") - fun searchUser( - @RequestParam keyword: String, - ): CommonResponse> = - CommonResponse.success(UserResponseCode.USER_FIND_BY_ID_SUCCESS, getUserQueryService.searchUser(keyword)) - - @GetMapping("/details") - @Operation(summary = "특정 멤버 상세 조회") - fun findUser( - @RequestParam userId: Long, - ): CommonResponse = - CommonResponse.success(UserResponseCode.USER_DETAILS_SUCCESS, getUserQueryService.findUserDetails(userId)) - @GetMapping @Operation(summary = "내 정보 조회") fun find( @@ -116,13 +87,4 @@ class UserController( updateUserProfileUseCase.updateProfile(request, userId) return CommonResponse.success(UserResponseCode.USER_UPDATE_SUCCESS) } - - @DeleteMapping - @Operation(summary = "동아리 탈퇴") - fun leave( - @Parameter(hidden = true) @CurrentUser userId: Long, - ): CommonResponse { - authUserUseCase.leave(userId) - return CommonResponse.success(UserResponseCode.USER_LEAVE_SUCCESS) - } } diff --git a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt index 5d807630..b0e9091c 100644 --- a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt +++ b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt @@ -71,6 +71,7 @@ class SecurityConfig( AuthorizationDecision(allowed) }.requestMatchers("/actuator/health") .permitAll() + // TODO: 전역 User.role 대신 clubMember 기반 권한 검증으로 교체해야 동아리별 ADMIN/LEAD가 admin API를 사용할 수 있다. .requestMatchers( "/api/v1/admin/**", "/api/v4/admin/**", diff --git a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCaseTest.kt index 26c819db..85304506 100644 --- a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCaseTest.kt @@ -6,6 +6,7 @@ import com.weeth.domain.account.domain.repository.AccountRepository import com.weeth.domain.cardinal.domain.repository.CardinalReader import com.weeth.domain.cardinal.fixture.CardinalTestFixture import com.weeth.domain.club.domain.repository.ClubReader +import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.club.fixture.ClubTestFixture import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec @@ -19,13 +20,15 @@ class ManageAccountUseCaseTest : val accountRepository = mockk(relaxed = true) val cardinalReader = mockk(relaxed = true) val clubReader = mockk(relaxed = true) - val useCase = ManageAccountUseCase(accountRepository, cardinalReader, clubReader) + val clubMemberPolicy = mockk(relaxed = true) + val useCase = ManageAccountUseCase(accountRepository, cardinalReader, clubReader, clubMemberPolicy) val clubId = 1L + val userId = 100L val club = ClubTestFixture.createClub() beforeTest { - clearMocks(accountRepository, cardinalReader, clubReader) + clearMocks(accountRepository, cardinalReader, clubReader, clubMemberPolicy) every { clubReader.getClubById(clubId) } returns club } @@ -35,7 +38,7 @@ class ManageAccountUseCaseTest : val request = AccountSaveRequest("설명", 100_000, 40) every { accountRepository.existsByClubIdAndCardinal(clubId, 40) } returns true - shouldThrow { useCase.save(clubId, request) } + shouldThrow { useCase.save(clubId, request, userId) } } } @@ -47,8 +50,9 @@ class ManageAccountUseCaseTest : CardinalTestFixture.createCardinal(cardinalNumber = 40, year = 2026, semester = 1) every { accountRepository.save(any()) } answers { firstArg() } - useCase.save(clubId, request) + useCase.save(clubId, request, userId) + verify(exactly = 1) { clubMemberPolicy.requireAdmin(clubId, userId) } verify(exactly = 1) { accountRepository.save(any()) } } } diff --git a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCaseTest.kt index 03cca591..3a186915 100644 --- a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCaseTest.kt @@ -11,6 +11,7 @@ import com.weeth.domain.account.fixture.AccountTestFixture import com.weeth.domain.account.fixture.ReceiptTestFixture import com.weeth.domain.cardinal.domain.repository.CardinalReader import com.weeth.domain.cardinal.fixture.CardinalTestFixture +import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.file.application.dto.request.FileSaveRequest import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.entity.File @@ -33,6 +34,7 @@ class ManageReceiptUseCaseTest : val fileReader = mockk() val fileRepository = mockk(relaxed = true) val cardinalReader = mockk(relaxed = true) + val clubMemberPolicy = mockk(relaxed = true) val fileMapper = mockk() val useCase = ManageReceiptUseCase( @@ -41,15 +43,29 @@ class ManageReceiptUseCaseTest : fileReader, fileRepository, cardinalReader, + clubMemberPolicy, fileMapper, ) + val userId = 10L + beforeTest { - clearMocks(receiptRepository, accountRepository, fileReader, fileRepository, cardinalReader, fileMapper) + clearMocks( + receiptRepository, + accountRepository, + fileReader, + fileRepository, + cardinalReader, + clubMemberPolicy, + fileMapper, + ) } - fun stubExistingCardinal(cardinalNumber: Int) { - every { cardinalReader.getByCardinalNumber(cardinalNumber) } returns + fun stubExistingCardinal( + clubId: Long, + cardinalNumber: Int, + ) { + every { cardinalReader.findByClubIdAndCardinalNumber(clubId, cardinalNumber) } returns CardinalTestFixture.createCardinal(cardinalNumber = cardinalNumber, year = 2026, semester = 1) } @@ -57,6 +73,7 @@ class ManageReceiptUseCaseTest : context("파일이 있는 경우") { it("영수증 저장 후 fileRepository.saveAll이 호출된다") { val account = AccountTestFixture.createAccount(cardinal = 40) + val clubId = account.club.id val savedReceipt = ReceiptTestFixture.createReceipt(id = 10L, amount = 5_000, account = account) val files = listOf(mockk()) val request = @@ -69,12 +86,12 @@ class ManageReceiptUseCaseTest : listOf(FileSaveRequest("receipt.png", "TEMP/2024-09/receipt.png", 200L, "image/png")), ) - stubExistingCardinal(40) - every { accountRepository.findByCardinal(40) } returns account + stubExistingCardinal(clubId, 40) + every { accountRepository.findByClubIdAndCardinal(clubId, 40) } returns account every { receiptRepository.save(any()) } returns savedReceipt every { fileMapper.toFileList(request.files, FileOwnerType.RECEIPT, savedReceipt.id) } returns files - useCase.save(request) + useCase.save(clubId, userId, request) verify(exactly = 1) { receiptRepository.save(any()) } verify(exactly = 1) { fileRepository.saveAll(files) } @@ -84,16 +101,17 @@ class ManageReceiptUseCaseTest : context("파일이 없는 경우") { it("fileRepository.saveAll은 빈 리스트로 호출된다") { val account = AccountTestFixture.createAccount(cardinal = 40) + val clubId = account.club.id val savedReceipt = ReceiptTestFixture.createReceipt(id = 11L, amount = 3_000, account = account) val request = ReceiptSaveRequest("교통비", "지하철", 3_000, LocalDate.of(2024, 9, 2), 40, emptyList()) - stubExistingCardinal(40) - every { accountRepository.findByCardinal(40) } returns account + stubExistingCardinal(clubId, 40) + every { accountRepository.findByClubIdAndCardinal(clubId, 40) } returns account every { receiptRepository.save(any()) } returns savedReceipt every { fileMapper.toFileList(emptyList(), FileOwnerType.RECEIPT, savedReceipt.id) } returns emptyList() - useCase.save(request) + useCase.save(clubId, userId, request) verify(exactly = 1) { receiptRepository.save(any()) } verify(exactly = 1) { fileRepository.saveAll(emptyList()) } @@ -103,11 +121,12 @@ class ManageReceiptUseCaseTest : context("존재하지 않는 기수로 저장 시") { it("AccountNotFoundException을 던진다") { val request = ReceiptSaveRequest("간식비", "편의점", 5_000, LocalDate.of(2024, 9, 1), 99, null) + val clubId = 1L - stubExistingCardinal(99) - every { accountRepository.findByCardinal(99) } returns null + stubExistingCardinal(clubId, 99) + every { accountRepository.findByClubIdAndCardinal(clubId, 99) } returns null - shouldThrow { useCase.save(request) } + shouldThrow { useCase.save(clubId, userId, request) } } } } @@ -116,6 +135,7 @@ class ManageReceiptUseCaseTest : it("업데이트 파일이 있으면 기존 파일을 삭제 후 새 파일을 저장한다") { val receiptId = 10L val account = AccountTestFixture.createAccount(cardinal = 40) + val clubId = account.club.id val receipt = ReceiptTestFixture.createReceipt(id = receiptId, amount = 1_000, account = account) account.spend(Money.of(receipt.amount)) val request = @@ -130,13 +150,13 @@ class ManageReceiptUseCaseTest : val oldFiles = listOf(mockk()) val newFiles = listOf(mockk()) - stubExistingCardinal(request.cardinal) - every { accountRepository.findByCardinal(request.cardinal) } returns account + stubExistingCardinal(clubId, request.cardinal) + every { accountRepository.findByClubIdAndCardinal(clubId, request.cardinal) } returns account every { receiptRepository.findById(receiptId) } returns Optional.of(receipt) every { fileReader.findAll(FileOwnerType.RECEIPT, receiptId, null) } returns oldFiles every { fileMapper.toFileList(request.files, FileOwnerType.RECEIPT, receiptId) } returns newFiles - useCase.update(receiptId, request) + useCase.update(clubId, userId, receiptId, request) verify(exactly = 1) { fileRepository.deleteAll(oldFiles) } verify(exactly = 1) { fileRepository.saveAll(newFiles) } @@ -145,20 +165,22 @@ class ManageReceiptUseCaseTest : it("다른 기수의 장부에 속한 영수증을 수정하면 ReceiptAccountMismatchException을 던진다") { val receiptId = 20L val accountA = AccountTestFixture.createAccount(id = 1L, cardinal = 40) + val clubId = accountA.club.id val accountB = AccountTestFixture.createAccount(id = 2L, cardinal = 41) val receipt = ReceiptTestFixture.createReceipt(id = receiptId, amount = 1_000, account = accountB) val request = ReceiptUpdateRequest("desc", "source", 2_000, LocalDate.of(2026, 1, 1), 40, null) - stubExistingCardinal(request.cardinal) - every { accountRepository.findByCardinal(request.cardinal) } returns accountA + stubExistingCardinal(clubId, request.cardinal) + every { accountRepository.findByClubIdAndCardinal(clubId, request.cardinal) } returns accountA every { receiptRepository.findById(receiptId) } returns Optional.of(receipt) - shouldThrow { useCase.update(receiptId, request) } + shouldThrow { useCase.update(clubId, userId, receiptId, request) } } it("빈 리스트로 업데이트 시 기존 파일을 모두 삭제한다") { val receiptId = 11L val account = AccountTestFixture.createAccount(cardinal = 40) + val clubId = account.club.id val receipt = ReceiptTestFixture.createReceipt(id = receiptId, amount = 1_000, account = account) account.spend(Money.of(receipt.amount)) val request = @@ -172,13 +194,13 @@ class ManageReceiptUseCaseTest : ) val oldFiles = listOf(mockk()) - stubExistingCardinal(request.cardinal) - every { accountRepository.findByCardinal(request.cardinal) } returns account + stubExistingCardinal(clubId, request.cardinal) + every { accountRepository.findByClubIdAndCardinal(clubId, request.cardinal) } returns account every { receiptRepository.findById(receiptId) } returns Optional.of(receipt) every { fileReader.findAll(FileOwnerType.RECEIPT, receiptId, null) } returns oldFiles every { fileMapper.toFileList(emptyList(), FileOwnerType.RECEIPT, receiptId) } returns emptyList() - useCase.update(receiptId, request) + useCase.update(clubId, userId, receiptId, request) verify(exactly = 1) { fileRepository.deleteAll(oldFiles) } verify(exactly = 1) { fileRepository.saveAll(emptyList()) } @@ -189,6 +211,7 @@ class ManageReceiptUseCaseTest : it("관련 파일 삭제 후 cancelSpend가 호출되고 영수증이 삭제된다") { val receiptId = 5L val account = AccountTestFixture.createAccount(currentAmount = 100_000) + val clubId = account.club.id val receipt = ReceiptTestFixture.createReceipt(id = receiptId, amount = 10_000, account = account) account.spend(Money.of(receipt.amount)) val files = listOf(mockk()) @@ -196,7 +219,7 @@ class ManageReceiptUseCaseTest : every { receiptRepository.findById(receiptId) } returns Optional.of(receipt) every { fileReader.findAll(FileOwnerType.RECEIPT, receiptId, null) } returns files - useCase.delete(receiptId) + useCase.delete(clubId, userId, receiptId) verify(exactly = 1) { fileRepository.deleteAll(files) } verify(exactly = 1) { receiptRepository.delete(receipt) } diff --git a/src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountQueryServiceTest.kt index 4fd7cd87..0c5cebab 100644 --- a/src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountQueryServiceTest.kt @@ -7,6 +7,7 @@ import com.weeth.domain.account.domain.repository.AccountRepository import com.weeth.domain.account.domain.repository.ReceiptRepository import com.weeth.domain.account.fixture.AccountTestFixture import com.weeth.domain.account.fixture.ReceiptTestFixture +import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.repository.FileReader @@ -23,6 +24,7 @@ class GetAccountQueryServiceTest : val accountRepository = mockk() val receiptRepository = mockk() val fileReader = mockk() + val clubMemberPolicy = mockk(relaxed = true) val accountMapper = mockk() val receiptMapper = mockk() val fileMapper = mockk() @@ -31,15 +33,25 @@ class GetAccountQueryServiceTest : accountRepository, receiptRepository, fileReader, + clubMemberPolicy, accountMapper, receiptMapper, fileMapper, ) val clubId = 1L + val userId = 7L beforeTest { - clearMocks(accountRepository, receiptRepository, fileReader, accountMapper, receiptMapper, fileMapper) + clearMocks( + accountRepository, + receiptRepository, + fileReader, + clubMemberPolicy, + accountMapper, + receiptMapper, + fileMapper, + ) } describe("findByCardinal") { @@ -58,7 +70,7 @@ class GetAccountQueryServiceTest : every { receiptMapper.toResponses(any(), any()) } returns emptyList() every { accountMapper.toResponse(account, emptyList()) } returns accountResponse - val result = queryService.findByCardinal(clubId, 40) + val result = queryService.findByCardinal(clubId, userId, 40) result shouldBe accountResponse verify(exactly = 1) { fileReader.findAll(FileOwnerType.RECEIPT, listOf(1L, 2L), null) } @@ -74,7 +86,7 @@ class GetAccountQueryServiceTest : every { receiptMapper.toResponses(emptyList(), emptyMap()) } returns emptyList() every { accountMapper.toResponse(account, emptyList()) } returns accountResponse - queryService.findByCardinal(clubId, 40) + queryService.findByCardinal(clubId, userId, 40) verify(exactly = 1) { fileReader.findAll(FileOwnerType.RECEIPT, emptyList(), null) } } @@ -84,7 +96,7 @@ class GetAccountQueryServiceTest : it("AccountNotFoundException을 던진다") { every { accountRepository.findByClubIdAndCardinal(clubId, 99) } returns null - shouldThrow { queryService.findByCardinal(clubId, 99) } + shouldThrow { queryService.findByCardinal(clubId, userId, 99) } } } } diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapperTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapperTest.kt index 700475d2..6e6d98fe 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapperTest.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapperTest.kt @@ -5,7 +5,7 @@ import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createAdminUser import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createAttendance import com.weeth.domain.attendance.fixture.AttendanceTestFixture.enrichUserProfile import com.weeth.domain.attendance.fixture.AttendanceTestFixture.setAttendanceId -import com.weeth.domain.attendance.fixture.AttendanceTestFixture.setUserAttendanceStats +import com.weeth.domain.club.fixture.ClubMemberTestFixture import com.weeth.domain.session.fixture.SessionTestFixture.createOneDaySession import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.nulls.shouldBeNull @@ -21,10 +21,14 @@ class AttendanceMapperTest : it("사용자 + 당일 출석 객체를 MainResponse로 매핑한다") { val today = LocalDate.now() val session = createOneDaySession(today, 1, 1111, "Today") - val user = createActiveUser("이지훈") - val attendance = createAttendance(session, user) + val member = + ClubMemberTestFixture.createActiveMember( + club = session.club, + user = createActiveUser("이지훈"), + ) + val attendance = createAttendance(session, member) - val main = mapper.toSummaryResponse(user, attendance) + val main = mapper.toSummaryResponse(member, attendance) main.shouldNotBeNull() main.title shouldBe session.title @@ -35,9 +39,9 @@ class AttendanceMapperTest : } it("attendance가 null이면 필드는 null로 매핑") { - val user = createActiveUser("이지훈") + val member = ClubMemberTestFixture.createActiveMember(user = createActiveUser("이지훈")) - val main = mapper.toSummaryResponse(user, null) + val main = mapper.toSummaryResponse(member, null) main.shouldNotBeNull() main.title.shouldBeNull() @@ -49,10 +53,14 @@ class AttendanceMapperTest : it("일반 유저는 출석 코드가 null로 매핑된다") { val today = LocalDate.now() val session = createOneDaySession(today, 1, 1234, "Today") - val user = createActiveUser("일반유저") - val attendance = createAttendance(session, user) + val member = + ClubMemberTestFixture.createActiveMember( + club = session.club, + user = createActiveUser("일반유저"), + ) + val attendance = createAttendance(session, member) - val main = mapper.toSummaryResponse(user, attendance) + val main = mapper.toSummaryResponse(member, attendance) main.shouldNotBeNull() main.code.shouldBeNull() @@ -65,9 +73,10 @@ class AttendanceMapperTest : val expectedCode = 1234 val session = createOneDaySession(today, 1, expectedCode, "Today") val adminUser = createAdminUser("관리자") - val attendance = createAttendance(session, adminUser) + val member = ClubMemberTestFixture.createAdminMember(club = session.club, user = adminUser) + val attendance = createAttendance(session, member) - val main = mapper.toSummaryResponse(adminUser, attendance, isAdmin = true) + val main = mapper.toSummaryResponse(member, attendance, isAdmin = true) main.shouldNotBeNull() main.code shouldBe expectedCode @@ -81,8 +90,12 @@ class AttendanceMapperTest : describe("toResponse") { it("단일 출석을 AttendanceResponse로 매핑한다") { val session = createOneDaySession(LocalDate.now().minusDays(1), 1, 2222, "D-1") - val user = createActiveUser("사용자A") - val attendance = createAttendance(session, user) + val member = + ClubMemberTestFixture.createActiveMember( + club = session.club, + user = createActiveUser("사용자A"), + ) + val attendance = createAttendance(session, member) val response = mapper.toResponse(attendance) @@ -98,21 +111,22 @@ class AttendanceMapperTest : it("사용자 + Response 리스트를 DetailResponse로 매핑(total = attend + absence)") { val base = LocalDate.now() val m1 = createOneDaySession(base.minusDays(2), 1, 1000, "D-2") - val m2 = createOneDaySession(base.minusDays(1), 1, 1001, "D-1") - val user = createActiveUser("이지훈") - setUserAttendanceStats(user, 3, 2) + val m2 = createOneDaySession(base.minusDays(1), 1, 1001, "D-1", club = m1.club) + val member = ClubMemberTestFixture.createActiveMember(club = m1.club, user = createActiveUser("이지훈")) + repeat(3) { member.attend() } + repeat(2) { member.absent() } - val a1 = createAttendance(m1, user) - val a2 = createAttendance(m2, user) + val a1 = createAttendance(m1, member) + val a2 = createAttendance(m2, member) val r1 = mapper.toResponse(a1) val r2 = mapper.toResponse(a2) - val detail = mapper.toDetailResponse(user, listOf(r1, r2)) + val detail = mapper.toDetailResponse(member, listOf(r1, r2)) detail.shouldNotBeNull() detail.attendances shouldBe listOf(r1, r2) - detail.total shouldBe user.attendanceCount + user.absenceCount + detail.total shouldBe member.attendanceStats.attendanceCount + member.attendanceStats.absenceCount } } @@ -121,8 +135,9 @@ class AttendanceMapperTest : val session = createOneDaySession(LocalDate.now(), 1, 3333, "Info") val user = createActiveUser("유저B") enrichUserProfile(user, "컴퓨터공학과", "20201234") + val member = ClubMemberTestFixture.createActiveMember(club = session.club, user = user) - val attendance = createAttendance(session, user) + val attendance = createAttendance(session, member) setAttendanceId(attendance, 10L) val info = mapper.toInfoResponse(attendance) diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCaseTest.kt index 010cf54d..c229da1d 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCaseTest.kt @@ -3,6 +3,7 @@ package com.weeth.domain.attendance.application.usecase.command import com.weeth.domain.attendance.application.dto.response.QrTokenResponse import com.weeth.domain.attendance.application.mapper.AttendanceMapper import com.weeth.domain.attendance.domain.port.QrAttendancePort +import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.session.application.exception.SessionNotFoundException import com.weeth.domain.session.domain.repository.SessionReader import com.weeth.domain.session.fixture.SessionTestFixture @@ -22,10 +23,11 @@ class GenerateQrTokenUseCaseTest : val sessionReader = mockk() val qrAttendancePort = mockk() val attendanceMapper = mockk() + val clubMemberPolicy = mockk(relaxed = true) - val useCase = GenerateQrTokenUseCase(sessionReader, qrAttendancePort, attendanceMapper) + val useCase = GenerateQrTokenUseCase(sessionReader, qrAttendancePort, attendanceMapper, clubMemberPolicy) - beforeTest { clearMocks(sessionReader, qrAttendancePort, attendanceMapper) } + beforeTest { clearMocks(sessionReader, qrAttendancePort, attendanceMapper, clubMemberPolicy) } describe("execute") { val sessionId = 1L @@ -45,9 +47,10 @@ class GenerateQrTokenUseCaseTest : every { qrAttendancePort.store(sessionId, code) } just Runs every { attendanceMapper.toQrTokenResponse(eq(session), any()) } returns expectedResponse - val result = useCase.execute(sessionId) + val result = useCase.execute(sessionId, 10L, 20L) result shouldBe expectedResponse + verify(exactly = 1) { clubMemberPolicy.requireAdmin(10L, 20L) } verify(exactly = 1) { qrAttendancePort.store(sessionId, code) } } } @@ -56,7 +59,7 @@ class GenerateQrTokenUseCaseTest : it("SessionNotFoundException을 던진다") { every { sessionReader.getById(sessionId) } throws SessionNotFoundException() - shouldThrow { useCase.execute(sessionId) } + shouldThrow { useCase.execute(sessionId, 10L, 20L) } verify(exactly = 0) { qrAttendancePort.store(any(), any()) } } diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCaseTest.kt index e2bd93c3..f1700abf 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCaseTest.kt @@ -1,136 +1,135 @@ package com.weeth.domain.attendance.application.usecase.command +import com.weeth.domain.attendance.application.dto.request.UpdateAttendanceStatusRequest import com.weeth.domain.attendance.application.exception.AlreadyAttendedException -import com.weeth.domain.attendance.application.exception.AttendanceCodeMismatchException import com.weeth.domain.attendance.application.exception.AttendanceNotFoundException -import com.weeth.domain.attendance.application.exception.QrTokenExpiredException -import com.weeth.domain.attendance.domain.enums.AttendanceStatus import com.weeth.domain.attendance.domain.port.QrAttendancePort import com.weeth.domain.attendance.domain.repository.AttendanceRepository -import com.weeth.domain.attendance.fixture.AttendanceTestFixture -import com.weeth.domain.session.application.exception.SessionNotInProgressException +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.fixture.ClubMemberTestFixture import com.weeth.domain.session.domain.repository.SessionReader import com.weeth.domain.session.fixture.SessionTestFixture -import com.weeth.domain.user.domain.repository.UserReader import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk -import java.time.LocalDateTime class ManageAttendanceUseCaseTest : DescribeSpec({ - val userReader = mockk() + val clubMemberPolicy = mockk() val sessionReader = mockk() val attendanceRepository = mockk() val qrAttendancePort = mockk() - val useCase = ManageAttendanceUseCase(userReader, sessionReader, attendanceRepository, qrAttendancePort) + val useCase = + ManageAttendanceUseCase( + clubMemberPolicy, + sessionReader, + attendanceRepository, + qrAttendancePort, + ) - beforeTest { clearMocks(userReader, sessionReader, attendanceRepository, qrAttendancePort) } + beforeTest { + clearMocks(clubMemberPolicy, sessionReader, attendanceRepository, qrAttendancePort) + } describe("checkIn") { - val userId = 1L - val code = 123456 - val sessionId = 10L - - context("유효한 코드 + PENDING 상태 + 출석 가능 시간") { - it("출석 상태를 ATTEND로 변경하고 user.attend()를 호출한다") { - val session = - SessionTestFixture.createSession( - id = sessionId, - code = code, - start = LocalDateTime.now().minusMinutes(5), - end = LocalDateTime.now().plusMinutes(55), - ) - val user = AttendanceTestFixture.createActiveUser("홍길동") - val attendance = AttendanceTestFixture.createAttendance(session, user) - - every { qrAttendancePort.getCode(sessionId) } returns code - every { sessionReader.getById(sessionId) } returns session - every { userReader.getById(userId) } returns user - every { attendanceRepository.findBySessionAndUserWithLock(session, user) } returns attendance - - useCase.checkIn(userId, sessionId, code) - - attendance.status shouldBe AttendanceStatus.ATTEND - } + val clubMember = ClubMemberTestFixture.createActiveMember() + val session = + SessionTestFixture.createInProgressSession( + cardinal = 1, + code = 123456, + title = "Test Session", + club = clubMember.club, + ) + val attendance = + com.weeth.domain.attendance.domain.entity.Attendance + .create(session, clubMember) + + it("정상 체크인 시 출석 상태와 멤버 통계를 갱신한다") { + every { qrAttendancePort.getCode(session.id) } returns session.code + every { sessionReader.getById(session.id) } returns session + every { clubMemberPolicy.getActiveMember(clubMember.club.id, clubMember.user.id) } returns clubMember + every { attendanceRepository.findBySessionAndClubMemberWithLock(session, clubMember) } returns + attendance + + useCase.checkIn(clubMember.club.id, clubMember.user.id, session.id, session.code) + + attendance.status shouldBe com.weeth.domain.attendance.domain.enums.AttendanceStatus.ATTEND + clubMember.attendanceStats.attendanceCount shouldBe 1 } - context("만료된 QR (Redis miss)") { - it("QrTokenExpiredException을 던진다") { - every { qrAttendancePort.getCode(sessionId) } returns null - - shouldThrow { useCase.checkIn(userId, sessionId, code) } + it("이미 출석 처리된 경우 예외를 던진다") { + val attendedAttendance = + com.weeth.domain.attendance.domain.entity.Attendance + .create( + session, + clubMember, + ).also { + it.attend() + } + every { qrAttendancePort.getCode(session.id) } returns session.code + every { sessionReader.getById(session.id) } returns session + every { clubMemberPolicy.getActiveMember(clubMember.club.id, clubMember.user.id) } returns clubMember + every { attendanceRepository.findBySessionAndClubMemberWithLock(session, clubMember) } returns + attendedAttendance + + shouldThrow { + useCase.checkIn(clubMember.club.id, clubMember.user.id, session.id, session.code) } } - context("코드 불일치") { - it("AttendanceCodeMismatchException을 던진다") { - every { qrAttendancePort.getCode(sessionId) } returns 999999 + it("출석 레코드가 없으면 예외를 던진다") { + every { qrAttendancePort.getCode(session.id) } returns session.code + every { sessionReader.getById(session.id) } returns session + every { clubMemberPolicy.getActiveMember(clubMember.club.id, clubMember.user.id) } returns clubMember + every { attendanceRepository.findBySessionAndClubMemberWithLock(session, clubMember) } returns null - shouldThrow { useCase.checkIn(userId, sessionId, code) } + shouldThrow { + useCase.checkIn(clubMember.club.id, clubMember.user.id, session.id, session.code) } } + } - context("출석 가능 시간 외 (세션 시작 10분 전 ~ 종료 10분 후 범위 초과)") { - it("SessionNotInProgressException을 던진다") { - val session = - SessionTestFixture.createSession( - id = sessionId, - code = code, - start = LocalDateTime.now().minusHours(3), - end = LocalDateTime.now().minusHours(1), - ) + describe("updateStatus") { + it("관리자가 ATTEND로 변경하면 ClubMember 통계를 갱신한다") { + val admin = ClubMemberTestFixture.createAdminMember() + val member = ClubMemberTestFixture.createActiveMember(club = admin.club) + val attendance = + com.weeth.domain.attendance.domain.entity.Attendance.create( + SessionTestFixture.createSession(club = admin.club), + member, + ) - every { qrAttendancePort.getCode(sessionId) } returns code - every { sessionReader.getById(sessionId) } returns session + every { clubMemberPolicy.requireAdmin(admin.club.id, admin.user.id) } returns admin + every { attendanceRepository.findByIdWithClubMember(1L) } returns attendance - shouldThrow { useCase.checkIn(userId, sessionId, code) } - } - } + useCase.updateStatus(admin.club.id, admin.user.id, listOf(UpdateAttendanceStatusRequest(1L, "ATTEND"))) - context("이미 ATTEND 상태인 출석") { - it("AlreadyAttendedException을 던진다") { - val session = - SessionTestFixture.createSession( - id = sessionId, - code = code, - start = LocalDateTime.now().minusMinutes(5), - end = LocalDateTime.now().plusMinutes(55), - ) - val user = AttendanceTestFixture.createActiveUser("홍길동") - val attendance = AttendanceTestFixture.createAttendance(session, user).also { it.attend() } - - every { qrAttendancePort.getCode(sessionId) } returns code - every { sessionReader.getById(sessionId) } returns session - every { userReader.getById(userId) } returns user - every { attendanceRepository.findBySessionAndUserWithLock(session, user) } returns attendance - - shouldThrow { useCase.checkIn(userId, sessionId, code) } - } + attendance.status shouldBe com.weeth.domain.attendance.domain.enums.AttendanceStatus.ATTEND + member.attendanceStats.attendanceCount shouldBe 1 } - context("Attendance 레코드가 없는 경우") { - it("AttendanceNotFoundException을 던진다") { - val session = - SessionTestFixture.createSession( - id = sessionId, - code = code, - start = LocalDateTime.now().minusMinutes(5), - end = LocalDateTime.now().plusMinutes(55), - ) - val user = AttendanceTestFixture.createActiveUser("홍길동") - - every { qrAttendancePort.getCode(sessionId) } returns code - every { sessionReader.getById(sessionId) } returns session - every { userReader.getById(userId) } returns user - every { attendanceRepository.findBySessionAndUserWithLock(session, user) } returns null - - shouldThrow { useCase.checkIn(userId, sessionId, code) } - } + it("관리자가 PENDING으로 되돌리면 기존 통계를 차감한다") { + val admin = ClubMemberTestFixture.createAdminMember() + val member = ClubMemberTestFixture.createActiveMember(club = admin.club) + val attendance = + com.weeth.domain.attendance.domain.entity.Attendance.create( + SessionTestFixture.createSession(club = admin.club), + member, + ) + attendance.attend() + member.attend() + + every { clubMemberPolicy.requireAdmin(admin.club.id, admin.user.id) } returns admin + every { attendanceRepository.findByIdWithClubMember(1L) } returns attendance + + useCase.updateStatus(admin.club.id, admin.user.id, listOf(UpdateAttendanceStatusRequest(1L, "PENDING"))) + + attendance.status shouldBe com.weeth.domain.attendance.domain.enums.AttendanceStatus.PENDING + member.attendanceStats.attendanceCount shouldBe 0 } } }) diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt index 91f34f5d..bb72f956 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt @@ -1,20 +1,19 @@ package com.weeth.domain.attendance.application.usecase.query -import com.weeth.domain.attendance.application.dto.response.AttendanceDetailResponse -import com.weeth.domain.attendance.application.dto.response.AttendanceInfoResponse -import com.weeth.domain.attendance.application.dto.response.AttendanceResponse -import com.weeth.domain.attendance.application.dto.response.AttendanceSummaryResponse +import com.weeth.domain.attendance.application.exception.AttendanceNotFoundException import com.weeth.domain.attendance.application.mapper.AttendanceMapper import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.attendance.domain.enums.AttendanceStatus import com.weeth.domain.attendance.domain.repository.AttendanceRepository -import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createActiveUser -import com.weeth.domain.cardinal.domain.entity.Cardinal -import com.weeth.domain.session.domain.entity.Session +import com.weeth.domain.cardinal.fixture.CardinalTestFixture +import com.weeth.domain.club.domain.service.ClubMemberCardinalPolicy +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.fixture.ClubMemberTestFixture import com.weeth.domain.session.domain.repository.SessionReader -import com.weeth.domain.user.domain.enums.Status -import com.weeth.domain.user.domain.repository.UserReader -import com.weeth.domain.user.domain.service.UserCardinalPolicy +import com.weeth.domain.session.fixture.SessionTestFixture +import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe import io.mockk.every import io.mockk.mockk @@ -22,108 +21,118 @@ import io.mockk.verify class GetAttendanceQueryServiceTest : DescribeSpec({ - - val userReader = mockk() - val userCardinalPolicy = mockk() + val clubMemberPolicy = mockk() + val clubMemberCardinalPolicy = mockk() val sessionReader = mockk() val attendanceRepository = mockk() - val attendanceMapper = mockk() + val attendanceMapper = AttendanceMapper() val queryService = GetAttendanceQueryService( - userReader, - userCardinalPolicy, + clubMemberPolicy, + clubMemberCardinalPolicy, sessionReader, attendanceRepository, attendanceMapper, ) - val clubId = 0L // TODO: PR4에서 실제 clubId 기반으로 전환 - val userId = 10L - - describe("find") { - it("오늘 출석 정보가 있으면 mapper.toSummaryResponse(user, attendance, isAdmin=false) 호출") { - val user = createActiveUser("이지훈") - val todayAttendance = mockk() - val mapped = mockk() + describe("findAttendance") { + it("오늘 출석 요약을 ClubMember 기준으로 반환한다") { + val member = ClubMemberTestFixture.createActiveMember() + member.attend() + val session = + SessionTestFixture.createInProgressSession( + cardinal = 1, + code = 111111, + title = "오늘 모임", + club = member.club, + ) + val attendance = Attendance.create(session, member) - every { userReader.getById(userId) } returns user - every { attendanceRepository.findTodayByUserId(eq(userId), any(), any()) } returns todayAttendance - every { attendanceMapper.toSummaryResponse(eq(user), eq(todayAttendance), eq(false)) } returns mapped + every { clubMemberPolicy.getActiveMember(member.club.id, member.user.id) } returns member + every { attendanceRepository.findTodayByClubMemberId(member.id, any(), any()) } returns attendance - val actual = queryService.findAttendance(clubId, userId) + val result = queryService.findAttendance(member.club.id, member.user.id) - actual shouldBe mapped - verify { attendanceMapper.toSummaryResponse(eq(user), eq(todayAttendance), eq(false)) } + result.attendanceRate shouldBe member.attendanceStats.attendanceRate + result.title shouldBe session.title + result.status shouldBe AttendanceStatus.PENDING + verify(exactly = 1) { clubMemberPolicy.getActiveMember(member.club.id, member.user.id) } } + } - it("오늘 출석이 없다면 mapper.toSummaryResponse(user, null, isAdmin=false) 호출") { - val user = createActiveUser("이지훈") - val mapped = mockk() + describe("findAllDetailsByCurrentCardinal") { + it("현재 기수의 출석 상세 목록과 통계를 반환한다") { + val member = ClubMemberTestFixture.createActiveMember() + repeat(2) { member.attend() } + repeat(1) { member.absent() } + val cardinal = + CardinalTestFixture.createCardinal( + id = 1L, + club = member.club, + cardinalNumber = 8, + year = 2026, + semester = 1, + ) + val session1 = + SessionTestFixture.createSession( + id = 1L, + club = member.club, + cardinal = 8, + title = "1주차", + ) + val session2 = + SessionTestFixture.createSession( + id = 2L, + club = member.club, + cardinal = 8, + title = "2주차", + ) + val attendances = listOf(Attendance.create(session1, member), Attendance.create(session2, member)) - every { userReader.getById(userId) } returns user - every { attendanceRepository.findTodayByUserId(eq(userId), any(), any()) } returns null - every { attendanceMapper.toSummaryResponse(user, null, false) } returns mapped + every { clubMemberPolicy.getActiveMember(member.club.id, member.user.id) } returns member + every { clubMemberCardinalPolicy.getCurrentCardinal(member) } returns cardinal + every { attendanceRepository.findAllByClubMemberIdAndCardinal(member.id, 8) } returns attendances - val actual = queryService.findAttendance(clubId, userId) + val result = queryService.findAllDetailsByCurrentCardinal(member.club.id, member.user.id) - actual shouldBe mapped - verify { attendanceMapper.toSummaryResponse(user, null, false) } + result.attendanceCount shouldBe 2 + result.absenceCount shouldBe 1 + result.total shouldBe 3 + result.attendances shouldHaveSize 2 + result.attendances.map { it.title } shouldBe listOf("1주차", "2주차") } } - describe("findAllDetailsByCurrentCardinal") { - it("현재 기수의 출석 목록을 매핑하여 Detail 반환") { - val user = createActiveUser("이지훈") - val attendance1 = mockk() - val attendance2 = mockk() - - every { userReader.getById(userId) } returns user - val currentCardinal = mockk() - every { currentCardinal.cardinalNumber } returns 1 - every { userCardinalPolicy.getCurrentCardinal(user) } returns currentCardinal - every { attendanceRepository.findAllByUserIdAndCardinal(userId, 1) } returns - listOf(attendance1, attendance2) - - val response1 = mockk() - val response2 = mockk() - every { attendanceMapper.toResponse(attendance1) } returns response1 - every { attendanceMapper.toResponse(attendance2) } returns response2 - - val expectedDetail = mockk() - every { attendanceMapper.toDetailResponse(eq(user), any()) } returns expectedDetail - - val actualDetail = queryService.findAllDetailsByCurrentCardinal(clubId, userId) - - actualDetail shouldBe expectedDetail - verify { - attendanceMapper.toDetailResponse( - eq(user), - match { it.size == 2 }, - ) - } + describe("findAllAttendanceBySession") { + it("관리자는 세션별 출석 목록을 조회할 수 있다") { + val admin = ClubMemberTestFixture.createAdminMember() + val member = ClubMemberTestFixture.createActiveMember(club = admin.club) + val session = SessionTestFixture.createSession(id = 10L, club = admin.club, title = "세션") + val attendance = Attendance.create(session, member).also { it.attend() } + + every { clubMemberPolicy.requireAdmin(admin.club.id, admin.user.id) } returns admin + every { sessionReader.getById(session.id) } returns session + every { attendanceRepository.findAllBySessionAndClubMemberMemberStatus(session, any()) } returns + listOf(attendance) + + val result = queryService.findAllAttendanceBySession(admin.club.id, admin.user.id, session.id) + + result shouldHaveSize 1 + result.first().name shouldBe member.user.name + result.first().status shouldBe AttendanceStatus.ATTEND } - } - describe("findAllAttendanceBySession") { - it("해당 정기모임의 출석 정보를 조회") { - val sessionId = 1L - val session = mockk() - val attendance1 = mockk() - val attendance2 = mockk() - val response1 = mockk() - val response2 = mockk() - - every { sessionReader.getById(sessionId) } returns session - every { - attendanceRepository.findAllBySessionAndUserStatus(session, Status.ACTIVE) - } returns listOf(attendance1, attendance2) - every { attendanceMapper.toInfoResponse(attendance1) } returns response1 - every { attendanceMapper.toInfoResponse(attendance2) } returns response2 - - val result = queryService.findAllAttendanceBySession(clubId, sessionId) - - result shouldBe listOf(response1, response2) + it("다른 동아리 세션이면 예외를 던진다") { + val admin = ClubMemberTestFixture.createAdminMember() + val otherSession = SessionTestFixture.createSession(id = 10L) + + every { clubMemberPolicy.requireAdmin(admin.club.id, admin.user.id) } returns admin + every { sessionReader.getById(otherSession.id) } returns otherSession + + shouldThrow { + queryService.findAllAttendanceBySession(admin.club.id, admin.user.id, otherSession.id) + } } } }) diff --git a/src/test/kotlin/com/weeth/domain/attendance/domain/entity/AttendanceTest.kt b/src/test/kotlin/com/weeth/domain/attendance/domain/entity/AttendanceTest.kt index 1551fe79..0d42cdbc 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/domain/entity/AttendanceTest.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/domain/entity/AttendanceTest.kt @@ -3,6 +3,7 @@ package com.weeth.domain.attendance.domain.entity import com.weeth.domain.attendance.domain.enums.AttendanceStatus import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createActiveUser import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createAttendance +import com.weeth.domain.club.fixture.ClubMemberTestFixture import com.weeth.domain.session.fixture.SessionTestFixture.createOneDaySession import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe @@ -16,7 +17,11 @@ class AttendanceTest : describe("attend") { it("상태를 ATTEND로 변경한다") { val user = createActiveUser("테스트유저") - val attendance = createAttendance(session, user) + val attendance = + createAttendance( + session, + ClubMemberTestFixture.createActiveMember(club = session.club, user = user), + ) attendance.attend() @@ -27,7 +32,11 @@ class AttendanceTest : describe("close") { it("상태를 ABSENT로 변경한다") { val user = createActiveUser("테스트유저") - val attendance = createAttendance(session, user) + val attendance = + createAttendance( + session, + ClubMemberTestFixture.createActiveMember(club = session.club, user = user), + ) attendance.close() @@ -38,14 +47,22 @@ class AttendanceTest : describe("isPending") { it("상태가 PENDING이면 true를 반환한다") { val user = createActiveUser("테스트유저") - val attendance = createAttendance(session, user) + val attendance = + createAttendance( + session, + ClubMemberTestFixture.createActiveMember(club = session.club, user = user), + ) attendance.isPending() shouldBe true } it("상태가 PENDING이 아니면 false를 반환한다") { val user = createActiveUser("테스트유저") - val attendance = createAttendance(session, user) + val attendance = + createAttendance( + session, + ClubMemberTestFixture.createActiveMember(club = session.club, user = user), + ) attendance.attend() attendance.isPending() shouldBe false @@ -55,14 +72,22 @@ class AttendanceTest : describe("isWrong") { it("코드가 일치하지 않으면 true를 반환한다") { val user = createActiveUser("테스트유저") - val attendance = createAttendance(session, user) + val attendance = + createAttendance( + session, + ClubMemberTestFixture.createActiveMember(club = session.club, user = user), + ) attendance.isWrong(9999) shouldBe true } it("코드가 일치하면 false를 반환한다") { val user = createActiveUser("테스트유저") - val attendance = createAttendance(session, user) + val attendance = + createAttendance( + session, + ClubMemberTestFixture.createActiveMember(club = session.club, user = user), + ) attendance.isWrong(1234) shouldBe false } diff --git a/src/test/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepositoryTest.kt b/src/test/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepositoryTest.kt index f0e16f14..e243859d 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepositoryTest.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepositoryTest.kt @@ -2,13 +2,15 @@ package com.weeth.domain.attendance.domain.repository import com.weeth.config.TestContainersConfig import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.repository.ClubMemberRepository import com.weeth.domain.club.domain.repository.ClubRepository import com.weeth.domain.club.fixture.ClubTestFixture import com.weeth.domain.session.domain.entity.Session import com.weeth.domain.session.domain.enums.SessionStatus import com.weeth.domain.session.domain.repository.SessionRepository import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.repository.UserRepository import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.collections.shouldBeEmpty @@ -27,11 +29,14 @@ class AttendanceRepositoryTest( private val sessionRepository: SessionRepository, private val userRepository: UserRepository, private val clubRepository: ClubRepository, + private val clubMemberRepository: ClubMemberRepository, ) : DescribeSpec({ lateinit var session: Session lateinit var activeUser1: User lateinit var activeUser2: User + lateinit var activeMember1: ClubMember + lateinit var activeMember2: ClubMember beforeEach { val club = clubRepository.save(ClubTestFixture.createClub()) @@ -69,16 +74,37 @@ class AttendanceRepositoryTest( activeUser2.accept() userRepository.saveAll(listOf(activeUser1, activeUser2)) - attendanceRepository.save(Attendance.create(session, activeUser1)) - attendanceRepository.save(Attendance.create(session, activeUser2)) + activeMember1 = + clubMemberRepository.save( + ClubMember( + club = club, + user = activeUser1, + memberStatus = MemberStatus.ACTIVE, + ), + ) + activeMember2 = + clubMemberRepository.save( + ClubMember( + club = club, + user = activeUser2, + memberStatus = MemberStatus.ACTIVE, + ), + ) + + attendanceRepository.save(Attendance.create(session, activeMember1)) + attendanceRepository.save(Attendance.create(session, activeMember2)) } - describe("findAllBySessionAndUserStatus") { - it("특정 세션 + 사용자 상태로 출석 목록 조회") { - val attendances = attendanceRepository.findAllBySessionAndUserStatus(session, Status.ACTIVE) + describe("findAllBySessionAndClubMemberMemberStatus") { + it("특정 세션 + 멤버 상태로 출석 목록 조회") { + val attendances = + attendanceRepository.findAllBySessionAndClubMemberMemberStatus( + session, + MemberStatus.ACTIVE, + ) attendances shouldHaveSize 2 - attendances.map { it.user.name } shouldContainExactlyInAnyOrder listOf("이지훈", "이강혁") + attendances.map { it.clubMember.user.name } shouldContainExactlyInAnyOrder listOf("이지훈", "이강혁") } } diff --git a/src/test/kotlin/com/weeth/domain/attendance/fixture/AttendanceTestFixture.kt b/src/test/kotlin/com/weeth/domain/attendance/fixture/AttendanceTestFixture.kt index 3db590ce..ce3a0984 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/fixture/AttendanceTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/fixture/AttendanceTestFixture.kt @@ -1,11 +1,10 @@ package com.weeth.domain.attendance.fixture import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.session.domain.entity.Session import com.weeth.domain.user.domain.entity.User import com.weeth.domain.user.domain.enums.Role -import com.weeth.domain.user.domain.enums.Status -import com.weeth.domain.user.domain.vo.AttendanceStats import org.springframework.test.util.ReflectionTestUtils import java.util.UUID @@ -24,8 +23,8 @@ object AttendanceTestFixture { fun createAttendance( session: Session, - user: User, - ): Attendance = Attendance.create(session, user) + clubMember: ClubMember, + ): Attendance = Attendance.create(session, clubMember) fun setAttendanceId( attendance: Attendance, @@ -34,29 +33,6 @@ object AttendanceTestFixture { ReflectionTestUtils.setField(attendance, "id", id) } - fun setUserAttendanceStats( - user: User, - attendanceCount: Int, - absenceCount: Int, - ) { - ReflectionTestUtils.setField( - user, - "attendanceStats", - AttendanceStats( - attendanceCount = attendanceCount, - absenceCount = absenceCount, - attendanceRate = - if (attendanceCount + absenceCount > - 0 - ) { - (attendanceCount * 100) / (attendanceCount + absenceCount) - } else { - 0 - }, - ), - ) - } - fun enrichUserProfile( user: User, department: String, diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt index 8658eba3..d36d14b0 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt @@ -9,6 +9,7 @@ import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.board.domain.vo.BoardConfig import com.weeth.domain.board.fixture.BoardTestFixture import com.weeth.domain.club.domain.repository.ClubReader +import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.club.fixture.ClubTestFixture import com.weeth.domain.user.domain.enums.Role import io.kotest.assertions.throwables.shouldThrow @@ -24,13 +25,15 @@ class ManageBoardUseCaseTest : val boardRepository = mockk() val boardMapper = BoardMapper() val clubReader = mockk() - val useCase = ManageBoardUseCase(boardRepository, boardMapper, clubReader) + val clubMemberPolicy = mockk(relaxed = true) + val useCase = ManageBoardUseCase(boardRepository, boardMapper, clubReader, clubMemberPolicy) val club = ClubTestFixture.createClub() val clubId = club.id + val userId = 10L beforeTest { - clearMocks(boardRepository, clubReader) + clearMocks(boardRepository, clubReader, clubMemberPolicy) every { boardRepository.save(any()) } answers { firstArg() } every { clubReader.getClubById(clubId) } returns club } @@ -46,7 +49,7 @@ class ManageBoardUseCaseTest : isPrivate = true, ) - val result = useCase.create(clubId, request) + val result = useCase.create(clubId, request, userId) result.name shouldBe "운영공지" result.type shouldBe BoardType.NOTICE @@ -61,7 +64,7 @@ class ManageBoardUseCaseTest : val board = BoardTestFixture.create(club = club, name = "기존", type = BoardType.GENERAL) every { boardRepository.findByIdAndIsDeletedFalse(1L) } returns board - val result = useCase.update(clubId, 1L, UpdateBoardRequest(name = "변경", isPrivate = true)) + val result = useCase.update(clubId, 1L, UpdateBoardRequest(name = "변경", isPrivate = true), userId) result.name shouldBe "변경" result.commentEnabled shouldBe true @@ -73,7 +76,7 @@ class ManageBoardUseCaseTest : val board = BoardTestFixture.create(club = club, name = "기존", type = BoardType.GENERAL) every { boardRepository.findByIdAndIsDeletedFalse(1L) } returns board - val result = useCase.update(clubId, 1L, UpdateBoardRequest()) + val result = useCase.update(clubId, 1L, UpdateBoardRequest(), userId) result.name shouldBe "기존" result.commentEnabled shouldBe true @@ -85,7 +88,7 @@ class ManageBoardUseCaseTest : every { boardRepository.findByIdAndIsDeletedFalse(999L) } returns null shouldThrow { - useCase.update(clubId, 999L, UpdateBoardRequest(name = "변경")) + useCase.update(clubId, 999L, UpdateBoardRequest(name = "변경"), userId) } } } @@ -95,7 +98,7 @@ class ManageBoardUseCaseTest : val board = BoardTestFixture.create(club = club, name = "일반", type = BoardType.GENERAL) every { boardRepository.findByIdAndIsDeletedFalse(1L) } returns board - useCase.delete(clubId, 1L) + useCase.delete(clubId, 1L, userId) board.isDeleted shouldBe true verify(exactly = 0) { boardRepository.delete(any()) } diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt index 687d3ba4..5e686180 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt @@ -15,6 +15,7 @@ import com.weeth.domain.board.domain.repository.PostRepository import com.weeth.domain.board.domain.vo.BoardConfig import com.weeth.domain.board.fixture.BoardTestFixture import com.weeth.domain.board.fixture.PostTestFixture +import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.file.application.dto.request.FileSaveRequest import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.entity.File @@ -42,6 +43,7 @@ class ManagePostUseCaseTest : val postRepository = mockk() val boardRepository = mockk() val userReader = mockk() + val clubMemberPolicy = mockk(relaxed = true) val fileRepository = mockk() val fileReader = mockk() val fileMapper = mockk() @@ -52,6 +54,7 @@ class ManagePostUseCaseTest : postRepository, boardRepository, userReader, + clubMemberPolicy, fileRepository, fileReader, fileMapper, @@ -84,7 +87,16 @@ class ManagePostUseCaseTest : ) beforeTest { - clearMocks(postRepository, boardRepository, userReader, fileRepository, fileReader, fileMapper, postMapper) + clearMocks( + postRepository, + boardRepository, + userReader, + clubMemberPolicy, + fileRepository, + fileReader, + fileMapper, + postMapper, + ) every { postRepository.save(any()) } answers { firstArg() } every { fileMapper.toFileList(any(), any(), any()) } returns emptyList() every { fileRepository.saveAll(any>()) } returns emptyList() diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt index 3162295b..6f9f1d08 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt @@ -5,6 +5,7 @@ import com.weeth.domain.board.application.mapper.BoardMapper import com.weeth.domain.board.domain.enums.BoardType import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.board.fixture.BoardTestFixture +import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.user.domain.enums.Role import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec @@ -16,10 +17,12 @@ import io.mockk.mockk class GetBoardQueryServiceTest : DescribeSpec({ val boardRepository = mockk() + val clubMemberPolicy = mockk(relaxed = true) val boardMapper = BoardMapper() - val queryService = GetBoardQueryService(boardRepository, boardMapper) + val queryService = GetBoardQueryService(boardRepository, clubMemberPolicy, boardMapper) val clubId = 1L + val userId = 10L describe("findBoards") { it("일반 사용자에게는 공개 게시판만 반환한다") { @@ -32,7 +35,7 @@ class GetBoardQueryServiceTest : every { boardRepository.findAllByClubIdAndIsDeletedFalseOrderByIdAsc(clubId) } returns listOf(publicBoard, privateBoard) - val result = queryService.findBoards(clubId, Role.USER) + val result = queryService.findBoards(clubId, userId, Role.USER) result shouldHaveSize 1 result.first().name shouldBe "일반" @@ -48,7 +51,7 @@ class GetBoardQueryServiceTest : every { boardRepository.findAllByClubIdAndIsDeletedFalseOrderByIdAsc(clubId) } returns listOf(publicBoard, privateBoard) - val result = queryService.findBoards(clubId, Role.ADMIN) + val result = queryService.findBoards(clubId, userId, Role.ADMIN) result shouldHaveSize 2 result.map { it.name } shouldBe listOf("일반", "운영") @@ -65,7 +68,7 @@ class GetBoardQueryServiceTest : every { boardRepository.findAllByClubIdOrderByIdAsc(clubId) } returns listOf(activeBoard, deletedBoard) - val result = queryService.findAllBoardsForAdmin(clubId) + val result = queryService.findAllBoardsForAdmin(clubId, userId) result shouldHaveSize 2 result.map { it.name } shouldBe listOf("일반", "삭제됨") @@ -80,7 +83,7 @@ class GetBoardQueryServiceTest : every { boardRepository.findAllByClubIdOrderByIdAsc(clubId) } returns listOf(publicBoard, privateBoard) - val result = queryService.findAllBoardsForAdmin(clubId) + val result = queryService.findAllBoardsForAdmin(clubId, userId) result shouldHaveSize 2 result.map { it.name } shouldBe listOf("일반", "운영") @@ -95,7 +98,7 @@ class GetBoardQueryServiceTest : } every { boardRepository.findByIdAndClubId(3L, clubId) } returns deletedBoard - val result = queryService.findBoardDetailForAdmin(clubId, 3L) + val result = queryService.findBoardDetailForAdmin(clubId, userId, 3L) result.isDeleted shouldBe true } @@ -107,7 +110,7 @@ class GetBoardQueryServiceTest : } every { boardRepository.findByIdAndClubId(2L, clubId) } returns privateBoard - val result = queryService.findBoardDetailForAdmin(clubId, 2L) + val result = queryService.findBoardDetailForAdmin(clubId, userId, 2L) result.isPrivate shouldBe true } @@ -116,7 +119,7 @@ class GetBoardQueryServiceTest : every { boardRepository.findByIdAndClubId(999L, clubId) } returns null shouldThrow { - queryService.findBoardDetailForAdmin(clubId, 999L) + queryService.findBoardDetailForAdmin(clubId, userId, 999L) } } } diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt index 741ab569..aad87cbd 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt @@ -10,6 +10,7 @@ import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.board.domain.repository.PostRepository import com.weeth.domain.board.fixture.BoardTestFixture import com.weeth.domain.board.fixture.PostTestFixture +import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.comment.application.dto.response.CommentResponse import com.weeth.domain.comment.application.usecase.query.GetCommentQueryService import com.weeth.domain.comment.domain.repository.CommentReader @@ -36,6 +37,7 @@ class GetPostQueryServiceTest : DescribeSpec({ val postRepository = mockk() val boardRepository = mockk() + val clubMemberPolicy = mockk(relaxed = true) val commentReader = mockk() val getCommentQueryService = mockk() val fileReader = mockk() @@ -46,6 +48,7 @@ class GetPostQueryServiceTest : GetPostQueryService( postRepository, boardRepository, + clubMemberPolicy, commentReader, getCommentQueryService, fileReader, @@ -54,11 +57,13 @@ class GetPostQueryServiceTest : ) val clubId = 1L // findPosts/searchPosts 테스트에서 boardRepository mock 인자로 사용 + val userId = 1L beforeTest { clearMocks( postRepository, boardRepository, + clubMemberPolicy, commentReader, getCommentQueryService, fileReader, @@ -72,7 +77,7 @@ class GetPostQueryServiceTest : every { postRepository.findByIdAndIsDeletedFalse(1L) } returns null shouldThrow { - queryService.findPost(clubId, 1L, Role.USER) + queryService.findPost(clubId, userId, 1L, Role.USER) } } @@ -131,7 +136,7 @@ class GetPostQueryServiceTest : every { postMapper.toDetailResponse(post, comments, fileResponses) } returns detail every { fileMapper.toFileResponse(files.first()) } returns fileResponses.first() - val result = queryService.findPost(actualClubId, 1L, Role.USER) + val result = queryService.findPost(actualClubId, userId, 1L, Role.USER) result.id shouldBe 1L result.comments.size shouldBe 1 @@ -154,7 +159,7 @@ class GetPostQueryServiceTest : every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post shouldThrow { - queryService.findPost(actualClubId, 1L, Role.USER) + queryService.findPost(actualClubId, userId, 1L, Role.USER) } } @@ -178,7 +183,7 @@ class GetPostQueryServiceTest : every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post shouldThrow { - queryService.findPost(actualClubId, 1L, Role.USER) + queryService.findPost(actualClubId, userId, 1L, Role.USER) } } } @@ -192,7 +197,7 @@ class GetPostQueryServiceTest : SliceImpl(emptyList(), pageable, false) shouldThrow { - queryService.searchPosts(clubId, 1L, "키워드", 0, 10, Role.USER) + queryService.searchPosts(clubId, userId, 1L, "키워드", 0, 10, Role.USER) } } @@ -202,7 +207,7 @@ class GetPostQueryServiceTest : every { boardRepository.findByIdAndClubIdAndIsDeletedFalse(1L, clubId) } returns privateBoard shouldThrow { - queryService.searchPosts(clubId, 1L, "키워드", 0, 10, Role.USER) + queryService.searchPosts(clubId, userId, 1L, "키워드", 0, 10, Role.USER) } } } @@ -210,19 +215,19 @@ class GetPostQueryServiceTest : describe("validatePage") { it("음수 페이지면 예외를 던진다") { shouldThrow { - queryService.findPosts(clubId, 1L, -1, 10, Role.USER) + queryService.findPosts(clubId, userId, 1L, -1, 10, Role.USER) } } it("pageSize가 0이면 예외를 던진다") { shouldThrow { - queryService.findPosts(clubId, 1L, 0, 0, Role.USER) + queryService.findPosts(clubId, userId, 1L, 0, 0, Role.USER) } } it("pageSize가 최대값을 초과하면 예외를 던진다") { shouldThrow { - queryService.findPosts(clubId, 1L, 0, 51, Role.USER) + queryService.findPosts(clubId, userId, 1L, 0, 51, Role.USER) } } } @@ -258,7 +263,7 @@ class GetPostQueryServiceTest : every { fileReader.findAll(FileOwnerType.POST, any>(), any()) } returns emptyList() every { postMapper.toListResponse(any(), any(), any()) } returns response - val result = queryService.findPosts(clubId, 1L, 0, 10, Role.USER) + val result = queryService.findPosts(clubId, userId, 1L, 0, 10, Role.USER) result.content.size shouldBe 1 verify(exactly = 1) { fileReader.findAll(FileOwnerType.POST, any>(), any()) } diff --git a/src/test/kotlin/com/weeth/domain/cardinal/application/usecase/command/CardinalUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/cardinal/application/usecase/command/CardinalUseCaseTest.kt index 38a0b381..b4200b9f 100644 --- a/src/test/kotlin/com/weeth/domain/cardinal/application/usecase/command/CardinalUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/cardinal/application/usecase/command/CardinalUseCaseTest.kt @@ -12,6 +12,7 @@ import com.weeth.domain.cardinal.domain.repository.CardinalRepository import com.weeth.domain.cardinal.domain.service.CardinalStatusPolicy import com.weeth.domain.cardinal.fixture.CardinalTestFixture import com.weeth.domain.club.domain.repository.ClubReader +import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.club.fixture.ClubTestFixture import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.collections.shouldHaveSize @@ -28,17 +29,32 @@ class CardinalUseCaseTest : val cardinalReader = mockk() val cardinalMapper = mockk() val clubReader = mockk() + val clubMemberPolicy = mockk(relaxed = true) val cardinalStatusPolicy = CardinalStatusPolicy(cardinalRepository) val manageCardinalUseCase = - ManageCardinalUseCase(cardinalRepository, cardinalMapper, cardinalStatusPolicy, clubReader) - val getCardinalQueryService = GetCardinalQueryService(cardinalRepository, cardinalReader, cardinalMapper) + ManageCardinalUseCase( + cardinalRepository, + cardinalMapper, + cardinalStatusPolicy, + clubReader, + clubMemberPolicy, + ) + val getCardinalQueryService = + GetCardinalQueryService(cardinalReader, clubMemberPolicy, cardinalMapper) val clubId = 1L + val userId = 99L val club = ClubTestFixture.createClub() beforeTest { - clearMocks(cardinalRepository, cardinalReader, cardinalMapper, clubReader) + clearMocks(cardinalRepository, cardinalReader, cardinalMapper, clubReader, clubMemberPolicy) every { clubReader.getClubById(clubId) } returns club + every { + clubMemberPolicy.getActiveMember( + clubId, + userId, + ) + } returns ClubTestFixture.createClubMember(club = club) } describe("save") { @@ -52,7 +68,7 @@ class CardinalUseCaseTest : every { cardinalMapper.toEntity(club, request) } returns toSave every { cardinalRepository.save(toSave) } returns saved - manageCardinalUseCase.save(clubId, request) + manageCardinalUseCase.save(clubId, request, userId) verify { cardinalRepository.findByClubIdAndCardinalNumber(clubId, 7) } verify { cardinalRepository.save(toSave) } @@ -75,7 +91,7 @@ class CardinalUseCaseTest : every { cardinalMapper.toEntity(club, request) } returns newCardinalBeforeSave every { cardinalRepository.save(newCardinalBeforeSave) } returns newCardinalAfterSave - manageCardinalUseCase.save(clubId, request) + manageCardinalUseCase.save(clubId, request, userId) verify { cardinalRepository.findAllInProgressWithLock() } verify { cardinalRepository.save(newCardinalBeforeSave) } @@ -91,7 +107,7 @@ class CardinalUseCaseTest : val cardinal = CardinalTestFixture.createCardinal(cardinalNumber = 6, year = 2024, semester = 2) every { cardinalRepository.findByIdAndClubId(1L, clubId) } returns cardinal - manageCardinalUseCase.update(clubId, CardinalUpdateRequest(1L, 2025, 1, false)) + manageCardinalUseCase.update(clubId, CardinalUpdateRequest(1L, 2025, 1, false), userId) cardinal.year shouldBe 2025 cardinal.semester shouldBe 1 @@ -116,7 +132,7 @@ class CardinalUseCaseTest : every { cardinalMapper.toResponse(cardinal1) } returns response1 every { cardinalMapper.toResponse(cardinal2) } returns response2 - val responses = getCardinalQueryService.findAll(clubId) + val responses = getCardinalQueryService.findAll(clubId, userId) verify { cardinalReader.findAllByClubIdOrderByCardinalNumberAsc(clubId) } verify(exactly = 2) { cardinalMapper.toResponse(any()) } diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCaseTest.kt index b581b7d2..04d44573 100644 --- a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCaseTest.kt @@ -1,26 +1,65 @@ package com.weeth.domain.club.application.usecase.command +import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException +import com.weeth.domain.cardinal.domain.repository.CardinalReader +import com.weeth.domain.cardinal.fixture.CardinalTestFixture +import com.weeth.domain.club.application.dto.request.ClubMemberApplyObRequest import com.weeth.domain.club.application.dto.request.ClubMemberRoleUpdateRequest import com.weeth.domain.club.application.exception.ClubMemberNotInClubException import com.weeth.domain.club.domain.enums.MemberRole import com.weeth.domain.club.domain.enums.MemberStatus -import com.weeth.domain.club.domain.repository.ClubMemberRepository +import com.weeth.domain.club.domain.repository.ClubMemberCardinalRepository +import com.weeth.domain.club.domain.service.ClubMemberCardinalPolicy import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.club.fixture.ClubMemberTestFixture +import com.weeth.domain.session.domain.repository.SessionReader +import com.weeth.domain.session.fixture.SessionTestFixture import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe +import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk import io.mockk.verify class AdminClubMemberUseCaseTest : DescribeSpec({ - val clubMemberRepository = mockk() val clubMemberPolicy = mockk() - val useCase = AdminClubMemberUseCase(clubMemberRepository, clubMemberPolicy) + val clubMemberCardinalPolicy = mockk(relaxed = true) + val cardinalReader = mockk(relaxed = true) + val sessionReader = mockk(relaxed = true) + val attendanceRepository = mockk(relaxed = true) + val clubMemberCardinalRepository = mockk(relaxed = true) + val useCase = + AdminClubMemberUseCase( + clubMemberPolicy, + clubMemberCardinalPolicy, + cardinalReader, + sessionReader, + attendanceRepository, + clubMemberCardinalRepository, + ) val adminMember = ClubMemberTestFixture.createAdminMember() + beforeTest { + clearMocks( + clubMemberPolicy, + clubMemberCardinalPolicy, + cardinalReader, + sessionReader, + attendanceRepository, + clubMemberCardinalRepository, + ) + every { + attendanceRepository.saveAll( + any>(), + ) + } answers + { firstArg() } + every { clubMemberCardinalRepository.save(any()) } answers { firstArg() } + } + describe("accept") { it("같은 동아리 소속 멤버를 승인한다") { val member = ClubMemberTestFixture.createWaitingMember() @@ -30,7 +69,6 @@ class AdminClubMemberUseCaseTest : useCase.accept(1L, 10L, 20L) member.memberStatus shouldBe MemberStatus.ACTIVE - verify(exactly = 0) { clubMemberRepository.getClubMemberById(any()) } } it("다른 동아리 소속 멤버면 예외가 발생한다") { @@ -70,4 +108,118 @@ class AdminClubMemberUseCaseTest : member.memberRole shouldBe MemberRole.ADMIN } } + + describe("applyOb") { + it("새 기수를 정상 등록한다") { + val member = ClubMemberTestFixture.createActiveMember(club = adminMember.club) + val cardinal = + CardinalTestFixture.createCardinal( + id = 1L, + club = adminMember.club, + cardinalNumber = 8, + year = 2026, + semester = 1, + ) + val session = SessionTestFixture.createSession(club = adminMember.club, cardinal = 8) + every { clubMemberPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubMemberPolicy.getMemberInClub(1L, 20L) } returns member + every { cardinalReader.findByClubIdAndCardinalNumber(1L, 8) } returns cardinal + every { clubMemberCardinalPolicy.notContains(member, cardinal) } returns true + every { clubMemberCardinalPolicy.isLatestOrFirstCardinal(member, cardinal) } returns true + every { sessionReader.findAllByClubIdAndCardinalIn(1L, listOf(8)) } returns listOf(session) + + useCase.applyOb(1L, 10L, listOf(ClubMemberApplyObRequest(20L, 8))) + + verify(exactly = 1) { clubMemberCardinalRepository.save(any()) } + verify( + exactly = 1, + ) { attendanceRepository.saveAll(any>()) } + } + + it("이미 등록된 기수는 무시한다") { + val member = ClubMemberTestFixture.createActiveMember(club = adminMember.club) + val cardinal = + CardinalTestFixture.createCardinal( + id = 1L, + club = adminMember.club, + cardinalNumber = 8, + year = 2026, + semester = 1, + ) + every { clubMemberPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubMemberPolicy.getMemberInClub(1L, 20L) } returns member + every { cardinalReader.findByClubIdAndCardinalNumber(1L, 8) } returns cardinal + every { clubMemberCardinalPolicy.notContains(member, cardinal) } returns false + + useCase.applyOb(1L, 10L, listOf(ClubMemberApplyObRequest(20L, 8))) + + verify(exactly = 0) { clubMemberCardinalRepository.save(any()) } + verify( + exactly = 0, + ) { attendanceRepository.saveAll(any>()) } + } + + it("동일한 요청이 중복으로 전달되면 1회만 처리한다") { + val session = SessionTestFixture.createSession(club = adminMember.club, cardinal = 8) + val member = ClubMemberTestFixture.createActiveMember(club = adminMember.club) + val cardinal = + CardinalTestFixture.createCardinal( + id = 1L, + club = adminMember.club, + cardinalNumber = 8, + year = 2026, + semester = 1, + ) + every { clubMemberPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubMemberPolicy.getMemberInClub(1L, 20L) } returns member + every { cardinalReader.findByClubIdAndCardinalNumber(1L, 8) } returns cardinal + every { clubMemberCardinalPolicy.notContains(member, cardinal) } returns true + every { clubMemberCardinalPolicy.isLatestOrFirstCardinal(member, cardinal) } returns true + every { sessionReader.findAllByClubIdAndCardinalIn(1L, listOf(8)) } returns listOf(session) + + useCase.applyOb(1L, 10L, listOf(ClubMemberApplyObRequest(20L, 8), ClubMemberApplyObRequest(20L, 8))) + + verify(exactly = 1) { clubMemberCardinalRepository.save(any()) } + verify( + exactly = 1, + ) { attendanceRepository.saveAll(any>()) } + } + + it("존재하지 않는 기수면 예외가 발생한다") { + val member = ClubMemberTestFixture.createActiveMember(club = adminMember.club) + every { clubMemberPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubMemberPolicy.getMemberInClub(1L, 20L) } returns member + every { cardinalReader.findByClubIdAndCardinalNumber(1L, 8) } returns null + + shouldThrow { + useCase.applyOb(1L, 10L, listOf(ClubMemberApplyObRequest(20L, 8))) + } + } + + it("현재 기수 등록 시 출석 통계를 초기화한다") { + val member = ClubMemberTestFixture.createActiveMember(club = adminMember.club) + val cardinal = + CardinalTestFixture.createCardinal( + id = 1L, + club = adminMember.club, + cardinalNumber = 8, + year = 2026, + semester = 1, + ) + repeat(2) { member.attend() } + repeat(1) { member.absent() } + every { clubMemberPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubMemberPolicy.getMemberInClub(1L, 20L) } returns member + every { cardinalReader.findByClubIdAndCardinalNumber(1L, 8) } returns cardinal + every { clubMemberCardinalPolicy.notContains(member, cardinal) } returns true + every { clubMemberCardinalPolicy.isLatestOrFirstCardinal(member, cardinal) } returns true + every { sessionReader.findAllByClubIdAndCardinalIn(1L, listOf(8)) } returns emptyList() + + useCase.applyOb(1L, 10L, listOf(ClubMemberApplyObRequest(20L, 8))) + + member.attendanceStats.attendanceCount shouldBe 0 + member.attendanceStats.absenceCount shouldBe 0 + member.attendanceStats.attendanceRate shouldBe 0 + } + } }) diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt index 0fe42dcd..ad07f812 100644 --- a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt @@ -1,7 +1,6 @@ package com.weeth.domain.club.application.usecase.command import com.weeth.domain.club.application.dto.request.ClubUpdateRequest -import com.weeth.domain.club.application.mapper.ClubMapper import com.weeth.domain.club.domain.repository.ClubMemberRepository import com.weeth.domain.club.domain.repository.ClubRepository import com.weeth.domain.club.domain.service.ClubMemberPolicy @@ -19,8 +18,7 @@ class ManageClubUseCaseTest : val clubMemberRepository = mockk() val userReader = mockk() val clubMemberPolicy = mockk() - val clubMapper = mockk() - val useCase = ManageClubUseCase(clubRepository, clubMemberRepository, userReader, clubMemberPolicy, clubMapper) + val useCase = ManageClubUseCase(clubRepository, clubMemberRepository, userReader, clubMemberPolicy) val adminMember = com.weeth.domain.club.fixture.ClubMemberTestFixture .createAdminMember() diff --git a/src/test/kotlin/com/weeth/domain/club/domain/service/ClubMemberCardinalPolicyTest.kt b/src/test/kotlin/com/weeth/domain/club/domain/service/ClubMemberCardinalPolicyTest.kt new file mode 100644 index 00000000..c15a6b01 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/club/domain/service/ClubMemberCardinalPolicyTest.kt @@ -0,0 +1,180 @@ +package com.weeth.domain.club.domain.service + +import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException +import com.weeth.domain.cardinal.fixture.CardinalTestFixture +import com.weeth.domain.club.domain.entity.ClubMemberCardinal +import com.weeth.domain.club.domain.repository.ClubMemberCardinalReader +import com.weeth.domain.club.fixture.ClubMemberTestFixture +import com.weeth.domain.club.fixture.ClubTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk + +class ClubMemberCardinalPolicyTest : + DescribeSpec({ + val clubMemberCardinalReader = mockk() + val policy = ClubMemberCardinalPolicy(clubMemberCardinalReader) + + val club = ClubTestFixture.createClub() + val member = ClubMemberTestFixture.createActiveMember(club = club) + + beforeTest { + clearMocks(clubMemberCardinalReader) + } + + describe("getCurrentCardinal") { + context("기수가 존재하는 경우") { + it("최신 기수의 Cardinal을 반환해야 한다") { + val cardinal = + CardinalTestFixture.createCardinal( + club = club, + cardinalNumber = 5, + year = 2026, + semester = 1, + ) + val memberCardinal = ClubMemberCardinal.create(clubMember = member, cardinal = cardinal) + + every { clubMemberCardinalReader.findLatestCardinalByClubMember(member) } returns memberCardinal + + val result = policy.getCurrentCardinal(member) + + result shouldBe cardinal + } + } + + context("기수가 존재하지 않는 경우") { + it("CardinalNotFoundException을 발생시켜야 한다") { + every { clubMemberCardinalReader.findLatestCardinalByClubMember(member) } returns null + + shouldThrow { + policy.getCurrentCardinal(member) + } + } + } + } + + describe("notContains") { + val cardinal = + CardinalTestFixture.createCardinal( + id = 10L, + club = club, + cardinalNumber = 3, + year = 2025, + semester = 1, + ) + + context("멤버가 해당 기수에 속하지 않는 경우") { + it("true를 반환해야 한다") { + every { + clubMemberCardinalReader.existsByClubMemberAndCardinalId(member, cardinal.id) + } returns false + + policy.notContains(member, cardinal) shouldBe true + } + } + + context("멤버가 해당 기수에 이미 속한 경우") { + it("false를 반환해야 한다") { + every { + clubMemberCardinalReader.existsByClubMemberAndCardinalId(member, cardinal.id) + } returns true + + policy.notContains(member, cardinal) shouldBe false + } + } + } + + describe("isCurrent") { + context("기수 이력이 없는 경우") { + it("true를 반환해야 한다 (첫 기수 등록)") { + val cardinal = + CardinalTestFixture.createCardinal( + club = club, + cardinalNumber = 1, + year = 2024, + semester = 1, + ) + + every { clubMemberCardinalReader.findLatestCardinalByClubMember(member) } returns null + + policy.isLatestOrFirstCardinal(member, cardinal) shouldBe true + } + } + + context("전달된 기수가 최신 기수보다 높은 경우") { + it("true를 반환해야 한다") { + val latestCardinal = + CardinalTestFixture.createCardinal( + club = club, + cardinalNumber = 3, + year = 2025, + semester = 1, + ) + val newCardinal = + CardinalTestFixture.createCardinal( + club = club, + cardinalNumber = 4, + year = 2025, + semester = 2, + ) + val latestMemberCardinal = + ClubMemberCardinal.create(clubMember = member, cardinal = latestCardinal) + + every { + clubMemberCardinalReader.findLatestCardinalByClubMember(member) + } returns latestMemberCardinal + + policy.isLatestOrFirstCardinal(member, newCardinal) shouldBe true + } + } + + context("전달된 기수가 최신 기수와 같은 경우") { + it("false를 반환해야 한다") { + val cardinal = + CardinalTestFixture.createCardinal( + club = club, + cardinalNumber = 3, + year = 2025, + semester = 1, + ) + val memberCardinal = ClubMemberCardinal.create(clubMember = member, cardinal = cardinal) + + every { + clubMemberCardinalReader.findLatestCardinalByClubMember(member) + } returns memberCardinal + + policy.isLatestOrFirstCardinal(member, cardinal) shouldBe false + } + } + + context("전달된 기수가 최신 기수보다 낮은 경우") { + it("false를 반환해야 한다 (OB 기수 등록 시나리오)") { + val latestCardinal = + CardinalTestFixture.createCardinal( + club = club, + cardinalNumber = 5, + year = 2026, + semester = 1, + ) + val oldCardinal = + CardinalTestFixture.createCardinal( + club = club, + cardinalNumber = 2, + year = 2024, + semester = 2, + ) + val latestMemberCardinal = + ClubMemberCardinal.create(clubMember = member, cardinal = latestCardinal) + + every { + clubMemberCardinalReader.findLatestCardinalByClubMember(member) + } returns latestMemberCardinal + + policy.isLatestOrFirstCardinal(member, oldCardinal) shouldBe false + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AdminUserUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AdminUserUseCaseTest.kt deleted file mode 100644 index 541b0c1c..00000000 --- a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AdminUserUseCaseTest.kt +++ /dev/null @@ -1,204 +0,0 @@ -package com.weeth.domain.user.application.usecase.command - -import com.weeth.domain.attendance.domain.entity.Attendance -import com.weeth.domain.attendance.domain.repository.AttendanceRepository -import com.weeth.domain.cardinal.domain.repository.CardinalReader -import com.weeth.domain.cardinal.fixture.CardinalTestFixture -import com.weeth.domain.session.domain.repository.SessionReader -import com.weeth.domain.user.application.dto.request.UserApplyObRequest -import com.weeth.domain.user.application.dto.request.UserIdsRequest -import com.weeth.domain.user.application.dto.request.UserRoleUpdateRequest -import com.weeth.domain.user.domain.entity.UserCardinal -import com.weeth.domain.user.domain.enums.Role -import com.weeth.domain.user.domain.enums.Status -import com.weeth.domain.user.domain.repository.UserCardinalRepository -import com.weeth.domain.user.domain.repository.UserReader -import com.weeth.domain.user.domain.service.UserCardinalPolicy -import com.weeth.domain.user.fixture.SessionTestFixture -import com.weeth.domain.user.fixture.UserTestFixture -import io.kotest.core.spec.style.DescribeSpec -import io.kotest.matchers.shouldBe -import io.mockk.clearMocks -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify - -class AdminUserUseCaseTest : - DescribeSpec({ - val userReader = mockk() - val userCardinalPolicy = mockk() - val cardinalReader = mockk() - val sessionReader = mockk() - val attendanceRepository = mockk() - val userCardinalRepository = mockk() - - val useCase = - AdminUserUseCase( - userReader = userReader, - userCardinalPolicy = userCardinalPolicy, - cardinalReader = cardinalReader, - sessionReader = sessionReader, - attendanceRepository = attendanceRepository, - userCardinalRepository = userCardinalRepository, - ) - - beforeTest { - clearMocks( - userReader, - userCardinalPolicy, - cardinalReader, - sessionReader, - attendanceRepository, - userCardinalRepository, - ) - } - - describe("accept") { - it("비활성 유저 승인 시 출석 초기화를 수행한다") { - val user = UserTestFixture.createWaitingUser1(1L) - val currentCardinal = - CardinalTestFixture.createCardinal( - id = 1L, - cardinalNumber = 8, - year = 2025, - semester = 1, - ) - val sessions = listOf(SessionTestFixture.createSession(cardinalNumber = 8)) - - every { userReader.findAllByIds(listOf(1L)) } returns listOf(user) - every { userCardinalPolicy.getCurrentCardinal(user) } returns currentCardinal - every { sessionReader.findAllByCardinal(8) } returns sessions - every { attendanceRepository.saveAll(any>()) } answers { firstArg() } - - useCase.accept(UserIdsRequest(listOf(1L))) - - verify(exactly = 1) { attendanceRepository.saveAll(any>()) } - user.status shouldBe Status.ACTIVE - } - - it("이미 활성 상태인 유저는 승인 처리를 건너뛴다") { - val user = UserTestFixture.createActiveUser1(1L) - val currentCardinal = - CardinalTestFixture.createCardinal( - id = 1L, - cardinalNumber = 8, - year = 2025, - semester = 1, - ) - - every { userReader.findAllByIds(listOf(1L)) } returns listOf(user) - every { userCardinalPolicy.getCurrentCardinal(user) } returns currentCardinal - - useCase.accept(UserIdsRequest(listOf(1L))) - - user.status shouldBe Status.ACTIVE - verify(exactly = 0) { sessionReader.findAllByCardinal(any()) } - verify(exactly = 0) { attendanceRepository.saveAll(any>()) } - } - } - - describe("updateRole") { - it("권한 변경 시 엔티티 권한을 갱신한다") { - val user = UserTestFixture.createActiveUser1(1L) - every { userReader.getById(1L) } returns user - - useCase.updateRole(listOf(UserRoleUpdateRequest(1L, Role.ADMIN))) - - user.role shouldBe Role.ADMIN - } - } - - describe("ban") { - it("회원 추방 시 상태를 BANNED로 변경한다") { - val user = UserTestFixture.createActiveUser1(1L) - every { userReader.findAllByIds(listOf(1L)) } returns listOf(user) - - useCase.ban(UserIdsRequest(listOf(1L))) - - user.status shouldBe Status.BANNED - } - } - - describe("applyOb") { - it("중복 요청을 제거하고 새 기수에 등록한다") { - val user = UserTestFixture.createActiveUser1(1L) - val nextCardinal = - CardinalTestFixture.createCardinal( - id = 2L, - cardinalNumber = 4, - year = 2024, - semester = 2, - ) - val sessions = listOf(SessionTestFixture.createSession(cardinalNumber = 4)) - - val requests = - listOf( - UserApplyObRequest(userId = 1L, cardinal = 4), - UserApplyObRequest(userId = 1L, cardinal = 4), - ) - - every { userReader.findAllByIds(listOf(1L)) } returns listOf(user) - every { cardinalReader.getByCardinalNumber(4) } returns nextCardinal - every { userCardinalPolicy.notContains(user, nextCardinal) } returns true - every { userCardinalPolicy.isCurrent(user, nextCardinal) } returns true - every { sessionReader.findAllByCardinal(4) } returns sessions - every { attendanceRepository.saveAll(any>()) } answers { firstArg() } - every { userCardinalRepository.save(any()) } answers { firstArg() } - - useCase.applyOb(requests) - - // 중복 제거되어 1번만 실행 - verify(exactly = 1) { userCardinalRepository.save(any()) } - verify(exactly = 1) { attendanceRepository.saveAll(any>()) } - } - - it("새 기수이지만 현재 기수보다 이전이면 출석 초기화 없이 등록만 한다") { - val user = UserTestFixture.createActiveUser1(1L) - val nextCardinal = - CardinalTestFixture.createCardinal( - id = 2L, - cardinalNumber = 3, - year = 2023, - semester = 2, - ) - - every { userReader.findAllByIds(listOf(1L)) } returns listOf(user) - every { cardinalReader.getByCardinalNumber(3) } returns nextCardinal - every { userCardinalPolicy.notContains(user, nextCardinal) } returns true - every { userCardinalPolicy.isCurrent(user, nextCardinal) } returns false - every { userCardinalRepository.save(any()) } answers { firstArg() } - - useCase.applyOb(listOf(UserApplyObRequest(userId = 1L, cardinal = 3))) - - verify(exactly = 1) { userCardinalRepository.save(any()) } - verify(exactly = 0) { sessionReader.findAllByCardinal(any()) } - verify(exactly = 0) { attendanceRepository.saveAll(any>()) } - } - - it("이미 등록된 기수이면 건너뛴다") { - val user = UserTestFixture.createActiveUser1(1L) - val nextCardinal = - CardinalTestFixture.createCardinal( - id = 2L, - cardinalNumber = 4, - year = 2024, - semester = 2, - ) - - every { userReader.findAllByIds(listOf(1L)) } returns listOf(user) - every { cardinalReader.getByCardinalNumber(4) } returns nextCardinal - every { userCardinalPolicy.notContains(user, nextCardinal) } returns false - - useCase.applyOb(listOf(UserApplyObRequest(userId = 1L, cardinal = 4))) - - verify(exactly = 0) { userCardinalRepository.save(any()) } - } - - it("요청이 비어 있으면 아무 작업도 수행하지 않는다") { - useCase.applyOb(emptyList()) - - verify(exactly = 0) { userReader.findAllByIds(any()) } - verify(exactly = 0) { userCardinalRepository.save(any()) } - } - } - }) diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryServiceTest.kt deleted file mode 100644 index ebeb5c15..00000000 --- a/src/test/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryServiceTest.kt +++ /dev/null @@ -1,128 +0,0 @@ -package com.weeth.domain.user.application.usecase.query - -import com.weeth.domain.cardinal.domain.repository.CardinalReader -import com.weeth.domain.cardinal.fixture.CardinalTestFixture -import com.weeth.domain.user.application.dto.response.UserDetailsResponse -import com.weeth.domain.user.application.dto.response.UserProfileResponse -import com.weeth.domain.user.application.dto.response.UserSummaryResponse -import com.weeth.domain.user.application.mapper.UserMapper -import com.weeth.domain.user.domain.entity.UserCardinal -import com.weeth.domain.user.domain.repository.UserCardinalRepository -import com.weeth.domain.user.domain.repository.UserRepository -import com.weeth.domain.user.fixture.UserTestFixture -import io.kotest.core.spec.style.DescribeSpec -import io.kotest.matchers.shouldBe -import io.mockk.every -import io.mockk.mockk - -class GetUserQueryServiceTest : - DescribeSpec({ - val userRepository = mockk() - val cardinalReader = mockk() - val userCardinalRepository = mockk() - val mapper = mockk() - - val queryService = - GetUserQueryService( - userRepository, - cardinalReader, - userCardinalRepository, - mapper, - ) - - describe("existsByEmail") { - it("repository exists 결과를 반환한다") { - every { userRepository.existsByEmailValue("foo@bar.com") } returns true - - queryService.existsByEmail("foo@bar.com") shouldBe true - } - } - - describe("findUserDetails") { - it("user와 cardinal 목록을 조회해 UserDetailsResponse로 매핑한다") { - val user = UserTestFixture.createActiveUser1(1L) - val cardinal = - CardinalTestFixture.createCardinal( - id = 10L, - cardinalNumber = 6, - year = 2024, - semester = 2, - ) - val userCardinals = listOf(UserCardinal.create(user, cardinal)) - val response = - UserDetailsResponse( - 1, - user.name, - user.emailValue, - user.studentId, - user.department, - listOf(6), - user.role, - ) - - every { userRepository.getById(1L) } returns user - every { userCardinalRepository.findAllByUser(user) } returns userCardinals - every { mapper.toUserDetailsResponse(user, userCardinals) } returns response - - queryService.findUserDetails(1L) shouldBe response - } - } - - describe("findMyProfile") { - it("내 프로필을 UserProfileResponse로 매핑한다") { - val user = UserTestFixture.createActiveUser1(2L) - val cardinal = - CardinalTestFixture.createCardinal( - id = 11L, - cardinalNumber = 7, - year = 2025, - semester = 1, - ) - val userCardinals = listOf(UserCardinal.create(user, cardinal)) - val response = - UserProfileResponse( - 2, - user.name, - user.emailValue, - user.studentId, - user.telValue, - user.department, - listOf(7), - user.role, - ) - - every { userRepository.getById(2L) } returns user - every { userCardinalRepository.findAllByUser(user) } returns userCardinals - every { mapper.toUserProfileResponse(user, userCardinals) } returns response - - queryService.findMyProfile(2L) shouldBe response - } - } - - describe("findMyInfo") { - it("내 정보를 UserSummaryResponse로 매핑한다") { - val user = UserTestFixture.createActiveUser1(3L) - val cardinal = - CardinalTestFixture.createCardinal( - id = 12L, - cardinalNumber = 8, - year = 2025, - semester = 2, - ) - val userCardinals = listOf(UserCardinal.create(user, cardinal)) - val response = - UserSummaryResponse( - 3, - user.name, - listOf(8), - user.role, - ) - - every { userRepository.getById(3L) } returns user - every { userCardinalRepository.findAllByUser(user) } returns userCardinals - every { mapper.toUserSummaryResponse(user, userCardinals) } returns response - - queryService.findMyInfo(3L) shouldBe response - } - } - }) diff --git a/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt b/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt index 3a75b187..97b1e236 100644 --- a/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt @@ -23,17 +23,6 @@ class UserTest : user.status shouldBe Status.LEFT } - "attendance 카운터 및 출석률 계산" { - val user = User(name = "test", email = Email.from("test@test.com"), studentId = "20200001") - user.attend() - user.attend() - user.absent() - - user.attendanceCount shouldBe 2 - user.absenceCount shouldBe 1 - user.attendanceRate shouldBe (2 * 100 / 3) - } - "updateRole / hasRole" { val user = User(name = "test", email = Email.from("test@test.com"), studentId = "20200001") user.updateRole(Role.ADMIN) @@ -86,15 +75,6 @@ class UserTest : user.isProfileCompleted() shouldBe true } - "패널티 카운트 0일 때 감소해도 0 유지" { - val user = User(name = "test", email = Email.from("test@test.com")) - user.penaltyCount shouldBe 0 - - user.decrementPenaltyCount() - - user.penaltyCount shouldBe 0 - } - "isActive / isInactive 동작" { val user = User(name = "test", email = Email.from("test@test.com")) user.isActive() shouldBe false diff --git a/src/test/kotlin/com/weeth/domain/user/domain/repository/UserCardinalRepositoryTest.kt b/src/test/kotlin/com/weeth/domain/user/domain/repository/UserCardinalRepositoryTest.kt deleted file mode 100644 index ee37a7a2..00000000 --- a/src/test/kotlin/com/weeth/domain/user/domain/repository/UserCardinalRepositoryTest.kt +++ /dev/null @@ -1,113 +0,0 @@ -package com.weeth.domain.user.domain.repository - -import com.weeth.config.TestContainersConfig -import com.weeth.domain.cardinal.domain.repository.CardinalRepository -import com.weeth.domain.cardinal.fixture.CardinalTestFixture -import com.weeth.domain.club.domain.repository.ClubRepository -import com.weeth.domain.club.fixture.ClubTestFixture -import com.weeth.domain.user.domain.entity.UserCardinal -import com.weeth.domain.user.fixture.UserTestFixture -import io.kotest.core.spec.style.DescribeSpec -import io.kotest.matchers.collections.shouldHaveSize -import io.kotest.matchers.shouldBe -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest -import org.springframework.context.annotation.Import - -@DataJpaTest -@Import(TestContainersConfig::class) -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -class UserCardinalRepositoryTest( - private val userRepository: UserRepository, - private val cardinalRepository: CardinalRepository, - private val userCardinalRepository: UserCardinalRepository, - private val clubRepository: ClubRepository, -) : DescribeSpec({ - - describe("findAllByUserOrderByCardinalCardinalNumberDesc") { - it("유저별 기수가 내림차순으로 조회된다") { - val user = UserTestFixture.createActiveUser1() - userRepository.save(user) - val club = clubRepository.save(ClubTestFixture.createClub()) - - val cardinal1 = - cardinalRepository.save( - CardinalTestFixture.createCardinal(club = club, cardinalNumber = 5, year = 2023, semester = 1), - ) - val cardinal2 = - cardinalRepository.save( - CardinalTestFixture.createCardinal(club = club, cardinalNumber = 6, year = 2023, semester = 2), - ) - val cardinal3 = - cardinalRepository.save( - CardinalTestFixture.createCardinal(club = club, cardinalNumber = 7, year = 2024, semester = 1), - ) - - userCardinalRepository.saveAll( - listOf( - UserCardinal.create(user, cardinal1), - UserCardinal.create(user, cardinal2), - UserCardinal.create(user, cardinal3), - ), - ) - - val result = userCardinalRepository.findAllByUserOrderByCardinalCardinalNumberDesc(user) - - result shouldHaveSize 3 - result[0].cardinal.cardinalNumber shouldBe 7 - result[1].cardinal.cardinalNumber shouldBe 6 - result[2].cardinal.cardinalNumber shouldBe 5 - } - } - - describe("findAllByUsers") { - it("여러 유저의 기수를 유저별 내림차순으로 조회한다") { - val user1 = UserTestFixture.createActiveUser1() - val user2 = UserTestFixture.createActiveUser2() - userRepository.save(user1) - userRepository.save(user2) - val club = clubRepository.save(ClubTestFixture.createClub()) - - val c1 = - cardinalRepository.save( - CardinalTestFixture.createCardinal(club = club, cardinalNumber = 5, year = 2023, semester = 1), - ) - val c2 = - cardinalRepository.save( - CardinalTestFixture.createCardinal(club = club, cardinalNumber = 6, year = 2023, semester = 2), - ) - val c3 = - cardinalRepository.save( - CardinalTestFixture.createCardinal(club = club, cardinalNumber = 7, year = 2024, semester = 1), - ) - val c4 = - cardinalRepository.save( - CardinalTestFixture.createCardinal(club = club, cardinalNumber = 8, year = 2024, semester = 2), - ) - - userCardinalRepository.saveAll( - listOf( - UserCardinal.create(user1, c3), - UserCardinal.create(user1, c2), - ), - ) - userCardinalRepository.saveAll( - listOf( - UserCardinal.create(user2, c4), - UserCardinal.create(user2, c1), - ), - ) - - val result = userCardinalRepository.findAllByUsers(listOf(user1, user2)) - - result shouldHaveSize 4 - result[0].user.id shouldBe user1.id - result[0].cardinal.cardinalNumber shouldBe 7 - result[1].cardinal.cardinalNumber shouldBe 6 - - result[2].user.id shouldBe user2.id - result[2].cardinal.cardinalNumber shouldBe 8 - result[3].cardinal.cardinalNumber shouldBe 5 - } - } - }) diff --git a/src/test/kotlin/com/weeth/domain/user/domain/repository/UserRepositoryTest.kt b/src/test/kotlin/com/weeth/domain/user/domain/repository/UserRepositoryTest.kt deleted file mode 100644 index c407d2da..00000000 --- a/src/test/kotlin/com/weeth/domain/user/domain/repository/UserRepositoryTest.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.weeth.domain.user.domain.repository - -import com.weeth.config.TestContainersConfig -import com.weeth.domain.cardinal.domain.repository.CardinalRepository -import com.weeth.domain.cardinal.fixture.CardinalTestFixture -import com.weeth.domain.club.domain.repository.ClubRepository -import com.weeth.domain.club.fixture.ClubTestFixture -import com.weeth.domain.user.domain.enums.Status -import com.weeth.domain.user.fixture.UserCardinalTestFixture -import com.weeth.domain.user.fixture.UserTestFixture -import io.kotest.core.spec.style.DescribeSpec -import io.kotest.matchers.collections.shouldContainExactly -import io.kotest.matchers.collections.shouldHaveSize -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest -import org.springframework.context.annotation.Import -import org.springframework.data.domain.PageRequest - -@DataJpaTest -@Import(TestContainersConfig::class) -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -class UserRepositoryTest( - private val userRepository: UserRepository, - private val userCardinalRepository: UserCardinalRepository, - private val cardinalRepository: CardinalRepository, - private val clubRepository: ClubRepository, -) : DescribeSpec({ - - lateinit var cardinal7: com.weeth.domain.cardinal.domain.entity.Cardinal - lateinit var cardinal8: com.weeth.domain.cardinal.domain.entity.Cardinal - - beforeEach { - val club = clubRepository.save(ClubTestFixture.createClub()) - cardinal7 = - cardinalRepository.save( - CardinalTestFixture.createCardinal(club = club, cardinalNumber = 7, year = 2026, semester = 1), - ) - cardinal8 = - cardinalRepository.save( - CardinalTestFixture.createCardinal(club = club, cardinalNumber = 8, year = 2026, semester = 2), - ) - - val user1 = userRepository.save(UserTestFixture.createActiveUser1()) - val user2 = userRepository.save(UserTestFixture.createActiveUser2()) - val user3 = userRepository.save(UserTestFixture.createWaitingUser1()) - - user1.accept() - user2.accept() - userCardinalRepository.flush() - - userCardinalRepository.save(UserCardinalTestFixture.linkUserCardinal(user1, cardinal7)) - userCardinalRepository.save(UserCardinalTestFixture.linkUserCardinal(user2, cardinal8)) - userCardinalRepository.save(UserCardinalTestFixture.linkUserCardinal(user3, cardinal7)) - } - - describe("findAllByCardinalAndStatus") { - it("특정 기수 + 상태에 맞는 유저만 조회된다") { - val result = userRepository.findAllByCardinalAndStatus(cardinal7, Status.ACTIVE) - - result shouldHaveSize 1 - result.map { it.name } shouldContainExactly listOf("적순") - } - } - - describe("findAllByStatusOrderedByCardinalAndName") { - it("상태별로 최신 기수순 + 이름 오름차순으로 정렬된다") { - val pageable = PageRequest.of(0, 10) - - val resultSlice = userRepository.findAllByStatusOrderedByCardinalAndName(Status.ACTIVE, pageable) - val result = resultSlice.content - - result shouldHaveSize 2 - result.map { it.name } shouldContainExactly listOf("적순2", "적순") - } - } - - describe("findAllByCardinalOrderByNameAsc") { - it("Active인 유저들 중 특정 기수 + 이름 오름차순으로 정렬한다") { - val pageable = PageRequest.of(0, 10) - - val resultSlice = userRepository.findAllByCardinalOrderByNameAsc(Status.ACTIVE, cardinal7, pageable) - val result = resultSlice.content - - result shouldHaveSize 1 - result.map { it.name } shouldContainExactly listOf("적순") - } - } - }) diff --git a/src/test/kotlin/com/weeth/domain/user/domain/service/UserCardinalPolicyTest.kt b/src/test/kotlin/com/weeth/domain/user/domain/service/UserCardinalPolicyTest.kt deleted file mode 100644 index 833f3f0d..00000000 --- a/src/test/kotlin/com/weeth/domain/user/domain/service/UserCardinalPolicyTest.kt +++ /dev/null @@ -1,76 +0,0 @@ -package com.weeth.domain.user.domain.service - -import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException -import com.weeth.domain.cardinal.fixture.CardinalTestFixture -import com.weeth.domain.user.domain.repository.UserCardinalReader -import com.weeth.domain.user.fixture.UserCardinalTestFixture -import com.weeth.domain.user.fixture.UserTestFixture -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.core.spec.style.DescribeSpec -import io.kotest.matchers.booleans.shouldBeFalse -import io.kotest.matchers.booleans.shouldBeTrue -import io.kotest.matchers.shouldBe -import io.mockk.every -import io.mockk.mockk - -class UserCardinalPolicyTest : - DescribeSpec({ - val userCardinalReader = mockk() - val policy = UserCardinalPolicy(userCardinalReader) - - describe("getCurrentCardinal") { - it("가장 큰 기수 번호를 반환한다") { - val user = UserTestFixture.createActiveUser1(1L) - val cardinal5 = - CardinalTestFixture.createCardinal( - id = 2L, - cardinalNumber = 5, - year = 2025, - semester = 1, - ) - - every { userCardinalReader.findTopByUserOrderByCardinalNumberDesc(user) } returns - UserCardinalTestFixture.linkUserCardinal(user, cardinal5) - - policy.getCurrentCardinal(user).cardinalNumber shouldBe 5 - } - - it("기수 이력이 없으면 예외를 던진다") { - val user = UserTestFixture.createActiveUser1(1L) - every { userCardinalReader.findTopByUserOrderByCardinalNumberDesc(user) } returns null - - shouldThrow { - policy.getCurrentCardinal(user) - } - } - } - - describe("notContains") { - it("이미 포함된 기수면 false를 반환한다") { - val user = UserTestFixture.createActiveUser1(1L) - val cardinal = - CardinalTestFixture.createCardinal( - id = 2L, - cardinalNumber = 5, - year = 2025, - semester = 1, - ) - every { userCardinalReader.findAllByUser(user) } returns - listOf(UserCardinalTestFixture.linkUserCardinal(user, cardinal)) - - policy.notContains(user, cardinal).shouldBeFalse() - } - } - - describe("isCurrent") { - it("신규 기수가 현재 기수보다 크면 true를 반환한다") { - val user = UserTestFixture.createActiveUser1(1L) - val current = CardinalTestFixture.createCardinal(id = 1L, cardinalNumber = 4, year = 2024, semester = 2) - val next = CardinalTestFixture.createCardinal(id = 2L, cardinalNumber = 5, year = 2025, semester = 1) - every { userCardinalReader.findTopByUserOrderByCardinalNumberDesc(user) } returns - UserCardinalTestFixture.linkUserCardinal(user, current) - - policy.isCurrent(user, next).shouldBeTrue() - } - } - }) diff --git a/src/test/kotlin/com/weeth/domain/user/fixture/UserCardinalTestFixture.kt b/src/test/kotlin/com/weeth/domain/user/fixture/UserCardinalTestFixture.kt deleted file mode 100644 index 11b01744..00000000 --- a/src/test/kotlin/com/weeth/domain/user/fixture/UserCardinalTestFixture.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.weeth.domain.user.fixture - -import com.weeth.domain.cardinal.domain.entity.Cardinal -import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.entity.UserCardinal - -object UserCardinalTestFixture { - fun linkUserCardinal( - user: User, - cardinal: Cardinal, - ): UserCardinal = UserCardinal.create(user, cardinal) -} From 76eee80ec32488c96e0f6a29b4fcc54657ae2411 Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:48:43 +0900 Subject: [PATCH 24/73] =?UTF-8?q?[WTH-197]=20=EB=8F=99=EC=95=84=EB=A6=AC?= =?UTF-8?q?=20=EC=A0=95=EB=B3=B4=20=EA=B3=B5=EA=B0=9C=20api=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 동아리 정보 공개 API 구현 * refactor: @PathVariable 어노테이션 제거 * refactor: dto명 수정 --- .../presentation/AccountAdminController.kt | 3 +-- .../account/presentation/AccountController.kt | 2 +- .../presentation/ReceiptAdminController.kt | 6 +++--- .../presentation/AttendanceAdminController.kt | 8 ++++---- .../presentation/AttendanceController.kt | 7 +++---- .../presentation/BoardAdminController.kt | 10 +++++----- .../board/presentation/BoardController.kt | 3 +-- .../board/presentation/PostController.kt | 14 ++++++------- .../presentation/CardinalAdminController.kt | 5 ++--- .../presentation/CardinalController.kt | 3 +-- ...{ClubResponse.kt => ClubPublicResponse.kt} | 6 +----- .../club/application/mapper/ClubMapper.kt | 6 ++---- .../usecase/query/GetClubQueryService.kt | 4 ++-- .../club/presentation/ClubAdminController.kt | 20 +++++++++---------- .../club/presentation/ClubController.kt | 18 +++++++---------- .../presentation/DashboardController.kt | 11 +++++----- .../presentation/PenaltyAdminController.kt | 9 ++++----- .../presentation/PenaltyUserController.kt | 3 +-- .../presentation/EventAdminController.kt | 6 +++--- .../schedule/presentation/EventController.kt | 2 +- .../presentation/ScheduleController.kt | 5 ++--- .../presentation/SessionAdminController.kt | 8 ++++---- .../session/presentation/SessionController.kt | 2 +- .../com/weeth/global/config/SecurityConfig.kt | 3 +++ 24 files changed, 74 insertions(+), 90 deletions(-) rename src/main/kotlin/com/weeth/domain/club/application/dto/response/{ClubResponse.kt => ClubPublicResponse.kt} (71%) diff --git a/src/main/kotlin/com/weeth/domain/account/presentation/AccountAdminController.kt b/src/main/kotlin/com/weeth/domain/account/presentation/AccountAdminController.kt index eccb9b5d..ffbbc65b 100644 --- a/src/main/kotlin/com/weeth/domain/account/presentation/AccountAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/account/presentation/AccountAdminController.kt @@ -13,7 +13,6 @@ import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.Valid -import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping @@ -29,7 +28,7 @@ class AccountAdminController( @PostMapping @Operation(summary = "회비 총 금액 기입") fun save( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @RequestBody @Valid dto: AccountSaveRequest, @Parameter(hidden = true) @CurrentUser userId: Long, diff --git a/src/main/kotlin/com/weeth/domain/account/presentation/AccountController.kt b/src/main/kotlin/com/weeth/domain/account/presentation/AccountController.kt index 2c1ccb36..6bc7e7a3 100644 --- a/src/main/kotlin/com/weeth/domain/account/presentation/AccountController.kt +++ b/src/main/kotlin/com/weeth/domain/account/presentation/AccountController.kt @@ -27,7 +27,7 @@ class AccountController( @GetMapping("/{cardinal}") @Operation(summary = "회비 내역 조회") fun find( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, @PathVariable cardinal: Int, diff --git a/src/main/kotlin/com/weeth/domain/account/presentation/ReceiptAdminController.kt b/src/main/kotlin/com/weeth/domain/account/presentation/ReceiptAdminController.kt index 355aa9d8..9de17985 100644 --- a/src/main/kotlin/com/weeth/domain/account/presentation/ReceiptAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/account/presentation/ReceiptAdminController.kt @@ -34,7 +34,7 @@ class ReceiptAdminController( @PostMapping @Operation(summary = "회비 사용 내역 기입") fun save( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, @RequestBody @Valid dto: ReceiptSaveRequest, @@ -46,7 +46,7 @@ class ReceiptAdminController( @DeleteMapping("/{receiptId}") @Operation(summary = "회비 사용 내역 취소") fun delete( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, @PathVariable receiptId: Long, @@ -58,7 +58,7 @@ class ReceiptAdminController( @PatchMapping("/{receiptId}") @Operation(summary = "회비 사용 내역 수정") fun update( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, @PathVariable receiptId: Long, diff --git a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceAdminController.kt b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceAdminController.kt index f567b2ae..da33d194 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceAdminController.kt @@ -39,7 +39,7 @@ class AttendanceAdminController( @PatchMapping("/close") @Operation(summary = "출석 마감") fun close( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, @RequestParam now: LocalDate, @@ -52,7 +52,7 @@ class AttendanceAdminController( @GetMapping("/{sessionId}") @Operation(summary = "모든 인원 정기모임 출석 정보 조회") fun getAllAttendance( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, @PathVariable sessionId: Long, @@ -65,7 +65,7 @@ class AttendanceAdminController( @PatchMapping("/status") @Operation(summary = "모든 인원 정기모임 개별 출석 상태 수정") fun updateAttendanceStatus( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, @RequestBody @Valid attendanceUpdates: List, @@ -77,7 +77,7 @@ class AttendanceAdminController( @PostMapping("/{sessionId}/qr") @Operation(summary = "QR 코드 생성") fun generateQr( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @PathVariable sessionId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, diff --git a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt index e623b669..912e045c 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt @@ -15,7 +15,6 @@ import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping @@ -32,7 +31,7 @@ class AttendanceController( @PostMapping("/check-in") @Operation(summary = "출석체크") fun checkIn( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, @RequestBody checkIn: CheckInRequest, @@ -44,7 +43,7 @@ class AttendanceController( @GetMapping @Operation(summary = "출석 메인페이지") fun find( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse = @@ -56,7 +55,7 @@ class AttendanceController( @GetMapping("/detail") @Operation(summary = "출석 내역 상세조회") fun findAll( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse = diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt b/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt index a389bb87..240317df 100644 --- a/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt @@ -37,7 +37,7 @@ class BoardAdminController( @GetMapping @Operation(summary = "게시판 전체 목록 조회 (삭제/비공개 포함)") fun findAllBoards( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse> = @@ -49,7 +49,7 @@ class BoardAdminController( @GetMapping("/{boardId}") @Operation(summary = "게시판 상세 조회 (삭제된 게시판 포함)") fun findBoard( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @PathVariable boardId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, @@ -62,7 +62,7 @@ class BoardAdminController( @PostMapping @Operation(summary = "게시판 생성") fun createBoard( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @RequestBody @Valid request: CreateBoardRequest, @Parameter(hidden = true) @CurrentUser userId: Long, @@ -75,7 +75,7 @@ class BoardAdminController( @PatchMapping("/{boardId}") @Operation(summary = "게시판 설정/이름 수정") fun updateBoard( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @PathVariable boardId: Long, @RequestBody @Valid request: UpdateBoardRequest, @@ -89,7 +89,7 @@ class BoardAdminController( @DeleteMapping("/{boardId}") @Operation(summary = "게시판 삭제") fun deleteBoard( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @PathVariable boardId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt b/src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt index d95c0bf0..7f39decc 100644 --- a/src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt +++ b/src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt @@ -14,7 +14,6 @@ import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @@ -28,7 +27,7 @@ class BoardController( @GetMapping @Operation(summary = "게시판 목록 조회") fun findBoards( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, @Parameter(hidden = true) @CurrentUserRole role: Role, // TODO: 멀티 테넨시 지원으로 Jwt에 포함한 Role은 삭제 예정 diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt b/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt index a7e8b048..44ea9ced 100644 --- a/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt +++ b/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt @@ -44,7 +44,7 @@ class PostController( @PostMapping("/{boardId}/posts") @Operation(summary = "게시글 작성") fun save( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @PathVariable boardId: Long, @RequestBody @Valid request: CreatePostRequest, @@ -58,7 +58,7 @@ class PostController( @GetMapping("/{boardId}/posts") @Operation(summary = "게시글 목록 조회") fun findPosts( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @PathVariable boardId: Long, @RequestParam(defaultValue = "0") pageNumber: Int, @@ -74,7 +74,7 @@ class PostController( @GetMapping("/posts/{postId}") @Operation(summary = "게시글 상세 조회") fun findPost( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @PathVariable postId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, @@ -88,7 +88,7 @@ class PostController( @PatchMapping("/posts/{postId}") @Operation(summary = "게시글 수정") fun update( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @PathVariable postId: Long, @RequestBody @Valid request: UpdatePostRequest, @@ -102,7 +102,7 @@ class PostController( @DeleteMapping("/posts/{postId}") @Operation(summary = "게시글 삭제") fun delete( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @PathVariable postId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, @@ -114,7 +114,7 @@ class PostController( @GetMapping("/{boardId}/posts/search") @Operation(summary = "게시글 검색") fun searchPosts( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @PathVariable boardId: Long, @RequestParam keyword: String, @@ -131,7 +131,7 @@ class PostController( @PostMapping("/{boardId}/notices/read-all") @Operation(summary = "공지 읽음 처리", description = "공지 게시판 진입 시 마지막 읽음 시간을 현재 시각으로 갱신합니다.") fun markAllNoticesRead( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @PathVariable boardId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, diff --git a/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalAdminController.kt b/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalAdminController.kt index 866d5b0c..78c1607a 100644 --- a/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalAdminController.kt @@ -15,7 +15,6 @@ import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.Valid import org.springframework.web.bind.annotation.PatchMapping -import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping @@ -31,7 +30,7 @@ class CardinalAdminController( @PatchMapping @Operation(summary = "기수 정보 수정 API") fun update( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @RequestBody @Valid request: CardinalUpdateRequest, @Parameter(hidden = true) @CurrentUser userId: Long, @@ -43,7 +42,7 @@ class CardinalAdminController( @PostMapping @Operation(summary = "새로운 기수 정보 저장 API") fun save( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @RequestBody @Valid request: CardinalSaveRequest, @Parameter(hidden = true) @CurrentUser userId: Long, diff --git a/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalController.kt b/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalController.kt index f579d4fb..4e594e6e 100644 --- a/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalController.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalController.kt @@ -13,7 +13,6 @@ import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @@ -27,7 +26,7 @@ class CardinalController( @GetMapping @Operation(summary = "현재 저장된 기수 목록 조회 API") fun findAllCardinals( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse> = diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubResponse.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubPublicResponse.kt similarity index 71% rename from src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubResponse.kt rename to src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubPublicResponse.kt index 2287cef8..c7ffbe53 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubResponse.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubPublicResponse.kt @@ -2,17 +2,13 @@ package com.weeth.domain.club.application.dto.response import io.swagger.v3.oas.annotations.media.Schema -data class ClubResponse( +data class ClubPublicResponse( @field:Schema(description = "동아리 ID (Base62 인코딩)", example = "1A2b3C") val id: String, @field:Schema(description = "동아리 이름", example = "Leets") val name: String, - @field:Schema(description = "학교 이름", example = "가천대학교") - val schoolName: String, @field:Schema(description = "동아리 소개", example = "함께 배우고 성장하는 개발자 커뮤니티") val description: String?, @field:Schema(description = "프로필 사진 URL") val profileImageUrl: String?, - @field:Schema(description = "배경 사진 URL") - val backgroundImageUrl: String?, ) diff --git a/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt b/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt index 9a6f9737..f74e6bff 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt @@ -4,7 +4,7 @@ import com.weeth.domain.club.application.dto.response.ClubDetailResponse import com.weeth.domain.club.application.dto.response.ClubInfoResponse import com.weeth.domain.club.application.dto.response.ClubMemberProfileResponse import com.weeth.domain.club.application.dto.response.ClubMemberResponse -import com.weeth.domain.club.application.dto.response.ClubResponse +import com.weeth.domain.club.application.dto.response.ClubPublicResponse import com.weeth.domain.club.domain.entity.Club import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.club.domain.entity.ClubMemberCardinal @@ -26,13 +26,11 @@ class ClubMapper { ) fun toResponse(club: Club) = - ClubResponse( + ClubPublicResponse( id = TsidBase62Encoder.encode(club.id), name = club.name, - schoolName = club.schoolName, description = club.description, profileImageUrl = club.profileImageUrl, - backgroundImageUrl = club.backgroundImageUrl, ) fun toDetailResponse(club: Club) = diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubQueryService.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubQueryService.kt index 119b6620..2051d44f 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubQueryService.kt @@ -2,7 +2,7 @@ package com.weeth.domain.club.application.usecase.query import com.weeth.domain.club.application.dto.response.ClubDetailResponse import com.weeth.domain.club.application.dto.response.ClubInfoResponse -import com.weeth.domain.club.application.dto.response.ClubResponse +import com.weeth.domain.club.application.dto.response.ClubPublicResponse import com.weeth.domain.club.application.mapper.ClubMapper import com.weeth.domain.club.domain.repository.ClubMemberReader import com.weeth.domain.club.domain.repository.ClubReader @@ -27,7 +27,7 @@ class GetClubQueryService( } } - fun findClub(clubId: Long): ClubResponse { + fun findClub(clubId: Long): ClubPublicResponse { val club = clubReader.getClubById(clubId) return clubMapper.toResponse(club) diff --git a/src/main/kotlin/com/weeth/domain/club/presentation/ClubAdminController.kt b/src/main/kotlin/com/weeth/domain/club/presentation/ClubAdminController.kt index 63567718..ff37eb3e 100644 --- a/src/main/kotlin/com/weeth/domain/club/presentation/ClubAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/club/presentation/ClubAdminController.kt @@ -42,7 +42,7 @@ class ClubAdminController( @Operation(summary = "동아리 상세 정보 조회") fun getClubDetail( @Parameter(hidden = true) @CurrentUser userId: Long, - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, ): CommonResponse { val detail = getClubQueryService.findClubDetailForAdmin(clubId, userId) @@ -53,7 +53,7 @@ class ClubAdminController( @Operation(summary = "동아리 정보 수정") fun update( @Parameter(hidden = true) @CurrentUser userId: Long, - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @Valid @RequestBody request: ClubUpdateRequest, ): CommonResponse { @@ -65,7 +65,7 @@ class ClubAdminController( @Operation(summary = "동아리 프로필 사진 삭제") fun deleteProfileImage( @Parameter(hidden = true) @CurrentUser userId: Long, - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, ): CommonResponse { manageClubUseCase.deleteProfileImage(clubId, userId) @@ -76,7 +76,7 @@ class ClubAdminController( @Operation(summary = "동아리 배경 사진 삭제") fun deleteBackgroundImage( @Parameter(hidden = true) @CurrentUser userId: Long, - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, ): CommonResponse { manageClubUseCase.deleteBackgroundImage(clubId, userId) @@ -87,7 +87,7 @@ class ClubAdminController( @Operation(summary = "초대 코드 재생성 (MVP 미사용)", deprecated = true) fun regenerateCode( @Parameter(hidden = true) @CurrentUser userId: Long, - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, ): CommonResponse { manageClubUseCase.regenerateCode(clubId, userId) @@ -98,7 +98,7 @@ class ClubAdminController( @Operation(summary = "동아리 멤버 목록 조회") fun getClubMembers( @Parameter(hidden = true) @CurrentUser userId: Long, - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, ): CommonResponse> { val members = getClubMemberQueryService.findClubMembersForAdmin(clubId, userId) @@ -109,7 +109,7 @@ class ClubAdminController( @Operation(summary = "멤버 승인") fun acceptMember( @Parameter(hidden = true) @CurrentUser userId: Long, - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @PathVariable clubMemberId: Long, ): CommonResponse { @@ -121,7 +121,7 @@ class ClubAdminController( @Operation(summary = "멤버 추방") fun banMember( @Parameter(hidden = true) @CurrentUser userId: Long, - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @PathVariable clubMemberId: Long, ): CommonResponse { @@ -133,7 +133,7 @@ class ClubAdminController( @Operation(summary = "멤버 권한 변경") fun updateMemberRole( @Parameter(hidden = true) @CurrentUser userId: Long, - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @PathVariable clubMemberId: Long, @Valid @RequestBody request: ClubMemberRoleUpdateRequest, @@ -146,7 +146,7 @@ class ClubAdminController( @Operation(summary = "멤버 OB 기수 등록") fun applyOb( @Parameter(hidden = true) @CurrentUser userId: Long, - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @Valid @RequestBody requests: List, ): CommonResponse { diff --git a/src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt b/src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt index 5270f2e8..c7830ac2 100644 --- a/src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt +++ b/src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt @@ -4,8 +4,7 @@ import com.weeth.domain.club.application.dto.request.ClubCreateRequest import com.weeth.domain.club.application.dto.request.ClubJoinRequest import com.weeth.domain.club.application.dto.response.ClubInfoResponse import com.weeth.domain.club.application.dto.response.ClubMemberProfileResponse -import com.weeth.domain.club.application.dto.response.ClubMemberResponse -import com.weeth.domain.club.application.dto.response.ClubResponse +import com.weeth.domain.club.application.dto.response.ClubPublicResponse import com.weeth.domain.club.application.exception.ClubErrorCode import com.weeth.domain.club.application.usecase.command.ManageClubMemberUsecase import com.weeth.domain.club.application.usecase.command.ManageClubUseCase @@ -23,11 +22,9 @@ import jakarta.validation.Valid import org.springframework.http.HttpStatus import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController @@ -64,12 +61,11 @@ class ClubController( } @GetMapping("/{clubId}") - @Operation(summary = "동아리 정보 조회 (이름, 소개, 이미지)") + @Operation(summary = "동아리 공개 정보 조회 (이름, 소개, 프로필 사진) - 인증 불필요") fun getClubPublicInfo( - @Parameter(hidden = true) @CurrentUser userId: Long, - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, - ): CommonResponse { + ): CommonResponse { val info = getClubQueryService.findClub(clubId) return CommonResponse.success(ClubResponseCode.CLUB_FIND_SUCCESS, info) @@ -79,7 +75,7 @@ class ClubController( @Operation(summary = "동아리 가입") fun join( @Parameter(hidden = true) @CurrentUser userId: Long, - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @Valid @RequestBody request: ClubJoinRequest, ): CommonResponse { @@ -92,7 +88,7 @@ class ClubController( @Operation(summary = "동아리 탈퇴") fun leave( @Parameter(hidden = true) @CurrentUser userId: Long, - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, ): CommonResponse { manageClubMemberUsecase.leave(clubId, userId) @@ -104,7 +100,7 @@ class ClubController( @Operation(summary = "내 멤버 정보 조회") fun getMyMemberInfo( @Parameter(hidden = true) @CurrentUser userId: Long, - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, ): CommonResponse { val meInfo = getClubMemberQueryService.findMyMemberProfile(clubId, userId) diff --git a/src/main/kotlin/com/weeth/domain/dashboard/presentation/DashboardController.kt b/src/main/kotlin/com/weeth/domain/dashboard/presentation/DashboardController.kt index 09e2dad6..0d2ba989 100644 --- a/src/main/kotlin/com/weeth/domain/dashboard/presentation/DashboardController.kt +++ b/src/main/kotlin/com/weeth/domain/dashboard/presentation/DashboardController.kt @@ -19,7 +19,6 @@ import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.data.domain.Slice import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController @@ -34,7 +33,7 @@ class DashboardController( @GetMapping("/home") @Operation(summary = "홈 조회") fun getHome( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable("clubId") clubId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse = @@ -46,7 +45,7 @@ class DashboardController( @GetMapping("/recent-posts") @Operation(summary = "최신 게시글 조회") fun getRecentPosts( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable("clubId") clubId: Long, @RequestParam(defaultValue = "0") pageNumber: Int, @RequestParam(defaultValue = "10") pageSize: Int, @@ -60,7 +59,7 @@ class DashboardController( @GetMapping("/recent-notices") @Operation(summary = "최신 공지 조회") fun getRecentNotices( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable("clubId") clubId: Long, @RequestParam(defaultValue = "5") size: Int, @Parameter(hidden = true) @CurrentUser userId: Long, @@ -73,7 +72,7 @@ class DashboardController( @GetMapping("/monthly-schedules") @Operation(summary = "월간 일정 조회") fun getMonthlySchedules( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable("clubId") clubId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse> = @@ -85,7 +84,7 @@ class DashboardController( @GetMapping("/unread-notice") @Operation(summary = "2주 이내 읽지 않은 공지 조회") fun getUnreadNotice( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable("clubId") clubId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse = diff --git a/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyAdminController.kt b/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyAdminController.kt index d1c44c99..a119f5d7 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyAdminController.kt @@ -20,7 +20,6 @@ import jakarta.validation.Valid import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PatchMapping -import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping @@ -40,7 +39,7 @@ class PenaltyAdminController( @PostMapping @Operation(summary = "패널티 부여") fun assignPenalty( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, @Valid @RequestBody request: SavePenaltyRequest, @@ -52,7 +51,7 @@ class PenaltyAdminController( @PatchMapping @Operation(summary = "패널티 수정") fun update( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, @Valid @RequestBody request: UpdatePenaltyRequest, @@ -64,7 +63,7 @@ class PenaltyAdminController( @GetMapping @Operation(summary = "전체 패널티 조회") fun findAll( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, @RequestParam(required = false) cardinal: Int?, @@ -77,7 +76,7 @@ class PenaltyAdminController( @DeleteMapping @Operation(summary = "패널티 삭제") fun delete( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, @RequestParam penaltyId: Long, diff --git a/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyUserController.kt b/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyUserController.kt index 00cb4daa..7f2baa53 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyUserController.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyUserController.kt @@ -12,7 +12,6 @@ import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @@ -26,7 +25,7 @@ class PenaltyUserController( @GetMapping @Operation(summary = "본인 패널티 조회") fun findAllPenalties( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse = diff --git a/src/main/kotlin/com/weeth/domain/schedule/presentation/EventAdminController.kt b/src/main/kotlin/com/weeth/domain/schedule/presentation/EventAdminController.kt index 51e81678..32e9def8 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/presentation/EventAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/presentation/EventAdminController.kt @@ -31,7 +31,7 @@ class EventAdminController( @PostMapping @Operation(summary = "일정 생성") fun create( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @Valid @RequestBody dto: ScheduleSaveRequest, @Parameter(hidden = true) @CurrentUser userId: Long, @@ -43,7 +43,7 @@ class EventAdminController( @PatchMapping("/{eventId}") @Operation(summary = "일정 수정") fun update( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @PathVariable eventId: Long, @Valid @RequestBody dto: ScheduleUpdateRequest, @@ -56,7 +56,7 @@ class EventAdminController( @DeleteMapping("/{eventId}") @Operation(summary = "일정 삭제") fun delete( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @PathVariable eventId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, diff --git a/src/main/kotlin/com/weeth/domain/schedule/presentation/EventController.kt b/src/main/kotlin/com/weeth/domain/schedule/presentation/EventController.kt index 7f660cab..343e4ee7 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/presentation/EventController.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/presentation/EventController.kt @@ -26,7 +26,7 @@ class EventController( @GetMapping("/{eventId}") @Operation(summary = "일정 상세 조회") fun getEvent( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @PathVariable eventId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, diff --git a/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt b/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt index 05f34247..82ea37e7 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt @@ -11,7 +11,6 @@ import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.format.annotation.DateTimeFormat import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController @@ -26,7 +25,7 @@ class ScheduleController( @GetMapping("/monthly") @Operation(summary = "월별 일정 조회") fun findByMonthly( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) start: LocalDateTime, @@ -40,7 +39,7 @@ class ScheduleController( @GetMapping("/yearly") @Operation(summary = "연도별 일정 조회") fun findByYearly( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, @RequestParam year: Int, diff --git a/src/main/kotlin/com/weeth/domain/session/presentation/SessionAdminController.kt b/src/main/kotlin/com/weeth/domain/session/presentation/SessionAdminController.kt index 68c1b661..60f378c3 100644 --- a/src/main/kotlin/com/weeth/domain/session/presentation/SessionAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/session/presentation/SessionAdminController.kt @@ -36,7 +36,7 @@ class SessionAdminController( @PostMapping @Operation(summary = "정기모임 생성") fun create( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @Valid @RequestBody dto: ScheduleSaveRequest, @Parameter(hidden = true) @CurrentUser userId: Long, @@ -48,7 +48,7 @@ class SessionAdminController( @PatchMapping("/{sessionId}") @Operation(summary = "정기모임 수정") fun update( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @PathVariable sessionId: Long, @Valid @RequestBody dto: ScheduleUpdateRequest, @@ -61,7 +61,7 @@ class SessionAdminController( @DeleteMapping("/{sessionId}") @Operation(summary = "정기모임 삭제") fun delete( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @PathVariable sessionId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, @@ -73,7 +73,7 @@ class SessionAdminController( @GetMapping @Operation(summary = "정기모임 목록 조회") fun getSessionInfos( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @RequestParam(required = false) cardinal: Int?, @Parameter(hidden = true) @CurrentUser userId: Long, diff --git a/src/main/kotlin/com/weeth/domain/session/presentation/SessionController.kt b/src/main/kotlin/com/weeth/domain/session/presentation/SessionController.kt index b89cd2d3..1b912e6f 100644 --- a/src/main/kotlin/com/weeth/domain/session/presentation/SessionController.kt +++ b/src/main/kotlin/com/weeth/domain/session/presentation/SessionController.kt @@ -26,7 +26,7 @@ class SessionController( @GetMapping("/{sessionId}") @Operation(summary = "정기모임 상세 조회") fun getSession( - @PathVariable @TsidParam + @TsidParam @TsidPathVariable clubId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, @PathVariable sessionId: Long, diff --git a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt index b0e9091c..ebe4e055 100644 --- a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt +++ b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt @@ -7,6 +7,7 @@ import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider import com.weeth.global.auth.jwt.filter.JwtAuthenticationProcessingFilter import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpMethod import org.springframework.security.authorization.AuthorizationDecision import org.springframework.security.config.Customizer.withDefaults import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity @@ -49,6 +50,8 @@ class SecurityConfig( ).permitAll() .requestMatchers("/health-check") .permitAll() + .requestMatchers(HttpMethod.GET, "/api/v4/clubs/*") + .permitAll() .requestMatchers( "/admin", "/admin/login", From e2d0673b754fc2154717ca1af0e58c09598a812d Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:37:26 +0900 Subject: [PATCH 25/73] =?UTF-8?q?[WTH-198]=20=EB=8F=99=EC=95=84=EB=A6=AC?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A4=20=EA=B0=80=EC=9E=85=EC=8B=9C=20=EA=B8=B0?= =?UTF-8?q?=EC=88=98=20=EC=9E=85=EB=A0=A5=20=EB=B0=8F=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20(#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 최초 기수 설정 dto 및 중복 설정 예외 추가 * feat: 락을 포함한 조회 및 검증 메서드 추가. (추후 WTH-200 이슈에서도 사용 예정) * feat: 락을 포함한 조회 및 검증 메서드 추가. * refactor: 동아리 개설시 기수 초기화 로직 추가 * feat: 기수 초기 설정시 출석 초기화 로직 추가 * refactor: 개설시 기수 입력 추가 * feat: 활동 기수 최초 설정 API 추가 * docs: 주석 추가 * refactor: PathVariable 제거 * refactor: n+1 제거 및 주석 추가 * refactor: 미사용 import 문 제거 * refactor: 가볍게 검증하도록 수정 --- .claude/rules/api-design.md | 4 +- .../dto/request/ClubCreateRequest.kt | 7 + .../request/ClubMemberCardinalSetRequest.kt | 11 ++ .../exception/CardinalAlreadySetException.kt | 5 + .../application/exception/ClubErrorCode.kt | 3 + .../usecase/command/AdminClubMemberUseCase.kt | 2 + .../command/ManageClubMemberUsecase.kt | 55 +++++- .../usecase/command/ManageClubUseCase.kt | 25 ++- .../ClubMemberCardinalRepository.kt | 2 + .../domain/repository/ClubMemberReader.kt | 5 + .../domain/repository/ClubMemberRepository.kt | 8 + .../club/domain/service/ClubMemberPolicy.kt | 11 ++ .../club/presentation/ClubController.kt | 15 ++ .../club/presentation/ClubResponseCode.kt | 1 + .../command/ManageClubMemberUseCaseTest.kt | 183 +++++++++++++++++- .../usecase/command/ManageClubUseCaseTest.kt | 105 +++++++++- 16 files changed, 435 insertions(+), 7 deletions(-) create mode 100644 src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubMemberCardinalSetRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/application/exception/CardinalAlreadySetException.kt diff --git a/.claude/rules/api-design.md b/.claude/rules/api-design.md index 66445fb5..346c158d 100644 --- a/.claude/rules/api-design.md +++ b/.claude/rules/api-design.md @@ -19,10 +19,10 @@ class UserController( ## Club-scoped API -Club resources use `/api/v4/clubs/{clubId}/...`. `clubId` is Base62 TSID — use three annotations together: +Club resources use `/api/v4/clubs/{clubId}/...`. `clubId` is Base62 TSID — use two annotations together: ```kotlin -@PathVariable @TsidParam // IDE warning suppression + Swagger (type: string) +@TsidParam // Swagger (type: string) @TsidPathVariable clubId: Long // decodes Base62 → Long at runtime ``` diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubCreateRequest.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubCreateRequest.kt index eebb2245..0fa69bdb 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubCreateRequest.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubCreateRequest.kt @@ -2,6 +2,7 @@ package com.weeth.domain.club.application.dto.request import io.swagger.v3.oas.annotations.media.Schema import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Positive import jakarta.validation.constraints.Size data class ClubCreateRequest( @@ -13,12 +14,18 @@ data class ClubCreateRequest( @field:NotBlank @field:Size(max = 50) val schoolName: String, + // TODO: 길이 제한 추가 @field:Schema(description = "동아리 소개", example = "함께 배우고 성장하는 개발자 커뮤니티") val description: String? = null, + // TODO: 얘는 선택 @field:Schema(description = "연락 이메일", example = "club@example.com") val contactEmail: String? = null, + // TODO: 얘는 필수 @field:Schema(description = "연락 전화번호", example = "010-1234-5678") val contactPhoneNumber: String? = null, + @field:Schema(description = "가장 최근 기수 번호", example = "7") + @field:Positive + val currentCardinal: Int, @field:Schema(description = "프로필 사진 S3 URL", example = "https://s3.amazonaws.com/bucket/profile.jpg") val profileImageUrl: String? = null, @field:Schema(description = "배경 사진 S3 URL", example = "https://s3.amazonaws.com/bucket/background.jpg") diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubMemberCardinalSetRequest.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubMemberCardinalSetRequest.kt new file mode 100644 index 00000000..41e2e701 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubMemberCardinalSetRequest.kt @@ -0,0 +1,11 @@ +package com.weeth.domain.club.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotEmpty +import jakarta.validation.constraints.Positive + +data class ClubMemberCardinalSetRequest( + @field:Schema(description = "활동 기수 번호 목록", example = "[1, 2, 3]") + @field:NotEmpty + val cardinals: List<@Positive Int>, +) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/CardinalAlreadySetException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/CardinalAlreadySetException.kt new file mode 100644 index 00000000..ca425c30 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/CardinalAlreadySetException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class CardinalAlreadySetException : BaseException(ClubErrorCode.CARDINAL_ALREADY_SET) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt index d00851d0..3d8d6ae1 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt @@ -35,4 +35,7 @@ enum class ClubErrorCode( @ExplainError("요청한 멤버가 해당 동아리에 속하지 않을 때 발생합니다.") CLUB_MEMBER_NOT_IN_CLUB(21108, HttpStatus.BAD_REQUEST, "해당 동아리에 속한 멤버가 아닙니다."), + + @ExplainError("이미 활동 기수가 설정된 멤버가 다시 설정을 시도할 때 발생합니다.") + CARDINAL_ALREADY_SET(21109, HttpStatus.CONFLICT, "이미 활동 기수가 설정되어 있습니다."), } diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt index 9ef2f24f..aadf66d0 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt @@ -64,6 +64,7 @@ class AdminClubMemberUseCase( member.updateRole(request.memberRole) } + // TODO: setInitialCardinals와 동시 호출 시 출석 중복 생성 가능 — 멤버 단위 락 추가 검토 @Transactional fun applyOb( clubId: Long, @@ -96,6 +97,7 @@ class AdminClubMemberUseCase( } } + // TODO: ManageClubMemberUsecase.initializeAttendances와 중복 — MVP 후 공통 서비스로 추출 private fun initializeAttendances( clubId: Long, member: ClubMember, diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt index 9d477294..f7e9364a 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt @@ -1,15 +1,25 @@ package com.weeth.domain.club.application.usecase.command +import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException +import com.weeth.domain.cardinal.domain.entity.Cardinal +import com.weeth.domain.cardinal.domain.repository.CardinalReader import com.weeth.domain.club.application.dto.request.ClubJoinRequest +import com.weeth.domain.club.application.dto.request.ClubMemberCardinalSetRequest import com.weeth.domain.club.application.exception.AlreadyJoinedException import com.weeth.domain.club.application.exception.CannotLeaveAsLeadException +import com.weeth.domain.club.application.exception.CardinalAlreadySetException import com.weeth.domain.club.application.exception.ClubCantJoinException import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.entity.ClubMemberCardinal import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.repository.ClubMemberCardinalRepository import com.weeth.domain.club.domain.repository.ClubMemberRepository import com.weeth.domain.club.domain.repository.ClubRepository import com.weeth.domain.club.domain.service.ClubCodePolicy import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.session.domain.repository.SessionReader import com.weeth.domain.user.domain.repository.UserReader import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -21,13 +31,16 @@ import org.springframework.transaction.annotation.Transactional class ManageClubMemberUsecase( private val clubRepository: ClubRepository, private val clubMemberRepository: ClubMemberRepository, + private val clubMemberCardinalRepository: ClubMemberCardinalRepository, + private val cardinalReader: CardinalReader, + private val sessionReader: SessionReader, + private val attendanceRepository: AttendanceRepository, private val userReader: UserReader, private val clubMemberPolicy: ClubMemberPolicy, ) { /** * 초대 코드가 일치하면 자동으로 활성 상태로 가입됨 * MVP에서는 단일 동아리 지원만 가능 - * TODO: 출석 초기화 */ @Transactional fun join( @@ -67,6 +80,46 @@ class ManageClubMemberUsecase( clubMemberRepository.save(member) } + /** + * 활동 기수를 최초 1회 설정 + * 이미 설정된 경우 CardinalAlreadySetException 발생 + */ + @Transactional + fun setInitialCardinals( + clubId: Long, + userId: Long, + request: ClubMemberCardinalSetRequest, + ) { + val member = clubMemberPolicy.getActiveMemberWithLock(clubId, userId) + + if (clubMemberCardinalRepository.existsByClubMember(member)) { + throw CardinalAlreadySetException() + } + + val cardinals = + request.cardinals.distinct().map { number -> + cardinalReader.findByClubIdAndCardinalNumber(clubId, number) + ?: throw CardinalNotFoundException() + } + + clubMemberCardinalRepository.saveAll(cardinals.map { ClubMemberCardinal.create(member, it) }) + + initializeAttendances(clubId, member, cardinals) + } + + // TODO: AdminClubMemberUseCase.initializeAttendances와 중복 — MVP 후 공통 서비스로 추출 + private fun initializeAttendances( + clubId: Long, + member: ClubMember, + cardinals: List, + ) { + val sessions = sessionReader.findAllByClubIdAndCardinalIn(clubId, cardinals.map { it.cardinalNumber }) + if (sessions.isEmpty()) return + + val attendances = sessions.map { Attendance.create(session = it, clubMember = member) } + attendanceRepository.saveAll(attendances) + } + /** * LEAD 권한을 가진 멤버는 탈퇴 불가 */ diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt index 453231af..5b8fda31 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt @@ -1,10 +1,15 @@ package com.weeth.domain.club.application.usecase.command +import com.weeth.domain.cardinal.domain.entity.Cardinal +import com.weeth.domain.cardinal.domain.enums.CardinalStatus +import com.weeth.domain.cardinal.domain.repository.CardinalRepository import com.weeth.domain.club.application.dto.request.ClubCreateRequest import com.weeth.domain.club.application.dto.request.ClubUpdateRequest import com.weeth.domain.club.domain.entity.Club import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.entity.ClubMemberCardinal import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.repository.ClubMemberCardinalRepository import com.weeth.domain.club.domain.repository.ClubMemberRepository import com.weeth.domain.club.domain.repository.ClubRepository import com.weeth.domain.club.domain.service.ClubCodePolicy @@ -22,14 +27,15 @@ import org.springframework.transaction.annotation.Transactional class ManageClubUseCase( private val clubRepository: ClubRepository, private val clubMemberRepository: ClubMemberRepository, + private val cardinalRepository: CardinalRepository, + private val clubMemberCardinalRepository: ClubMemberCardinalRepository, private val userReader: UserReader, private val clubMemberPolicy: ClubMemberPolicy, ) { /** * 새로운 동아리를 생성 * 생성자는 자동으로 LEAD 권한 설정 - * 동아리 생성은 관리자 권한이 필요 없음 - * todo: 기수 관련 설정 필수 처리 + * 1기부터 currentCardinal기까지 Cardinal을 자동 생성하고, LEAD를 최신 기수에 배정 */ @Transactional fun create( @@ -70,6 +76,21 @@ class ManageClubUseCase( } clubMemberRepository.save(leadMember) + + // 1기 - currentCardinal기까지 Cardinal 자동 생성 + val cardinals = + (1..request.currentCardinal).map { number -> + Cardinal.create( + club = club, + cardinalNumber = number, + status = if (number == request.currentCardinal) CardinalStatus.IN_PROGRESS else CardinalStatus.DONE, + ) + } + + cardinalRepository.saveAll(cardinals) + + // LEAD 멤버를 최신 기수에 배정 + clubMemberCardinalRepository.save(ClubMemberCardinal.create(leadMember, cardinals.last())) } @Transactional diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberCardinalRepository.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberCardinalRepository.kt index b030e197..e31f6aad 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberCardinalRepository.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberCardinalRepository.kt @@ -11,6 +11,8 @@ interface ClubMemberCardinalRepository : ClubMemberCardinalReader { override fun findAllByClubMember(clubMember: ClubMember): List + fun existsByClubMember(clubMember: ClubMember): Boolean + @Query( """ SELECT cmc diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt index c57bb7d8..f01e18c6 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt @@ -13,6 +13,11 @@ interface ClubMemberReader { userId: Long, ): ClubMember? + fun findByClubIdAndUserIdWithLock( + clubId: Long, + userId: Long, + ): ClubMember? + fun findAllByClubId(clubId: Long): List fun findAllByUserId(userId: Long): List diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt index 1621b568..6584365d 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt @@ -32,6 +32,14 @@ interface ClubMemberRepository : userId: Long, ): ClubMember? + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query("SELECT cm FROM ClubMember cm WHERE cm.club.id = :clubId AND cm.user.id = :userId") + override fun findByClubIdAndUserIdWithLock( + @Param("clubId") clubId: Long, + @Param("userId") userId: Long, + ): ClubMember? + @Query( """ SELECT cm diff --git a/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt index 3fac4d2e..06b512a1 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt @@ -45,6 +45,17 @@ class ClubMemberPolicy( } } + fun getActiveMemberWithLock( + clubId: Long, + userId: Long, + ): ClubMember { + val member = + clubMemberReader.findByClubIdAndUserIdWithLock(clubId, userId) + ?: throw ClubMemberNotFoundException() + if (!member.isActive()) throw MemberNotActiveException() + return member + } + fun getMemberInClub( clubId: Long, clubMemberId: Long, diff --git a/src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt b/src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt index c7830ac2..92b2376f 100644 --- a/src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt +++ b/src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt @@ -2,6 +2,7 @@ package com.weeth.domain.club.presentation import com.weeth.domain.club.application.dto.request.ClubCreateRequest import com.weeth.domain.club.application.dto.request.ClubJoinRequest +import com.weeth.domain.club.application.dto.request.ClubMemberCardinalSetRequest import com.weeth.domain.club.application.dto.response.ClubInfoResponse import com.weeth.domain.club.application.dto.response.ClubMemberProfileResponse import com.weeth.domain.club.application.dto.response.ClubPublicResponse @@ -108,5 +109,19 @@ class ClubController( return CommonResponse.success(ClubResponseCode.MEMBER_FIND_ME_SUCCESS, meInfo) } + @PostMapping("/{clubId}/members/me/cardinals") + @Operation(summary = "활동 기수 최초 설정 (최초 1회만 가능)") + @ResponseStatus(HttpStatus.CREATED) + fun setInitialCardinals( + @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + @Valid @RequestBody request: ClubMemberCardinalSetRequest, + ): CommonResponse { + manageClubMemberUsecase.setInitialCardinals(clubId, userId, request) + + return CommonResponse.success(ClubResponseCode.MEMBER_CARDINAL_SET_SUCCESS) + } + // TODO: MVP 후 동아리 멤버 조회 기능 구현 } diff --git a/src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt b/src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt index ddd31a04..85109c10 100644 --- a/src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt @@ -24,4 +24,5 @@ enum class ClubResponseCode( CLUB_PROFILE_IMAGE_DELETED_SUCCESS(11113, HttpStatus.OK, "동아리 프로필 사진이 삭제되었습니다."), CLUB_BACKGROUND_IMAGE_DELETED_SUCCESS(11114, HttpStatus.OK, "동아리 배경 사진이 삭제되었습니다."), MEMBER_APPLY_OB_SUCCESS(11115, HttpStatus.OK, "멤버의 OB 기수 등록이 완료되었습니다."), + MEMBER_CARDINAL_SET_SUCCESS(11116, HttpStatus.CREATED, "활동 기수가 설정되었습니다."), } diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt index a12f668d..55c8094d 100644 --- a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt @@ -1,11 +1,22 @@ package com.weeth.domain.club.application.usecase.command +import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException +import com.weeth.domain.cardinal.domain.repository.CardinalReader +import com.weeth.domain.cardinal.fixture.CardinalTestFixture import com.weeth.domain.club.application.dto.request.ClubJoinRequest +import com.weeth.domain.club.application.dto.request.ClubMemberCardinalSetRequest +import com.weeth.domain.club.application.exception.CardinalAlreadySetException import com.weeth.domain.club.application.exception.ClubCantJoinException +import com.weeth.domain.club.domain.entity.ClubMemberCardinal +import com.weeth.domain.club.domain.repository.ClubMemberCardinalRepository import com.weeth.domain.club.domain.repository.ClubMemberRepository import com.weeth.domain.club.domain.repository.ClubRepository import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.fixture.ClubMemberTestFixture import com.weeth.domain.club.fixture.ClubTestFixture +import com.weeth.domain.session.domain.repository.SessionReader +import com.weeth.domain.session.fixture.SessionTestFixture import com.weeth.domain.user.domain.repository.UserReader import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.assertions.throwables.shouldThrow @@ -19,6 +30,10 @@ class ManageClubMemberUseCaseTest : DescribeSpec({ val clubRepository = mockk() val clubMemberRepository = mockk() + val clubMemberCardinalRepository = mockk(relaxed = true) + val cardinalReader = mockk() + val sessionReader = mockk() + val attendanceRepository = mockk(relaxed = true) val userReader = mockk() val clubMemberPolicy = mockk() @@ -26,15 +41,181 @@ class ManageClubMemberUseCaseTest : ManageClubMemberUsecase( clubRepository = clubRepository, clubMemberRepository = clubMemberRepository, + clubMemberCardinalRepository = clubMemberCardinalRepository, + cardinalReader = cardinalReader, + sessionReader = sessionReader, + attendanceRepository = attendanceRepository, userReader = userReader, clubMemberPolicy = clubMemberPolicy, ) beforeTest { - clearMocks(clubRepository, clubMemberRepository, userReader, clubMemberPolicy) + clearMocks( + clubRepository, + clubMemberRepository, + clubMemberCardinalRepository, + cardinalReader, + sessionReader, + attendanceRepository, + userReader, + clubMemberPolicy, + ) every { clubMemberRepository.save(any()) } answers { firstArg() } } + describe("setInitialCardinals") { + val club = ClubTestFixture.createClub() + val member = ClubMemberTestFixture.createActiveMember(club = club) + + context("복수 기수를 최초 설정하는 경우") { + it("요청 기수 수만큼 ClubMemberCardinal이 저장되고, 각 기수의 세션에 출석이 초기화된다") { + val cardinal30 = + CardinalTestFixture.createCardinal( + id = 1L, + club = club, + cardinalNumber = 30, + year = 2024, + semester = 1, + ) + val cardinal31 = + CardinalTestFixture.createCardinal( + id = 2L, + club = club, + cardinalNumber = 31, + year = 2024, + semester = 2, + ) + val session30 = SessionTestFixture.createSession(club = club, cardinal = 30) + val session31 = SessionTestFixture.createSession(club = club, cardinal = 31) + + every { clubMemberPolicy.getActiveMemberWithLock(1L, 10L) } returns member + every { clubMemberCardinalRepository.existsByClubMember(member) } returns false + every { cardinalReader.findByClubIdAndCardinalNumber(1L, 30) } returns cardinal30 + every { cardinalReader.findByClubIdAndCardinalNumber(1L, 31) } returns cardinal31 + every { clubMemberCardinalRepository.saveAll(any>()) } answers + { firstArg() } + every { + sessionReader.findAllByClubIdAndCardinalIn(1L, listOf(30, 31)) + } returns listOf(session30, session31) + + useCase.setInitialCardinals(1L, 10L, ClubMemberCardinalSetRequest(cardinals = listOf(30, 31))) + + verify(exactly = 1) { + clubMemberCardinalRepository.saveAll( + match> { + it.size == + 2 + }, + ) + } + verify(exactly = 1) { + attendanceRepository.saveAll( + match> { + it.size == + 2 + }, + ) + } + } + } + + context("세션이 없는 기수를 설정하는 경우") { + it("ClubMemberCardinal만 저장되고 출석은 초기화되지 않는다") { + val cardinal = + CardinalTestFixture.createCardinal( + id = 1L, + club = club, + cardinalNumber = 30, + year = 2024, + semester = 1, + ) + + every { clubMemberPolicy.getActiveMemberWithLock(1L, 10L) } returns member + every { clubMemberCardinalRepository.existsByClubMember(member) } returns false + every { cardinalReader.findByClubIdAndCardinalNumber(1L, 30) } returns cardinal + every { clubMemberCardinalRepository.saveAll(any>()) } answers + { firstArg() } + every { sessionReader.findAllByClubIdAndCardinalIn(1L, listOf(30)) } returns emptyList() + + useCase.setInitialCardinals(1L, 10L, ClubMemberCardinalSetRequest(cardinals = listOf(30))) + + verify(exactly = 1) { + clubMemberCardinalRepository.saveAll( + match> { + it.size == + 1 + }, + ) + } + verify( + exactly = 0, + ) { + attendanceRepository.saveAll( + any>(), + ) + } + } + } + + context("요청에 중복 기수가 포함된 경우") { + it("중복을 제거하고 1개만 저장한다") { + val cardinal = + CardinalTestFixture.createCardinal( + id = 1L, + club = club, + cardinalNumber = 30, + year = 2024, + semester = 1, + ) + + every { clubMemberPolicy.getActiveMemberWithLock(1L, 10L) } returns member + every { clubMemberCardinalRepository.existsByClubMember(member) } returns false + every { cardinalReader.findByClubIdAndCardinalNumber(1L, 30) } returns cardinal + every { clubMemberCardinalRepository.saveAll(any>()) } answers + { firstArg() } + every { sessionReader.findAllByClubIdAndCardinalIn(1L, listOf(30)) } returns emptyList() + + useCase.setInitialCardinals(1L, 10L, ClubMemberCardinalSetRequest(cardinals = listOf(30, 30))) + + verify(exactly = 1) { + clubMemberCardinalRepository.saveAll( + match> { + it.size == + 1 + }, + ) + } + } + } + + context("이미 기수가 설정된 멤버가 재설정을 시도하는 경우") { + it("CardinalAlreadySetException이 발생한다") { + every { clubMemberPolicy.getActiveMemberWithLock(1L, 10L) } returns member + every { clubMemberCardinalRepository.existsByClubMember(member) } returns true + + shouldThrow { + useCase.setInitialCardinals(1L, 10L, ClubMemberCardinalSetRequest(cardinals = listOf(31))) + } + + verify(exactly = 0) { clubMemberCardinalRepository.saveAll(any>()) } + } + } + + context("존재하지 않는 기수를 요청하는 경우") { + it("CardinalNotFoundException이 발생한다") { + every { clubMemberPolicy.getActiveMemberWithLock(1L, 10L) } returns member + every { clubMemberCardinalRepository.existsByClubMember(member) } returns false + every { cardinalReader.findByClubIdAndCardinalNumber(1L, 99) } returns null + + shouldThrow { + useCase.setInitialCardinals(1L, 10L, ClubMemberCardinalSetRequest(cardinals = listOf(99))) + } + + verify(exactly = 0) { clubMemberCardinalRepository.saveAll(any>()) } + } + } + } + describe("join") { context("이미 다른 동아리에서 ACTIVE 상태로 활동 중인 경우") { it("MVP 단일 동아리 정책에 따라 가입할 수 없다") { diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt index ad07f812..f42be319 100644 --- a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt @@ -1,28 +1,131 @@ package com.weeth.domain.club.application.usecase.command +import com.weeth.domain.cardinal.domain.entity.Cardinal +import com.weeth.domain.cardinal.domain.enums.CardinalStatus +import com.weeth.domain.cardinal.domain.repository.CardinalRepository +import com.weeth.domain.club.application.dto.request.ClubCreateRequest import com.weeth.domain.club.application.dto.request.ClubUpdateRequest +import com.weeth.domain.club.domain.entity.ClubMemberCardinal +import com.weeth.domain.club.domain.repository.ClubMemberCardinalRepository import com.weeth.domain.club.domain.repository.ClubMemberRepository import com.weeth.domain.club.domain.repository.ClubRepository import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.club.domain.vo.ClubContact import com.weeth.domain.club.fixture.ClubTestFixture import com.weeth.domain.user.domain.repository.UserReader +import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe +import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify class ManageClubUseCaseTest : DescribeSpec({ val clubRepository = mockk() val clubMemberRepository = mockk() + val cardinalRepository = mockk() + val clubMemberCardinalRepository = mockk() val userReader = mockk() val clubMemberPolicy = mockk() - val useCase = ManageClubUseCase(clubRepository, clubMemberRepository, userReader, clubMemberPolicy) + val useCase = + ManageClubUseCase( + clubRepository, + clubMemberRepository, + cardinalRepository, + clubMemberCardinalRepository, + userReader, + clubMemberPolicy, + ) val adminMember = com.weeth.domain.club.fixture.ClubMemberTestFixture .createAdminMember() + beforeTest { + clearMocks( + clubRepository, + clubMemberRepository, + cardinalRepository, + clubMemberCardinalRepository, + userReader, + clubMemberPolicy, + ) + every { clubRepository.save(any()) } answers { firstArg() } + every { clubMemberRepository.save(any()) } answers { firstArg() } + every { cardinalRepository.saveAll(any>()) } answers { firstArg() } + every { clubMemberCardinalRepository.save(any()) } answers { firstArg() } + } + + describe("create") { + val user = UserTestFixture.createActiveUser1() + + context("N기 동아리를 개설하는 경우") { + it("1기부터 N기까지 Cardinal이 생성되며, 마지막 기수만 IN_PROGRESS이다") { + val cardinalSlot = slot>() + every { userReader.getById(10L) } returns user + every { cardinalRepository.saveAll(capture(cardinalSlot)) } answers { firstArg() } + + useCase.create( + 10L, + ClubCreateRequest( + name = "테스트", + schoolName = "가천대", + currentCardinal = 3, + contactEmail = "test@example.com", + ), + ) + + val cardinals = cardinalSlot.captured + cardinals.size shouldBe 3 + cardinals[0].cardinalNumber shouldBe 1 + cardinals[0].status shouldBe CardinalStatus.DONE + cardinals[1].cardinalNumber shouldBe 2 + cardinals[1].status shouldBe CardinalStatus.DONE + cardinals[2].cardinalNumber shouldBe 3 + cardinals[2].status shouldBe CardinalStatus.IN_PROGRESS + } + + it("LEAD 멤버가 최신 기수에 ClubMemberCardinal로 배정된다") { + every { userReader.getById(10L) } returns user + + useCase.create( + 10L, + ClubCreateRequest( + name = "테스트", + schoolName = "가천대", + currentCardinal = 3, + contactEmail = "test@example.com", + ), + ) + + verify(exactly = 1) { clubMemberCardinalRepository.save(any()) } + } + + it("1기만 있는 동아리 개설 시 Cardinal 1개가 IN_PROGRESS로 생성된다") { + val cardinalSlot = slot>() + every { userReader.getById(10L) } returns user + every { cardinalRepository.saveAll(capture(cardinalSlot)) } answers { firstArg() } + + useCase.create( + 10L, + ClubCreateRequest( + name = "테스트", + schoolName = "가천대", + currentCardinal = 1, + contactEmail = "test@example.com", + ), + ) + + val cardinals = cardinalSlot.captured + cardinals.size shouldBe 1 + cardinals[0].cardinalNumber shouldBe 1 + cardinals[0].status shouldBe CardinalStatus.IN_PROGRESS + } + } + } + describe("update") { it("null 필드는 유지하고 전달된 필드만 수정한다") { val club = From af1b842f344c4d46d0fce82ccf98bf5e00b80494 Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:31:12 +0900 Subject: [PATCH 26/73] =?UTF-8?q?[WTH-203]=20=EB=8F=99=EC=95=84=EB=A6=AC?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A4/=EA=B0=80=EC=9E=85=20=EC=88=98=20?= =?UTF-8?q?=EC=A0=9C=ED=95=9C=20(#30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 동아리 최대 가입 예외 추가 * refactor: 동아리 생성가입 제한 로직 추가 * refactor: 미사용 예외 삭제 * docs: 주석 최신화 * refactor: 충돌해결 * refactor: 동시성 문제 해소 --- .../ClubCreateLimitExceededException.kt | 5 ++ .../application/exception/ClubErrorCode.kt | 9 +- ...n.kt => ClubJoinLimitExceededException.kt} | 2 +- .../command/ManageClubMemberUsecase.kt | 13 +-- .../usecase/command/ManageClubUseCase.kt | 4 +- .../domain/repository/ClubMemberReader.kt | 7 ++ .../domain/repository/ClubMemberRepository.kt | 16 ++++ .../club/domain/service/ClubMemberPolicy.kt | 39 ++++++++ .../command/ManageClubMemberUseCaseTest.kt | 37 +++++--- .../usecase/command/ManageClubUseCaseTest.kt | 36 +++++++- .../domain/service/ClubMemberPolicyTest.kt | 89 +++++++++++++++++++ 11 files changed, 228 insertions(+), 29 deletions(-) create mode 100644 src/main/kotlin/com/weeth/domain/club/application/exception/ClubCreateLimitExceededException.kt rename src/main/kotlin/com/weeth/domain/club/application/exception/{ClubCantJoinException.kt => ClubJoinLimitExceededException.kt} (53%) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/ClubCreateLimitExceededException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubCreateLimitExceededException.kt new file mode 100644 index 00000000..423276ab --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubCreateLimitExceededException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class ClubCreateLimitExceededException : BaseException(ClubErrorCode.CLUB_CREATE_LIMIT_EXCEEDED) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt index 3d8d6ae1..b5cadd65 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt @@ -30,12 +30,15 @@ enum class ClubErrorCode( @ExplainError("비활성 멤버가 동아리 리소스에 접근할 때 발생합니다.") MEMBER_NOT_ACTIVE(21106, HttpStatus.FORBIDDEN, "비활성 멤버입니다."), - @ExplainError("MVP 단계에서 여러 동아리에 지원하려고 하는 경우 발생합니다. MVP는 단일 동아리 지원만 가능합니다.") - CLUB_CANT_JOIN(21107, HttpStatus.BAD_REQUEST, "MVP에서 동아리는 1개만 지원 가능합니다."), - @ExplainError("요청한 멤버가 해당 동아리에 속하지 않을 때 발생합니다.") CLUB_MEMBER_NOT_IN_CLUB(21108, HttpStatus.BAD_REQUEST, "해당 동아리에 속한 멤버가 아닙니다."), @ExplainError("이미 활동 기수가 설정된 멤버가 다시 설정을 시도할 때 발생합니다.") CARDINAL_ALREADY_SET(21109, HttpStatus.CONFLICT, "이미 활동 기수가 설정되어 있습니다."), + + @ExplainError("일반 멤버(USER)로 가입 가능한 동아리 수(최대 1개)를 초과했을 때 발생합니다.") + CLUB_JOIN_LIMIT_EXCEEDED(21110, HttpStatus.CONFLICT, "가입 가능한 동아리 수를 초과했습니다."), + + @ExplainError("동아리장(LEAD)으로 생성 가능한 동아리 수(최대 1개)를 초과했을 때 발생합니다.") + CLUB_CREATE_LIMIT_EXCEEDED(21111, HttpStatus.CONFLICT, "생성 가능한 동아리 수를 초과했습니다."), } diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/ClubCantJoinException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubJoinLimitExceededException.kt similarity index 53% rename from src/main/kotlin/com/weeth/domain/club/application/exception/ClubCantJoinException.kt rename to src/main/kotlin/com/weeth/domain/club/application/exception/ClubJoinLimitExceededException.kt index 41b4c451..dead730c 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/exception/ClubCantJoinException.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubJoinLimitExceededException.kt @@ -2,4 +2,4 @@ package com.weeth.domain.club.application.exception import com.weeth.global.common.exception.BaseException -class ClubCantJoinException : BaseException(ClubErrorCode.CLUB_CANT_JOIN) +class ClubJoinLimitExceededException : BaseException(ClubErrorCode.CLUB_JOIN_LIMIT_EXCEEDED) diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt index f7e9364a..acc90eb7 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt @@ -10,7 +10,6 @@ import com.weeth.domain.club.application.dto.request.ClubMemberCardinalSetReques import com.weeth.domain.club.application.exception.AlreadyJoinedException import com.weeth.domain.club.application.exception.CannotLeaveAsLeadException import com.weeth.domain.club.application.exception.CardinalAlreadySetException -import com.weeth.domain.club.application.exception.ClubCantJoinException import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.club.domain.entity.ClubMemberCardinal import com.weeth.domain.club.domain.enums.MemberRole @@ -40,7 +39,8 @@ class ManageClubMemberUsecase( ) { /** * 초대 코드가 일치하면 자동으로 활성 상태로 가입됨 - * MVP에서는 단일 동아리 지원만 가능 + * 역할(LEAD/USER)별 가입 제한 정책 적용 + * TODO: 출석 초기화 */ @Transactional fun join( @@ -56,14 +56,7 @@ class ManageClubMemberUsecase( throw AlreadyJoinedException() } - val isJoinedAnotherClub = - clubMemberRepository - .findAllByUserId(userId) - .any { it.club.id != clubId && it.isActive() } - - if (isJoinedAnotherClub) { - throw ClubCantJoinException() - } + clubMemberPolicy.validateJoinLimit(userId) ClubCodePolicy.validate(club.code, request.code) diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt index 5b8fda31..735eed20 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt @@ -43,7 +43,9 @@ class ManageClubUseCase( request: ClubCreateRequest, ) { val user = - userReader.getById(userId) + userReader.getByIdWithLock(userId) + + clubMemberPolicy.validateCreateLimit(userId) val code = ClubCodePolicy.generateCode() val clubContact = diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt index f01e18c6..1997f9ed 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt @@ -1,6 +1,7 @@ package com.weeth.domain.club.domain.repository import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.enums.MemberRole import com.weeth.domain.club.domain.enums.MemberStatus interface ClubMemberReader { @@ -30,4 +31,10 @@ interface ClubMemberReader { clubId: Long, memberStatus: MemberStatus, ): List + + fun countByUserIdAndMemberStatusAndMemberRole( + userId: Long, + memberStatus: MemberStatus, + memberRole: MemberRole, + ): Long } diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt index 6584365d..29b373fe 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt @@ -1,6 +1,7 @@ package com.weeth.domain.club.domain.repository import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.enums.MemberRole import com.weeth.domain.club.domain.enums.MemberStatus import jakarta.persistence.LockModeType import jakarta.persistence.QueryHint @@ -77,4 +78,19 @@ interface ClubMemberRepository : override fun countActiveByClubId( @Param("clubId") clubId: Long, ): Long + + @Query( + """ + SELECT COUNT(cm) + FROM ClubMember cm + WHERE cm.user.id = :userId + AND cm.memberStatus = :memberStatus + AND cm.memberRole = :memberRole + """, + ) + override fun countByUserIdAndMemberStatusAndMemberRole( + @Param("userId") userId: Long, + @Param("memberStatus") memberStatus: MemberStatus, + @Param("memberRole") memberRole: MemberRole, + ): Long } diff --git a/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt index 06b512a1..cfbc6279 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt @@ -1,10 +1,14 @@ package com.weeth.domain.club.domain.service +import com.weeth.domain.club.application.exception.ClubCreateLimitExceededException +import com.weeth.domain.club.application.exception.ClubJoinLimitExceededException import com.weeth.domain.club.application.exception.ClubMemberNotFoundException import com.weeth.domain.club.application.exception.ClubMemberNotInClubException import com.weeth.domain.club.application.exception.MemberNotActiveException import com.weeth.domain.club.application.exception.NotClubAdminException import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.enums.MemberStatus import com.weeth.domain.club.domain.repository.ClubMemberReader import org.springframework.stereotype.Service @@ -66,4 +70,39 @@ class ClubMemberPolicy( if (member.club.id != clubId) throw ClubMemberNotInClubException() return member } + + /** + * 일반 멤버(USER)로 가입 가능한 동아리 수 제한 검증 + */ + fun validateJoinLimit(userId: Long) { + val activeUserCount = + clubMemberReader.countByUserIdAndMemberStatusAndMemberRole( + userId, + MemberStatus.ACTIVE, + MemberRole.USER, + ) + if (activeUserCount >= MAX_USER_CLUBS) { + throw ClubJoinLimitExceededException() + } + } + + /** + * 동아리장(LEAD)으로 생성 가능한 동아리 수 제한 검증 + */ + fun validateCreateLimit(userId: Long) { + val activeLeadCount = + clubMemberReader.countByUserIdAndMemberStatusAndMemberRole( + userId, + MemberStatus.ACTIVE, + MemberRole.LEAD, + ) + if (activeLeadCount >= MAX_LEAD_CLUBS) { + throw ClubCreateLimitExceededException() + } + } + + companion object { + private const val MAX_LEAD_CLUBS = 1 + private const val MAX_USER_CLUBS = 1 + } } diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt index 55c8094d..2df5aa4f 100644 --- a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt @@ -7,7 +7,7 @@ import com.weeth.domain.cardinal.fixture.CardinalTestFixture import com.weeth.domain.club.application.dto.request.ClubJoinRequest import com.weeth.domain.club.application.dto.request.ClubMemberCardinalSetRequest import com.weeth.domain.club.application.exception.CardinalAlreadySetException -import com.weeth.domain.club.application.exception.ClubCantJoinException +import com.weeth.domain.club.application.exception.ClubJoinLimitExceededException import com.weeth.domain.club.domain.entity.ClubMemberCardinal import com.weeth.domain.club.domain.repository.ClubMemberCardinalRepository import com.weeth.domain.club.domain.repository.ClubMemberRepository @@ -23,6 +23,7 @@ import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.mockk.clearMocks import io.mockk.every +import io.mockk.justRun import io.mockk.mockk import io.mockk.verify @@ -217,23 +218,17 @@ class ManageClubMemberUseCaseTest : } describe("join") { - context("이미 다른 동아리에서 ACTIVE 상태로 활동 중인 경우") { - it("MVP 단일 동아리 정책에 따라 가입할 수 없다") { + context("이미 USER로 1개 동아리에 가입한 사용자가 가입 시도하는 경우") { + it("ClubJoinLimitExceededException이 발생한다") { val targetClub = ClubTestFixture.createClub(code = "JOIN-CODE") - val anotherClub = ClubTestFixture.createClub() val user = UserTestFixture.createActiveUser1() - val anotherClubMember = - ClubTestFixture.createClubMember( - club = anotherClub, - user = user, - ) every { clubRepository.getClubById(1L) } returns targetClub every { userReader.getByIdWithLock(10L) } returns user every { clubMemberRepository.findByClubIdAndUserId(1L, 10L) } returns null - every { clubMemberRepository.findAllByUserId(10L) } returns listOf(anotherClubMember) + every { clubMemberPolicy.validateJoinLimit(10L) } throws ClubJoinLimitExceededException() - shouldThrow { + shouldThrow { useCase.join( clubId = 1L, userId = 10L, @@ -244,5 +239,25 @@ class ManageClubMemberUseCaseTest : verify(exactly = 0) { clubMemberRepository.save(any()) } } } + + context("LEAD로 1개 동아리를 생성한 사용자가 USER로 가입 시도하는 경우") { + it("역할이 다르므로 가입에 성공한다") { + val targetClub = ClubTestFixture.createClub(code = "JOIN-CODE") + val user = UserTestFixture.createActiveUser1() + + every { clubRepository.getClubById(1L) } returns targetClub + every { userReader.getByIdWithLock(10L) } returns user + every { clubMemberRepository.findByClubIdAndUserId(1L, 10L) } returns null + justRun { clubMemberPolicy.validateJoinLimit(10L) } + + useCase.join( + clubId = 1L, + userId = 10L, + request = ClubJoinRequest(code = "JOIN-CODE"), + ) + + verify(exactly = 1) { clubMemberRepository.save(any()) } + } + } } }) diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt index f42be319..6a7c469f 100644 --- a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt @@ -5,6 +5,7 @@ import com.weeth.domain.cardinal.domain.enums.CardinalStatus import com.weeth.domain.cardinal.domain.repository.CardinalRepository import com.weeth.domain.club.application.dto.request.ClubCreateRequest import com.weeth.domain.club.application.dto.request.ClubUpdateRequest +import com.weeth.domain.club.application.exception.ClubCreateLimitExceededException import com.weeth.domain.club.domain.entity.ClubMemberCardinal import com.weeth.domain.club.domain.repository.ClubMemberCardinalRepository import com.weeth.domain.club.domain.repository.ClubMemberRepository @@ -14,10 +15,13 @@ import com.weeth.domain.club.domain.vo.ClubContact import com.weeth.domain.club.fixture.ClubTestFixture import com.weeth.domain.user.domain.repository.UserReader import com.weeth.domain.user.fixture.UserTestFixture +import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe +import io.mockk.Runs import io.mockk.clearMocks import io.mockk.every +import io.mockk.just import io.mockk.mockk import io.mockk.slot import io.mockk.verify @@ -56,6 +60,7 @@ class ManageClubUseCaseTest : every { clubMemberRepository.save(any()) } answers { firstArg() } every { cardinalRepository.saveAll(any>()) } answers { firstArg() } every { clubMemberCardinalRepository.save(any()) } answers { firstArg() } + every { clubMemberPolicy.validateCreateLimit(any()) } just Runs } describe("create") { @@ -64,7 +69,7 @@ class ManageClubUseCaseTest : context("N기 동아리를 개설하는 경우") { it("1기부터 N기까지 Cardinal이 생성되며, 마지막 기수만 IN_PROGRESS이다") { val cardinalSlot = slot>() - every { userReader.getById(10L) } returns user + every { userReader.getByIdWithLock(10L) } returns user every { cardinalRepository.saveAll(capture(cardinalSlot)) } answers { firstArg() } useCase.create( @@ -88,7 +93,7 @@ class ManageClubUseCaseTest : } it("LEAD 멤버가 최신 기수에 ClubMemberCardinal로 배정된다") { - every { userReader.getById(10L) } returns user + every { userReader.getByIdWithLock(10L) } returns user useCase.create( 10L, @@ -105,7 +110,7 @@ class ManageClubUseCaseTest : it("1기만 있는 동아리 개설 시 Cardinal 1개가 IN_PROGRESS로 생성된다") { val cardinalSlot = slot>() - every { userReader.getById(10L) } returns user + every { userReader.getByIdWithLock(10L) } returns user every { cardinalRepository.saveAll(capture(cardinalSlot)) } answers { firstArg() } useCase.create( @@ -124,6 +129,31 @@ class ManageClubUseCaseTest : cardinals[0].status shouldBe CardinalStatus.IN_PROGRESS } } + + context("이미 LEAD로 1개 동아리를 생성한 사용자가 생성 시도하는 경우") { + it("ClubCreateLimitExceededException이 발생하고, 이후 로직이 실행되지 않는다") { + every { userReader.getByIdWithLock(13L) } returns user + every { clubMemberPolicy.validateCreateLimit(13L) } throws ClubCreateLimitExceededException() + + shouldThrow { + useCase.create( + 13L, + ClubCreateRequest( + name = "새 동아리", + schoolName = "가천대학교", + description = "소개", + currentCardinal = 3, + ), + ) + } + + verify(exactly = 1) { userReader.getByIdWithLock(13L) } + verify(exactly = 1) { clubMemberPolicy.validateCreateLimit(13L) } + verify(exactly = 0) { clubRepository.save(any()) } + verify(exactly = 0) { clubMemberRepository.save(any()) } + verify(exactly = 0) { cardinalRepository.saveAll(any>()) } + } + } } describe("update") { diff --git a/src/test/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicyTest.kt b/src/test/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicyTest.kt index 658e75d3..32241258 100644 --- a/src/test/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicyTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicyTest.kt @@ -1,11 +1,16 @@ package com.weeth.domain.club.domain.service +import com.weeth.domain.club.application.exception.ClubCreateLimitExceededException +import com.weeth.domain.club.application.exception.ClubJoinLimitExceededException import com.weeth.domain.club.application.exception.ClubMemberNotFoundException import com.weeth.domain.club.application.exception.ClubMemberNotInClubException import com.weeth.domain.club.application.exception.MemberNotActiveException import com.weeth.domain.club.application.exception.NotClubAdminException +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.enums.MemberStatus import com.weeth.domain.club.domain.repository.ClubMemberReader import com.weeth.domain.club.fixture.ClubTestFixture +import io.kotest.assertions.throwables.shouldNotThrowAny import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.mockk.clearMocks @@ -140,4 +145,88 @@ class ClubMemberPolicyTest : } } } + + describe("validateJoinLimit") { + context("USER로 가입한 동아리가 없는 경우") { + it("검증을 통과해야 한다") { + every { + clubMemberReader.countByUserIdAndMemberStatusAndMemberRole( + 1L, + MemberStatus.ACTIVE, + MemberRole.USER, + ) + } returns 0L + + shouldNotThrowAny { + policy.validateJoinLimit(1L) + } + } + } + + context("이미 USER로 1개 동아리에 가입한 경우") { + it("ClubJoinLimitExceededException을 발생시켜야 한다") { + every { + clubMemberReader.countByUserIdAndMemberStatusAndMemberRole( + 1L, + MemberStatus.ACTIVE, + MemberRole.USER, + ) + } returns 1L + + shouldThrow { + policy.validateJoinLimit(1L) + } + } + } + + context("LEAD로 1개 동아리를 생성했지만 USER 가입은 없는 경우") { + it("검증을 통과해야 한다 (역할이 다르므로 허용)") { + every { + clubMemberReader.countByUserIdAndMemberStatusAndMemberRole( + 1L, + MemberStatus.ACTIVE, + MemberRole.USER, + ) + } returns 0L + + shouldNotThrowAny { + policy.validateJoinLimit(1L) + } + } + } + } + + describe("validateCreateLimit") { + context("LEAD로 생성한 동아리가 없는 경우") { + it("검증을 통과해야 한다") { + every { + clubMemberReader.countByUserIdAndMemberStatusAndMemberRole( + 1L, + MemberStatus.ACTIVE, + MemberRole.LEAD, + ) + } returns 0L + + shouldNotThrowAny { + policy.validateCreateLimit(1L) + } + } + } + + context("이미 LEAD로 1개 동아리를 생성한 경우") { + it("ClubCreateLimitExceededException을 발생시켜야 한다") { + every { + clubMemberReader.countByUserIdAndMemberStatusAndMemberRole( + 1L, + MemberStatus.ACTIVE, + MemberRole.LEAD, + ) + } returns 1L + + shouldThrow { + policy.validateCreateLimit(1L) + } + } + } + } }) From 29c3d20d6a55eb456ba05aad2f85e54a1f499a15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=84=EC=88=98=ED=98=84?= <128474444+soo0711@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:59:09 +0900 Subject: [PATCH 27/73] =?UTF-8?q?[WTH-210]=20=ED=95=99=EA=B5=90=20?= =?UTF-8?q?=ED=95=99=EA=B3=BC=20open=20api=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Redis 캐시 설정 추가 * feat: 커리어넷 API 설정 추가 * feat: 학교/학과 도메인 레이어 추가 * feat: 커리어넷 Port/어댑터 구현 * feat: 학교/학과 조회 API 엔드포인트 추가 * feat: 학교/학과 조회 API 공개 접근 허용 * test: 학교/학과 api 테스트 구현 * docs: university 도메인 코드 범위 추가 * style: 개행 추가 * refactor: 커리어넷 Port 구조 개선 * style: 린트 적용 * refactor: 코드 리뷰 반영 * test: 변경된 구조에 맞게 테스트 수정 * fix: Redis 캐시 Kotlin 역직렬화 오류 수정 * style: 린트 적용 * fix: Redis 캐시 역직렬화 타입 검증 범위 제한 * refactor: 응답이 한글부터 정렬되게 수정 * fix: university API 엔드포인트 경로 수정 * refactor: GetSchoolQueryService, GetMajorQueryService를 GetUniversityQueryService로 통합 * refactor: port 네이밍 변경 * test: QueryService 통합에 맞춰 테스트 수정 * test: 학교/학과 캐시 통합 테스트 및 성능 벤치마크 추가 * refactor: 코드 리뷰 반영 * refactor: TTL 7일로 수정 * fix: MySQL 버전 수정 * fix: Jackson 역직렬화 허용 타입을 ArrayList로 제한 --- .claude/rules/api-design.md | 3 + .gitignore | 1 + build.gradle.kts | 3 + .../application/dto/response/MajorResponse.kt | 10 ++ .../dto/response/SchoolResponse.kt | 10 ++ .../exception/CareerNetApiException.kt | 5 + .../exception/UniversityErrorCode.kt | 14 ++ .../application/mapper/UniversityMapper.kt | 22 ++++ .../query/GetUniversityQueryService.kt | 34 +++++ .../university/domain/model/MajorData.kt | 6 + .../university/domain/model/SchoolData.kt | 6 + .../domain/port/UniversityInfoPort.kt | 10 ++ .../infrastructure/CareerNetAdapter.kt | 122 ++++++++++++++++++ .../presentation/UniversityController.kt | 33 +++++ .../presentation/UniversityResponseCode.kt | 13 ++ .../com/weeth/global/config/CacheConfig.kt | 57 ++++++++ .../com/weeth/global/config/SecurityConfig.kt | 2 + .../com/weeth/global/config/SwaggerConfig.kt | 1 + .../config/properties/CareerNetProperties.kt | 14 ++ src/main/resources/application.yml | 4 + .../com/weeth/config/CacheBenchmarkUtil.kt | 70 ++++++++++ .../query/GetUniversityQueryServiceTest.kt | 36 ++++++ .../query/UniversityCacheIntegrationTest.kt | 109 ++++++++++++++++ .../query/UniversityRealApiCacheTest.kt | 69 ++++++++++ src/test/resources/application-test.yml | 4 + 25 files changed, 658 insertions(+) create mode 100644 src/main/kotlin/com/weeth/domain/university/application/dto/response/MajorResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/university/application/dto/response/SchoolResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/university/application/exception/CareerNetApiException.kt create mode 100644 src/main/kotlin/com/weeth/domain/university/application/exception/UniversityErrorCode.kt create mode 100644 src/main/kotlin/com/weeth/domain/university/application/mapper/UniversityMapper.kt create mode 100644 src/main/kotlin/com/weeth/domain/university/application/usecase/query/GetUniversityQueryService.kt create mode 100644 src/main/kotlin/com/weeth/domain/university/domain/model/MajorData.kt create mode 100644 src/main/kotlin/com/weeth/domain/university/domain/model/SchoolData.kt create mode 100644 src/main/kotlin/com/weeth/domain/university/domain/port/UniversityInfoPort.kt create mode 100644 src/main/kotlin/com/weeth/domain/university/infrastructure/CareerNetAdapter.kt create mode 100644 src/main/kotlin/com/weeth/domain/university/presentation/UniversityController.kt create mode 100644 src/main/kotlin/com/weeth/domain/university/presentation/UniversityResponseCode.kt create mode 100644 src/main/kotlin/com/weeth/global/config/CacheConfig.kt create mode 100644 src/main/kotlin/com/weeth/global/config/properties/CareerNetProperties.kt create mode 100644 src/test/kotlin/com/weeth/config/CacheBenchmarkUtil.kt create mode 100644 src/test/kotlin/com/weeth/domain/university/application/usecase/query/GetUniversityQueryServiceTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/university/application/usecase/query/UniversityCacheIntegrationTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/university/application/usecase/query/UniversityRealApiCacheTest.kt diff --git a/.claude/rules/api-design.md b/.claude/rules/api-design.md index 346c158d..bbefc763 100644 --- a/.claude/rules/api-design.md +++ b/.claude/rules/api-design.md @@ -105,6 +105,7 @@ enum class UserResponseCode( | 10 | cardinal | 11000~ | 21000~ | — | | 11 | club | 11100~ | 21100~ | — | | 12 | dashboard | 11200~ | 21200~ | — | +| 13 | university | 11300~ | — | 31300~ | | 90 | jwt/auth | — | 29000~ | — | | 99 | common | — | — | 39900~ | @@ -124,6 +125,7 @@ enum class UserResponseCode( | Cardinal | `CardinalResponseCode` | `110xx` | `domain/cardinal/presentation/` | | Club | `ClubResponseCode` | `111xx` | `domain/club/presentation/` | | Dashboard | `DashboardResponseCode` | `112xx` | `domain/dashboard/presentation/` | +| University | `UniversityResponseCode` | `113xx` | `domain/university/presentation/` | ## Domain Error Codes @@ -141,6 +143,7 @@ enum class UserResponseCode( | Cardinal | `CardinalErrorCode` | `210xx` | `domain/cardinal/application/exception/` | | Club | `ClubErrorCode` | `211xx` | `domain/club/application/exception/` | | Dashboard | `DashboardErrorCode` | `212xx` | `domain/dashboard/application/exception/` | +| University | `UniversityErrorCode` | `313xx` (infra) | `domain/university/application/exception/` | | JWT (Global) | `JwtErrorCode` | `290xx` | `global/auth/jwt/application/exception/` | ## HTTP Methods diff --git a/.gitignore b/.gitignore index 784ceabb..820815ce 100644 --- a/.gitignore +++ b/.gitignore @@ -41,5 +41,6 @@ out/ ### Environment Variables ### src/main/resources/*.env src/main/resources/*.p8 +src/test/resources/*.env .env.local .env.*.local diff --git a/build.gradle.kts b/build.gradle.kts index d37fb3f4..8db554d9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -52,6 +52,9 @@ dependencies { // Redis implementation("org.springframework.boot:spring-boot-starter-data-redis") + // Cache + implementation("org.springframework.boot:spring-boot-starter-cache") + // Actuator + Prometheus implementation("org.springframework.boot:spring-boot-starter-actuator") runtimeOnly("io.micrometer:micrometer-registry-prometheus") diff --git a/src/main/kotlin/com/weeth/domain/university/application/dto/response/MajorResponse.kt b/src/main/kotlin/com/weeth/domain/university/application/dto/response/MajorResponse.kt new file mode 100644 index 00000000..4282dd6e --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/university/application/dto/response/MajorResponse.kt @@ -0,0 +1,10 @@ +package com.weeth.domain.university.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class MajorResponse( + @field:Schema(description = "학과명", example = "컴퓨터공학과") + val majorName: String, + @field:Schema(description = "계열", example = "공학계열") + val category: String, +) diff --git a/src/main/kotlin/com/weeth/domain/university/application/dto/response/SchoolResponse.kt b/src/main/kotlin/com/weeth/domain/university/application/dto/response/SchoolResponse.kt new file mode 100644 index 00000000..ad2b7275 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/university/application/dto/response/SchoolResponse.kt @@ -0,0 +1,10 @@ +package com.weeth.domain.university.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class SchoolResponse( + @field:Schema(description = "학교명", example = "가천대학교") + val schoolName: String, + @field:Schema(description = "지역", example = "경기도") + val region: String, +) diff --git a/src/main/kotlin/com/weeth/domain/university/application/exception/CareerNetApiException.kt b/src/main/kotlin/com/weeth/domain/university/application/exception/CareerNetApiException.kt new file mode 100644 index 00000000..5b1a9b43 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/university/application/exception/CareerNetApiException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.university.application.exception + +import com.weeth.global.common.exception.BaseException + +class CareerNetApiException : BaseException(UniversityErrorCode.CAREER_NET_API_ERROR) diff --git a/src/main/kotlin/com/weeth/domain/university/application/exception/UniversityErrorCode.kt b/src/main/kotlin/com/weeth/domain/university/application/exception/UniversityErrorCode.kt new file mode 100644 index 00000000..1b2d993f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/university/application/exception/UniversityErrorCode.kt @@ -0,0 +1,14 @@ +package com.weeth.domain.university.application.exception + +import com.weeth.global.common.exception.ErrorCodeInterface +import com.weeth.global.common.exception.ExplainError +import org.springframework.http.HttpStatus + +enum class UniversityErrorCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ErrorCodeInterface { + @ExplainError("커리어넷 Open API 호출에 실패했을 때 발생합니다.") + CAREER_NET_API_ERROR(31300, HttpStatus.INTERNAL_SERVER_ERROR, "학교/학과 정보를 불러오는데 실패했습니다."), +} diff --git a/src/main/kotlin/com/weeth/domain/university/application/mapper/UniversityMapper.kt b/src/main/kotlin/com/weeth/domain/university/application/mapper/UniversityMapper.kt new file mode 100644 index 00000000..f17909e8 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/university/application/mapper/UniversityMapper.kt @@ -0,0 +1,22 @@ +package com.weeth.domain.university.application.mapper + +import com.weeth.domain.university.application.dto.response.MajorResponse +import com.weeth.domain.university.application.dto.response.SchoolResponse +import com.weeth.domain.university.domain.model.MajorData +import com.weeth.domain.university.domain.model.SchoolData +import org.springframework.stereotype.Component + +@Component +class UniversityMapper { + fun toSchoolResponse(data: SchoolData): SchoolResponse = + SchoolResponse( + schoolName = data.name, + region = data.region, + ) + + fun toMajorResponse(data: MajorData): MajorResponse = + MajorResponse( + majorName = data.name, + category = data.category, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/university/application/usecase/query/GetUniversityQueryService.kt b/src/main/kotlin/com/weeth/domain/university/application/usecase/query/GetUniversityQueryService.kt new file mode 100644 index 00000000..c3382fc0 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/university/application/usecase/query/GetUniversityQueryService.kt @@ -0,0 +1,34 @@ +package com.weeth.domain.university.application.usecase.query + +import com.weeth.domain.university.application.dto.response.MajorResponse +import com.weeth.domain.university.application.dto.response.SchoolResponse +import com.weeth.domain.university.application.mapper.UniversityMapper +import com.weeth.domain.university.domain.port.UniversityInfoPort +import org.springframework.cache.annotation.Cacheable +import org.springframework.stereotype.Service + +@Service +class GetUniversityQueryService( + private val universityInfoPort: UniversityInfoPort, + private val universityMapper: UniversityMapper, +) { + @Cacheable(value = ["schools"], key = "'all'") + fun getSchools(): List = + universityInfoPort + .getSchools() + .sortedWith(koreanFirstComparator { it.name }) + .map(universityMapper::toSchoolResponse) + + @Cacheable(value = ["majors"], key = "'all'") + fun getMajors(): List = + universityInfoPort + .getMajors() + .sortedWith(koreanFirstComparator { it.name }) + .map(universityMapper::toMajorResponse) + + private fun koreanFirstComparator(selector: (T) -> String): Comparator = + compareBy( + { selector(it).firstOrNull()?.let { c -> c !in '가'..'힣' } ?: true }, + { selector(it) }, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/university/domain/model/MajorData.kt b/src/main/kotlin/com/weeth/domain/university/domain/model/MajorData.kt new file mode 100644 index 00000000..fe271dd7 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/university/domain/model/MajorData.kt @@ -0,0 +1,6 @@ +package com.weeth.domain.university.domain.model + +data class MajorData( + val name: String, + val category: String, +) diff --git a/src/main/kotlin/com/weeth/domain/university/domain/model/SchoolData.kt b/src/main/kotlin/com/weeth/domain/university/domain/model/SchoolData.kt new file mode 100644 index 00000000..f1213089 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/university/domain/model/SchoolData.kt @@ -0,0 +1,6 @@ +package com.weeth.domain.university.domain.model + +data class SchoolData( + val name: String, + val region: String, +) diff --git a/src/main/kotlin/com/weeth/domain/university/domain/port/UniversityInfoPort.kt b/src/main/kotlin/com/weeth/domain/university/domain/port/UniversityInfoPort.kt new file mode 100644 index 00000000..6def3f4f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/university/domain/port/UniversityInfoPort.kt @@ -0,0 +1,10 @@ +package com.weeth.domain.university.domain.port + +import com.weeth.domain.university.domain.model.MajorData +import com.weeth.domain.university.domain.model.SchoolData + +interface UniversityInfoPort { + fun getSchools(): List + + fun getMajors(): List +} diff --git a/src/main/kotlin/com/weeth/domain/university/infrastructure/CareerNetAdapter.kt b/src/main/kotlin/com/weeth/domain/university/infrastructure/CareerNetAdapter.kt new file mode 100644 index 00000000..6cfa2c66 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/university/infrastructure/CareerNetAdapter.kt @@ -0,0 +1,122 @@ +package com.weeth.domain.university.infrastructure + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.weeth.domain.university.application.exception.CareerNetApiException +import com.weeth.domain.university.domain.model.MajorData +import com.weeth.domain.university.domain.model.SchoolData +import com.weeth.domain.university.domain.port.UniversityInfoPort +import com.weeth.global.config.properties.CareerNetProperties +import org.slf4j.LoggerFactory +import org.springframework.core.ParameterizedTypeReference +import org.springframework.stereotype.Component +import org.springframework.web.client.RestClient + +@Component +class CareerNetAdapter( + private val properties: CareerNetProperties, + restClientBuilder: RestClient.Builder, +) : UniversityInfoPort { + private val restClient = + restClientBuilder + .baseUrl(properties.baseUrl) + .build() + + companion object { + private const val SVC_TYPE = "api" + private const val GUBUN = "univ_list" + private const val CONTENT_TYPE = "json" + private const val PER_PAGE = 100 + private val log = LoggerFactory.getLogger(CareerNetAdapter::class.java) + } + + override fun getSchools(): List = + fetchAllPages(::fetchSchoolPage) + .map { SchoolData(it.schoolName, it.region) } + + override fun getMajors(): List = + fetchAllPages(::fetchMajorPage) + .map { MajorData(it.mClass, it.lClass) } + + private fun fetchAllPages(fetchPage: (Int) -> List): List { + val firstPage = fetchPage(1) + val totalCount = firstPage.firstOrNull()?.totalCount?.toIntOrNull() ?: 0 + val totalPages = ((totalCount + PER_PAGE - 1) / PER_PAGE).coerceAtLeast(1) + return firstPage + (2..totalPages).flatMap(fetchPage) + } + + private fun fetchSchoolPage(page: Int): List = + runCatching { + restClient + .get() + .uri { builder -> + builder + .queryParam("apiKey", properties.key) + .queryParam("contentType", CONTENT_TYPE) + .queryParam("svcType", SVC_TYPE) + .queryParam("svcCode", "SCHOOL") + .queryParam("gubun", GUBUN) + .queryParam("thisPage", page) + .queryParam("perPage", PER_PAGE) + .build() + }.retrieve() + .body(object : ParameterizedTypeReference>() {}) + ?.dataSearch + ?.content + ?: emptyList() + }.getOrElse { e -> + log.error("커리어넷 학교 목록 조회 실패", e) + throw CareerNetApiException() + } + + private fun fetchMajorPage(page: Int): List = + runCatching { + restClient + .get() + .uri { builder -> + builder + .queryParam("apiKey", properties.key) + .queryParam("contentType", CONTENT_TYPE) + .queryParam("svcType", SVC_TYPE) + .queryParam("svcCode", "MAJOR") + .queryParam("gubun", GUBUN) + .queryParam("thisPage", page) + .queryParam("perPage", PER_PAGE) + .build() + }.retrieve() + .body(object : ParameterizedTypeReference>() {}) + ?.dataSearch + ?.content + ?: emptyList() + }.getOrElse { e -> + log.error("커리어넷 학과 목록 조회 실패", e) + throw CareerNetApiException() + } +} + +internal interface CareerNetItem { + val totalCount: String +} + +@JsonIgnoreProperties(ignoreUnknown = true) +internal data class CareerNetResponse( + val dataSearch: DataSearch?, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +internal data class DataSearch( + val content: List = emptyList(), +) + +@JsonIgnoreProperties(ignoreUnknown = true) +internal data class CareerNetSchoolItem( + val schoolName: String, + val region: String, + override val totalCount: String, +) : CareerNetItem + +@JsonIgnoreProperties(ignoreUnknown = true) +internal data class CareerNetMajorItem( + val lClass: String, + val mClass: String, + override val totalCount: String, +) : CareerNetItem diff --git a/src/main/kotlin/com/weeth/domain/university/presentation/UniversityController.kt b/src/main/kotlin/com/weeth/domain/university/presentation/UniversityController.kt new file mode 100644 index 00000000..deccc825 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/university/presentation/UniversityController.kt @@ -0,0 +1,33 @@ +package com.weeth.domain.university.presentation + +import com.weeth.domain.university.application.dto.response.MajorResponse +import com.weeth.domain.university.application.dto.response.SchoolResponse +import com.weeth.domain.university.application.exception.UniversityErrorCode +import com.weeth.domain.university.application.usecase.query.GetUniversityQueryService +import com.weeth.domain.university.presentation.UniversityResponseCode.MAJOR_FIND_ALL_SUCCESS +import com.weeth.domain.university.presentation.UniversityResponseCode.SCHOOL_FIND_ALL_SUCCESS +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "UNIVERSITY", description = "학교/학과 API") +@RestController +@RequestMapping("/api/v4/university") +@ApiErrorCodeExample(UniversityErrorCode::class) +class UniversityController( + private val getUniversityQueryService: GetUniversityQueryService, +) { + @GetMapping("/schools") + @Operation(summary = "학교 목록 조회") + fun getSchools(): CommonResponse> = + CommonResponse.success(SCHOOL_FIND_ALL_SUCCESS, getUniversityQueryService.getSchools()) + + @GetMapping("/majors") + @Operation(summary = "학과 목록 조회") + fun getMajors(): CommonResponse> = + CommonResponse.success(MAJOR_FIND_ALL_SUCCESS, getUniversityQueryService.getMajors()) +} diff --git a/src/main/kotlin/com/weeth/domain/university/presentation/UniversityResponseCode.kt b/src/main/kotlin/com/weeth/domain/university/presentation/UniversityResponseCode.kt new file mode 100644 index 00000000..3e53f4ca --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/university/presentation/UniversityResponseCode.kt @@ -0,0 +1,13 @@ +package com.weeth.domain.university.presentation + +import com.weeth.global.common.response.ResponseCodeInterface +import org.springframework.http.HttpStatus + +enum class UniversityResponseCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ResponseCodeInterface { + SCHOOL_FIND_ALL_SUCCESS(11300, HttpStatus.OK, "학교 목록을 성공적으로 조회했습니다."), + MAJOR_FIND_ALL_SUCCESS(11301, HttpStatus.OK, "학과 목록을 성공적으로 조회했습니다."), +} diff --git a/src/main/kotlin/com/weeth/global/config/CacheConfig.kt b/src/main/kotlin/com/weeth/global/config/CacheConfig.kt new file mode 100644 index 00000000..4e65c564 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/config/CacheConfig.kt @@ -0,0 +1,57 @@ +package com.weeth.global.config + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator +import org.springframework.cache.annotation.EnableCaching +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +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 +import java.time.Duration + +/* + Spring Cache 추상화(@Cacheable)를 Redis와 연결하는 설정 + 키: String, 값: JSON 직렬화, 기본 TTL: 7일 + */ +@EnableCaching +@Configuration +class CacheConfig( + private val redisConnectionFactory: RedisConnectionFactory, + private val objectMapper: ObjectMapper, +) { + @Bean + fun cacheManager(): RedisCacheManager { + // Spring Boot 자동 구성 ObjectMapper(KotlinModule 포함)를 기반으로 + // 타입 정보(@class)를 포함한 Redis 전용 ObjectMapper 생성 + val redisObjectMapper = + objectMapper.copy().activateDefaultTyping( + BasicPolymorphicTypeValidator + .builder() + .allowIfSubType("com.weeth.") + .allowIfSubType(java.util.ArrayList::class.java) + .build(), + ObjectMapper.DefaultTyping.NON_FINAL, + ) + + val defaultConfig = + RedisCacheConfiguration + .defaultCacheConfig() + .entryTtl(Duration.ofDays(7)) + .serializeKeysWith( + RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer()), + ).serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer( + GenericJackson2JsonRedisSerializer(redisObjectMapper), + ), + ) + + return RedisCacheManager + .builder(redisConnectionFactory) + .cacheDefaults(defaultConfig) + .build() + } +} diff --git a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt index ebe4e055..6d52e098 100644 --- a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt +++ b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt @@ -52,6 +52,8 @@ class SecurityConfig( .permitAll() .requestMatchers(HttpMethod.GET, "/api/v4/clubs/*") .permitAll() + .requestMatchers(HttpMethod.GET, "/api/v4/university/*") + .permitAll() .requestMatchers( "/admin", "/admin/login", diff --git a/src/main/kotlin/com/weeth/global/config/SwaggerConfig.kt b/src/main/kotlin/com/weeth/global/config/SwaggerConfig.kt index 68274790..6bd31f9a 100644 --- a/src/main/kotlin/com/weeth/global/config/SwaggerConfig.kt +++ b/src/main/kotlin/com/weeth/global/config/SwaggerConfig.kt @@ -43,6 +43,7 @@ private const val SWAGGER_DESCRIPTION = "| Cardinal | 10 | 110xx | 210xx | — |\n" + "| Club | 11 | 111xx | 211xx | — |\n" + "| Dashboard | 12 | 112xx | 212xx | — |\n" + + "| University | 13 | 113xx | — | 313xx |\n" + "| Auth/JWT | 90 | — | 290xx | — |\n\n" + "> 각 API의 상세 응답 예시는 Swagger의 **Responses** 섹션에서 확인하세요." diff --git a/src/main/kotlin/com/weeth/global/config/properties/CareerNetProperties.kt b/src/main/kotlin/com/weeth/global/config/properties/CareerNetProperties.kt new file mode 100644 index 00000000..cd852f54 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/config/properties/CareerNetProperties.kt @@ -0,0 +1,14 @@ +package com.weeth.global.config.properties + +import jakarta.validation.constraints.NotBlank +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.validation.annotation.Validated + +@Validated +@ConfigurationProperties(prefix = "career-net") +data class CareerNetProperties( + @field:NotBlank + val key: String, + @field:NotBlank + val baseUrl: String, +) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7596a5a1..535ad46d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -45,3 +45,7 @@ app: file: cdn-base-url: ${CDN_BASE_URL:} presigned-url-expiration-minutes: 5 + +career-net: + key: ${CAREER_NET_API_KEY} + base-url: https://www.career.go.kr/cnet/openapi/getOpenApi diff --git a/src/test/kotlin/com/weeth/config/CacheBenchmarkUtil.kt b/src/test/kotlin/com/weeth/config/CacheBenchmarkUtil.kt new file mode 100644 index 00000000..884c92d5 --- /dev/null +++ b/src/test/kotlin/com/weeth/config/CacheBenchmarkUtil.kt @@ -0,0 +1,70 @@ +package com.weeth.config + +import kotlin.system.measureTimeMillis + +/** + * Redis Cache 캐시 미스/히트 성능 측정 유틸. + * + * - [benchmarkRounds]: 1번 실제 호출(캐시 미스) + N-1번 캐시 히트로 성능 차이를 측정한다. + * 캐싱 없이 N번 호출했을 경우의 총 시간은 실측값이 아닌 추정치(missTimeMs × N)이다. + * + * 사용법: + * ``` + * val result = CacheBenchmarkUtil.benchmarkRounds( + * cacheName = "schools", + * rounds = 20, + * clearCache = { cacheManager.getCache("schools")?.clear() }, + * ) { getUniversityQueryService.getSchools() } + * println(result) + * result.totalWithCacheMs shouldBeLessThan result.estimatedTotalWithoutCacheMs + * ``` + */ +object CacheBenchmarkUtil { + data class MultiRoundResult( + val cacheName: String, + val rounds: Int, + val missTimeMs: Long, + val avgHitTimeMs: Long, + ) { + // 실측값이 아닌 추정치: 캐싱 없이 N번 호출한다고 가정한 예상 시간 + val estimatedTotalWithoutCacheMs: Long get() = missTimeMs * rounds + val totalWithCacheMs: Long get() = missTimeMs + avgHitTimeMs * (rounds - 1) + val hitRate: Double get() = (rounds - 1).toDouble() / rounds * 100 + val speedup: Long get() = estimatedTotalWithoutCacheMs / totalWithCacheMs.coerceAtLeast(1) + + override fun toString(): String { + val maxMs = estimatedTotalWithoutCacheMs.coerceAtLeast(1) + val noBar = "█".repeat((estimatedTotalWithoutCacheMs * BAR_WIDTH / maxMs).toInt().coerceAtLeast(1)) + val withBar = "█".repeat((totalWithCacheMs * BAR_WIDTH / maxMs).toInt().coerceAtLeast(1)) + return """ + |[CacheBenchmark][$cacheName] $rounds rounds + | without cache │$noBar ~${estimatedTotalWithoutCacheMs}ms (${missTimeMs}ms × $rounds 추정) + | with cache │$withBar ${totalWithCacheMs}ms (${missTimeMs}ms + ${rounds - 1} × ${avgHitTimeMs}ms) + | hit rate: ${"%.1f".format(hitRate)}% (${rounds - 1}/$rounds) + | speedup: ${speedup}x + """.trimMargin() + } + } + + private const val BAR_WIDTH = 40 + + fun benchmarkRounds( + cacheName: String, + rounds: Int, + clearCache: () -> Unit, + block: () -> Unit, + ): MultiRoundResult { + clearCache() + val missTimeMs = measureTimeMillis { block() } + + val hitTimes = (1 until rounds).map { measureTimeMillis { block() } } + val avgHitTimeMs = if (hitTimes.isEmpty()) 0L else hitTimes.average().toLong() + + return MultiRoundResult( + cacheName = cacheName, + rounds = rounds, + missTimeMs = missTimeMs, + avgHitTimeMs = avgHitTimeMs, + ) + } +} diff --git a/src/test/kotlin/com/weeth/domain/university/application/usecase/query/GetUniversityQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/university/application/usecase/query/GetUniversityQueryServiceTest.kt new file mode 100644 index 00000000..8a632569 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/university/application/usecase/query/GetUniversityQueryServiceTest.kt @@ -0,0 +1,36 @@ +package com.weeth.domain.university.application.usecase.query + +import com.weeth.domain.university.application.exception.CareerNetApiException +import com.weeth.domain.university.application.mapper.UniversityMapper +import com.weeth.domain.university.domain.port.UniversityInfoPort +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.mockk.every +import io.mockk.mockk + +class GetUniversityQueryServiceTest : + DescribeSpec({ + val universityInfoPort = mockk() + val universityMapper = mockk() + val queryService = GetUniversityQueryService(universityInfoPort, universityMapper) + + describe("getSchools") { + context("커리어넷 API 오류 시") { + it("CareerNetApiException을 전파한다") { + every { universityInfoPort.getSchools() } throws CareerNetApiException() + + shouldThrow { queryService.getSchools() } + } + } + } + + describe("getMajors") { + context("커리어넷 API 오류 시") { + it("CareerNetApiException을 전파한다") { + every { universityInfoPort.getMajors() } throws CareerNetApiException() + + shouldThrow { queryService.getMajors() } + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/university/application/usecase/query/UniversityCacheIntegrationTest.kt b/src/test/kotlin/com/weeth/domain/university/application/usecase/query/UniversityCacheIntegrationTest.kt new file mode 100644 index 00000000..f780acc9 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/university/application/usecase/query/UniversityCacheIntegrationTest.kt @@ -0,0 +1,109 @@ +package com.weeth.domain.university.application.usecase.query + +import com.ninjasquad.springmockk.MockkBean +import com.weeth.config.TestContainersConfig +import com.weeth.domain.university.domain.model.MajorData +import com.weeth.domain.university.domain.model.SchoolData +import com.weeth.domain.university.domain.port.UniversityInfoPort +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.verify +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.cache.CacheManager +import org.springframework.context.annotation.Import +import org.springframework.test.context.ActiveProfiles + +/* + [캐시 통합 테스트] + - 실제 Redis(Testcontainers)와 Spring Cache AOP를 사용하여 캐시 동작을 검증합니다. + - UniversityInfoPort는 Mock으로 대체하여 실제 CareerNet API를 호출하지 않습니다. + - 각 테스트 전 캐시를 초기화하여 테스트 간 간섭을 방지합니다. + - 성능 벤치마크는 UniversityRealApiCacheTest에서 실제 API를 사용해 측정합니다. + */ +@SpringBootTest +@ActiveProfiles("test") +@Import(TestContainersConfig::class) +class UniversityCacheIntegrationTest( + private val getUniversityQueryService: GetUniversityQueryService, + @MockkBean private val universityInfoPort: UniversityInfoPort, + private val cacheManager: CacheManager, +) : DescribeSpec({ + + val schoolData = (1..20).map { SchoolData("학교$it", "서울") } + val majorData = (1..20).map { MajorData("학과$it", "공학계열") } + + beforeEach { + clearMocks(universityInfoPort) + cacheManager.getCache("schools")?.clear() + cacheManager.getCache("majors")?.clear() + } + + describe("getSchools") { + context("캐시 미스") { + it("UniversityInfoPort를 1번 호출하고 결과를 반환한다") { + every { universityInfoPort.getSchools() } returns schoolData + + val result = getUniversityQueryService.getSchools() + + result shouldHaveSize 20 + verify(exactly = 1) { universityInfoPort.getSchools() } + } + } + + context("캐시 히트") { + it("두 번 호출해도 포트는 1번만 호출된다") { + every { universityInfoPort.getSchools() } returns schoolData + + getUniversityQueryService.getSchools() // cache miss + getUniversityQueryService.getSchools() // cache hit + + verify(exactly = 1) { universityInfoPort.getSchools() } + } + + it("캐시에서 반환한 결과가 포트 응답과 동일하다") { + every { universityInfoPort.getSchools() } returns schoolData + + val first = getUniversityQueryService.getSchools() + val second = getUniversityQueryService.getSchools() + + first shouldBe second + } + } + } + + describe("getMajors") { + context("캐시 미스") { + it("UniversityInfoPort를 1번 호출하고 결과를 반환한다") { + every { universityInfoPort.getMajors() } returns majorData + + val result = getUniversityQueryService.getMajors() + + result shouldHaveSize 20 + verify(exactly = 1) { universityInfoPort.getMajors() } + } + } + + context("캐시 히트") { + it("두 번 호출해도 포트는 1번만 호출된다") { + every { universityInfoPort.getMajors() } returns majorData + + getUniversityQueryService.getMajors() // cache miss + getUniversityQueryService.getMajors() // cache hit + + verify(exactly = 1) { universityInfoPort.getMajors() } + } + + it("캐시에서 반환한 결과가 포트 응답과 동일하다") { + every { universityInfoPort.getMajors() } returns majorData + + val first = getUniversityQueryService.getMajors() + val second = getUniversityQueryService.getMajors() + + first shouldBe second + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/university/application/usecase/query/UniversityRealApiCacheTest.kt b/src/test/kotlin/com/weeth/domain/university/application/usecase/query/UniversityRealApiCacheTest.kt new file mode 100644 index 00000000..971fc54f --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/university/application/usecase/query/UniversityRealApiCacheTest.kt @@ -0,0 +1,69 @@ +package com.weeth.domain.university.application.usecase.query + +import com.weeth.config.CacheBenchmarkUtil +import com.weeth.config.TestContainersConfig +import io.kotest.common.ExperimentalKotest +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.longs.shouldBeLessThan +import org.junit.jupiter.api.Tag +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.cache.CacheManager +import org.springframework.context.annotation.Import +import org.springframework.test.context.ActiveProfiles + +/* + [실제 CareerNet API 캐시 성능 벤치마크] + - 실제 CareerNetAdapter를 사용하여 캐시 미스/히트 성능 차이를 측정합니다. + - 20번 호출 시 캐싱 없을 때(20 × API)와 캐싱 있을 때(1 × API + 19 × Redis) 비교합니다. + - CAREER_NET_API_KEY 환경변수가 없으면 전체 테스트를 스킵합니다. + - 실행: export $(cat .env | xargs) && ./gradlew test --tests "UniversityRealApiCacheTest" + */ +@Tag("performance") +@OptIn(ExperimentalKotest::class) +@SpringBootTest +@ActiveProfiles("test") +@Import(TestContainersConfig::class) +class UniversityRealApiCacheTest( + private val getUniversityQueryService: GetUniversityQueryService, + private val cacheManager: CacheManager, +) : DescribeSpec({ + + val hasRealApiKey = !System.getenv("CAREER_NET_API_KEY").isNullOrBlank() + + beforeEach { + cacheManager.getCache("schools")?.clear() + cacheManager.getCache("majors")?.clear() + } + + describe("getSchools - 실제 API").config(enabled = hasRealApiKey) { + it("캐싱 없이 20번 vs 캐싱 있을 때 20번 성능 비교") { + val result = + CacheBenchmarkUtil.benchmarkRounds( + cacheName = "schools", + rounds = 20, + clearCache = { cacheManager.getCache("schools")?.clear() }, + ) { + getUniversityQueryService.getSchools() + } + println(result) + + result.totalWithCacheMs shouldBeLessThan result.estimatedTotalWithoutCacheMs + } + } + + describe("getMajors - 실제 API").config(enabled = hasRealApiKey) { + it("캐싱 없이 20번 vs 캐싱 있을 때 20번 성능 비교") { + val result = + CacheBenchmarkUtil.benchmarkRounds( + cacheName = "majors", + rounds = 20, + clearCache = { cacheManager.getCache("majors")?.clear() }, + ) { + getUniversityQueryService.getMajors() + } + println(result) + + result.totalWithCacheMs shouldBeLessThan result.estimatedTotalWithoutCacheMs + } + } + }) diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index bd5030bb..318c2cfd 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -42,6 +42,10 @@ auth: keys_uri: https://appleid.apple.com/auth/keys private_key_path: test/AuthKey_TEST.p8 +career-net: + key: ${CAREER_NET_API_KEY:dummy-key-for-test} + base-url: https://www.career.go.kr/cnet/openapi/getOpenApi + cloud: aws: s3: From 1ce537379dae578dbb42d4bd53ce7d9068912f85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=84=EC=88=98=ED=98=84?= <128474444+soo0711@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:16:50 +0900 Subject: [PATCH 28/73] =?UTF-8?q?[WTH-208]=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=ED=94=84=EB=A1=9C=ED=95=84=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: User 엔티티 term, profile 필드 추가 * feat: 약관 동의 api 구현 * feat: 프로필 이미지 업로드/수정 api 구현 * feat: OAuth 프로필 이미지 저장 * feat: 파일 타입에 USER PROFILE 추가 * feat: UserInfo 공통 DTO 추가 * refactor: 사용자 정보가 필요한 Response/Mapper에서 UserInfo로 통일 * feat: 프로필 bio 및 profileImageUrl 필드 추가 * test: 사용자 프로필 필드 변경에 따른 관련 테스트 수정 * style: 린트 적용 * feat: 일정 클럽 필터링 기능 추가 * refactor: 대시보드 내활동 userInfo 네이밍 변경 * style: UserMapper DTO 생성자 named arguments 적용 * fix: CommonResponse nullable 타입 수정 * refactor: agreeTerms 검증 및 할당 로직 개선 * refactor: User 엔티티 생성자 정리 및 lateinit 제거 * fix: UserInfo.role nullable 타입 제거 * fix: AgreeTermsRequest 검증 메시지 추가 * test: User 엔티티 agreeTerms, updateProfileImageUrl, bio 테스트 추가 * fix: bio 필드 최대 길이 30자로 변경 * fix: profileImageUrl 공백 입력 시 null 정규화 * fix: User 생성자 name, profileImageUrl 검증, 정규화 추가 * refactor: User 생성자에서 id 제거 및 관련 테스트 변경 * refactor: bio 필드를 User에서 ClubMember로 이전 * refactor: 동아리별 멤버 프사 추가 및 소셜 프사 제거 * refactor: 소셜 로그인 이름 미제공 시 이메일 앞자리로 대체 가입할 수 있게 수정 * refactor: ClubMember 프로필 이미지 검증 추가 및 대시보드에 동아리 프사 추가 * style: bio 기본값 제거 및 Swagger 예제 추가 * feat: 동아리 멤버 프로필 사진 삭제 API 추가 * test: 동아리 멤버 프로필 사진 삭제 UseCase 테스트 추가 * refactor: 동아리 활동 프로필 수정 API 통합 * refactor: 동아리 멤버 프로필 전체 가입 동아리에 일괄 적용 * docs: 주석 추가 * style: 린트 적용 * refactor: 코드 리뷰 반영 * feat: User 엔티티에 school 필드 추가 * refactor: 응답코드 번호에 맞게 수정 * refactor: 미사용 api 제거 --- .../dto/response/PostDetailResponse.kt | 8 +- .../dto/response/PostListResponse.kt | 8 +- .../board/application/mapper/PostMapper.kt | 7 +- .../dto/request/UpdateMemberBioRequest.kt | 10 ++ .../dto/request/UpdateMemberProfileRequest.kt | 15 ++ .../dto/response/ClubMemberProfileResponse.kt | 4 + .../club/application/mapper/ClubMapper.kt | 6 +- .../command/ManageClubMemberUsecase.kt | 58 +++++++ .../domain/club/domain/entity/ClubMember.kt | 24 +++ .../club/presentation/ClubController.kt | 23 +++ .../club/presentation/ClubResponseCode.kt | 2 + .../dto/response/CommentResponse.kt | 8 +- .../application/mapper/CommentMapper.kt | 4 +- .../dto/response/DashboardMyInfoResponse.kt | 7 +- .../dto/response/DashboardPostResponse.kt | 7 +- .../application/mapper/DashboardMapper.kt | 19 +-- .../usecase/query/GetDashboardQueryService.kt | 15 +- .../domain/file/domain/enums/FileOwnerType.kt | 2 + .../schedule/domain/repository/EventReader.kt | 6 + .../domain/repository/EventRepository.kt | 6 + .../dto/request/AgreeTermsRequest.kt | 13 ++ .../dto/request/UpdateUserProfileRequest.kt | 4 + .../user/application/dto/response/UserInfo.kt | 26 ++++ .../dto/response/UserProfileResponse.kt | 4 + .../user/application/mapper/UserMapper.kt | 26 ++-- .../usecase/command/AgreeTermsUseCase.kt | 20 +++ .../usecase/command/SocialLoginUseCase.kt | 2 +- .../command/UpdateUserProfileUseCase.kt | 1 + .../weeth/domain/user/domain/entity/User.kt | 80 ++++++---- .../domain/user/domain/vo/SocialAuthResult.kt | 1 - .../infrastructure/AppleSocialAuthAdapter.kt | 1 - .../infrastructure/KakaoSocialAuthAdapter.kt | 2 - .../user/presentation/UserController.kt | 13 ++ .../user/presentation/UserResponseCode.kt | 1 + .../global/auth/kakao/dto/KakaoProfile.kt | 2 - .../application/mapper/PostMapperTest.kt | 6 +- .../usecase/command/ManagePostUseCaseTest.kt | 14 +- .../command/MarkNoticeReadUseCaseTest.kt | 2 +- .../usecase/query/GetPostQueryServiceTest.kt | 7 +- .../command/ManageClubMemberUseCaseTest.kt | 144 ++++++++++++++++++ .../query/GetClubMemberQueryServiceTest.kt | 2 +- .../club/fixture/ClubMemberTestFixture.kt | 4 +- .../query/GetCommentQueryServiceTest.kt | 4 +- .../query/GetDashboardQueryServiceTest.kt | 14 +- .../usecase/command/AgreeTermsUseCaseTest.kt | 53 +++++++ .../usecase/command/SocialLoginUseCaseTest.kt | 2 - .../domain/user/domain/entity/UserTest.kt | 84 ++++++++++ .../domain/user/fixture/UserTestFixture.kt | 31 ++-- 48 files changed, 667 insertions(+), 135 deletions(-) create mode 100644 src/main/kotlin/com/weeth/domain/club/application/dto/request/UpdateMemberBioRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/application/dto/request/UpdateMemberProfileRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/dto/request/AgreeTermsRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/dto/response/UserInfo.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/usecase/command/AgreeTermsUseCase.kt create mode 100644 src/test/kotlin/com/weeth/domain/user/application/usecase/command/AgreeTermsUseCaseTest.kt diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt index d28aca24..bbcfad18 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt @@ -2,17 +2,15 @@ package com.weeth.domain.board.application.dto.response import com.weeth.domain.comment.application.dto.response.CommentResponse import com.weeth.domain.file.application.dto.response.FileResponse -import com.weeth.domain.user.domain.enums.Role +import com.weeth.domain.user.application.dto.response.UserInfo import io.swagger.v3.oas.annotations.media.Schema import java.time.LocalDateTime data class PostDetailResponse( @field:Schema(description = "게시글 ID") val id: Long, - @field:Schema(description = "작성자명") - val name: String, - @field:Schema(description = "작성자 역할") - val role: Role, + @field:Schema(description = "작성자 정보") + val author: UserInfo, @field:Schema(description = "제목") val title: String, @field:Schema(description = "내용") diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt index 628a1592..5950c173 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt @@ -1,16 +1,14 @@ package com.weeth.domain.board.application.dto.response -import com.weeth.domain.user.domain.enums.Role +import com.weeth.domain.user.application.dto.response.UserInfo import io.swagger.v3.oas.annotations.media.Schema import java.time.LocalDateTime data class PostListResponse( @field:Schema(description = "게시글 ID") val id: Long, - @field:Schema(description = "작성자명") - val name: String, - @field:Schema(description = "작성자 역할") - val role: Role, + @field:Schema(description = "작성자 정보") + val author: UserInfo, @field:Schema(description = "제목") val title: String, @field:Schema(description = "내용") diff --git a/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt b/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt index c984d77f..58cc3d79 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt @@ -6,6 +6,7 @@ import com.weeth.domain.board.application.dto.response.PostSaveResponse import com.weeth.domain.board.domain.entity.Post import com.weeth.domain.comment.application.dto.response.CommentResponse import com.weeth.domain.file.application.dto.response.FileResponse +import com.weeth.domain.user.application.dto.response.UserInfo import org.springframework.stereotype.Component import java.time.LocalDateTime @@ -19,8 +20,7 @@ class PostMapper { files: List, ) = PostDetailResponse( id = post.id, - name = post.user.name, - role = post.user.role, + author = UserInfo.from(post.user), title = post.title, content = post.content, time = post.modifiedAt, @@ -35,8 +35,7 @@ class PostMapper { now: LocalDateTime, ) = PostListResponse( id = post.id, - name = post.user.name, - role = post.user.role, + author = UserInfo.from(post.user), title = post.title, content = post.content, time = post.modifiedAt, diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/request/UpdateMemberBioRequest.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/request/UpdateMemberBioRequest.kt new file mode 100644 index 00000000..a6f7ffce --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/request/UpdateMemberBioRequest.kt @@ -0,0 +1,10 @@ +package com.weeth.domain.club.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.Size + +data class UpdateMemberBioRequest( + @field:Schema(description = "자기소개", example = "안녕하세요!") + @field:Size(max = 30) + val bio: String?, +) diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/request/UpdateMemberProfileRequest.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/request/UpdateMemberProfileRequest.kt new file mode 100644 index 00000000..c299a5d1 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/request/UpdateMemberProfileRequest.kt @@ -0,0 +1,15 @@ +package com.weeth.domain.club.application.dto.request + +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.Valid +import jakarta.validation.constraints.Size + +data class UpdateMemberProfileRequest( + @field:Schema(description = "프로필 사진") + @field:Valid + val profileImage: FileSaveRequest? = null, + @field:Schema(description = "자기소개", example = "안녕하세요!") + @field:Size(max = 30) + val bio: String? = null, +) diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberProfileResponse.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberProfileResponse.kt index 425d1e30..b7432d6f 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberProfileResponse.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberProfileResponse.kt @@ -26,4 +26,8 @@ data class ClubMemberProfileResponse( val studentId: String, @field:Schema(description = "소속 기수 목록", example = "[6, 7]") val cardinals: List, + @field:Schema(description = "동아리 프로필 이미지 URL", example = "https://cdn.example.com/profile.jpg") + val profileImageUrl: String?, + @field:Schema(description = "자기소개") + val bio: String?, ) diff --git a/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt b/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt index f74e6bff..85858996 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt @@ -55,7 +55,7 @@ class ClubMapper { name = member.user.name, email = member.user.emailValue, tel = member.user.telValue, - school = null, // todo: User 도메인 반영 작업시 학교 정보 추가 + school = member.user.school, department = member.user.department, studentId = member.user.studentId, cardinals = toCardinalNumbers(cardinals), @@ -76,10 +76,12 @@ class ClubMapper { name = member.user.name, email = member.user.emailValue, tel = member.user.telValue, - school = null, // todo: User 도메인 반영 작업시 학교 정보 추가 + school = member.user.school, department = member.user.department, studentId = member.user.studentId, cardinals = toCardinalNumbers(cardinals), + profileImageUrl = member.profileImageUrl, + bio = member.bio, ) private fun toCardinalNumbers(cardinals: List): List { diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt index acc90eb7..46b4e25c 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt @@ -7,9 +7,11 @@ import com.weeth.domain.cardinal.domain.entity.Cardinal import com.weeth.domain.cardinal.domain.repository.CardinalReader import com.weeth.domain.club.application.dto.request.ClubJoinRequest import com.weeth.domain.club.application.dto.request.ClubMemberCardinalSetRequest +import com.weeth.domain.club.application.dto.request.UpdateMemberProfileRequest import com.weeth.domain.club.application.exception.AlreadyJoinedException import com.weeth.domain.club.application.exception.CannotLeaveAsLeadException import com.weeth.domain.club.application.exception.CardinalAlreadySetException +import com.weeth.domain.club.application.exception.ClubMemberNotFoundException import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.club.domain.entity.ClubMemberCardinal import com.weeth.domain.club.domain.enums.MemberRole @@ -18,6 +20,11 @@ import com.weeth.domain.club.domain.repository.ClubMemberRepository import com.weeth.domain.club.domain.repository.ClubRepository import com.weeth.domain.club.domain.service.ClubCodePolicy import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.file.domain.entity.File +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.enums.FileStatus +import com.weeth.domain.file.domain.port.FileAccessUrlPort +import com.weeth.domain.file.domain.repository.FileRepository import com.weeth.domain.session.domain.repository.SessionReader import com.weeth.domain.user.domain.repository.UserReader import org.springframework.stereotype.Service @@ -36,6 +43,8 @@ class ManageClubMemberUsecase( private val attendanceRepository: AttendanceRepository, private val userReader: UserReader, private val clubMemberPolicy: ClubMemberPolicy, + private val fileRepository: FileRepository, + private val fileAccessUrlPort: FileAccessUrlPort, ) { /** * 초대 코드가 일치하면 자동으로 활성 상태로 가입됨 @@ -73,6 +82,55 @@ class ManageClubMemberUsecase( clubMemberRepository.save(member) } + @Transactional + fun updateProfile( + userId: Long, + request: UpdateMemberProfileRequest, + ) { + val members = clubMemberRepository.findActiveByUserId(userId) + if (members.isEmpty()) throw ClubMemberNotFoundException() + + request.profileImage?.let { profileImage -> + fileRepository + .findAllByOwnerTypeAndOwnerIdAndStatus( + FileOwnerType.CLUB_MEMBER_PROFILE, + userId, + FileStatus.UPLOADED, + ).forEach { it.markDeleted() } + + val file = + File.createUploaded( + fileName = profileImage.fileName, + storageKey = profileImage.storageKey, + fileSize = profileImage.fileSize, + contentType = profileImage.contentType, + ownerType = FileOwnerType.CLUB_MEMBER_PROFILE, + ownerId = userId, + ) + fileRepository.save(file) + + val resolvedUrl = fileAccessUrlPort.resolve(file.storageKey.value) + members.forEach { it.updateProfileImageUrl(resolvedUrl) } + } + + request.bio?.let { bio -> members.forEach { it.updateBio(bio) } } + } + + @Transactional + fun deleteProfileImage(userId: Long) { + val members = clubMemberRepository.findActiveByUserId(userId) + if (members.isEmpty()) throw ClubMemberNotFoundException() + + fileRepository + .findAllByOwnerTypeAndOwnerIdAndStatus( + FileOwnerType.CLUB_MEMBER_PROFILE, + userId, + FileStatus.UPLOADED, + ).forEach { it.markDeleted() } + + members.forEach { it.removeProfileImage() } + } + /** * 활동 기수를 최초 1회 설정 * 이미 설정된 경우 CardinalAlreadySetException 발생 diff --git a/src/main/kotlin/com/weeth/domain/club/domain/entity/ClubMember.kt b/src/main/kotlin/com/weeth/domain/club/domain/entity/ClubMember.kt index c37bef70..808d12f8 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/entity/ClubMember.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/entity/ClubMember.kt @@ -63,6 +63,14 @@ class ClubMember( var penaltyCount: Int = 0 private set + @Column(length = 500) + var profileImageUrl: String? = null + private set + + @Column(length = 30) + var bio: String? = null + private set + fun accept() { check(memberStatus == MemberStatus.WAITING) { "대기 상태인 멤버만 승인할 수 있습니다." } memberStatus = MemberStatus.ACTIVE @@ -111,6 +119,22 @@ class ClubMember( penaltyCount++ } + fun updateProfileImageUrl(url: String?) { + val trimmed = url?.trim()?.takeIf { it.isNotBlank() } + require((trimmed?.length ?: 0) <= 500) { "프로필 이미지 URL은 500자 이하여야 합니다." } + this.profileImageUrl = trimmed + } + + fun removeProfileImage() { + this.profileImageUrl = null + } + + fun updateBio(bio: String?) { + val trimmed = bio?.trim()?.takeIf { it.isNotBlank() } + require((trimmed?.length ?: 0) <= 30) { "자기소개는 30자 이하여야 합니다." } + this.bio = trimmed + } + fun decrementPenaltyCount() { if (penaltyCount > 0) { penaltyCount-- diff --git a/src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt b/src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt index 92b2376f..fe9f6fb4 100644 --- a/src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt +++ b/src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt @@ -3,6 +3,7 @@ package com.weeth.domain.club.presentation import com.weeth.domain.club.application.dto.request.ClubCreateRequest import com.weeth.domain.club.application.dto.request.ClubJoinRequest import com.weeth.domain.club.application.dto.request.ClubMemberCardinalSetRequest +import com.weeth.domain.club.application.dto.request.UpdateMemberProfileRequest import com.weeth.domain.club.application.dto.response.ClubInfoResponse import com.weeth.domain.club.application.dto.response.ClubMemberProfileResponse import com.weeth.domain.club.application.dto.response.ClubPublicResponse @@ -23,6 +24,7 @@ import jakarta.validation.Valid import org.springframework.http.HttpStatus import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping @@ -109,6 +111,27 @@ class ClubController( return CommonResponse.success(ClubResponseCode.MEMBER_FIND_ME_SUCCESS, meInfo) } + // TODO: 추후 동아리별 프로필 수정으로 변경 시 clubId 경로 변수 추가 및 단일 ClubMember만 수정하도록 변경 + @PatchMapping("/members/me") + @Operation(summary = "내 클럽 활동 프로필 수정 (프로필 사진, 자기소개)") + fun updateMyProfile( + @Parameter(hidden = true) @CurrentUser userId: Long, + @Valid @RequestBody request: UpdateMemberProfileRequest, + ): CommonResponse { + manageClubMemberUsecase.updateProfile(userId, request) + return CommonResponse.success(ClubResponseCode.MEMBER_PROFILE_UPDATED_SUCCESS) + } + + // TODO: 추후 동아리별 프로필 수정으로 변경 시 clubId 경로 변수 추가 및 단일 ClubMember만 수정하도록 변경 + @DeleteMapping("/members/me/profile-image") + @Operation(summary = "동아리 프로필 사진 삭제") + fun deleteMyProfileImage( + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + manageClubMemberUsecase.deleteProfileImage(userId) + return CommonResponse.success(ClubResponseCode.MEMBER_PROFILE_IMAGE_DELETED_SUCCESS) + } + @PostMapping("/{clubId}/members/me/cardinals") @Operation(summary = "활동 기수 최초 설정 (최초 1회만 가능)") @ResponseStatus(HttpStatus.CREATED) diff --git a/src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt b/src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt index 85109c10..bbb1f2cd 100644 --- a/src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt @@ -25,4 +25,6 @@ enum class ClubResponseCode( CLUB_BACKGROUND_IMAGE_DELETED_SUCCESS(11114, HttpStatus.OK, "동아리 배경 사진이 삭제되었습니다."), MEMBER_APPLY_OB_SUCCESS(11115, HttpStatus.OK, "멤버의 OB 기수 등록이 완료되었습니다."), MEMBER_CARDINAL_SET_SUCCESS(11116, HttpStatus.CREATED, "활동 기수가 설정되었습니다."), + MEMBER_PROFILE_IMAGE_DELETED_SUCCESS(11117, HttpStatus.OK, "동아리 프로필 사진이 삭제되었습니다."), + MEMBER_PROFILE_UPDATED_SUCCESS(11118, HttpStatus.OK, "프로필이 성공적으로 수정되었습니다."), } diff --git a/src/main/kotlin/com/weeth/domain/comment/application/dto/response/CommentResponse.kt b/src/main/kotlin/com/weeth/domain/comment/application/dto/response/CommentResponse.kt index c3df92f9..4759190e 100644 --- a/src/main/kotlin/com/weeth/domain/comment/application/dto/response/CommentResponse.kt +++ b/src/main/kotlin/com/weeth/domain/comment/application/dto/response/CommentResponse.kt @@ -1,17 +1,15 @@ package com.weeth.domain.comment.application.dto.response import com.weeth.domain.file.application.dto.response.FileResponse -import com.weeth.domain.user.domain.enums.Role +import com.weeth.domain.user.application.dto.response.UserInfo import io.swagger.v3.oas.annotations.media.Schema import java.time.LocalDateTime data class CommentResponse( @field:Schema(description = "댓글 ID", example = "1") val id: Long, - @field:Schema(description = "작성자 이름", example = "홍길동") - val name: String, - @field:Schema(description = "작성자 역할", example = "USER") - val role: Role, + @field:Schema(description = "작성자 정보") + val author: UserInfo, @field:Schema(description = "댓글 내용", example = "댓글입니다.") val content: String, @field:Schema(description = "작성 시간", example = "2026-02-18T12:00:00") diff --git a/src/main/kotlin/com/weeth/domain/comment/application/mapper/CommentMapper.kt b/src/main/kotlin/com/weeth/domain/comment/application/mapper/CommentMapper.kt index 80f41d93..6397e5b9 100644 --- a/src/main/kotlin/com/weeth/domain/comment/application/mapper/CommentMapper.kt +++ b/src/main/kotlin/com/weeth/domain/comment/application/mapper/CommentMapper.kt @@ -3,6 +3,7 @@ package com.weeth.domain.comment.application.mapper import com.weeth.domain.comment.application.dto.response.CommentResponse import com.weeth.domain.comment.domain.entity.Comment import com.weeth.domain.file.application.dto.response.FileResponse +import com.weeth.domain.user.application.dto.response.UserInfo import org.springframework.stereotype.Component @Component @@ -14,8 +15,7 @@ class CommentMapper { ): CommentResponse = CommentResponse( id = comment.id, - name = comment.user.name, - role = comment.user.role, + author = UserInfo.from(comment.user), content = comment.content, time = comment.modifiedAt, fileUrls = fileUrls, diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardMyInfoResponse.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardMyInfoResponse.kt index f59dc57a..502c491e 100644 --- a/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardMyInfoResponse.kt +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardMyInfoResponse.kt @@ -1,11 +1,12 @@ package com.weeth.domain.dashboard.application.dto.response +import com.weeth.domain.user.application.dto.response.UserInfo import io.swagger.v3.oas.annotations.media.Schema data class DashboardMyInfoResponse( - @field:Schema(description = "이름", example = "홍길동") - val name: String, - @field:Schema(description = "프로필 이미지 URL") + @field:Schema(description = "사용자 정보") + val userInfo: UserInfo, + @field:Schema(description = "동아리 프로필 이미지 URL") val profileImageUrl: String?, @field:Schema(description = "자기소개") val bio: String?, diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardPostResponse.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardPostResponse.kt index b65cc05d..efbc110c 100644 --- a/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardPostResponse.kt +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardPostResponse.kt @@ -1,16 +1,15 @@ package com.weeth.domain.dashboard.application.dto.response import com.weeth.domain.file.application.dto.response.FileResponse +import com.weeth.domain.user.application.dto.response.UserInfo import io.swagger.v3.oas.annotations.media.Schema import java.time.LocalDateTime data class DashboardPostResponse( @field:Schema(description = "게시글 ID", example = "1") val id: Long, - @field:Schema(description = "작성자 이름", example = "홍길동") - val name: String, - @field:Schema(description = "작성자 프로필 이미지 URL") - val authorProfileImageUrl: String?, + @field:Schema(description = "작성자 정보") + val author: UserInfo, @field:Schema(description = "제목", example = "안녕하세요") val title: String, @field:Schema(description = "내용", example = "오늘은 날씨가 좋네요") diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/mapper/DashboardMapper.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/mapper/DashboardMapper.kt index 77e7c140..4ba76f62 100644 --- a/src/main/kotlin/com/weeth/domain/dashboard/application/mapper/DashboardMapper.kt +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/mapper/DashboardMapper.kt @@ -16,6 +16,7 @@ import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.entity.File import com.weeth.domain.schedule.domain.entity.Event import com.weeth.domain.session.domain.entity.Session +import com.weeth.domain.user.application.dto.response.UserInfo import com.weeth.domain.user.domain.entity.User import com.weeth.global.common.id.TsidBase62Encoder import org.springframework.stereotype.Component @@ -39,12 +40,14 @@ class DashboardMapper( code = club.code, ) - fun toMyInfoResponse(user: User) = - DashboardMyInfoResponse( - name = user.name, - profileImageUrl = null, // TODO: 프로필 이미지 기능 구현 후 연동 - bio = null, // TODO: 자기소개 기능 구현 후 연동 - ) + fun toMyInfoResponse( + user: User, + clubMember: ClubMember, + ) = DashboardMyInfoResponse( + userInfo = UserInfo.from(user), + profileImageUrl = clubMember.profileImageUrl, + bio = clubMember.bio, + ) fun toHomeResponse( club: Club, @@ -95,13 +98,11 @@ class DashboardMapper( fun toPostResponse( post: Post, - authorProfileImage: File?, files: List, now: LocalDateTime, ) = DashboardPostResponse( id = post.id, - name = post.user.name, - authorProfileImageUrl = authorProfileImage?.let { fileMapper.toFileResponse(it).fileUrl }, + author = UserInfo.from(post.user), title = post.title, content = post.content, time = post.createdAt, diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryService.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryService.kt index 9f9e470e..e9c2d678 100644 --- a/src/main/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryService.kt @@ -40,19 +40,18 @@ class GetDashboardQueryService( clubId: Long, userId: Long, ): DashboardHomeResponse { - clubMemberPolicy.getActiveMember(clubId, userId) + val myMember = clubMemberPolicy.getActiveMember(clubId, userId) val club = clubReader.getClubById(clubId) val memberCount = clubMemberReader.countActiveByClubId(clubId) - // TODO: 해당 클럽 회원인지 검증 후 클럽의 오늘 일정만 조회 val todayStart = LocalDate.now().atStartOfDay() val todayEnd = todayStart.plusDays(1).minusNanos(1) - val todayEvents = eventReader.findByDateRange(todayStart, todayEnd) - val todaySessions = sessionReader.findByDateRange(todayStart, todayEnd) + val todayEvents = eventReader.findByClubIdAndDateRange(clubId, todayStart, todayEnd) + val todaySessions = sessionReader.findAllByClubIdAndStartBetween(clubId, todayStart, todayEnd) val myClubs = clubMemberReader.findActiveByUserId(userId).map(dashboardMapper::toMyClubResponse) - val myInfo = dashboardMapper.toMyInfoResponse(userReader.getById(userId)) + val myInfo = dashboardMapper.toMyInfoResponse(userReader.getById(userId), myMember) return dashboardMapper.toHomeResponse( club = club, @@ -84,7 +83,6 @@ class GetDashboardQueryService( return posts.map { post -> dashboardMapper.toPostResponse( post = post, - authorProfileImage = null, // TODO: 유저 프로필 이미지 기능 구현 후 연동 files = filesByPostId[post.id] ?: emptyList(), now = now, ) @@ -110,12 +108,11 @@ class GetDashboardQueryService( ): List { clubMemberPolicy.getActiveMember(clubId, userId) - // TODO: 해당 클럽 회원인지 검증 후 클럽의 일정만 조회 val monthStart = LocalDate.now().withDayOfMonth(1).atStartOfDay() val monthEnd = monthStart.plusMonths(1).minusNanos(1) - val events = eventReader.findByDateRange(monthStart, monthEnd) - val sessions = sessionReader.findByDateRange(monthStart, monthEnd) + val events = eventReader.findByClubIdAndDateRange(clubId, monthStart, monthEnd) + val sessions = sessionReader.findAllByClubIdAndStartBetween(clubId, monthStart, monthEnd) return dashboardMapper.toScheduleResponses(events, sessions) } diff --git a/src/main/kotlin/com/weeth/domain/file/domain/enums/FileOwnerType.kt b/src/main/kotlin/com/weeth/domain/file/domain/enums/FileOwnerType.kt index ea495c6a..79e82b8e 100644 --- a/src/main/kotlin/com/weeth/domain/file/domain/enums/FileOwnerType.kt +++ b/src/main/kotlin/com/weeth/domain/file/domain/enums/FileOwnerType.kt @@ -4,4 +4,6 @@ enum class FileOwnerType { POST, COMMENT, RECEIPT, + USER_PROFILE, + CLUB_MEMBER_PROFILE, } diff --git a/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventReader.kt b/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventReader.kt index 79e6ea5c..e7fe161f 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventReader.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventReader.kt @@ -9,5 +9,11 @@ interface EventReader { end: LocalDateTime, ): List + fun findByClubIdAndDateRange( + clubId: Long, + start: LocalDateTime, + end: LocalDateTime, + ): List + fun findAllByCardinal(cardinal: Int): List } diff --git a/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventRepository.kt b/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventRepository.kt index 2f3aec04..f9ba7014 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventRepository.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventRepository.kt @@ -19,6 +19,12 @@ interface EventRepository : end: LocalDateTime, ): List = findByStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc(end, start) + override fun findByClubIdAndDateRange( + clubId: Long, + start: LocalDateTime, + end: LocalDateTime, + ): List = findByClubIdAndStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc(clubId, end, start) + override fun findAllByCardinal(cardinal: Int): List fun findAllByClubIdAndCardinal( diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/request/AgreeTermsRequest.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/request/AgreeTermsRequest.kt new file mode 100644 index 00000000..23149522 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/request/AgreeTermsRequest.kt @@ -0,0 +1,13 @@ +package com.weeth.domain.user.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.AssertTrue + +data class AgreeTermsRequest( + @field:Schema(description = "서비스 이용약관 동의", example = "true") + @field:AssertTrue(message = "서비스 이용약관에 동의해야 합니다") + val termsAgreed: Boolean, + @field:Schema(description = "개인정보 처리방침 동의", example = "true") + @field:AssertTrue(message = "개인정보 처리방침에 동의해야 합니다") + val privacyAgreed: Boolean, +) diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/request/UpdateUserProfileRequest.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/request/UpdateUserProfileRequest.kt index fdd8fd1c..38d65816 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/dto/request/UpdateUserProfileRequest.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/request/UpdateUserProfileRequest.kt @@ -3,6 +3,7 @@ package com.weeth.domain.user.application.dto.request import io.swagger.v3.oas.annotations.media.Schema import jakarta.validation.constraints.Email import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size data class UpdateUserProfileRequest( @field:Schema(description = "이름", example = "홍길동") @@ -18,6 +19,9 @@ data class UpdateUserProfileRequest( @field:Schema(description = "전화번호", example = "01012345678") @field:NotBlank val tel: String, + @field:Schema(description = "학교", example = "가천대학교") + @field:NotBlank + val school: String, @field:Schema(description = "학과", example = "컴퓨터공학과") @field:NotBlank val department: String, diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserInfo.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserInfo.kt new file mode 100644 index 00000000..16361cfa --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserInfo.kt @@ -0,0 +1,26 @@ +package com.weeth.domain.user.application.dto.response + +import com.weeth.domain.user.domain.entity.User +import com.weeth.domain.user.domain.enums.Role +import io.swagger.v3.oas.annotations.media.Schema + +data class UserInfo( + @field:Schema(description = "사용자 ID", example = "1") + val id: Long, + @field:Schema(description = "이름", example = "홍길동") + val name: String, + @field:Schema(description = "프로필 이미지 URL") + val profileImageUrl: String?, + @field:Schema(description = "권한", example = "USER") + val role: Role, +) { + companion object { + fun from(user: User) = + UserInfo( + id = user.id, + name = user.name, + profileImageUrl = user.profileImageUrl, + role = user.role, + ) + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserProfileResponse.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserProfileResponse.kt index 5e011df0..8bddbf47 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserProfileResponse.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserProfileResponse.kt @@ -14,10 +14,14 @@ data class UserProfileResponse( val studentId: String, @field:Schema(description = "전화번호", example = "01012345678") val tel: String, + @field:Schema(description = "학교", example = "가천대학교") + val school: String, @field:Schema(description = "학과", example = "컴퓨터공학과") val department: String, @field:Schema(description = "소속 기수 목록", example = "[6, 7]") val cardinals: List, @field:Schema(description = "권한", example = "USER", nullable = true) val role: Role?, + @field:Schema(description = "프로필 이미지 URL") + val profileImageUrl: String?, ) diff --git a/src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt b/src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt index 25b0540f..e8e2a1b5 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt @@ -21,21 +21,23 @@ class UserMapper { fun toUserProfileResponse(user: User): UserProfileResponse = UserProfileResponse( - user.id, - user.name, - user.emailValue, - user.studentId, - user.telValue, - user.department, - emptyList(), - user.role, + id = user.id, + name = user.name, + email = user.emailValue, + studentId = user.studentId, + tel = user.telValue, + school = user.school, + department = user.department, + cardinals = emptyList(), + role = user.role, + profileImageUrl = user.profileImageUrl, ) fun toUserSummaryResponse(user: User): UserSummaryResponse = UserSummaryResponse( - user.id, - user.name, - emptyList(), - user.role, + id = user.id, + name = user.name, + cardinals = emptyList(), + role = user.role, ) } diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AgreeTermsUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AgreeTermsUseCase.kt new file mode 100644 index 00000000..593574e6 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AgreeTermsUseCase.kt @@ -0,0 +1,20 @@ +package com.weeth.domain.user.application.usecase.command + +import com.weeth.domain.user.application.dto.request.AgreeTermsRequest +import com.weeth.domain.user.domain.repository.UserRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class AgreeTermsUseCase( + private val userRepository: UserRepository, +) { + @Transactional + fun execute( + userId: Long, + request: AgreeTermsRequest, + ) { + val user = userRepository.getById(userId) + user.agreeTerms(request.termsAgreed, request.privacyAgreed) + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCase.kt index c02fb83b..f114095a 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCase.kt @@ -62,7 +62,7 @@ class SocialLoginUseCase( val user = userRepository.save( User.create( - name = authResult.name ?: "", + name = authResult.name?.takeIf { it.isNotBlank() } ?: email.substringBefore("@"), email = email, status = Status.ACTIVE, // 소셜 로그인으로 회원가입 한 경우 바로 가입 승인 ), diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/UpdateUserProfileUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/UpdateUserProfileUseCase.kt index 52441444..6d1f9f21 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/UpdateUserProfileUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/UpdateUserProfileUseCase.kt @@ -25,6 +25,7 @@ class UpdateUserProfileUseCase( email = Email.from(request.email), studentId = request.studentId, tel = PhoneNumber.from(request.tel), + school = request.school, department = request.department, ) } diff --git a/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt b/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt index 786dfbfd..0a7f1de7 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt @@ -19,7 +19,17 @@ import jakarta.persistence.Table @Entity @Table(name = "users") -class User protected constructor() : BaseEntity() { // todo: 엔티티 정리 (생성자 정리, lateinit 제거 등) +class User( + name: String, + email: Email, + studentId: String = "", + tel: PhoneNumber = PhoneNumber.from(""), + school: String = "", + department: String = "", + status: Status = Status.WAITING, + role: Role = Role.USER, + profileImageUrl: String? = null, +) : BaseEntity() { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "user_id") @@ -27,56 +37,52 @@ class User protected constructor() : BaseEntity() { // todo: 엔티티 정리 ( private set @Column(nullable = false, length = 50) - lateinit var name: String + var name: String = name.trim().also { require(it.isNotBlank()) { "이름은 공백일 수 없습니다." } } private set @Convert(converter = EmailConverter::class) @Column(name = "email", nullable = false, length = 255) - lateinit var email: Email + var email: Email = email private set @Column(nullable = false, length = 20) - lateinit var studentId: String + var studentId: String = studentId private set @Convert(converter = PhoneNumberConverter::class) @Column(name = "tel", nullable = false, length = 20) - lateinit var tel: PhoneNumber + var tel: PhoneNumber = tel + private set + + @Column(nullable = false, length = 50) + var school: String = school private set @Column(nullable = false, length = 100) - lateinit var department: String + var department: String = department private set @Enumerated(EnumType.STRING) @Column(nullable = false, length = 20) - var status: Status = Status.WAITING + var status: Status = status private set @Enumerated(EnumType.STRING) @Column(nullable = false, length = 20) - var role: Role = Role.USER + var role: Role = role private set - constructor( - id: Long = 0L, - name: String, - email: Email, - studentId: String = "", - tel: PhoneNumber = PhoneNumber.from(""), - department: String = "", - status: Status = Status.WAITING, - role: Role = Role.USER, - ) : this() { - this.id = id - this.name = name.trim() - this.email = email - this.studentId = studentId - this.tel = tel - this.department = department - this.status = status - this.role = role - } + @Column(nullable = false) + var termsAgreed: Boolean = false + private set + + @Column(nullable = false) + var privacyAgreed: Boolean = false + private set + + @Column(length = 500) + var profileImageUrl: String? = profileImageUrl?.trim()?.takeIf { it.isNotBlank() } + private set val emailValue: String get() = email.value @@ -98,6 +104,7 @@ class User protected constructor() : BaseEntity() { // todo: 엔티티 정리 ( name.isNotBlank() && studentId.isNotBlank() && telValue.isNotBlank() && + school.isNotBlank() && department.isNotBlank() fun update( @@ -105,6 +112,7 @@ class User protected constructor() : BaseEntity() { // todo: 엔티티 정리 ( email: Email, studentId: String, tel: PhoneNumber, + school: String, department: String, ) { require(name.isNotBlank()) { "이름은 공백일 수 없습니다." } @@ -112,9 +120,23 @@ class User protected constructor() : BaseEntity() { // todo: 엔티티 정리 ( this.email = email this.studentId = studentId this.tel = tel + this.school = school this.department = department } + fun agreeTerms( + termsAgreed: Boolean, + privacyAgreed: Boolean, + ) { + require(termsAgreed && privacyAgreed) { "모든 약관에 동의해야 합니다." } + this.termsAgreed = termsAgreed + this.privacyAgreed = privacyAgreed + } + + fun updateProfileImageUrl(url: String?) { + this.profileImageUrl = url?.trim()?.takeIf { it.isNotBlank() } + } + fun accept() { status = Status.ACTIVE } @@ -135,16 +157,20 @@ class User protected constructor() : BaseEntity() { // todo: 엔티티 정리 ( email: String, studentId: String = "", tel: String = "", + school: String = "", department: String = "", status: Status = Status.WAITING, + profileImageUrl: String? = null, ): User = User( name = name, email = Email.from(email), studentId = studentId, tel = PhoneNumber.from(tel), + school = school, department = department, status = status, + profileImageUrl = profileImageUrl, ) } } diff --git a/src/main/kotlin/com/weeth/domain/user/domain/vo/SocialAuthResult.kt b/src/main/kotlin/com/weeth/domain/user/domain/vo/SocialAuthResult.kt index d34b1bc7..99de1e8e 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/vo/SocialAuthResult.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/vo/SocialAuthResult.kt @@ -7,6 +7,5 @@ data class SocialAuthResult( val providerUserId: String, val email: String, val emailVerified: Boolean, - val profileImageUrl: String?, val name: String?, ) diff --git a/src/main/kotlin/com/weeth/domain/user/infrastructure/AppleSocialAuthAdapter.kt b/src/main/kotlin/com/weeth/domain/user/infrastructure/AppleSocialAuthAdapter.kt index e8911621..c2f416b5 100644 --- a/src/main/kotlin/com/weeth/domain/user/infrastructure/AppleSocialAuthAdapter.kt +++ b/src/main/kotlin/com/weeth/domain/user/infrastructure/AppleSocialAuthAdapter.kt @@ -23,7 +23,6 @@ class AppleSocialAuthAdapter( providerUserId = userInfo.appleId, email = email, emailVerified = userInfo.emailVerified, - profileImageUrl = null, name = providerName, ) } diff --git a/src/main/kotlin/com/weeth/domain/user/infrastructure/KakaoSocialAuthAdapter.kt b/src/main/kotlin/com/weeth/domain/user/infrastructure/KakaoSocialAuthAdapter.kt index b459513b..17a23f15 100644 --- a/src/main/kotlin/com/weeth/domain/user/infrastructure/KakaoSocialAuthAdapter.kt +++ b/src/main/kotlin/com/weeth/domain/user/infrastructure/KakaoSocialAuthAdapter.kt @@ -18,7 +18,6 @@ class KakaoSocialAuthAdapter( val userInfo = kakaoAuthService.getUserInfo(kakaoToken.accessToken) val account = userInfo.kakaoAccount val email = account.email?.trim()?.lowercase() - val profileImageUrl = account.profile?.profileImageUrl?.trim() val providerName = account.profile ?.nickname @@ -34,7 +33,6 @@ class KakaoSocialAuthAdapter( providerUserId = userInfo.id.toString(), email = email, emailVerified = account.isEmailVerified, - profileImageUrl = profileImageUrl, name = providerName, ) } diff --git a/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt b/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt index 37190c4b..ce508d7e 100644 --- a/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt +++ b/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt @@ -1,11 +1,13 @@ package com.weeth.domain.user.presentation +import com.weeth.domain.user.application.dto.request.AgreeTermsRequest import com.weeth.domain.user.application.dto.request.SocialLoginRequest import com.weeth.domain.user.application.dto.request.UpdateUserProfileRequest import com.weeth.domain.user.application.dto.response.SocialLoginResponse import com.weeth.domain.user.application.dto.response.UserProfileResponse import com.weeth.domain.user.application.dto.response.UserSummaryResponse import com.weeth.domain.user.application.exception.UserErrorCode +import com.weeth.domain.user.application.usecase.command.AgreeTermsUseCase import com.weeth.domain.user.application.usecase.command.AuthUserUseCase import com.weeth.domain.user.application.usecase.command.SocialLoginUseCase import com.weeth.domain.user.application.usecase.command.UpdateUserProfileUseCase @@ -36,6 +38,7 @@ class UserController( private val authUserUseCase: AuthUserUseCase, private val socialLoginUseCase: SocialLoginUseCase, private val updateUserProfileUseCase: UpdateUserProfileUseCase, + private val agreeTermsUseCase: AgreeTermsUseCase, private val getUserQueryService: GetUserQueryService, ) { @PostMapping("/social/kakao") @@ -78,6 +81,16 @@ class UserController( ): CommonResponse = CommonResponse.success(UserResponseCode.USER_FIND_BY_ID_SUCCESS, getUserQueryService.findMyInfo(userId)) + @PostMapping("/terms") + @Operation(summary = "약관 동의") + fun agreeTerms( + @RequestBody @Valid request: AgreeTermsRequest, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + agreeTermsUseCase.execute(userId, request) + return CommonResponse.success(UserResponseCode.USER_TERMS_AGREE_SUCCESS) + } + @PatchMapping @Operation(summary = "내 정보 수정") fun update( diff --git a/src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt b/src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt index 29b5bd07..435232ae 100644 --- a/src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt @@ -20,4 +20,5 @@ enum class UserResponseCode( USER_LEAVE_SUCCESS(10909, HttpStatus.OK, "회원 탈퇴가 성공적으로 처리되었습니다."), JWT_REFRESH_SUCCESS(10910, HttpStatus.OK, "토큰 재발급에 성공했습니다."), SOCIAL_LOGIN_SUCCESS(10911, HttpStatus.OK, "소셜 로그인이 성공적으로 처리되었습니다."), + USER_TERMS_AGREE_SUCCESS(10912, HttpStatus.OK, "약관 동의가 성공적으로 처리되었습니다."), } diff --git a/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoProfile.kt b/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoProfile.kt index b21cb5e8..e7ce2ef3 100644 --- a/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoProfile.kt +++ b/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoProfile.kt @@ -5,6 +5,4 @@ import com.fasterxml.jackson.annotation.JsonProperty data class KakaoProfile( @field:JsonProperty("nickname") val nickname: String?, - @field:JsonProperty("profile_image_url") - val profileImageUrl: String?, ) diff --git a/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt b/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt index 4a6eb701..5531f799 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt @@ -4,6 +4,7 @@ import com.weeth.domain.board.domain.entity.Post import com.weeth.domain.comment.application.dto.response.CommentResponse import com.weeth.domain.file.application.dto.response.FileResponse import com.weeth.domain.file.domain.enums.FileStatus +import com.weeth.domain.user.application.dto.response.UserInfo import com.weeth.domain.user.domain.entity.User import com.weeth.domain.user.domain.enums.Role import io.kotest.core.spec.style.DescribeSpec @@ -19,8 +20,10 @@ class PostMapperTest : val user = mockk() val post = mockk() + every { user.id } returns 1L every { user.name } returns "테스터" every { user.role } returns Role.USER + every { user.profileImageUrl } returns null every { post.id } returns 1L every { post.title } returns "제목" @@ -46,8 +49,7 @@ class PostMapperTest : listOf( CommentResponse( id = 10L, - name = "댓글작성자", - role = Role.USER, + author = UserInfo(id = 2L, name = "댓글작성자", profileImageUrl = null, role = Role.USER), content = "댓글", time = LocalDateTime.now(), fileUrls = emptyList(), diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt index 5e686180..60eb7c28 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt @@ -37,6 +37,7 @@ import io.mockk.just import io.mockk.mockk import io.mockk.runs import io.mockk.verify +import org.springframework.test.util.ReflectionTestUtils class ManagePostUseCaseTest : DescribeSpec({ @@ -79,12 +80,13 @@ class ManagePostUseCaseTest : role: Role = Role.USER, ): User = User( - id = id, name = "적순", email = Email.from("test1@test.com"), status = Status.ACTIVE, role = role, - ) + ).apply { + ReflectionTestUtils.setField(this, "id", id) + } beforeTest { clearMocks( @@ -215,7 +217,7 @@ class ManagePostUseCaseTest : } it("files가 있으면 기존 파일을 soft delete 후 교체한다") { - val user = UserTestFixture.createActiveUser1(1L) + val user = createUser(1L, Role.USER) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val clubId = board.club.id val post = PostTestFixture.create(title = "제목", content = "내용", user = user, board = board) @@ -251,7 +253,7 @@ class ManagePostUseCaseTest : } it("title이 null이면 기존 제목을 유지한다") { - val user = UserTestFixture.createActiveUser1(1L) + val user = createUser(1L, Role.USER) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val clubId = board.club.id val post = Post.create("원래 제목", "원래 내용", user, board) @@ -267,7 +269,7 @@ class ManagePostUseCaseTest : } it("content가 null이면 기존 내용을 유지한다") { - val user = UserTestFixture.createActiveUser1(1L) + val user = createUser(1L, Role.USER) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val clubId = board.club.id val post = Post.create("원래 제목", "원래 내용", user, board) @@ -331,7 +333,7 @@ class ManagePostUseCaseTest : describe("delete") { it("삭제 시 첨부 파일과 게시글을 soft delete한다") { - val user = UserTestFixture.createActiveUser1(1L) + val user = createUser(1L, Role.USER) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val clubId = board.club.id val post = PostTestFixture.create(title = "제목", content = "내용", user = user, board = board) diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/MarkNoticeReadUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/MarkNoticeReadUseCaseTest.kt index 54fc525b..e0243796 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/MarkNoticeReadUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/MarkNoticeReadUseCaseTest.kt @@ -47,7 +47,7 @@ class MarkNoticeReadUseCaseTest : val userId = 1L val clubId = 1L val boardId = 1L - val user = UserTestFixture.createActiveUser1(userId) + val user = UserTestFixture.createActiveUser1(1L) val club = ClubTestFixture.createClub().also { ReflectionTestUtils.setField(it, "id", clubId) } val clubMember = ClubTestFixture.createClubMember(club = club, user = user) val noticeBoard = BoardTestFixture.createNoticeBoard(club = club) diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt index aad87cbd..6cf3e75e 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt @@ -20,6 +20,7 @@ import com.weeth.domain.file.domain.entity.File import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.enums.FileStatus import com.weeth.domain.file.domain.repository.FileReader +import com.weeth.domain.user.application.dto.response.UserInfo import com.weeth.domain.user.domain.enums.Role import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.assertions.throwables.shouldThrow @@ -119,8 +120,7 @@ class GetPostQueryServiceTest : val detail = com.weeth.domain.board.application.dto.response.PostDetailResponse( id = 1L, - name = "적순", - role = Role.USER, + author = UserInfo(id = 1L, name = "적순", profileImageUrl = null, role = Role.USER), title = "제목", content = "내용", time = LocalDateTime.now(), @@ -248,8 +248,7 @@ class GetPostQueryServiceTest : val response = com.weeth.domain.board.application.dto.response.PostListResponse( id = 10L, - name = "적순", - role = Role.USER, + author = UserInfo(id = 1L, name = "적순", profileImageUrl = null, role = Role.USER), title = "제목", content = "내용", time = LocalDateTime.now(), diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt index 2df5aa4f..e331d1b7 100644 --- a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt @@ -6,8 +6,10 @@ import com.weeth.domain.cardinal.domain.repository.CardinalReader import com.weeth.domain.cardinal.fixture.CardinalTestFixture import com.weeth.domain.club.application.dto.request.ClubJoinRequest import com.weeth.domain.club.application.dto.request.ClubMemberCardinalSetRequest +import com.weeth.domain.club.application.dto.request.UpdateMemberProfileRequest import com.weeth.domain.club.application.exception.CardinalAlreadySetException import com.weeth.domain.club.application.exception.ClubJoinLimitExceededException +import com.weeth.domain.club.application.exception.ClubMemberNotFoundException import com.weeth.domain.club.domain.entity.ClubMemberCardinal import com.weeth.domain.club.domain.repository.ClubMemberCardinalRepository import com.weeth.domain.club.domain.repository.ClubMemberRepository @@ -15,12 +17,19 @@ import com.weeth.domain.club.domain.repository.ClubRepository import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.club.fixture.ClubMemberTestFixture import com.weeth.domain.club.fixture.ClubTestFixture +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.enums.FileStatus +import com.weeth.domain.file.domain.port.FileAccessUrlPort +import com.weeth.domain.file.domain.repository.FileRepository +import com.weeth.domain.file.fixture.FileTestFixture import com.weeth.domain.session.domain.repository.SessionReader import com.weeth.domain.session.fixture.SessionTestFixture import com.weeth.domain.user.domain.repository.UserReader import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe import io.mockk.clearMocks import io.mockk.every import io.mockk.justRun @@ -37,6 +46,8 @@ class ManageClubMemberUseCaseTest : val attendanceRepository = mockk(relaxed = true) val userReader = mockk() val clubMemberPolicy = mockk() + val fileRepository = mockk() + val fileAccessUrlPort = mockk() val useCase = ManageClubMemberUsecase( @@ -48,6 +59,8 @@ class ManageClubMemberUseCaseTest : attendanceRepository = attendanceRepository, userReader = userReader, clubMemberPolicy = clubMemberPolicy, + fileRepository = fileRepository, + fileAccessUrlPort = fileAccessUrlPort, ) beforeTest { @@ -60,8 +73,139 @@ class ManageClubMemberUseCaseTest : attendanceRepository, userReader, clubMemberPolicy, + fileRepository, + fileAccessUrlPort, ) every { clubMemberRepository.save(any()) } answers { firstArg() } + every { fileRepository.save(any()) } answers { firstArg() } + } + + describe("updateProfile") { + val userId = 10L + val profileImageRequest = + FileSaveRequest( + fileName = "profile.png", + storageKey = "CLUB_MEMBER_PROFILE/2026-03/00000000-0000-0000-0000-000000000000_profile.png", + fileSize = 102400L, + contentType = "image/png", + ) + + context("프로필 사진만 변경할 때") { + it("모든 활성 ClubMember의 기존 파일을 soft delete하고 새 파일로 URL을 업데이트한다") { + val member1 = ClubMemberTestFixture.createActiveMember(id = 1L) + val member2 = ClubMemberTestFixture.createActiveMember(id = 2L) + val existingFile = + FileTestFixture.createFile( + id = 1L, + fileName = "old.png", + ownerType = FileOwnerType.CLUB_MEMBER_PROFILE, + ownerId = userId, + ) + val resolvedUrl = "https://cdn.example.com/profile.png" + + every { clubMemberRepository.findActiveByUserId(userId) } returns listOf(member1, member2) + every { + fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus( + FileOwnerType.CLUB_MEMBER_PROFILE, + userId, + FileStatus.UPLOADED, + ) + } returns listOf(existingFile) + every { fileAccessUrlPort.resolve(any()) } returns resolvedUrl + + useCase.updateProfile(userId, UpdateMemberProfileRequest(profileImage = profileImageRequest)) + + existingFile.status shouldBe FileStatus.DELETED + member1.profileImageUrl shouldBe resolvedUrl + member2.profileImageUrl shouldBe resolvedUrl + verify(exactly = 1) { fileRepository.save(any()) } + } + } + + context("bio만 변경할 때") { + it("모든 활성 ClubMember의 bio를 업데이트하고 파일 관련 작업은 수행하지 않는다") { + val member1 = ClubMemberTestFixture.createActiveMember(id = 1L) + val member2 = ClubMemberTestFixture.createActiveMember(id = 2L) + + every { clubMemberRepository.findActiveByUserId(userId) } returns listOf(member1, member2) + + useCase.updateProfile(userId, UpdateMemberProfileRequest(bio = "안녕하세요!")) + + member1.bio shouldBe "안녕하세요!" + member2.bio shouldBe "안녕하세요!" + verify(exactly = 0) { fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus(any(), any(), any()) } + verify(exactly = 0) { fileRepository.save(any()) } + } + } + + context("bio를 빈 문자열로 보낼 때") { + it("모든 활성 ClubMember의 bio가 null로 저장된다") { + val member1 = ClubMemberTestFixture.createActiveMember(id = 1L) + val member2 = ClubMemberTestFixture.createActiveMember(id = 2L) + + every { clubMemberRepository.findActiveByUserId(userId) } returns listOf(member1, member2) + + useCase.updateProfile(userId, UpdateMemberProfileRequest(bio = "")) + + member1.bio shouldBe null + member2.bio shouldBe null + } + } + + context("활성 동아리 멤버십이 없을 때") { + it("ClubMemberNotFoundException을 던진다") { + every { clubMemberRepository.findActiveByUserId(userId) } returns emptyList() + + shouldThrow { + useCase.updateProfile(userId, UpdateMemberProfileRequest(bio = "안녕하세요!")) + } + } + } + } + + describe("deleteProfileImage") { + val userId = 10L + + context("활성 멤버가 프로필 사진을 삭제할 때") { + it("모든 활성 ClubMember의 파일을 soft delete하고 URL을 null로 만든다") { + val member1 = ClubMemberTestFixture.createActiveMember(id = 1L) + val member2 = ClubMemberTestFixture.createActiveMember(id = 2L) + member1.updateProfileImageUrl("https://cdn.example.com/profile.png") + member2.updateProfileImageUrl("https://cdn.example.com/profile.png") + val existingFile = + FileTestFixture.createFile( + id = 1L, + fileName = "profile.png", + ownerType = FileOwnerType.CLUB_MEMBER_PROFILE, + ownerId = userId, + ) + + every { clubMemberRepository.findActiveByUserId(userId) } returns listOf(member1, member2) + every { + fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus( + FileOwnerType.CLUB_MEMBER_PROFILE, + userId, + FileStatus.UPLOADED, + ) + } returns listOf(existingFile) + + useCase.deleteProfileImage(userId) + + existingFile.status shouldBe FileStatus.DELETED + member1.profileImageUrl shouldBe null + member2.profileImageUrl shouldBe null + } + } + + context("활성 동아리 멤버십이 없을 때") { + it("ClubMemberNotFoundException을 던진다") { + every { clubMemberRepository.findActiveByUserId(userId) } returns emptyList() + + shouldThrow { + useCase.deleteProfileImage(userId) + } + } + } } describe("setInitialCardinals") { diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryServiceTest.kt index 1959362b..3703ad06 100644 --- a/src/test/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryServiceTest.kt @@ -37,7 +37,7 @@ class GetClubMemberQueryServiceTest : val club = ClubTestFixture.createClub() val admin = ClubTestFixture.createClubMember(club = club, memberRole = MemberRole.ADMIN) val member = - ClubTestFixture.createClubMember(club = club, user = UserTestFixture.createActiveUser1(id = 3L)) + ClubTestFixture.createClubMember(club = club, user = UserTestFixture.createActiveUser1(1L)) val cardinal7 = Cardinal.create(club = club, cardinalNumber = 7) val cardinal6 = Cardinal.create(club = club, cardinalNumber = 6) val memberCardinals = diff --git a/src/test/kotlin/com/weeth/domain/club/fixture/ClubMemberTestFixture.kt b/src/test/kotlin/com/weeth/domain/club/fixture/ClubMemberTestFixture.kt index 3971ad06..e6f43faf 100644 --- a/src/test/kotlin/com/weeth/domain/club/fixture/ClubMemberTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/club/fixture/ClubMemberTestFixture.kt @@ -6,9 +6,11 @@ import com.weeth.domain.club.domain.enums.MemberRole import com.weeth.domain.club.domain.enums.MemberStatus import com.weeth.domain.user.domain.entity.User import com.weeth.domain.user.fixture.UserTestFixture +import org.springframework.test.util.ReflectionTestUtils object ClubMemberTestFixture { fun createActiveMember( + id: Long = 0L, club: Club = ClubTestFixture.createClub(), user: User = UserTestFixture.createActiveUser1(), memberRole: MemberRole = MemberRole.USER, @@ -18,7 +20,7 @@ object ClubMemberTestFixture { user = user, memberStatus = MemberStatus.ACTIVE, memberRole = memberRole, - ) + ).also { if (id != 0L) ReflectionTestUtils.setField(it, "id", id) } fun createWaitingMember( club: Club = ClubTestFixture.createClub(), diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt index 4d212e80..4d227382 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt @@ -7,6 +7,7 @@ import com.weeth.domain.comment.fixture.CommentTestFixture import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.repository.FileReader +import com.weeth.domain.user.application.dto.response.UserInfo import com.weeth.domain.user.domain.enums.Role import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.core.spec.style.DescribeSpec @@ -36,8 +37,7 @@ class GetCommentQueryServiceTest : children: List = emptyList(), ) = CommentResponse( id = id, - name = "테스트유저", - role = Role.USER, + author = UserInfo(id = 1L, name = "테스트유저", profileImageUrl = null, role = Role.USER), content = "content", time = LocalDateTime.now(), fileUrls = emptyList(), diff --git a/src/test/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryServiceTest.kt index 3a05bc3d..73e063ac 100644 --- a/src/test/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryServiceTest.kt @@ -63,7 +63,7 @@ class GetDashboardQueryServiceTest : val userId = 1L val club = ClubTestFixture.createClub() val clubMember = ClubTestFixture.createClubMember(club = club) - val user = UserTestFixture.createActiveUser1(userId) + val user = UserTestFixture.createActiveUser1(1L) beforeTest { clearMocks( @@ -84,9 +84,9 @@ class GetDashboardQueryServiceTest : every { clubReader.getClubById(clubId) } returns club every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns clubMember every { clubMemberReader.countActiveByClubId(clubId) } returns 10L - every { eventReader.findByDateRange(any(), any()) } returns emptyList() + every { eventReader.findByClubIdAndDateRange(clubId, any(), any()) } returns emptyList() every { - sessionReader.findByDateRange(any(), any()) + sessionReader.findAllByClubIdAndStartBetween(clubId, any(), any()) } returns emptyList() every { clubMemberReader.findActiveByUserId(userId) } returns listOf(clubMember) every { userReader.getById(userId) } returns user @@ -144,9 +144,9 @@ class GetDashboardQueryServiceTest : every { clubReader.getClubById(clubId) } returns club every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns clubMember every { clubMemberReader.countActiveByClubId(clubId) } returns 5L - every { eventReader.findByDateRange(any(), any()) } returns listOf(event) + every { eventReader.findByClubIdAndDateRange(clubId, any(), any()) } returns listOf(event) every { - sessionReader.findByDateRange(any(), any()) + sessionReader.findAllByClubIdAndStartBetween(clubId, any(), any()) } returns listOf(session) every { clubMemberReader.findActiveByUserId(userId) } returns listOf(clubMember) every { userReader.getById(userId) } returns user @@ -215,9 +215,9 @@ class GetDashboardQueryServiceTest : val event = ScheduleTestFixture.createEvent(id = 1L) every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns clubMember - every { eventReader.findByDateRange(any(), any()) } returns listOf(event) + every { eventReader.findByClubIdAndDateRange(clubId, any(), any()) } returns listOf(event) every { - sessionReader.findByDateRange(any(), any()) + sessionReader.findAllByClubIdAndStartBetween(clubId, any(), any()) } returns emptyList() val result = queryService.getMonthlySchedules(clubId, userId) diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AgreeTermsUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AgreeTermsUseCaseTest.kt new file mode 100644 index 00000000..fc71702f --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AgreeTermsUseCaseTest.kt @@ -0,0 +1,53 @@ +package com.weeth.domain.user.application.usecase.command + +import com.weeth.domain.user.application.dto.request.AgreeTermsRequest +import com.weeth.domain.user.domain.repository.UserRepository +import com.weeth.domain.user.fixture.UserTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk + +class AgreeTermsUseCaseTest : + DescribeSpec({ + val userRepository = mockk() + val useCase = AgreeTermsUseCase(userRepository) + + beforeTest { clearMocks(userRepository) } + + describe("execute") { + context("모든 약관에 동의한 경우") { + it("약관 동의 상태를 true로 변경한다") { + val user = UserTestFixture.createActiveUser1(1L) + every { userRepository.getById(1L) } returns user + + useCase.execute(1L, AgreeTermsRequest(termsAgreed = true, privacyAgreed = true)) + + user.termsAgreed shouldBe true + user.privacyAgreed shouldBe true + } + } + + context("약관에 동의하지 않은 경우") { + it("termsAgreed가 false이면 예외가 발생한다") { + val user = UserTestFixture.createActiveUser1(1L) + every { userRepository.getById(1L) } returns user + + shouldThrow { + useCase.execute(1L, AgreeTermsRequest(termsAgreed = false, privacyAgreed = true)) + } + } + + it("privacyAgreed가 false이면 예외가 발생한다") { + val user = UserTestFixture.createActiveUser1(1L) + every { userRepository.getById(1L) } returns user + + shouldThrow { + useCase.execute(1L, AgreeTermsRequest(termsAgreed = true, privacyAgreed = false)) + } + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCaseTest.kt index bc297816..46a038d9 100644 --- a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCaseTest.kt @@ -55,7 +55,6 @@ class SocialLoginUseCaseTest : providerUserId = "apple-user-1", email = "", emailVerified = false, - profileImageUrl = null, name = null, ) @@ -85,7 +84,6 @@ class SocialLoginUseCaseTest : providerUserId = "apple-user-2", email = "", emailVerified = false, - profileImageUrl = null, name = null, ) diff --git a/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt b/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt index 97b1e236..5dd5753c 100644 --- a/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt @@ -42,6 +42,12 @@ class UserTest : user.status shouldBe Status.ACTIVE } + "생성 시 빈 이름은 예외가 발생한다" { + shouldThrow { + User(name = " ", email = Email.from("test@test.com")) + } + } + "update에서 빈 이름은 예외가 발생한다" { val user = User(name = "test", email = Email.from("test@test.com")) @@ -51,6 +57,7 @@ class UserTest : email = Email.from("test@test.com"), studentId = "123", tel = PhoneNumber.from("01012345678"), + school = "가천대학교", department = "CS", ) } @@ -69,6 +76,7 @@ class UserTest : email = "test@test.com", studentId = "20200001", tel = "01012345678", + school = "가천대학교", department = "CS", ) @@ -96,4 +104,80 @@ class UserTest : user.leave() user.isBannedOrLeft() shouldBe true } + + "agreeTerms 성공 — 모두 true" { + val user = User(name = "test", email = Email.from("test@test.com")) + + user.agreeTerms(termsAgreed = true, privacyAgreed = true) + + user.termsAgreed shouldBe true + user.privacyAgreed shouldBe true + } + + "agreeTerms 실패 — termsAgreed가 false" { + val user = User(name = "test", email = Email.from("test@test.com")) + + shouldThrow { + user.agreeTerms(termsAgreed = false, privacyAgreed = true) + } + } + + "agreeTerms 실패 — privacyAgreed가 false" { + val user = User(name = "test", email = Email.from("test@test.com")) + + shouldThrow { + user.agreeTerms(termsAgreed = true, privacyAgreed = false) + } + } + + "updateProfileImageUrl 정상 설정" { + val user = User(name = "test", email = Email.from("test@test.com")) + + user.updateProfileImageUrl("https://example.com/image.png") + + user.profileImageUrl shouldBe "https://example.com/image.png" + } + + "updateProfileImageUrl null로 초기화" { + val user = + User( + name = "test", + email = Email.from("test@test.com"), + profileImageUrl = "https://example.com/old.png", + ) + + user.updateProfileImageUrl(null) + + user.profileImageUrl shouldBe null + } + + "생성 시 profileImageUrl 공백은 null로 정규화" { + val user = + User( + name = "test", + email = Email.from("test@test.com"), + profileImageUrl = " ", + ) + + user.profileImageUrl shouldBe null + } + + "생성 시 profileImageUrl 앞뒤 공백 제거" { + val user = + User( + name = "test", + email = Email.from("test@test.com"), + profileImageUrl = " https://example.com/image.png ", + ) + + user.profileImageUrl shouldBe "https://example.com/image.png" + } + + "updateProfileImageUrl 앞뒤 공백 제거" { + val user = User(name = "test", email = Email.from("test@test.com")) + + user.updateProfileImageUrl(" https://example.com/image.png ") + + user.profileImageUrl shouldBe "https://example.com/image.png" + } }) diff --git a/src/test/kotlin/com/weeth/domain/user/fixture/UserTestFixture.kt b/src/test/kotlin/com/weeth/domain/user/fixture/UserTestFixture.kt index 30b10b07..dfc49380 100644 --- a/src/test/kotlin/com/weeth/domain/user/fixture/UserTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/user/fixture/UserTestFixture.kt @@ -4,46 +4,47 @@ import com.weeth.domain.user.domain.entity.User import com.weeth.domain.user.domain.enums.Role import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.vo.Email +import org.springframework.test.util.ReflectionTestUtils object UserTestFixture { - fun createActiveUser1(id: Long? = null): User = + fun createActiveUser1(id: Long = 0L): User = User( - id = id ?: 0L, name = "적순", email = Email.from("test1@test.com"), status = Status.ACTIVE, - ) + ).applyId(id) - fun createActiveUser2(id: Long? = null): User = + fun createActiveUser2(id: Long = 0L): User = User( - id = id ?: 0L, name = "적순2", email = Email.from("test2@test.com"), status = Status.ACTIVE, - ) + ).applyId(id) - fun createWaitingUser1(id: Long? = null): User = + fun createWaitingUser1(id: Long = 0L): User = User( - id = id ?: 0L, name = "순적", email = Email.from("test2@test.com"), status = Status.WAITING, - ) + ).applyId(id) - fun createWaitingUser2(id: Long? = null): User = + fun createWaitingUser2(id: Long = 0L): User = User( - id = id ?: 0L, name = "순적2", email = Email.from("test3@test.com"), status = Status.WAITING, - ) + ).applyId(id) - fun createAdmin(id: Long? = null): User = + fun createAdmin(id: Long = 0L): User = User( - id = id ?: 0L, name = "적순", email = Email.from("admin@test.com"), status = Status.ACTIVE, role = Role.ADMIN, - ) + ).applyId(id) + + private fun User.applyId(id: Long): User = + apply { + if (id != 0L) ReflectionTestUtils.setField(this, "id", id) + } } From 9c55e9a8b8da80d9100ed31bffb66931f97b14f2 Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:16:57 +0900 Subject: [PATCH 29/73] =?UTF-8?q?[WTH-201]=20=EB=8F=99=EC=95=84=EB=A6=AC?= =?UTF-8?q?=20=EC=9E=85=EB=A0=A5=20=EC=B6=9C=EB=A0=A5=EA=B0=92=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20(#31)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 주사용 연락처 정보 추가 * refactor: 주사용 연락처 추가 반영 작업 * refactor: Dto 검증 추가 및 미사용 import 문 제거 * refactor: 미사용 import 제거 및 주석 추가 * refactor: 전화번호 global vo로 전환 * refactor: 주사용 연락처 enum 추가 * refactor: 전화번호 예시 "-" 제거 * docs: 주석 추가 * refactor: 주 연락처가 이메일인데, 이메일이 없는 경우 커스텀 예외 처리 --- .../dto/request/ClubCreateRequest.kt | 14 ++++-- .../dto/request/ClubMemberApplyObRequest.kt | 3 ++ .../dto/request/ClubUpdateRequest.kt | 9 +++- .../dto/response/ClubDetailResponse.kt | 5 +- .../dto/response/ClubMemberResponse.kt | 2 - .../application/exception/ClubErrorCode.kt | 3 ++ ...EmailRequiredForPrimaryContactException.kt | 5 ++ .../club/application/mapper/ClubMapper.kt | 1 + .../usecase/command/AdminClubMemberUseCase.kt | 2 +- .../usecase/command/ManageClubUseCase.kt | 22 +++++++++ .../weeth/domain/club/domain/entity/Club.kt | 24 +++++++--- .../club/domain/enums/PrimaryContact.kt | 6 +++ .../domain/club/domain/vo/ClubContact.kt | 43 +++++++++++++---- .../command/UpdateUserProfileUseCase.kt | 2 +- .../weeth/domain/user/domain/entity/User.kt | 4 +- .../user/domain/repository/UserRepository.kt | 2 +- .../common}/converter/PhoneNumberConverter.kt | 4 +- .../common}/vo/PhoneNumber.kt | 2 +- .../usecase/command/ManageClubUseCaseTest.kt | 46 ++++++++++++++++--- .../club/domain/entity/ClubMemberTest.kt | 8 +++- .../domain/club/domain/entity/ClubTest.kt | 13 ++++-- .../domain/club/fixture/ClubTestFixture.kt | 8 +++- .../domain/user/domain/entity/UserTest.kt | 2 +- 23 files changed, 184 insertions(+), 46 deletions(-) create mode 100644 src/main/kotlin/com/weeth/domain/club/application/exception/EmailRequiredForPrimaryContactException.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/domain/enums/PrimaryContact.kt rename src/main/kotlin/com/weeth/{domain/user/domain => global/common}/converter/PhoneNumberConverter.kt (80%) rename src/main/kotlin/com/weeth/{domain/user/domain => global/common}/vo/PhoneNumber.kt (91%) diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubCreateRequest.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubCreateRequest.kt index 0fa69bdb..3a24286a 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubCreateRequest.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubCreateRequest.kt @@ -1,6 +1,8 @@ package com.weeth.domain.club.application.dto.request +import com.weeth.domain.club.domain.enums.PrimaryContact import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.Email import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.Positive import jakarta.validation.constraints.Size @@ -14,15 +16,17 @@ data class ClubCreateRequest( @field:NotBlank @field:Size(max = 50) val schoolName: String, - // TODO: 길이 제한 추가 @field:Schema(description = "동아리 소개", example = "함께 배우고 성장하는 개발자 커뮤니티") + @field:Size(max = 30) val description: String? = null, - // TODO: 얘는 선택 @field:Schema(description = "연락 이메일", example = "club@example.com") + @field:Email val contactEmail: String? = null, - // TODO: 얘는 필수 - @field:Schema(description = "연락 전화번호", example = "010-1234-5678") - val contactPhoneNumber: String? = null, + @field:Schema(description = "연락 전화번호", example = "01012345678") + @field:NotBlank + val contactPhoneNumber: String, + @field:Schema(description = "주 연락처", example = "PHONE") + val primaryContact: PrimaryContact, @field:Schema(description = "가장 최근 기수 번호", example = "7") @field:Positive val currentCardinal: Int, diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubMemberApplyObRequest.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubMemberApplyObRequest.kt index 7568c641..8a9068b6 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubMemberApplyObRequest.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubMemberApplyObRequest.kt @@ -1,10 +1,13 @@ package com.weeth.domain.club.application.dto.request import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.Positive data class ClubMemberApplyObRequest( @field:Schema(description = "대상 멤버 ID", example = "1") + @field:Positive val clubMemberId: Long, @field:Schema(description = "적용할 기수", example = "8") + @field:Positive val cardinal: Int, ) diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubUpdateRequest.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubUpdateRequest.kt index 494af9d9..8820ff4c 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubUpdateRequest.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubUpdateRequest.kt @@ -1,6 +1,8 @@ package com.weeth.domain.club.application.dto.request +import com.weeth.domain.club.domain.enums.PrimaryContact import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.Email import jakarta.validation.constraints.Size data class ClubUpdateRequest( @@ -11,11 +13,16 @@ data class ClubUpdateRequest( @field:Size(max = 50) val schoolName: String? = null, @field:Schema(description = "동아리 소개 (null=변경 안 함)", example = "함께 배우고 성장하는 개발자 커뮤니티") + @field:Size(max = 30) val description: String? = null, @field:Schema(description = "연락 이메일 (null=변경 안 함)", example = "club@example.com") + @field:Email val contactEmail: String? = null, - @field:Schema(description = "연락 전화번호 (null=변경 안 함)", example = "010-1234-5678") + @field:Schema(description = "연락 전화번호 (null=변경 안 함)", example = "01012345678") + @field:Size(min = 1) val contactPhoneNumber: String? = null, + @field:Schema(description = "주 연락처 (null=변경 안 함)", example = "PHONE") + val primaryContact: PrimaryContact? = null, @field:Schema(description = "프로필 사진 URL (null=변경 안 함)", example = "https://s3.amazonaws.com/bucket/profile.jpg") val profileImageUrl: String? = null, @field:Schema(description = "배경 사진 URL (null=변경 안 함)", example = "https://s3.amazonaws.com/bucket/background.jpg") diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubDetailResponse.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubDetailResponse.kt index 0a8d8c92..89f31aac 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubDetailResponse.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubDetailResponse.kt @@ -1,5 +1,6 @@ package com.weeth.domain.club.application.dto.response +import com.weeth.domain.club.domain.enums.PrimaryContact import io.swagger.v3.oas.annotations.media.Schema data class ClubDetailResponse( @@ -15,8 +16,10 @@ data class ClubDetailResponse( val description: String?, @field:Schema(description = "연락 이메일", example = "club@example.com") val contactEmail: String?, - @field:Schema(description = "연락 전화번호", example = "010-1234-5678") + @field:Schema(description = "연락 전화번호", example = "01012345678") val contactPhoneNumber: String?, + @field:Schema(description = "주 연락처", example = "PHONE") + val primaryContact: PrimaryContact, @field:Schema(description = "프로필 사진 URL") val profileImageUrl: String?, @field:Schema(description = "배경 사진 URL") diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberResponse.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberResponse.kt index 635eec8e..72d136f8 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberResponse.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberResponse.kt @@ -1,7 +1,5 @@ package com.weeth.domain.club.application.dto.response -import com.fasterxml.jackson.annotation.JsonIgnore -import com.fasterxml.jackson.annotation.JsonInclude import com.weeth.domain.club.domain.enums.MemberRole import com.weeth.domain.club.domain.enums.MemberStatus import io.swagger.v3.oas.annotations.media.Schema diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt index b5cadd65..418a08bb 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt @@ -41,4 +41,7 @@ enum class ClubErrorCode( @ExplainError("동아리장(LEAD)으로 생성 가능한 동아리 수(최대 1개)를 초과했을 때 발생합니다.") CLUB_CREATE_LIMIT_EXCEEDED(21111, HttpStatus.CONFLICT, "생성 가능한 동아리 수를 초과했습니다."), + + @ExplainError("주 연락처를 이메일로 설정했으나 이메일이 입력되지 않았을 때 발생합니다.") + EMAIL_REQUIRED_FOR_PRIMARY_CONTACT(21112, HttpStatus.BAD_REQUEST, "주 연락처를 이메일로 설정하려면 이메일을 입력해야 합니다."), } diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/EmailRequiredForPrimaryContactException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/EmailRequiredForPrimaryContactException.kt new file mode 100644 index 00000000..508e6cbf --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/EmailRequiredForPrimaryContactException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class EmailRequiredForPrimaryContactException : BaseException(ClubErrorCode.EMAIL_REQUIRED_FOR_PRIMARY_CONTACT) diff --git a/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt b/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt index 85858996..9b3268ae 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt @@ -42,6 +42,7 @@ class ClubMapper { description = club.description, contactEmail = club.clubContact.email, contactPhoneNumber = club.clubContact.phoneNumber, + primaryContact = club.clubContact.primaryContact, profileImageUrl = club.profileImageUrl, backgroundImageUrl = club.backgroundImageUrl, ) diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt index aadf66d0..12895a18 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt @@ -88,7 +88,7 @@ class AdminClubMemberUseCase( if (clubMemberCardinalPolicy.notContains(member, nextCardinal)) { if (clubMemberCardinalPolicy.isLatestOrFirstCardinal(member, nextCardinal)) { - member.resetAttendanceStats() + member.resetAttendanceStats() // TODO: 페널티 카운트도 초기화 initializeAttendances(clubId, member, nextCardinal) } diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt index 735eed20..f227d9ae 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt @@ -5,10 +5,12 @@ import com.weeth.domain.cardinal.domain.enums.CardinalStatus import com.weeth.domain.cardinal.domain.repository.CardinalRepository import com.weeth.domain.club.application.dto.request.ClubCreateRequest import com.weeth.domain.club.application.dto.request.ClubUpdateRequest +import com.weeth.domain.club.application.exception.EmailRequiredForPrimaryContactException import com.weeth.domain.club.domain.entity.Club import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.club.domain.entity.ClubMemberCardinal import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.enums.PrimaryContact import com.weeth.domain.club.domain.repository.ClubMemberCardinalRepository import com.weeth.domain.club.domain.repository.ClubMemberRepository import com.weeth.domain.club.domain.repository.ClubRepository @@ -36,6 +38,7 @@ class ManageClubUseCase( * 새로운 동아리를 생성 * 생성자는 자동으로 LEAD 권한 설정 * 1기부터 currentCardinal기까지 Cardinal을 자동 생성하고, LEAD를 최신 기수에 배정 + * TODO: CDN 도입을 위해 File로 저장하기. */ @Transactional fun create( @@ -46,12 +49,14 @@ class ManageClubUseCase( userReader.getByIdWithLock(userId) clubMemberPolicy.validateCreateLimit(userId) + validatePrimaryContactEmail(request.primaryContact, request.contactEmail) val code = ClubCodePolicy.generateCode() val clubContact = ClubContact.from( email = request.contactEmail, phoneNumber = request.contactPhoneNumber, + primaryContact = request.primaryContact, ) val club = @@ -105,12 +110,20 @@ class ManageClubUseCase( val club = clubRepository.getClubById(clubId) + if (request.primaryContact == PrimaryContact.EMAIL) { + val resolvedEmail = request.contactEmail ?: club.clubContact.email + if (resolvedEmail == null) { + throw EmailRequiredForPrimaryContactException() + } + } + club.update( name = request.name, schoolName = request.schoolName, description = request.description, contactEmail = request.contactEmail, contactPhoneNumber = request.contactPhoneNumber, + primaryContact = request.primaryContact, profileImageUrl = request.profileImageUrl, backgroundImageUrl = request.backgroundImageUrl, ) @@ -149,4 +162,13 @@ class ManageClubUseCase( val club = clubRepository.getClubById(clubId) club.removeBackgroundImage() } + + private fun validatePrimaryContactEmail( + primaryContact: PrimaryContact, + contactEmail: String?, + ) { + if (primaryContact == PrimaryContact.EMAIL && contactEmail == null) { + throw EmailRequiredForPrimaryContactException() + } + } } diff --git a/src/main/kotlin/com/weeth/domain/club/domain/entity/Club.kt b/src/main/kotlin/com/weeth/domain/club/domain/entity/Club.kt index a2b1c35b..9a98ba52 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/entity/Club.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/entity/Club.kt @@ -1,5 +1,6 @@ package com.weeth.domain.club.domain.entity +import com.weeth.domain.club.domain.enums.PrimaryContact import com.weeth.domain.club.domain.vo.ClubContact import com.weeth.global.common.entity.BaseEntity import com.weeth.global.common.id.TsidGenerator @@ -45,7 +46,7 @@ class Club( var code: String = code private set - @Column(length = 100) + @Column(length = 30) var description: String? = description private set @@ -73,6 +74,7 @@ class Club( description: String?, contactEmail: String?, contactPhoneNumber: String?, + primaryContact: PrimaryContact?, profileImageUrl: String?, backgroundImageUrl: String?, ) { @@ -84,20 +86,25 @@ class Club( require(it.isNotBlank()) { "학교 이름은 비어 있을 수 없습니다." } this.schoolName = it.trim() } - description?.let { this.description = it } + description?.let { + require(it.length <= MAX_DESCRIPTION_LENGTH) { "소개글은 ${MAX_DESCRIPTION_LENGTH}자 이하여야 합니다." } + this.description = it + } - updateContact(contactEmail, contactPhoneNumber) + updateContact(contactEmail, contactPhoneNumber, primaryContact) updateImageUrl(profileImageUrl, backgroundImageUrl) } private fun updateContact( contactEmail: String?, contactPhoneNumber: String?, + primaryContact: PrimaryContact?, ) { - if (contactEmail != null || contactPhoneNumber != null) { + if (contactEmail != null || contactPhoneNumber != null || primaryContact != null) { clubContact.update( - email = contactEmail ?: clubContact.email, - phoneNumber = contactPhoneNumber ?: clubContact.phoneNumber, + email = contactEmail, + phoneNumber = contactPhoneNumber, + primaryContact = primaryContact, ) } } @@ -133,6 +140,8 @@ class Club( } companion object { + private const val MAX_DESCRIPTION_LENGTH = 30 + fun create( name: String, code: String, @@ -145,6 +154,9 @@ class Club( require(name.isNotBlank()) { "동아리 이름은 비어 있을 수 없습니다." } require(code.isNotBlank()) { "초대 코드는 비어 있을 수 없습니다." } require(schoolName.isNotBlank()) { "학교 이름은 비어 있을 수 없습니다." } + description?.let { + require(it.length <= MAX_DESCRIPTION_LENGTH) { "소개글은 ${MAX_DESCRIPTION_LENGTH}자 이하여야 합니다." } + } return Club( name = name, code = code, diff --git a/src/main/kotlin/com/weeth/domain/club/domain/enums/PrimaryContact.kt b/src/main/kotlin/com/weeth/domain/club/domain/enums/PrimaryContact.kt new file mode 100644 index 00000000..e74bb7b4 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/domain/enums/PrimaryContact.kt @@ -0,0 +1,6 @@ +package com.weeth.domain.club.domain.enums + +enum class PrimaryContact { + EMAIL, + PHONE, +} diff --git a/src/main/kotlin/com/weeth/domain/club/domain/vo/ClubContact.kt b/src/main/kotlin/com/weeth/domain/club/domain/vo/ClubContact.kt index 6f42ebb0..f9e22d86 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/vo/ClubContact.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/vo/ClubContact.kt @@ -1,41 +1,64 @@ package com.weeth.domain.club.domain.vo +import com.weeth.domain.club.domain.enums.PrimaryContact +import com.weeth.global.common.vo.PhoneNumber import jakarta.persistence.Column import jakarta.persistence.Embeddable +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated /** * 동아리 연락처를 저장하기 위한 VO. - * email 혹은 phoneNumber 둘 중 하나는 반드시 존재해야 하며, 값이 있다면 둘 다 저장 가능. + * 전화번호는 필수이며, 이메일은 선택 사항이다. + * primaryContact는 주 연락처를 나타낸다. EMAIL을 선택하려면 이메일이 반드시 존재해야 한다. */ @Embeddable class ClubContact( email: String? = null, - phoneNumber: String? = null, + phoneNumber: String, + primaryContact: PrimaryContact, ) { @Column(name = "contact_email", length = 100) var email: String? = email private set - @Column(name = "contact_phone_number", length = 20) - var phoneNumber: String? = phoneNumber + @Column(name = "contact_phone_number", nullable = false, length = 20) + var phoneNumber: String = PhoneNumber.from(phoneNumber).value + private set + + @Enumerated(EnumType.STRING) + @Column(name = "primary_contact", nullable = false, length = 10) + var primaryContact: PrimaryContact = primaryContact private set fun update( email: String?, phoneNumber: String?, + primaryContact: PrimaryContact?, ) { - require(email != null || phoneNumber != null) { "이메일 또는 전화번호 중 하나는 반드시 입력해야 합니다." } - this.email = email - this.phoneNumber = phoneNumber + phoneNumber?.let { + this.phoneNumber = PhoneNumber.from(it).value + } + this.email = email ?: this.email + primaryContact?.let { + if (it == PrimaryContact.EMAIL) { + val resolvedEmail = email ?: this.email + require(resolvedEmail != null) { "주 연락처를 이메일로 설정하려면 이메일을 입력해야 합니다." } + } + this.primaryContact = it + } } companion object { fun from( email: String?, - phoneNumber: String?, + phoneNumber: String, + primaryContact: PrimaryContact, ): ClubContact { - require(email != null || phoneNumber != null) { "이메일 또는 전화번호 중 하나는 반드시 입력해야 합니다." } - return ClubContact(email = email, phoneNumber = phoneNumber) + if (primaryContact == PrimaryContact.EMAIL) { + require(email != null) { "주 연락처를 이메일로 설정하려면 이메일을 입력해야 합니다." } + } + return ClubContact(email = email, phoneNumber = phoneNumber, primaryContact = primaryContact) } } } diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/UpdateUserProfileUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/UpdateUserProfileUseCase.kt index 6d1f9f21..867fb514 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/UpdateUserProfileUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/UpdateUserProfileUseCase.kt @@ -5,7 +5,7 @@ import com.weeth.domain.user.application.exception.StudentIdExistsException import com.weeth.domain.user.application.exception.TelExistsException import com.weeth.domain.user.domain.repository.UserRepository import com.weeth.domain.user.domain.vo.Email -import com.weeth.domain.user.domain.vo.PhoneNumber +import com.weeth.global.common.vo.PhoneNumber import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional diff --git a/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt b/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt index 0a7f1de7..b9c78979 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt @@ -1,12 +1,12 @@ package com.weeth.domain.user.domain.entity import com.weeth.domain.user.domain.converter.EmailConverter -import com.weeth.domain.user.domain.converter.PhoneNumberConverter import com.weeth.domain.user.domain.enums.Role import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.vo.Email -import com.weeth.domain.user.domain.vo.PhoneNumber +import com.weeth.global.common.converter.PhoneNumberConverter import com.weeth.global.common.entity.BaseEntity +import com.weeth.global.common.vo.PhoneNumber import jakarta.persistence.Column import jakarta.persistence.Convert import jakarta.persistence.Entity diff --git a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserRepository.kt b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserRepository.kt index f51d40e8..a0f6098c 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserRepository.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserRepository.kt @@ -3,7 +3,7 @@ package com.weeth.domain.user.domain.repository import com.weeth.domain.user.application.exception.UserNotFoundException import com.weeth.domain.user.domain.entity.User import com.weeth.domain.user.domain.vo.Email -import com.weeth.domain.user.domain.vo.PhoneNumber +import com.weeth.global.common.vo.PhoneNumber import jakarta.persistence.LockModeType import jakarta.persistence.QueryHint import org.springframework.data.jpa.repository.JpaRepository diff --git a/src/main/kotlin/com/weeth/domain/user/domain/converter/PhoneNumberConverter.kt b/src/main/kotlin/com/weeth/global/common/converter/PhoneNumberConverter.kt similarity index 80% rename from src/main/kotlin/com/weeth/domain/user/domain/converter/PhoneNumberConverter.kt rename to src/main/kotlin/com/weeth/global/common/converter/PhoneNumberConverter.kt index c90c8129..44a63612 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/converter/PhoneNumberConverter.kt +++ b/src/main/kotlin/com/weeth/global/common/converter/PhoneNumberConverter.kt @@ -1,6 +1,6 @@ -package com.weeth.domain.user.domain.converter +package com.weeth.global.common.converter -import com.weeth.domain.user.domain.vo.PhoneNumber +import com.weeth.global.common.vo.PhoneNumber import jakarta.persistence.AttributeConverter import jakarta.persistence.Converter diff --git a/src/main/kotlin/com/weeth/domain/user/domain/vo/PhoneNumber.kt b/src/main/kotlin/com/weeth/global/common/vo/PhoneNumber.kt similarity index 91% rename from src/main/kotlin/com/weeth/domain/user/domain/vo/PhoneNumber.kt rename to src/main/kotlin/com/weeth/global/common/vo/PhoneNumber.kt index 743dcf7c..979c21d1 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/vo/PhoneNumber.kt +++ b/src/main/kotlin/com/weeth/global/common/vo/PhoneNumber.kt @@ -1,4 +1,4 @@ -package com.weeth.domain.user.domain.vo +package com.weeth.global.common.vo data class PhoneNumber private constructor( val value: String, diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt index 6a7c469f..56edc84e 100644 --- a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt @@ -7,6 +7,7 @@ import com.weeth.domain.club.application.dto.request.ClubCreateRequest import com.weeth.domain.club.application.dto.request.ClubUpdateRequest import com.weeth.domain.club.application.exception.ClubCreateLimitExceededException import com.weeth.domain.club.domain.entity.ClubMemberCardinal +import com.weeth.domain.club.domain.enums.PrimaryContact import com.weeth.domain.club.domain.repository.ClubMemberCardinalRepository import com.weeth.domain.club.domain.repository.ClubMemberRepository import com.weeth.domain.club.domain.repository.ClubRepository @@ -78,6 +79,8 @@ class ManageClubUseCaseTest : name = "테스트", schoolName = "가천대", currentCardinal = 3, + contactPhoneNumber = "01000000000", + primaryContact = PrimaryContact.PHONE, contactEmail = "test@example.com", ), ) @@ -101,6 +104,8 @@ class ManageClubUseCaseTest : name = "테스트", schoolName = "가천대", currentCardinal = 3, + contactPhoneNumber = "01000000000", + primaryContact = PrimaryContact.PHONE, contactEmail = "test@example.com", ), ) @@ -119,6 +124,8 @@ class ManageClubUseCaseTest : name = "테스트", schoolName = "가천대", currentCardinal = 1, + contactPhoneNumber = "01000000000", + primaryContact = PrimaryContact.PHONE, contactEmail = "test@example.com", ), ) @@ -143,6 +150,8 @@ class ManageClubUseCaseTest : schoolName = "가천대학교", description = "소개", currentCardinal = 3, + contactPhoneNumber = "01000000000", + primaryContact = PrimaryContact.PHONE, ), ) } @@ -163,7 +172,12 @@ class ManageClubUseCaseTest : name = "기존 동아리", schoolName = "가천대학교", description = "기존 소개", - clubContact = ClubContact.from(email = "club@example.com", phoneNumber = "010-1111-2222"), + clubContact = + ClubContact.from( + email = "club@example.com", + phoneNumber = "01011112222", + primaryContact = PrimaryContact.PHONE, + ), ) club.update( null, @@ -171,6 +185,7 @@ class ManageClubUseCaseTest : null, null, null, + null, "https://example.com/profile.png", "https://example.com/background.png", ) @@ -183,7 +198,7 @@ class ManageClubUseCaseTest : 10L, ClubUpdateRequest( schoolName = "연세대학교", - contactPhoneNumber = "010-9999-8888", + contactPhoneNumber = "01099998888", ), ) @@ -191,7 +206,7 @@ class ManageClubUseCaseTest : club.schoolName shouldBe "연세대학교" club.description shouldBe "기존 소개" club.clubContact.email shouldBe "club@example.com" - club.clubContact.phoneNumber shouldBe "010-9999-8888" + club.clubContact.phoneNumber shouldBe "01099998888" club.profileImageUrl shouldBe "https://example.com/profile.png" club.backgroundImageUrl shouldBe "https://example.com/background.png" } @@ -200,7 +215,12 @@ class ManageClubUseCaseTest : val club = ClubTestFixture.createClub( description = "기존 소개", - clubContact = ClubContact.from(email = "club@example.com", phoneNumber = null), + clubContact = + ClubContact.from( + email = "club@example.com", + phoneNumber = "01000000000", + primaryContact = PrimaryContact.PHONE, + ), ) every { clubMemberPolicy.requireAdmin(1L, 10L) } returns adminMember every { clubRepository.getClubById(1L) } returns club @@ -211,7 +231,7 @@ class ManageClubUseCaseTest : club.schoolName shouldBe "가천대학교" club.description shouldBe "기존 소개" club.clubContact.email shouldBe "club@example.com" - club.clubContact.phoneNumber shouldBe null + club.clubContact.phoneNumber shouldBe "01000000000" } } @@ -219,7 +239,12 @@ class ManageClubUseCaseTest : it("프로필 사진만 삭제하고 배경 사진은 유지한다") { val club = ClubTestFixture.createClub( - clubContact = ClubContact.from(email = "club@example.com", phoneNumber = "010-1111-2222"), + clubContact = + ClubContact.from( + email = "club@example.com", + phoneNumber = "01011112222", + primaryContact = PrimaryContact.PHONE, + ), ) club.update( null, @@ -227,6 +252,7 @@ class ManageClubUseCaseTest : null, null, null, + null, "https://example.com/profile.png", "https://example.com/background.png", ) @@ -245,7 +271,12 @@ class ManageClubUseCaseTest : it("배경 사진만 삭제하고 프로필 사진은 유지한다") { val club = ClubTestFixture.createClub( - clubContact = ClubContact.from(email = "club@example.com", phoneNumber = "010-1111-2222"), + clubContact = + ClubContact.from( + email = "club@example.com", + phoneNumber = "01011112222", + primaryContact = PrimaryContact.PHONE, + ), ) club.update( null, @@ -253,6 +284,7 @@ class ManageClubUseCaseTest : null, null, null, + null, "https://example.com/profile.png", "https://example.com/background.png", ) diff --git a/src/test/kotlin/com/weeth/domain/club/domain/entity/ClubMemberTest.kt b/src/test/kotlin/com/weeth/domain/club/domain/entity/ClubMemberTest.kt index 51567ca0..4233c774 100644 --- a/src/test/kotlin/com/weeth/domain/club/domain/entity/ClubMemberTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/domain/entity/ClubMemberTest.kt @@ -2,6 +2,7 @@ package com.weeth.domain.club.domain.entity import com.weeth.domain.club.domain.enums.MemberRole import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.enums.PrimaryContact import com.weeth.domain.club.domain.vo.ClubContact import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.assertions.throwables.shouldThrow @@ -15,7 +16,12 @@ class ClubMemberTest : name = "리츠", code = "LEETS001", schoolName = "가천대학교", - clubContact = ClubContact.from(email = "leets@test.com", phoneNumber = null), + clubContact = + ClubContact.from( + email = "leets@test.com", + phoneNumber = "01000000000", + primaryContact = PrimaryContact.PHONE, + ), ) val user = UserTestFixture.createActiveUser1() diff --git a/src/test/kotlin/com/weeth/domain/club/domain/entity/ClubTest.kt b/src/test/kotlin/com/weeth/domain/club/domain/entity/ClubTest.kt index 32b48066..fdd64c0c 100644 --- a/src/test/kotlin/com/weeth/domain/club/domain/entity/ClubTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/domain/entity/ClubTest.kt @@ -1,5 +1,6 @@ package com.weeth.domain.club.domain.entity +import com.weeth.domain.club.domain.enums.PrimaryContact import com.weeth.domain.club.domain.vo.ClubContact import com.weeth.domain.club.fixture.ClubTestFixture import io.hypersistence.tsid.TSID @@ -11,7 +12,12 @@ import io.kotest.matchers.shouldBe class ClubTest : StringSpec({ - val defaultContact = ClubContact.from(email = "leets@test.com", phoneNumber = null) + val defaultContact = + ClubContact.from( + email = "leets@test.com", + phoneNumber = "01000000000", + primaryContact = PrimaryContact.PHONE, + ) "Club 생성 — 이름과 코드를 가진다" { val club = Club.create(name = "리츠", code = "LEETS001", schoolName = "가천대학교", clubContact = defaultContact) @@ -43,6 +49,7 @@ class ClubTest : description = "업데이트된 소개", contactEmail = null, contactPhoneNumber = null, + primaryContact = null, profileImageUrl = null, backgroundImageUrl = null, ) @@ -55,7 +62,7 @@ class ClubTest : val club = Club.create(name = "리츠", code = "LEETS001", schoolName = "가천대학교", clubContact = defaultContact) shouldThrow { - club.update("", null, null, null, null, null, null) + club.update("", null, null, null, null, null, null, null) } } @@ -63,7 +70,7 @@ class ClubTest : val club = Club.create(name = "리츠", code = "LEETS001", schoolName = "가천대학교", clubContact = defaultContact) shouldThrow { - club.update(" ", null, null, null, null, null, null) + club.update(" ", null, null, null, null, null, null, null) } } diff --git a/src/test/kotlin/com/weeth/domain/club/fixture/ClubTestFixture.kt b/src/test/kotlin/com/weeth/domain/club/fixture/ClubTestFixture.kt index 8df0e10b..57f8951c 100644 --- a/src/test/kotlin/com/weeth/domain/club/fixture/ClubTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/club/fixture/ClubTestFixture.kt @@ -4,6 +4,7 @@ import com.weeth.domain.club.domain.entity.Club import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.club.domain.enums.MemberRole import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.enums.PrimaryContact import com.weeth.domain.club.domain.vo.ClubContact import com.weeth.domain.user.fixture.UserTestFixture import org.springframework.test.util.ReflectionTestUtils @@ -14,7 +15,12 @@ object ClubTestFixture { code: String = "TEST001", description: String? = "테스트 동아리 소개", schoolName: String = "가천대학교", - clubContact: ClubContact = ClubContact.from(email = "test@leets.com", phoneNumber = null), + clubContact: ClubContact = + ClubContact.from( + email = "test@leets.com", + phoneNumber = "01000000000", + primaryContact = PrimaryContact.PHONE, + ), ): Club { val club = Club.create( diff --git a/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt b/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt index 5dd5753c..2e3b2d47 100644 --- a/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt @@ -3,7 +3,7 @@ package com.weeth.domain.user.domain.entity import com.weeth.domain.user.domain.enums.Role import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.vo.Email -import com.weeth.domain.user.domain.vo.PhoneNumber +import com.weeth.global.common.vo.PhoneNumber import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.shouldBe From db775aec733ae05176e46ea8a522fc2b42d74a64 Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:09:17 +0900 Subject: [PATCH 30/73] =?UTF-8?q?[WTH-204]=20LEAD=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EC=9D=B4=EC=96=91=20API=20=EA=B5=AC=ED=98=84=20(#33)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: LEAD 권한 이양 API 구현 * feat: LEAD 권한 이양 관련 예외 추가 * refactor: 동시성 방어를 위한 락 추가 * docs: LEAD 관련 설명 추가 * test: 관련 테스트 추가 * docs: lint 설정 * refactor: 사전 검증 추가 * refactor: 커스텀 예외 검증으로 수정 --- .../request/ClubMemberRoleUpdateRequest.kt | 2 +- .../application/exception/ClubErrorCode.kt | 9 ++ .../exception/LeadSelfTransferException.kt | 5 + .../exception/LeadTransferOnlyException.kt | 5 + .../application/exception/NotLeadException.kt | 5 + .../usecase/command/AdminClubMemberUseCase.kt | 22 +++++ .../command/ManageClubMemberUsecase.kt | 2 +- .../domain/club/domain/entity/ClubMember.kt | 13 +++ .../club/domain/service/ClubMemberPolicy.kt | 12 +++ .../club/presentation/ClubAdminController.kt | 12 +++ .../club/presentation/ClubResponseCode.kt | 1 + .../command/AdminClubMemberUseCaseTest.kt | 96 +++++++++++++++++++ .../command/ManageClubMemberUseCaseTest.kt | 22 +++++ .../club/domain/entity/ClubMemberTest.kt | 41 ++++++++ .../club/fixture/ClubMemberTestFixture.kt | 11 +++ 15 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 src/main/kotlin/com/weeth/domain/club/application/exception/LeadSelfTransferException.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/application/exception/LeadTransferOnlyException.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/application/exception/NotLeadException.kt diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubMemberRoleUpdateRequest.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubMemberRoleUpdateRequest.kt index 665b1fa7..4fec742f 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubMemberRoleUpdateRequest.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubMemberRoleUpdateRequest.kt @@ -8,6 +8,6 @@ data class ClubMemberRoleUpdateRequest( @field:Schema(description = "멤버 ID", example = "1") @field:Positive val clubMemberId: Long, - @field:Schema(description = "변경할 권한", example = "ADMIN") + @field:Schema(description = "변경할 권한 (LEAD는 별도 API로 요청해주세요. 또한 LEAD는 사용자 뷰에 보이지 않게 해주세요)", example = "ADMIN") val memberRole: MemberRole, ) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt index 418a08bb..7ba252d9 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt @@ -44,4 +44,13 @@ enum class ClubErrorCode( @ExplainError("주 연락처를 이메일로 설정했으나 이메일이 입력되지 않았을 때 발생합니다.") EMAIL_REQUIRED_FOR_PRIMARY_CONTACT(21112, HttpStatus.BAD_REQUEST, "주 연락처를 이메일로 설정하려면 이메일을 입력해야 합니다."), + + @ExplainError("LEAD가 아닌 멤버가 LEAD 이양을 시도할 때 발생합니다.") + NOT_LEAD(21113, HttpStatus.FORBIDDEN, "LEAD만 권한을 이양할 수 있습니다."), + + @ExplainError("LEAD를 이양이 아닌 직접 역할 변경으로 설정하려 할 때 발생합니다.") + LEAD_TRANSFER_ONLY(21114, HttpStatus.BAD_REQUEST, "LEAD는 이양을 통해서만 변경할 수 있습니다."), + + @ExplainError("자기 자신에게 LEAD 권한을 이양하려 할 때 발생합니다.") + LEAD_SELF_TRANSFER(21115, HttpStatus.BAD_REQUEST, "자기 자신에게 LEAD를 이양할 수 없습니다."), } diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/LeadSelfTransferException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/LeadSelfTransferException.kt new file mode 100644 index 00000000..677f9f50 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/LeadSelfTransferException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class LeadSelfTransferException : BaseException(ClubErrorCode.LEAD_SELF_TRANSFER) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/LeadTransferOnlyException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/LeadTransferOnlyException.kt new file mode 100644 index 00000000..714c2553 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/LeadTransferOnlyException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class LeadTransferOnlyException : BaseException(ClubErrorCode.LEAD_TRANSFER_ONLY) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/NotLeadException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/NotLeadException.kt new file mode 100644 index 00000000..3ff50699 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/NotLeadException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class NotLeadException : BaseException(ClubErrorCode.NOT_LEAD) diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt index 12895a18..55012664 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt @@ -7,8 +7,12 @@ import com.weeth.domain.cardinal.domain.entity.Cardinal import com.weeth.domain.cardinal.domain.repository.CardinalReader import com.weeth.domain.club.application.dto.request.ClubMemberApplyObRequest import com.weeth.domain.club.application.dto.request.ClubMemberRoleUpdateRequest +import com.weeth.domain.club.application.exception.LeadSelfTransferException +import com.weeth.domain.club.application.exception.LeadTransferOnlyException +import com.weeth.domain.club.application.exception.NotLeadException import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.club.domain.entity.ClubMemberCardinal +import com.weeth.domain.club.domain.enums.MemberRole import com.weeth.domain.club.domain.repository.ClubMemberCardinalRepository import com.weeth.domain.club.domain.service.ClubMemberCardinalPolicy import com.weeth.domain.club.domain.service.ClubMemberPolicy @@ -61,9 +65,27 @@ class AdminClubMemberUseCase( clubMemberPolicy.requireAdmin(clubId, userId) val member = clubMemberPolicy.getMemberInClub(clubId, request.clubMemberId) + if (request.memberRole == MemberRole.LEAD) throw LeadTransferOnlyException() + if (member.isLead()) throw LeadTransferOnlyException() member.updateRole(request.memberRole) } + @Transactional + fun transferLead( + clubId: Long, + userId: Long, + targetClubMemberId: Long, + ) { + val currentLead = clubMemberPolicy.getActiveMemberWithLock(clubId, userId) + if (!currentLead.isLead()) throw NotLeadException() + + val target = clubMemberPolicy.getActiveMemberInClubWithLock(clubId, targetClubMemberId) + if (currentLead.id == target.id) throw LeadSelfTransferException() + + currentLead.releaseLead() + target.assignLead() + } + // TODO: setInitialCardinals와 동시 호출 시 출석 중복 생성 가능 — 멤버 단위 락 추가 검토 @Transactional fun applyOb( diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt index 46b4e25c..91b7e671 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt @@ -179,7 +179,7 @@ class ManageClubMemberUsecase( clubId: Long, userId: Long, ) { - val member = clubMemberPolicy.getActiveMember(clubId, userId) + val member = clubMemberPolicy.getActiveMemberWithLock(clubId, userId) if (member.memberRole == MemberRole.LEAD) { throw CannotLeaveAsLeadException() diff --git a/src/main/kotlin/com/weeth/domain/club/domain/entity/ClubMember.kt b/src/main/kotlin/com/weeth/domain/club/domain/entity/ClubMember.kt index 808d12f8..99a33271 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/entity/ClubMember.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/entity/ClubMember.kt @@ -90,11 +90,24 @@ class ClubMember( fun isActive(): Boolean = memberStatus == MemberStatus.ACTIVE fun updateRole(role: MemberRole) { + check(role != MemberRole.LEAD) { "LEAD는 이양을 통해서만 변경할 수 있습니다." } + check(!isLead()) { "LEAD의 권한은 이양을 통해서만 변경할 수 있습니다." } this.memberRole = role } fun isAdmin(): Boolean = memberRole == MemberRole.ADMIN + fun isLead(): Boolean = memberRole == MemberRole.LEAD + + fun releaseLead() { + check(isLead()) { "LEAD만 권한을 내려놓을 수 있습니다." } + this.memberRole = MemberRole.ADMIN + } + + fun assignLead() { + this.memberRole = MemberRole.LEAD + } + fun attend() { attendanceStats.attend() } diff --git a/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt index cfbc6279..fecb9019 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt @@ -105,4 +105,16 @@ class ClubMemberPolicy( private const val MAX_LEAD_CLUBS = 1 private const val MAX_USER_CLUBS = 1 } + + fun getActiveMemberInClubWithLock( + clubId: Long, + clubMemberId: Long, + ): ClubMember { + val member = + clubMemberReader.findByIdWithLock(clubMemberId) + ?: throw ClubMemberNotFoundException() + if (member.club.id != clubId) throw ClubMemberNotInClubException() + if (!member.isActive()) throw MemberNotActiveException() + return member + } } diff --git a/src/main/kotlin/com/weeth/domain/club/presentation/ClubAdminController.kt b/src/main/kotlin/com/weeth/domain/club/presentation/ClubAdminController.kt index ff37eb3e..028b63b4 100644 --- a/src/main/kotlin/com/weeth/domain/club/presentation/ClubAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/club/presentation/ClubAdminController.kt @@ -142,6 +142,18 @@ class ClubAdminController( return CommonResponse.success(ClubResponseCode.MEMBER_ROLE_UPDATED_SUCCESS) } + @PatchMapping("/members/{targetClubMemberId}/lead") + @Operation(summary = "LEAD 권한 이양") + fun transferLead( + @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + @PathVariable targetClubMemberId: Long, + ): CommonResponse { + adminClubMemberUseCase.transferLead(clubId, userId, targetClubMemberId) + return CommonResponse.success(ClubResponseCode.LEAD_TRANSFERRED_SUCCESS) + } + @PatchMapping("/members/apply-ob") @Operation(summary = "멤버 OB 기수 등록") fun applyOb( diff --git a/src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt b/src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt index bbb1f2cd..7dc491b3 100644 --- a/src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt @@ -27,4 +27,5 @@ enum class ClubResponseCode( MEMBER_CARDINAL_SET_SUCCESS(11116, HttpStatus.CREATED, "활동 기수가 설정되었습니다."), MEMBER_PROFILE_IMAGE_DELETED_SUCCESS(11117, HttpStatus.OK, "동아리 프로필 사진이 삭제되었습니다."), MEMBER_PROFILE_UPDATED_SUCCESS(11118, HttpStatus.OK, "프로필이 성공적으로 수정되었습니다."), + LEAD_TRANSFERRED_SUCCESS(11119, HttpStatus.OK, "LEAD 권한이 이양되었습니다."), } diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCaseTest.kt index 04d44573..b2d282b4 100644 --- a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCaseTest.kt @@ -7,12 +7,17 @@ import com.weeth.domain.cardinal.fixture.CardinalTestFixture import com.weeth.domain.club.application.dto.request.ClubMemberApplyObRequest import com.weeth.domain.club.application.dto.request.ClubMemberRoleUpdateRequest import com.weeth.domain.club.application.exception.ClubMemberNotInClubException +import com.weeth.domain.club.application.exception.LeadSelfTransferException +import com.weeth.domain.club.application.exception.LeadTransferOnlyException +import com.weeth.domain.club.application.exception.MemberNotActiveException +import com.weeth.domain.club.application.exception.NotLeadException import com.weeth.domain.club.domain.enums.MemberRole import com.weeth.domain.club.domain.enums.MemberStatus import com.weeth.domain.club.domain.repository.ClubMemberCardinalRepository import com.weeth.domain.club.domain.service.ClubMemberCardinalPolicy import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.club.fixture.ClubMemberTestFixture +import com.weeth.domain.club.fixture.ClubTestFixture import com.weeth.domain.session.domain.repository.SessionReader import com.weeth.domain.session.fixture.SessionTestFixture import io.kotest.assertions.throwables.shouldThrow @@ -22,6 +27,7 @@ import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk import io.mockk.verify +import org.springframework.test.util.ReflectionTestUtils class AdminClubMemberUseCaseTest : DescribeSpec({ @@ -107,6 +113,96 @@ class AdminClubMemberUseCaseTest : member.memberRole shouldBe MemberRole.ADMIN } + + it("LEAD로 직접 변경 시도하면 예외가 발생한다") { + val member = ClubMemberTestFixture.createActiveMember(memberRole = MemberRole.USER) + every { clubMemberPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubMemberPolicy.getMemberInClub(1L, 20L) } returns member + + shouldThrow { + useCase.updateMemberRole( + 1L, + 10L, + ClubMemberRoleUpdateRequest(clubMemberId = 20L, memberRole = MemberRole.LEAD), + ) + } + } + + it("LEAD 멤버의 역할을 직접 변경 시도하면 예외가 발생한다") { + val leadMember = ClubMemberTestFixture.createLeadMember() + every { clubMemberPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubMemberPolicy.getMemberInClub(1L, 20L) } returns leadMember + + shouldThrow { + useCase.updateMemberRole( + 1L, + 10L, + ClubMemberRoleUpdateRequest(clubMemberId = 20L, memberRole = MemberRole.ADMIN), + ) + } + } + } + + describe("transferLead") { + val club = ClubTestFixture.createClub() + + it("LEAD가 다른 멤버에게 권한을 이양한다") { + val lead = ClubMemberTestFixture.createLeadMember(club = club) + val target = ClubMemberTestFixture.createActiveMember(club = club) + ReflectionTestUtils.setField(lead, "id", 10L) + ReflectionTestUtils.setField(target, "id", 20L) + every { clubMemberPolicy.getActiveMemberWithLock(1L, 10L) } returns lead + every { clubMemberPolicy.getActiveMemberInClubWithLock(1L, 20L) } returns target + + useCase.transferLead(1L, 10L, 20L) + + lead.memberRole shouldBe MemberRole.ADMIN + target.memberRole shouldBe MemberRole.LEAD + } + + it("LEAD가 아닌 멤버가 이양을 시도하면 예외가 발생한다") { + val nonLead = ClubMemberTestFixture.createActiveMember(club = club) + every { clubMemberPolicy.getActiveMemberWithLock(1L, 10L) } returns nonLead + + shouldThrow { + useCase.transferLead(1L, 10L, 20L) + } + } + + it("자기 자신에게 이양을 시도하면 예외가 발생한다") { + val lead = ClubMemberTestFixture.createLeadMember(club = club) + ReflectionTestUtils.setField(lead, "id", 10L) + every { clubMemberPolicy.getActiveMemberWithLock(1L, 10L) } returns lead + every { clubMemberPolicy.getActiveMemberInClubWithLock(1L, 10L) } returns lead + + shouldThrow { + useCase.transferLead(1L, 10L, 10L) + } + } + + it("비활성 멤버에게 이양을 시도하면 예외가 발생한다") { + val lead = ClubMemberTestFixture.createLeadMember(club = club) + every { clubMemberPolicy.getActiveMemberWithLock(1L, 10L) } returns lead + every { + clubMemberPolicy.getActiveMemberInClubWithLock(1L, 20L) + } throws MemberNotActiveException() + + shouldThrow { + useCase.transferLead(1L, 10L, 20L) + } + } + + it("존재하지 않는 멤버에게 이양을 시도하면 예외가 발생한다") { + val lead = ClubMemberTestFixture.createLeadMember(club = club) + every { clubMemberPolicy.getActiveMemberWithLock(1L, 10L) } returns lead + every { + clubMemberPolicy.getActiveMemberInClubWithLock(1L, 99L) + } throws ClubMemberNotInClubException() + + shouldThrow { + useCase.transferLead(1L, 10L, 99L) + } + } } describe("applyOb") { diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt index e331d1b7..77c9cff7 100644 --- a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt @@ -7,10 +7,12 @@ import com.weeth.domain.cardinal.fixture.CardinalTestFixture import com.weeth.domain.club.application.dto.request.ClubJoinRequest import com.weeth.domain.club.application.dto.request.ClubMemberCardinalSetRequest import com.weeth.domain.club.application.dto.request.UpdateMemberProfileRequest +import com.weeth.domain.club.application.exception.CannotLeaveAsLeadException import com.weeth.domain.club.application.exception.CardinalAlreadySetException import com.weeth.domain.club.application.exception.ClubJoinLimitExceededException import com.weeth.domain.club.application.exception.ClubMemberNotFoundException import com.weeth.domain.club.domain.entity.ClubMemberCardinal +import com.weeth.domain.club.domain.enums.MemberStatus import com.weeth.domain.club.domain.repository.ClubMemberCardinalRepository import com.weeth.domain.club.domain.repository.ClubMemberRepository import com.weeth.domain.club.domain.repository.ClubRepository @@ -361,6 +363,26 @@ class ManageClubMemberUseCaseTest : } } + describe("leave") { + it("LEAD 멤버가 탈퇴를 시도하면 예외가 발생한다") { + val leadMember = ClubMemberTestFixture.createLeadMember() + every { clubMemberPolicy.getActiveMemberWithLock(1L, 10L) } returns leadMember + + shouldThrow { + useCase.leave(1L, 10L) + } + } + + it("일반 멤버가 탈퇴하면 LEFT 상태로 전환된다") { + val member = ClubMemberTestFixture.createActiveMember() + every { clubMemberPolicy.getActiveMemberWithLock(1L, 10L) } returns member + + useCase.leave(1L, 10L) + + member.memberStatus shouldBe MemberStatus.LEFT + } + } + describe("join") { context("이미 USER로 1개 동아리에 가입한 사용자가 가입 시도하는 경우") { it("ClubJoinLimitExceededException이 발생한다") { diff --git a/src/test/kotlin/com/weeth/domain/club/domain/entity/ClubMemberTest.kt b/src/test/kotlin/com/weeth/domain/club/domain/entity/ClubMemberTest.kt index 4233c774..043aae09 100644 --- a/src/test/kotlin/com/weeth/domain/club/domain/entity/ClubMemberTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/domain/entity/ClubMemberTest.kt @@ -190,4 +190,45 @@ class ClubMemberTest : member.leave() } } + + "releaseLead — LEAD 멤버를 ADMIN으로 변경한다" { + val member = ClubMember(club = club, user = user, memberRole = MemberRole.LEAD) + + member.releaseLead() + + member.memberRole shouldBe MemberRole.ADMIN + } + + "releaseLead — LEAD가 아닌 멤버가 호출하면 예외가 발생한다" { + val member = ClubMember(club = club, user = user, memberRole = MemberRole.ADMIN) + + shouldThrow { + member.releaseLead() + } + } + + "assignLead — 멤버를 LEAD로 변경한다" { + val member = ClubMember(club = club, user = user) + member.accept() + + member.assignLead() + + member.memberRole shouldBe MemberRole.LEAD + } + + "updateRole — LEAD로 직접 변경 시도하면 예외가 발생한다" { + val member = ClubMember(club = club, user = user) + + shouldThrow { + member.updateRole(MemberRole.LEAD) + } + } + + "updateRole — LEAD 멤버의 역할을 직접 변경 시도하면 예외가 발생한다" { + val member = ClubMember(club = club, user = user, memberRole = MemberRole.LEAD) + + shouldThrow { + member.updateRole(MemberRole.ADMIN) + } + } }) diff --git a/src/test/kotlin/com/weeth/domain/club/fixture/ClubMemberTestFixture.kt b/src/test/kotlin/com/weeth/domain/club/fixture/ClubMemberTestFixture.kt index e6f43faf..7bac729f 100644 --- a/src/test/kotlin/com/weeth/domain/club/fixture/ClubMemberTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/club/fixture/ClubMemberTestFixture.kt @@ -43,4 +43,15 @@ object ClubMemberTestFixture { memberStatus = MemberStatus.ACTIVE, memberRole = MemberRole.ADMIN, ) + + fun createLeadMember( + club: Club = ClubTestFixture.createClub(), + user: User = UserTestFixture.createActiveUser1(), + ): ClubMember = + ClubMember( + club = club, + user = user, + memberStatus = MemberStatus.ACTIVE, + memberRole = MemberRole.LEAD, + ) } From 9af4bb22c92286622aa24c2d8755931b37e50b67 Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:19:44 +0900 Subject: [PATCH 31/73] =?UTF-8?q?[WTH-196]=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=20=EC=88=98=EC=A0=95,=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EA=B4=80=EB=A0=A8=20=ED=95=84=EB=93=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: JWT Token에서 Role 제거 * refactor: CurrentUserRole 관련 파일 제거 * refactor: Board 도메인 Role 제거 및 LEAD 접근 제어 수정 * refactor: Board 도메인 Role 제거 * refactor: user.role, profileimage 제거 * refactor: 작성자 정보를 매핑하기 위한 ClubMember 조회 메서드 추가 * refactor: User.Role -> MemberRole로 전환 * refactor: UserInfo 반환을 위한 매핑 로직 추가 * test: 테스트 일괄 반영 * chore: ADMIN 경로 검증에서 일반 인증 경로로 수정 * chore: 대체 예정 Deprecated 처리 * refactor: storageKey를 저장하는 방식으로 수정 * refactor: storageKey를 저장하는 방식으로 수정 * test: 이미지 관련 테스트 일괄 반영 * refactor: 미사용 dto 제거 * refactor: dto 길이 제한 추가 * refactor: 헬퍼 메서드 추가 * refactor: 불필요 코드 제거 * refactor: 중복 검증 제거 * refactor: isAdmin 메서드 제거 --- .../query/GetAttendanceQueryService.kt | 2 +- .../dto/request/CreateBoardRequest.kt | 4 +- .../dto/request/UpdateBoardRequest.kt | 4 +- .../dto/response/BoardDetailResponse.kt | 4 +- .../board/application/mapper/PostMapper.kt | 15 ++++- .../usecase/command/ManagePostUseCase.kt | 20 +++--- .../usecase/query/GetBoardQueryService.kt | 6 +- .../usecase/query/GetPostQueryService.kt | 54 ++++++++++----- .../weeth/domain/board/domain/entity/Board.kt | 9 +-- .../domain/board/domain/vo/BoardConfig.kt | 4 +- .../presentation/BoardAdminController.kt | 2 - .../board/presentation/BoardController.kt | 5 +- .../board/presentation/PostController.kt | 11 +-- .../dto/request/ClubCreateRequest.kt | 10 +-- .../dto/request/ClubUpdateRequest.kt | 15 +++-- .../dto/request/UpdateMemberBioRequest.kt | 10 --- .../club/application/mapper/ClubMapper.kt | 15 +++-- .../command/ManageClubMemberUsecase.kt | 3 +- .../usecase/command/ManageClubUseCase.kt | 8 +-- .../weeth/domain/club/domain/entity/Club.kt | 44 ++++++------ .../domain/club/domain/entity/ClubMember.kt | 14 ++-- .../domain/club/domain/enums/MemberRole.kt | 5 +- .../domain/repository/ClubMemberReader.kt | 5 ++ .../domain/repository/ClubMemberRepository.kt | 14 ++++ .../club/domain/service/ClubMemberPolicy.kt | 5 +- .../application/mapper/CommentMapper.kt | 14 +++- .../usecase/query/GetCommentQueryService.kt | 14 ++-- .../dto/response/DashboardMyInfoResponse.kt | 2 - .../application/mapper/DashboardMapper.kt | 19 ++++-- .../usecase/query/GetDashboardQueryService.kt | 12 ++++ .../domain/file/domain/enums/FileOwnerType.kt | 1 - .../usecase/query/GetSessionQueryService.kt | 8 +-- .../dto/request/UserRoleUpdateRequest.kt | 7 +- .../user/application/dto/response/UserInfo.kt | 23 ++++--- .../dto/response/UserProfileResponse.kt | 6 +- .../dto/response/UserSummaryResponse.kt | 6 +- .../user/application/mapper/UserMapper.kt | 28 ++++++-- .../usecase/command/SocialLoginUseCase.kt | 2 +- .../usecase/query/GetUserQueryService.kt | 21 +++++- .../weeth/domain/user/domain/entity/User.kt | 28 +------- .../weeth/domain/user/domain/enums/Role.kt | 6 -- .../user/presentation/UserController.kt | 9 +-- .../global/auth/annotation/CurrentUserRole.kt | 5 -- .../application/service/JwtTokenExtractor.kt | 3 - .../application/usecase/JwtManageUseCase.kt | 18 +---- .../jwt/domain/port/RefreshTokenStorePort.kt | 10 --- .../jwt/domain/service/JwtTokenProvider.kt | 4 -- .../JwtAuthenticationProcessingFilter.kt | 4 +- .../RedisRefreshTokenStoreAdapter.kt | 22 ------ .../global/auth/model/AuthenticatedUser.kt | 3 - .../CurrentUserRoleArgumentResolver.kt | 50 -------------- .../com/weeth/global/config/SecurityConfig.kt | 4 +- .../com/weeth/global/config/WebMvcConfig.kt | 2 - .../fixture/AttendanceTestFixture.kt | 3 +- .../application/mapper/PostMapperTest.kt | 19 ++++-- .../usecase/command/ManageBoardUseCaseTest.kt | 10 +-- .../usecase/command/ManagePostUseCaseTest.kt | 46 ++++++------- .../usecase/query/GetBoardQueryServiceTest.kt | 10 ++- .../usecase/query/GetPostQueryServiceTest.kt | 67 +++++++++++++------ .../converter/BoardConfigConverterTest.kt | 4 +- .../board/domain/entity/BoardEntityTest.kt | 24 ++++--- .../domain/board/fixture/BoardTestFixture.kt | 4 +- .../command/ManageClubMemberUseCaseTest.kt | 16 ++--- .../usecase/command/ManageClubUseCaseTest.kt | 24 +++---- .../query/GetClubMemberQueryServiceTest.kt | 4 +- .../club/domain/entity/ClubMemberTest.kt | 4 +- .../domain/club/domain/entity/ClubTest.kt | 4 +- .../query/CommentQueryPerformanceTest.kt | 57 +++++++++++----- .../query/GetCommentQueryServiceTest.kt | 21 +++--- .../query/GetDashboardQueryServiceTest.kt | 10 ++- .../usecase/command/SocialLoginUseCaseTest.kt | 6 +- .../domain/user/domain/entity/UserTest.kt | 59 ---------------- .../domain/user/fixture/UserTestFixture.kt | 2 - .../service/JwtTokenExtractorTest.kt | 5 +- .../usecase/JwtManageUseCaseTest.kt | 12 ++-- .../domain/service/JwtTokenProviderTest.kt | 4 +- .../JwtAuthenticationProcessingFilterTest.kt | 6 +- .../RedisRefreshTokenStoreAdapterTest.kt | 30 +++------ .../CurrentUserArgumentResolverTest.kt | 3 +- 79 files changed, 506 insertions(+), 567 deletions(-) delete mode 100644 src/main/kotlin/com/weeth/domain/club/application/dto/request/UpdateMemberBioRequest.kt delete mode 100644 src/main/kotlin/com/weeth/domain/user/domain/enums/Role.kt delete mode 100644 src/main/kotlin/com/weeth/global/auth/annotation/CurrentUserRole.kt delete mode 100644 src/main/kotlin/com/weeth/global/auth/resolver/CurrentUserRoleArgumentResolver.kt diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt index 0b2e9600..1e8d5a74 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt @@ -37,7 +37,7 @@ class GetAttendanceQueryService( today.plusDays(1).atStartOfDay(), ) - return attendanceMapper.toSummaryResponse(clubMember, todayAttendance, isAdmin = clubMember.isAdmin()) + return attendanceMapper.toSummaryResponse(clubMember, todayAttendance, isAdmin = clubMember.isAdminOrLead()) } fun findAllDetailsByCurrentCardinal( diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/request/CreateBoardRequest.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/request/CreateBoardRequest.kt index 9a556341..fb710e9b 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/dto/request/CreateBoardRequest.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/request/CreateBoardRequest.kt @@ -1,7 +1,7 @@ package com.weeth.domain.board.application.dto.request import com.weeth.domain.board.domain.enums.BoardType -import com.weeth.domain.user.domain.enums.Role +import com.weeth.domain.club.domain.enums.MemberRole import io.swagger.v3.oas.annotations.media.Schema import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.NotNull @@ -18,7 +18,7 @@ data class CreateBoardRequest( @field:Schema(description = "댓글 허용 여부", example = "true") val commentEnabled: Boolean = true, @field:Schema(description = "게시글 작성 권한", example = "USER") - val writePermission: Role = Role.USER, + val writePermission: MemberRole = MemberRole.USER, @field:Schema(description = "비공개 게시판 여부", example = "false") val isPrivate: Boolean = false, ) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdateBoardRequest.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdateBoardRequest.kt index 644fa546..8943a2ef 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdateBoardRequest.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdateBoardRequest.kt @@ -1,6 +1,6 @@ package com.weeth.domain.board.application.dto.request -import com.weeth.domain.user.domain.enums.Role +import com.weeth.domain.club.domain.enums.MemberRole import io.swagger.v3.oas.annotations.media.Schema import jakarta.validation.constraints.Size @@ -11,7 +11,7 @@ data class UpdateBoardRequest( @field:Schema(description = "댓글 허용 여부", example = "true", nullable = true) val commentEnabled: Boolean? = null, @field:Schema(description = "게시글 작성 권한", example = "USER", nullable = true) - val writePermission: Role? = null, + val writePermission: MemberRole? = null, @field:Schema(description = "비공개 게시판 여부", example = "false", nullable = true) val isPrivate: Boolean? = null, ) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardDetailResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardDetailResponse.kt index 6a4e08bb..ba6d2b7e 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardDetailResponse.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardDetailResponse.kt @@ -2,7 +2,7 @@ package com.weeth.domain.board.application.dto.response import com.fasterxml.jackson.annotation.JsonInclude import com.weeth.domain.board.domain.enums.BoardType -import com.weeth.domain.user.domain.enums.Role +import com.weeth.domain.club.domain.enums.MemberRole import io.swagger.v3.oas.annotations.media.Schema @JsonInclude(JsonInclude.Include.NON_NULL) @@ -16,7 +16,7 @@ data class BoardDetailResponse( @field:Schema(description = "댓글 허용 여부") val commentEnabled: Boolean, @field:Schema(description = "게시글 작성 권한") - val writePermission: Role, + val writePermission: MemberRole, @field:Schema(description = "비공개 게시판 여부") val isPrivate: Boolean, @field:Schema(description = "삭제 여부 (관리자 페이지에서만 값 존재)") diff --git a/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt b/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt index 58cc3d79..23f913f2 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt @@ -4,23 +4,28 @@ import com.weeth.domain.board.application.dto.response.PostDetailResponse import com.weeth.domain.board.application.dto.response.PostListResponse import com.weeth.domain.board.application.dto.response.PostSaveResponse import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.comment.application.dto.response.CommentResponse import com.weeth.domain.file.application.dto.response.FileResponse +import com.weeth.domain.file.domain.port.FileAccessUrlPort import com.weeth.domain.user.application.dto.response.UserInfo import org.springframework.stereotype.Component import java.time.LocalDateTime @Component -class PostMapper { +class PostMapper( + private val fileAccessUrlPort: FileAccessUrlPort, +) { fun toSaveResponse(post: Post) = PostSaveResponse(id = post.id) fun toDetailResponse( post: Post, + authorMember: ClubMember, comments: List, files: List, ) = PostDetailResponse( id = post.id, - author = UserInfo.from(post.user), + author = UserInfo.of(post.user, authorMember.memberRole, resolveProfileImage(authorMember)), title = post.title, content = post.content, time = post.modifiedAt, @@ -31,11 +36,12 @@ class PostMapper { fun toListResponse( post: Post, + authorMember: ClubMember, hasFile: Boolean, now: LocalDateTime, ) = PostListResponse( id = post.id, - author = UserInfo.from(post.user), + author = UserInfo.of(post.user, authorMember.memberRole, resolveProfileImage(authorMember)), title = post.title, content = post.content, time = post.modifiedAt, @@ -43,4 +49,7 @@ class PostMapper { hasFile = hasFile, isNew = post.createdAt.isAfter(now.minusHours(24)), ) + + private fun resolveProfileImage(member: ClubMember): String? = + member.profileImageStorageKey?.let { fileAccessUrlPort.resolve(it) } } diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt index 6b37d967..6c9bef10 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt @@ -12,13 +12,13 @@ import com.weeth.domain.board.domain.entity.Board import com.weeth.domain.board.domain.entity.Post import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.board.domain.repository.PostRepository +import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.file.application.dto.request.FileSaveRequest import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.repository.FileReader import com.weeth.domain.file.domain.repository.FileRepository -import com.weeth.domain.user.domain.entity.User import com.weeth.domain.user.domain.repository.UserReader import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -41,10 +41,10 @@ class ManagePostUseCase( request: CreatePostRequest, userId: Long, ): PostSaveResponse { - clubMemberPolicy.getActiveMember(clubId, userId) + val member = clubMemberPolicy.getActiveMember(clubId, userId) val user = userReader.getById(userId) val board = findBoardInClub(boardId, clubId) - validateWritePermission(board, user) + validateWritePermission(board, member) val post = Post.create( @@ -52,7 +52,7 @@ class ManagePostUseCase( content = request.content, user = user, board = board, - cardinalNumber = request.cardinalNumber, // 기수의 경우는 프론트에서 명시적으로 입력을 받을지, 백엔드에서 최신 기수를 넣을지 UX 고민 후 결정 + cardinalNumber = request.cardinalNumber, // TODO: 백엔드에서 최신 기수 넣어주기 ) val savedPost = postRepository.save(post) @@ -67,12 +67,12 @@ class ManagePostUseCase( request: UpdatePostRequest, userId: Long, ): PostSaveResponse { - clubMemberPolicy.getActiveMember(clubId, userId) + val member = clubMemberPolicy.getActiveMember(clubId, userId) val user = userReader.getById(userId) val post = findPost(postId) if (post.board.club.id != clubId) throw PostNotFoundException() validateOwner(post, userId) - validateWritePermission(post.board, user) + validateWritePermission(post.board, member) post.update( newTitle = request.title, @@ -90,12 +90,12 @@ class ManagePostUseCase( postId: Long, userId: Long, ) { - clubMemberPolicy.getActiveMember(clubId, userId) + val member = clubMemberPolicy.getActiveMember(clubId, userId) val user = userReader.getById(userId) val post = findPost(postId) if (post.board.club.id != clubId) throw PostNotFoundException() validateOwner(post, userId) - validateWritePermission(post.board, user) + validateWritePermission(post.board, member) markPostFilesDeleted(post.id) post.markDeleted() @@ -120,9 +120,9 @@ class ManagePostUseCase( private fun validateWritePermission( board: Board, - user: User, + member: ClubMember, ) { - if (!board.canWriteBy(user.role)) { + if (!board.canWriteBy(member.memberRole)) { throw CategoryAccessDeniedException() } } diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt index 3b2cb92c..9ddfc54a 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt @@ -6,7 +6,6 @@ import com.weeth.domain.board.application.exception.BoardNotFoundException import com.weeth.domain.board.application.mapper.BoardMapper import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.club.domain.service.ClubMemberPolicy -import com.weeth.domain.user.domain.enums.Role import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -20,13 +19,12 @@ class GetBoardQueryService( fun findBoards( clubId: Long, userId: Long, - role: Role, ): List { - clubMemberPolicy.getActiveMember(clubId, userId) + val member = clubMemberPolicy.getActiveMember(clubId, userId) return boardRepository .findAllByClubIdAndIsDeletedFalseOrderByIdAsc(clubId) - .filter { it.isAccessibleBy(role) } + .filter { it.isAccessibleBy(member.memberRole) } .map(boardMapper::toListResponse) } diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt index 3350c303..bb24eed9 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt @@ -9,13 +9,15 @@ import com.weeth.domain.board.application.exception.PostNotFoundException import com.weeth.domain.board.application.mapper.PostMapper import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.board.domain.repository.PostRepository +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.repository.ClubMemberReader import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.comment.application.usecase.query.GetCommentQueryService import com.weeth.domain.comment.domain.repository.CommentReader import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.repository.FileReader -import com.weeth.domain.user.domain.enums.Role import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Slice import org.springframework.data.domain.Sort @@ -29,6 +31,7 @@ class GetPostQueryService( private val postRepository: PostRepository, private val boardRepository: BoardRepository, private val clubMemberPolicy: ClubMemberPolicy, + private val clubMemberReader: ClubMemberReader, private val commentReader: CommentReader, private val getCommentQueryService: GetCommentQueryService, private val fileReader: FileReader, @@ -43,20 +46,24 @@ class GetPostQueryService( clubId: Long, userId: Long, postId: Long, - role: Role, // TODO: 멀티 테넨시 지원으로 Jwt에 포함한 Role은 삭제 예정 ): PostDetailResponse { - clubMemberPolicy.getActiveMember(clubId, userId) + val member = clubMemberPolicy.getActiveMember(clubId, userId) val post = postRepository.findByIdAndIsDeletedFalse(postId) ?: throw PostNotFoundException() - if (post.board.club.id != clubId || post.board.isDeleted || !post.board.isAccessibleBy(role)) { + if (post.board.club.id != clubId || post.board.isDeleted || !post.board.isAccessibleBy(member.memberRole)) { throw PostNotFoundException() } val files = fileReader.findAll(FileOwnerType.POST, post.id).map(fileMapper::toFileResponse) val comments = commentReader.findAllByPostId(post.id) - val commentTree = getCommentQueryService.toCommentTreeResponses(comments) - return postMapper.toDetailResponse(post, commentTree, files) + val commentAuthorIds = comments.map { it.user.id }.distinct() + val allAuthorIds = (commentAuthorIds + post.user.id).distinct() + val memberMap = buildMemberMap(clubId, allAuthorIds) + + val commentTree = getCommentQueryService.toCommentTreeResponses(comments, memberMap) + + return postMapper.toDetailResponse(post, memberMap.getValue(post.user.id), commentTree, files) } fun findPosts( @@ -65,20 +72,22 @@ class GetPostQueryService( boardId: Long, pageNumber: Int, pageSize: Int, - role: Role, // TODO: 멀티 테넨시 지원으로 Jwt에 포함한 Role은 삭제 예정 ): Slice { - clubMemberPolicy.getActiveMember(clubId, userId) + val member = clubMemberPolicy.getActiveMember(clubId, userId) validatePage(pageNumber, pageSize) - validateBoardVisibility(boardId, clubId, role) + validateBoardVisibility(boardId, clubId, member.memberRole) val pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")) val posts = postRepository.findAllActiveByBoardId(boardId, pageable) val postIds = posts.content.map { it.id } val fileExistsByPostId = buildFileExistsMap(postIds) + val memberMap = buildMemberMap(clubId, posts.content.map { it.user.id }.distinct()) val now = LocalDateTime.now() - return posts.map { postMapper.toListResponse(it, fileExistsByPostId[it.id] == true, now) } + return posts.map { post -> + postMapper.toListResponse(post, memberMap.getValue(post.user.id), fileExistsByPostId[post.id] == true, now) + } } fun searchPosts( @@ -88,11 +97,10 @@ class GetPostQueryService( keyword: String, pageNumber: Int, pageSize: Int, - role: Role, // TODO: 멀티 테넨시 지원으로 Jwt에 포함한 Role은 삭제 예정 ): Slice { - clubMemberPolicy.getActiveMember(clubId, userId) + val member = clubMemberPolicy.getActiveMember(clubId, userId) validatePage(pageNumber, pageSize) - validateBoardVisibility(boardId, clubId, role) + validateBoardVisibility(boardId, clubId, member.memberRole) val pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")) val posts = postRepository.searchByBoardId(boardId, keyword.trim(), pageable) @@ -102,9 +110,23 @@ class GetPostQueryService( val postIds = posts.content.map { it.id } val fileExistsByPostId = buildFileExistsMap(postIds) + val memberMap = buildMemberMap(clubId, posts.content.map { it.user.id }.distinct()) val now = LocalDateTime.now() - return posts.map { postMapper.toListResponse(it, fileExistsByPostId[it.id] == true, now) } + return posts.map { post -> + postMapper.toListResponse(post, memberMap.getValue(post.user.id), fileExistsByPostId[post.id] == true, now) + } + } + + /** + * Post, Comment 조회 시 작성자 정보를 매핑하기 위한 헬퍼 메서드 + */ + private fun buildMemberMap( + clubId: Long, + userIds: List, + ): Map { + if (userIds.isEmpty()) return emptyMap() + return clubMemberReader.findAllByClubIdAndUserIds(clubId, userIds).associateBy { it.user.id } } private fun validatePage( @@ -127,11 +149,11 @@ class GetPostQueryService( private fun validateBoardVisibility( // todo: 볼 권한이 없는 경우 권한 관련 예외를 던져주는게 나을지 UX 상의 후 결정 boardId: Long, clubId: Long, - role: Role, + memberRole: MemberRole, ) { val board = boardRepository.findByIdAndClubIdAndIsDeletedFalse(boardId, clubId) ?: throw BoardNotFoundException() - if (!board.isAccessibleBy(role)) { + if (!board.isAccessibleBy(memberRole)) { throw BoardNotFoundException() } } diff --git a/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt b/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt index d7fc3a17..9906279e 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt @@ -4,7 +4,7 @@ import com.weeth.domain.board.domain.converter.BoardConfigConverter import com.weeth.domain.board.domain.enums.BoardType import com.weeth.domain.board.domain.vo.BoardConfig import com.weeth.domain.club.domain.entity.Club -import com.weeth.domain.user.domain.enums.Role +import com.weeth.domain.club.domain.enums.MemberRole import com.weeth.global.common.entity.BaseEntity import jakarta.persistence.Column import jakarta.persistence.Convert @@ -63,11 +63,12 @@ class Board( get() = config.commentEnabled val isAdminOnly: Boolean - get() = config.writePermission == Role.ADMIN + get() = config.writePermission.isAdminOrLead() - fun isAccessibleBy(role: Role): Boolean = role == Role.ADMIN || !config.isPrivate + fun isAccessibleBy(memberRole: MemberRole): Boolean = memberRole.isAdminOrLead() || !config.isPrivate - fun canWriteBy(role: Role): Boolean = isAccessibleBy(role) && (!isAdminOnly || role == Role.ADMIN) + fun canWriteBy(memberRole: MemberRole): Boolean = + isAccessibleBy(memberRole) && (memberRole.isAdminOrLead() || !isAdminOnly) fun updateConfig(newConfig: BoardConfig) { config = newConfig diff --git a/src/main/kotlin/com/weeth/domain/board/domain/vo/BoardConfig.kt b/src/main/kotlin/com/weeth/domain/board/domain/vo/BoardConfig.kt index a3dbee1d..6cad32e8 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/vo/BoardConfig.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/vo/BoardConfig.kt @@ -1,9 +1,9 @@ package com.weeth.domain.board.domain.vo -import com.weeth.domain.user.domain.enums.Role +import com.weeth.domain.club.domain.enums.MemberRole data class BoardConfig( val commentEnabled: Boolean = true, - val writePermission: Role = Role.USER, + val writePermission: MemberRole = MemberRole.USER, val isPrivate: Boolean = false, ) diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt b/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt index 240317df..a3df40b3 100644 --- a/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt @@ -15,7 +15,6 @@ import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.Valid -import org.springframework.security.access.prepost.PreAuthorize import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PatchMapping @@ -28,7 +27,6 @@ import org.springframework.web.bind.annotation.RestController @Tag(name = "Board-Admin", description = "Board Admin API") @RestController @RequestMapping("/api/v4/admin/clubs/{clubId}/boards") -@PreAuthorize("hasRole('ADMIN')") @ApiErrorCodeExample(BoardErrorCode::class) class BoardAdminController( private val manageBoardUseCase: ManageBoardUseCase, diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt b/src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt index 7f39decc..77a727fe 100644 --- a/src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt +++ b/src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt @@ -3,9 +3,7 @@ package com.weeth.domain.board.presentation import com.weeth.domain.board.application.dto.response.BoardListResponse import com.weeth.domain.board.application.exception.BoardErrorCode import com.weeth.domain.board.application.usecase.query.GetBoardQueryService -import com.weeth.domain.user.domain.enums.Role import com.weeth.global.auth.annotation.CurrentUser -import com.weeth.global.auth.annotation.CurrentUserRole import com.weeth.global.common.exception.ApiErrorCodeExample import com.weeth.global.common.response.CommonResponse import com.weeth.global.common.web.TsidParam @@ -30,10 +28,9 @@ class BoardController( @TsidParam @TsidPathVariable clubId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, - @Parameter(hidden = true) @CurrentUserRole role: Role, // TODO: 멀티 테넨시 지원으로 Jwt에 포함한 Role은 삭제 예정 ): CommonResponse> = CommonResponse.success( BoardResponseCode.BOARD_FIND_ALL_SUCCESS, - getBoardQueryService.findBoards(clubId, userId, role), + getBoardQueryService.findBoards(clubId, userId), ) } diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt b/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt index 44ea9ced..1681bc0c 100644 --- a/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt +++ b/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt @@ -9,9 +9,7 @@ import com.weeth.domain.board.application.exception.BoardErrorCode import com.weeth.domain.board.application.usecase.command.ManagePostUseCase import com.weeth.domain.board.application.usecase.command.MarkNoticeReadUseCase import com.weeth.domain.board.application.usecase.query.GetPostQueryService -import com.weeth.domain.user.domain.enums.Role import com.weeth.global.auth.annotation.CurrentUser -import com.weeth.global.auth.annotation.CurrentUserRole import com.weeth.global.auth.jwt.application.exception.JwtErrorCode import com.weeth.global.common.exception.ApiErrorCodeExample import com.weeth.global.common.response.CommonResponse @@ -64,11 +62,10 @@ class PostController( @RequestParam(defaultValue = "0") pageNumber: Int, @RequestParam(defaultValue = "10") pageSize: Int, @Parameter(hidden = true) @CurrentUser userId: Long, - @Parameter(hidden = true) @CurrentUserRole role: Role, // TODO: 멀티 테넨시 지원으로 Jwt에 포함한 Role은 삭제 예정 ): CommonResponse> = CommonResponse.success( BoardResponseCode.POST_FIND_ALL_SUCCESS, - getPostQueryService.findPosts(clubId, userId, boardId, pageNumber, pageSize, role), + getPostQueryService.findPosts(clubId, userId, boardId, pageNumber, pageSize), ) @GetMapping("/posts/{postId}") @@ -78,11 +75,10 @@ class PostController( @TsidPathVariable clubId: Long, @PathVariable postId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, - @Parameter(hidden = true) @CurrentUserRole role: Role, // TODO: 멀티 테넨시 지원으로 Jwt에 포함한 Role은 삭제 예정 ): CommonResponse = CommonResponse.success( BoardResponseCode.POST_FIND_BY_ID_SUCCESS, - getPostQueryService.findPost(clubId, userId, postId, role), + getPostQueryService.findPost(clubId, userId, postId), ) @PatchMapping("/posts/{postId}") @@ -121,11 +117,10 @@ class PostController( @RequestParam(defaultValue = "0") pageNumber: Int, @RequestParam(defaultValue = "10") pageSize: Int, @Parameter(hidden = true) @CurrentUser userId: Long, - @Parameter(hidden = true) @CurrentUserRole role: Role, // TODO: 멀티 테넨시 지원으로 Jwt에 포함한 Role은 삭제 예정 ): CommonResponse> = CommonResponse.success( BoardResponseCode.POST_SEARCH_SUCCESS, - getPostQueryService.searchPosts(clubId, userId, boardId, keyword, pageNumber, pageSize, role), + getPostQueryService.searchPosts(clubId, userId, boardId, keyword, pageNumber, pageSize), ) @PostMapping("/{boardId}/notices/read-all") diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubCreateRequest.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubCreateRequest.kt index 3a24286a..3c5e43e8 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubCreateRequest.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubCreateRequest.kt @@ -30,8 +30,10 @@ data class ClubCreateRequest( @field:Schema(description = "가장 최근 기수 번호", example = "7") @field:Positive val currentCardinal: Int, - @field:Schema(description = "프로필 사진 S3 URL", example = "https://s3.amazonaws.com/bucket/profile.jpg") - val profileImageUrl: String? = null, - @field:Schema(description = "배경 사진 S3 URL", example = "https://s3.amazonaws.com/bucket/background.jpg") - val backgroundImageUrl: String? = null, + // TODO: FileSaveRequest로 전환 (ClubMember 프로필과 동일 패턴) + @field:Schema(description = "프로필 사진 storageKey", example = "CLUB_PROFILE/2026-02/uuid_profile.png") + val profileImageStorageKey: String? = null, + // TODO: FileSaveRequest로 전환 (ClubMember 프로필과 동일 패턴) + @field:Schema(description = "배경 사진 storageKey", example = "CLUB_BACKGROUND/2026-02/uuid_background.png") + val backgroundImageStorageKey: String? = null, ) diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubUpdateRequest.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubUpdateRequest.kt index 8820ff4c..7f215f4b 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubUpdateRequest.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubUpdateRequest.kt @@ -23,8 +23,15 @@ data class ClubUpdateRequest( val contactPhoneNumber: String? = null, @field:Schema(description = "주 연락처 (null=변경 안 함)", example = "PHONE") val primaryContact: PrimaryContact? = null, - @field:Schema(description = "프로필 사진 URL (null=변경 안 함)", example = "https://s3.amazonaws.com/bucket/profile.jpg") - val profileImageUrl: String? = null, - @field:Schema(description = "배경 사진 URL (null=변경 안 함)", example = "https://s3.amazonaws.com/bucket/background.jpg") - val backgroundImageUrl: String? = null, + // TODO: FileSaveRequest로 전환 (ClubMember 프로필과 동일 패턴) + @field:Size(max = 500) + @field:Schema(description = "프로필 사진 storageKey (null=변경 안 함)", example = "CLUB_PROFILE/2026-02/uuid_profile.png") + val profileImageStorageKey: String? = null, + // TODO: FileSaveRequest로 전환 (ClubMember 프로필과 동일 패턴) + @field:Size(max = 500) + @field:Schema( + description = "배경 사진 storageKey (null=변경 안 함)", + example = "CLUB_BACKGROUND/2026-02/uuid_background.png", + ) + val backgroundImageStorageKey: String? = null, ) diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/request/UpdateMemberBioRequest.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/request/UpdateMemberBioRequest.kt deleted file mode 100644 index a6f7ffce..00000000 --- a/src/main/kotlin/com/weeth/domain/club/application/dto/request/UpdateMemberBioRequest.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.weeth.domain.club.application.dto.request - -import io.swagger.v3.oas.annotations.media.Schema -import jakarta.validation.constraints.Size - -data class UpdateMemberBioRequest( - @field:Schema(description = "자기소개", example = "안녕하세요!") - @field:Size(max = 30) - val bio: String?, -) diff --git a/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt b/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt index 9b3268ae..8ba8055c 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt @@ -8,11 +8,14 @@ import com.weeth.domain.club.application.dto.response.ClubPublicResponse import com.weeth.domain.club.domain.entity.Club import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.club.domain.entity.ClubMemberCardinal +import com.weeth.domain.file.domain.port.FileAccessUrlPort import com.weeth.global.common.id.TsidBase62Encoder import org.springframework.stereotype.Component @Component -class ClubMapper { +class ClubMapper( + private val fileAccessUrlPort: FileAccessUrlPort, +) { fun toInfoResponse( club: Club, member: ClubMember, @@ -30,7 +33,7 @@ class ClubMapper { id = TsidBase62Encoder.encode(club.id), name = club.name, description = club.description, - profileImageUrl = club.profileImageUrl, + profileImageUrl = resolveClubImage(club.profileImageStorageKey), ) fun toDetailResponse(club: Club) = @@ -43,8 +46,8 @@ class ClubMapper { contactEmail = club.clubContact.email, contactPhoneNumber = club.clubContact.phoneNumber, primaryContact = club.clubContact.primaryContact, - profileImageUrl = club.profileImageUrl, - backgroundImageUrl = club.backgroundImageUrl, + profileImageUrl = resolveClubImage(club.profileImageStorageKey), + backgroundImageUrl = resolveClubImage(club.backgroundImageStorageKey), ) fun toMemberResponse( @@ -81,10 +84,12 @@ class ClubMapper { department = member.user.department, studentId = member.user.studentId, cardinals = toCardinalNumbers(cardinals), - profileImageUrl = member.profileImageUrl, + profileImageUrl = member.profileImageStorageKey?.let { fileAccessUrlPort.resolve(it) }, bio = member.bio, ) + private fun resolveClubImage(storageKey: String?): String? = storageKey?.let { fileAccessUrlPort.resolve(it) } + private fun toCardinalNumbers(cardinals: List): List { if (cardinals.isEmpty()) { return emptyList() diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt index 91b7e671..c4fbc63d 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt @@ -109,8 +109,7 @@ class ManageClubMemberUsecase( ) fileRepository.save(file) - val resolvedUrl = fileAccessUrlPort.resolve(file.storageKey.value) - members.forEach { it.updateProfileImageUrl(resolvedUrl) } + members.forEach { it.updateProfileImageUrl(file.storageKey.value) } } request.bio?.let { bio -> members.forEach { it.updateBio(bio) } } diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt index f227d9ae..c6ac8d80 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt @@ -66,8 +66,8 @@ class ManageClubUseCase( schoolName = request.schoolName, clubContact = clubContact, description = request.description, - profileImageUrl = request.profileImageUrl, - backgroundImageUrl = request.backgroundImageUrl, + profileImageStorageKey = request.profileImageStorageKey, + backgroundImageStorageKey = request.backgroundImageStorageKey, ) clubRepository.save(club) @@ -124,8 +124,8 @@ class ManageClubUseCase( contactEmail = request.contactEmail, contactPhoneNumber = request.contactPhoneNumber, primaryContact = request.primaryContact, - profileImageUrl = request.profileImageUrl, - backgroundImageUrl = request.backgroundImageUrl, + profileImageStorageKey = request.profileImageStorageKey, + backgroundImageStorageKey = request.backgroundImageStorageKey, ) } diff --git a/src/main/kotlin/com/weeth/domain/club/domain/entity/Club.kt b/src/main/kotlin/com/weeth/domain/club/domain/entity/Club.kt index 9a98ba52..7a4fa298 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/entity/Club.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/entity/Club.kt @@ -28,8 +28,8 @@ class Club( description: String? = null, schoolName: String, clubContact: ClubContact, - profileImageUrl: String? = null, - backgroundImageUrl: String? = null, + profileImageStorageKey: String? = null, + backgroundImageStorageKey: String? = null, ) : BaseEntity() { // TSID(Time-Sorted Unique Identifier)로 관리 // Client 반환시 Base62 인코딩해서 String으로 반환 @@ -58,12 +58,14 @@ class Club( var clubContact: ClubContact = clubContact private set - @Column(length = 500) - var profileImageUrl: String? = profileImageUrl // 우선 URL로 저장 후 File로 붙일지 논의 + // TODO: FileSaveRequest + File 도메인 연동 필요 (ClubMember 프로필과 동일 패턴으로 전환) + @Column(name = "profile_image_url", length = 500) + var profileImageStorageKey: String? = profileImageStorageKey private set - @Column(length = 500) - var backgroundImageUrl: String? = backgroundImageUrl + // TODO: FileSaveRequest + File 도메인 연동 필요 (ClubMember 프로필과 동일 패턴으로 전환) + @Column(name = "background_image_url", length = 500) + var backgroundImageStorageKey: String? = backgroundImageStorageKey private set // todo: 동아리 삭제 지원 @@ -75,8 +77,8 @@ class Club( contactEmail: String?, contactPhoneNumber: String?, primaryContact: PrimaryContact?, - profileImageUrl: String?, - backgroundImageUrl: String?, + profileImageStorageKey: String?, + backgroundImageStorageKey: String?, ) { name?.let { require(it.isNotBlank()) { "동아리 이름은 비어 있을 수 없습니다." } @@ -92,7 +94,7 @@ class Club( } updateContact(contactEmail, contactPhoneNumber, primaryContact) - updateImageUrl(profileImageUrl, backgroundImageUrl) + updateImageStorageKey(profileImageStorageKey, backgroundImageStorageKey) } private fun updateContact( @@ -109,13 +111,13 @@ class Club( } } - private fun updateImageUrl( - profileImageUrl: String?, - backgroundImageUrl: String?, + private fun updateImageStorageKey( + profileImageStorageKey: String?, + backgroundImageStorageKey: String?, ) { - if (profileImageUrl != null || backgroundImageUrl != null) { - this.profileImageUrl = profileImageUrl ?: this.profileImageUrl - this.backgroundImageUrl = backgroundImageUrl ?: this.backgroundImageUrl + if (profileImageStorageKey != null || backgroundImageStorageKey != null) { + this.profileImageStorageKey = profileImageStorageKey ?: this.profileImageStorageKey + this.backgroundImageStorageKey = backgroundImageStorageKey ?: this.backgroundImageStorageKey } } @@ -125,11 +127,11 @@ class Club( } fun removeProfileImage() { - this.profileImageUrl = null + this.profileImageStorageKey = null } fun removeBackgroundImage() { - this.backgroundImageUrl = null + this.backgroundImageStorageKey = null } @PrePersist @@ -148,8 +150,8 @@ class Club( schoolName: String, clubContact: ClubContact, description: String? = null, - profileImageUrl: String? = null, - backgroundImageUrl: String? = null, + profileImageStorageKey: String? = null, + backgroundImageStorageKey: String? = null, ): Club { require(name.isNotBlank()) { "동아리 이름은 비어 있을 수 없습니다." } require(code.isNotBlank()) { "초대 코드는 비어 있을 수 없습니다." } @@ -163,8 +165,8 @@ class Club( description = description, schoolName = schoolName, clubContact = clubContact, - profileImageUrl = profileImageUrl, - backgroundImageUrl = backgroundImageUrl, + profileImageStorageKey = profileImageStorageKey, + backgroundImageStorageKey = backgroundImageStorageKey, ).apply { // 객체 생성시 TSID 할당 id = TsidGenerator.nextId() diff --git a/src/main/kotlin/com/weeth/domain/club/domain/entity/ClubMember.kt b/src/main/kotlin/com/weeth/domain/club/domain/entity/ClubMember.kt index 99a33271..ab7f37cc 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/entity/ClubMember.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/entity/ClubMember.kt @@ -64,7 +64,7 @@ class ClubMember( private set @Column(length = 500) - var profileImageUrl: String? = null + var profileImageStorageKey: String? = null private set @Column(length = 30) @@ -95,7 +95,7 @@ class ClubMember( this.memberRole = role } - fun isAdmin(): Boolean = memberRole == MemberRole.ADMIN + fun isAdminOrLead(): Boolean = memberRole.isAdminOrLead() fun isLead(): Boolean = memberRole == MemberRole.LEAD @@ -132,14 +132,14 @@ class ClubMember( penaltyCount++ } - fun updateProfileImageUrl(url: String?) { - val trimmed = url?.trim()?.takeIf { it.isNotBlank() } - require((trimmed?.length ?: 0) <= 500) { "프로필 이미지 URL은 500자 이하여야 합니다." } - this.profileImageUrl = trimmed + fun updateProfileImageUrl(storageKey: String?) { + val trimmed = storageKey?.trim()?.takeIf { it.isNotBlank() } + require((trimmed?.length ?: 0) <= 500) { "프로필 이미지 storageKey는 500자 이하여야 합니다." } + this.profileImageStorageKey = trimmed } fun removeProfileImage() { - this.profileImageUrl = null + this.profileImageStorageKey = null } fun updateBio(bio: String?) { diff --git a/src/main/kotlin/com/weeth/domain/club/domain/enums/MemberRole.kt b/src/main/kotlin/com/weeth/domain/club/domain/enums/MemberRole.kt index c750d77d..c0a17973 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/enums/MemberRole.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/enums/MemberRole.kt @@ -4,6 +4,7 @@ enum class MemberRole { USER, ADMIN, LEAD, // 동아리 개설한 인원의 역할. 추후 LEAD 권한 이양 API도 추가 - // TODO: ADMIN, LEAD 권한 관련 JWT, Filter - // 다른 동아리의 ADMIN인 경우는 JWT로 검증이 안되니까 JWT에서 Role을 빼야할 수도 있음 + ; + + fun isAdminOrLead(): Boolean = this == ADMIN || this == LEAD } diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt index 1997f9ed..652f54e7 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt @@ -37,4 +37,9 @@ interface ClubMemberReader { memberStatus: MemberStatus, memberRole: MemberRole, ): Long + + fun findAllByClubIdAndUserIds( + clubId: Long, + userIds: List, + ): List } diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt index 29b373fe..74c08de6 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt @@ -93,4 +93,18 @@ interface ClubMemberRepository : @Param("memberStatus") memberStatus: MemberStatus, @Param("memberRole") memberRole: MemberRole, ): Long + + @Query( + """ + SELECT cm + FROM ClubMember cm + JOIN FETCH cm.user + WHERE cm.club.id = :clubId + AND cm.user.id IN :userIds + """, + ) + override fun findAllByClubIdAndUserIds( + @Param("clubId") clubId: Long, + @Param("userIds") userIds: List, + ): List } diff --git a/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt index fecb9019..8b688a17 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt @@ -37,14 +37,13 @@ class ClubMemberPolicy( /** * 사용자가 동아리 관리자인지 검증 - * 활성 상태이고 + 관리자 권한 + * 활성 상태이고 + ADMIN 또는 LEAD 권한 */ fun requireAdmin( clubId: Long, userId: Long, ) = getActiveMember(clubId, userId).also { - // TODO: 동아리 생성자를 LEAD로 저장하고 있어 LEAD도 관리자 권한으로 취급할지 정책 정리가 필요하다. - if (!it.isAdmin()) { + if (!it.isAdminOrLead()) { throw NotClubAdminException() } } diff --git a/src/main/kotlin/com/weeth/domain/comment/application/mapper/CommentMapper.kt b/src/main/kotlin/com/weeth/domain/comment/application/mapper/CommentMapper.kt index 6397e5b9..006b5b0d 100644 --- a/src/main/kotlin/com/weeth/domain/comment/application/mapper/CommentMapper.kt +++ b/src/main/kotlin/com/weeth/domain/comment/application/mapper/CommentMapper.kt @@ -1,21 +1,31 @@ package com.weeth.domain.comment.application.mapper +import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.comment.application.dto.response.CommentResponse import com.weeth.domain.comment.domain.entity.Comment import com.weeth.domain.file.application.dto.response.FileResponse +import com.weeth.domain.file.domain.port.FileAccessUrlPort import com.weeth.domain.user.application.dto.response.UserInfo import org.springframework.stereotype.Component @Component -class CommentMapper { +class CommentMapper( + private val fileAccessUrlPort: FileAccessUrlPort, +) { fun toCommentDto( comment: Comment, + authorMember: ClubMember, children: List, fileUrls: List, ): CommentResponse = CommentResponse( id = comment.id, - author = UserInfo.from(comment.user), + author = + UserInfo.of( + comment.user, + authorMember.memberRole, + authorMember.profileImageStorageKey?.let { fileAccessUrlPort.resolve(it) }, + ), content = comment.content, time = comment.modifiedAt, fileUrls = fileUrls, diff --git a/src/main/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryService.kt b/src/main/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryService.kt index 1c698b91..54e9592a 100644 --- a/src/main/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryService.kt @@ -1,5 +1,6 @@ package com.weeth.domain.comment.application.usecase.query +import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.comment.application.dto.response.CommentResponse import com.weeth.domain.comment.application.mapper.CommentMapper import com.weeth.domain.comment.domain.entity.Comment @@ -20,7 +21,10 @@ class GetCommentQueryService( /** * Comment 리스트를 받아 자식, 부모 관계 트리를 형성하는 메서드 */ - fun toCommentTreeResponses(comments: List): List { + fun toCommentTreeResponses( + comments: List, + memberMap: Map, + ): List { if (comments.isEmpty()) { return emptyList() } @@ -38,17 +42,18 @@ class GetCommentQueryService( return comments .filter { it.parent == null } - .map { mapToCommentResponse(it, childrenByParentId, filesByCommentId) } + .map { mapToCommentResponse(it, childrenByParentId, filesByCommentId, memberMap) } } private fun mapToCommentResponse( comment: Comment, childrenByParentId: Map>, filesByCommentId: Map>, + memberMap: Map, ): CommentResponse { val children = childrenByParentId[comment.id] - ?.map { mapToCommentResponse(it, childrenByParentId, filesByCommentId) } + ?.map { mapToCommentResponse(it, childrenByParentId, filesByCommentId, memberMap) } ?: emptyList() val files = @@ -56,6 +61,7 @@ class GetCommentQueryService( ?.map(fileMapper::toFileResponse) ?: emptyList() - return commentMapper.toCommentDto(comment, children, files) + val authorMember = memberMap.getValue(comment.user.id) + return commentMapper.toCommentDto(comment, authorMember, children, files) } } diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardMyInfoResponse.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardMyInfoResponse.kt index 502c491e..f7a70769 100644 --- a/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardMyInfoResponse.kt +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardMyInfoResponse.kt @@ -6,8 +6,6 @@ import io.swagger.v3.oas.annotations.media.Schema data class DashboardMyInfoResponse( @field:Schema(description = "사용자 정보") val userInfo: UserInfo, - @field:Schema(description = "동아리 프로필 이미지 URL") - val profileImageUrl: String?, @field:Schema(description = "자기소개") val bio: String?, ) diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/mapper/DashboardMapper.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/mapper/DashboardMapper.kt index 4ba76f62..97731d9a 100644 --- a/src/main/kotlin/com/weeth/domain/dashboard/application/mapper/DashboardMapper.kt +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/mapper/DashboardMapper.kt @@ -14,6 +14,7 @@ import com.weeth.domain.dashboard.application.dto.response.DashboardUnreadNotice import com.weeth.domain.dashboard.domain.enums.ScheduleType import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.entity.File +import com.weeth.domain.file.domain.port.FileAccessUrlPort import com.weeth.domain.schedule.domain.entity.Event import com.weeth.domain.session.domain.entity.Session import com.weeth.domain.user.application.dto.response.UserInfo @@ -25,6 +26,7 @@ import java.time.LocalDateTime @Component class DashboardMapper( private val fileMapper: FileMapper, + private val fileAccessUrlPort: FileAccessUrlPort, ) { fun toClubInfoResponse( club: Club, @@ -35,8 +37,8 @@ class DashboardMapper( schoolName = club.schoolName, description = club.description, memberCount = memberCount, - profileImageUrl = club.profileImageUrl, - backgroundImageUrl = club.backgroundImageUrl, + profileImageUrl = resolveClubImage(club.profileImageStorageKey), + backgroundImageUrl = resolveClubImage(club.backgroundImageStorageKey), code = club.code, ) @@ -44,8 +46,7 @@ class DashboardMapper( user: User, clubMember: ClubMember, ) = DashboardMyInfoResponse( - userInfo = UserInfo.from(user), - profileImageUrl = clubMember.profileImageUrl, + userInfo = UserInfo.of(user, clubMember.memberRole, resolveProfileImage(clubMember)), bio = clubMember.bio, ) @@ -68,7 +69,7 @@ class DashboardMapper( name = cm.club.name, schoolName = cm.club.schoolName, description = cm.club.description, - profileImageUrl = cm.club.profileImageUrl, + profileImageUrl = resolveClubImage(cm.club.profileImageStorageKey), ) fun toScheduleResponses( @@ -98,11 +99,12 @@ class DashboardMapper( fun toPostResponse( post: Post, + authorMember: ClubMember, files: List, now: LocalDateTime, ) = DashboardPostResponse( id = post.id, - author = UserInfo.from(post.user), + author = UserInfo.of(post.user, authorMember.memberRole, resolveProfileImage(authorMember)), title = post.title, content = post.content, time = post.createdAt, @@ -129,4 +131,9 @@ class DashboardMapper( title = post.title, content = post.content, ) + + private fun resolveProfileImage(member: ClubMember): String? = + member.profileImageStorageKey?.let { fileAccessUrlPort.resolve(it) } + + private fun resolveClubImage(storageKey: String?): String? = storageKey?.let { fileAccessUrlPort.resolve(it) } } diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryService.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryService.kt index e9c2d678..2d06a620 100644 --- a/src/main/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryService.kt @@ -2,6 +2,7 @@ package com.weeth.domain.dashboard.application.usecase.query import com.weeth.domain.board.domain.enums.BoardType import com.weeth.domain.board.domain.repository.PostReader +import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.club.domain.repository.ClubMemberReader import com.weeth.domain.club.domain.repository.ClubReader import com.weeth.domain.club.domain.service.ClubMemberPolicy @@ -79,10 +80,13 @@ class GetDashboardQueryService( val now = LocalDateTime.now() val postIds = posts.content.map { it.id } val filesByPostId = fileReader.findAll(FileOwnerType.POST, postIds).groupBy { it.ownerId } + val authorIds = posts.content.map { it.user.id }.distinct() + val memberMap = buildMemberMap(clubId, authorIds) return posts.map { post -> dashboardMapper.toPostResponse( post = post, + authorMember = memberMap.getValue(post.user.id), files = filesByPostId[post.id] ?: emptyList(), now = now, ) @@ -117,6 +121,14 @@ class GetDashboardQueryService( return dashboardMapper.toScheduleResponses(events, sessions) } + private fun buildMemberMap( + clubId: Long, + userIds: List, + ): Map { + if (userIds.isEmpty()) return emptyMap() + return clubMemberReader.findAllByClubIdAndUserIds(clubId, userIds).associateBy { it.user.id } + } + fun getUnreadNotice( clubId: Long, userId: Long, diff --git a/src/main/kotlin/com/weeth/domain/file/domain/enums/FileOwnerType.kt b/src/main/kotlin/com/weeth/domain/file/domain/enums/FileOwnerType.kt index 79e82b8e..aec2909f 100644 --- a/src/main/kotlin/com/weeth/domain/file/domain/enums/FileOwnerType.kt +++ b/src/main/kotlin/com/weeth/domain/file/domain/enums/FileOwnerType.kt @@ -4,6 +4,5 @@ enum class FileOwnerType { POST, COMMENT, RECEIPT, - USER_PROFILE, CLUB_MEMBER_PROFILE, } diff --git a/src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt b/src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt index a8622a01..c5d9b0ee 100644 --- a/src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt @@ -7,8 +7,6 @@ import com.weeth.domain.schedule.application.mapper.SessionMapper import com.weeth.domain.session.application.exception.SessionNotFoundException import com.weeth.domain.session.domain.entity.Session import com.weeth.domain.session.domain.repository.SessionRepository -import com.weeth.domain.user.domain.enums.Role -import com.weeth.domain.user.domain.repository.UserReader import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.DayOfWeek @@ -19,7 +17,6 @@ import java.time.temporal.TemporalAdjusters @Transactional(readOnly = true) class GetSessionQueryService( private val sessionRepository: SessionRepository, - private val userReader: UserReader, private val clubMemberPolicy: ClubMemberPolicy, private val sessionMapper: SessionMapper, ) { @@ -28,11 +25,10 @@ class GetSessionQueryService( userId: Long, sessionId: Long, ): SessionResponse { - clubMemberPolicy.getActiveMember(clubId, userId) - val user = userReader.getById(userId) + val member = clubMemberPolicy.getActiveMember(clubId, userId) val session = sessionRepository.findByIdAndClubId(sessionId, clubId) ?: throw SessionNotFoundException() - return if (user.role == Role.ADMIN) { + return if (member.isAdminOrLead()) { sessionMapper.toAdminResponse(session) } else { sessionMapper.toResponse(session) diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/request/UserRoleUpdateRequest.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/request/UserRoleUpdateRequest.kt index e0c927ba..d3f575b6 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/dto/request/UserRoleUpdateRequest.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/request/UserRoleUpdateRequest.kt @@ -1,12 +1,11 @@ package com.weeth.domain.user.application.dto.request -import com.weeth.domain.user.domain.enums.Role +import com.weeth.domain.club.domain.enums.MemberRole import io.swagger.v3.oas.annotations.media.Schema -import jakarta.validation.constraints.NotNull data class UserRoleUpdateRequest( @field:Schema(description = "대상 사용자 ID", example = "1") val userId: Long, - @field:Schema(description = "변경할 권한", example = "ADMIN") - val role: Role, + @field:Schema(description = "변경할 동아리 내 권한", example = "ADMIN") + val role: MemberRole, ) diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserInfo.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserInfo.kt index 16361cfa..d677a32c 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserInfo.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserInfo.kt @@ -1,7 +1,7 @@ package com.weeth.domain.user.application.dto.response +import com.weeth.domain.club.domain.enums.MemberRole import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.enums.Role import io.swagger.v3.oas.annotations.media.Schema data class UserInfo( @@ -11,16 +11,19 @@ data class UserInfo( val name: String, @field:Schema(description = "프로필 이미지 URL") val profileImageUrl: String?, - @field:Schema(description = "권한", example = "USER") - val role: Role, + @field:Schema(description = "동아리 내 권한", example = "USER") + val role: MemberRole, ) { companion object { - fun from(user: User) = - UserInfo( - id = user.id, - name = user.name, - profileImageUrl = user.profileImageUrl, - role = user.role, - ) + fun of( + user: User, + role: MemberRole, + resolvedProfileImageUrl: String?, + ) = UserInfo( + id = user.id, + name = user.name, + profileImageUrl = resolvedProfileImageUrl, + role = role, + ) } } diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserProfileResponse.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserProfileResponse.kt index 8bddbf47..65f17bbf 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserProfileResponse.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserProfileResponse.kt @@ -1,6 +1,6 @@ package com.weeth.domain.user.application.dto.response -import com.weeth.domain.user.domain.enums.Role +import com.weeth.domain.club.domain.enums.MemberRole import io.swagger.v3.oas.annotations.media.Schema data class UserProfileResponse( @@ -20,8 +20,8 @@ data class UserProfileResponse( val department: String, @field:Schema(description = "소속 기수 목록", example = "[6, 7]") val cardinals: List, - @field:Schema(description = "권한", example = "USER", nullable = true) - val role: Role?, + @field:Schema(description = "동아리 내 권한", example = "USER", nullable = true) + val role: MemberRole?, @field:Schema(description = "프로필 이미지 URL") val profileImageUrl: String?, ) diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserSummaryResponse.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserSummaryResponse.kt index e589f519..9e8fe4f6 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserSummaryResponse.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserSummaryResponse.kt @@ -1,6 +1,6 @@ package com.weeth.domain.user.application.dto.response -import com.weeth.domain.user.domain.enums.Role +import com.weeth.domain.club.domain.enums.MemberRole import io.swagger.v3.oas.annotations.media.Schema data class UserSummaryResponse( @@ -10,6 +10,6 @@ data class UserSummaryResponse( val name: String, @field:Schema(description = "소속 기수 목록", example = "[6, 7]") val cardinals: List, - @field:Schema(description = "권한", example = "USER", nullable = true) - val role: Role?, + @field:Schema(description = "동아리 내 권한", example = "USER", nullable = true) + val role: MemberRole?, ) diff --git a/src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt b/src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt index e8e2a1b5..d4c5e43e 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt @@ -1,5 +1,7 @@ package com.weeth.domain.user.application.mapper +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.file.domain.port.FileAccessUrlPort import com.weeth.domain.user.application.dto.response.SocialLoginResponse import com.weeth.domain.user.application.dto.response.UserProfileResponse import com.weeth.domain.user.application.dto.response.UserSummaryResponse @@ -8,7 +10,9 @@ import com.weeth.global.auth.jwt.application.dto.JwtDto import org.springframework.stereotype.Component @Component -class UserMapper { +class UserMapper( + private val fileAccessUrlPort: FileAccessUrlPort, +) { fun toSocialLoginResponse( token: JwtDto, isNewUser: Boolean, @@ -19,7 +23,10 @@ class UserMapper { isNewUser = isNewUser, ) - fun toUserProfileResponse(user: User): UserProfileResponse = + fun toUserProfileResponse( + user: User, + clubMember: ClubMember, + ): UserProfileResponse = UserProfileResponse( id = user.id, name = user.name, @@ -29,8 +36,19 @@ class UserMapper { school = user.school, department = user.department, cardinals = emptyList(), - role = user.role, - profileImageUrl = user.profileImageUrl, + role = clubMember.memberRole, + profileImageUrl = clubMember.profileImageStorageKey?.let { fileAccessUrlPort.resolve(it) }, + ) + + fun toUserSummaryResponse( + user: User, + clubMember: ClubMember, + ): UserSummaryResponse = + UserSummaryResponse( + id = user.id, + name = user.name, + cardinals = emptyList(), + role = clubMember.memberRole, ) fun toUserSummaryResponse(user: User): UserSummaryResponse = @@ -38,6 +56,6 @@ class UserMapper { id = user.id, name = user.name, cardinals = emptyList(), - role = user.role, + role = null, ) } diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCase.kt index f114095a..760e12d9 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCase.kt @@ -42,7 +42,7 @@ class SocialLoginUseCase( if (user.isBannedOrLeft()) throw UserInActiveException() - val token = jwtManageUseCase.create(user.id, user.emailValue, user.role) + val token = jwtManageUseCase.create(user.id, user.emailValue) return userMapper.toSocialLoginResponse(token, isNewUser) } diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryService.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryService.kt index d4b41510..65dd7cc0 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryService.kt @@ -1,5 +1,6 @@ package com.weeth.domain.user.application.usecase.query +import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.user.application.dto.response.UserProfileResponse import com.weeth.domain.user.application.dto.response.UserSummaryResponse import com.weeth.domain.user.application.mapper.UserMapper @@ -11,15 +12,31 @@ import org.springframework.transaction.annotation.Transactional @Transactional(readOnly = true) class GetUserQueryService( private val userRepository: UserRepository, + private val clubMemberPolicy: ClubMemberPolicy, private val mapper: UserMapper, ) { fun existsByEmail(email: String): Boolean = userRepository.existsByEmailValue(email) - fun findMyProfile(userId: Long): UserProfileResponse { // todo: 동아리별 정보 추가 + fun findMyProfile( + clubId: Long, + userId: Long, + ): UserProfileResponse { val user = userRepository.getById(userId) - return mapper.toUserProfileResponse(user) + val member = clubMemberPolicy.getActiveMember(clubId, userId) + return mapper.toUserProfileResponse(user, member) } + // TODO: WTH-205에서 UserClubController에 연결 예정 + fun findMyInfo( + clubId: Long, + userId: Long, + ): UserSummaryResponse { + val user = userRepository.getById(userId) + val member = clubMemberPolicy.getActiveMember(clubId, userId) + return mapper.toUserSummaryResponse(user, member) + } + + @Deprecated("WTH-205에서 club-scoped API로 대체 예정") fun findMyInfo(userId: Long): UserSummaryResponse { val user = userRepository.getById(userId) return mapper.toUserSummaryResponse(user) diff --git a/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt b/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt index b9c78979..9fda6d55 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt @@ -1,7 +1,6 @@ package com.weeth.domain.user.domain.entity import com.weeth.domain.user.domain.converter.EmailConverter -import com.weeth.domain.user.domain.enums.Role import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.vo.Email import com.weeth.global.common.converter.PhoneNumberConverter @@ -27,8 +26,6 @@ class User( school: String = "", department: String = "", status: Status = Status.WAITING, - role: Role = Role.USER, - profileImageUrl: String? = null, ) : BaseEntity() { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -67,11 +64,6 @@ class User( var status: Status = status private set - @Enumerated(EnumType.STRING) - @Column(nullable = false, length = 20) - var role: Role = role - private set - @Column(nullable = false) var termsAgreed: Boolean = false private set @@ -80,10 +72,6 @@ class User( var privacyAgreed: Boolean = false private set - @Column(length = 500) - var profileImageUrl: String? = profileImageUrl?.trim()?.takeIf { it.isNotBlank() } - private set - val emailValue: String get() = email.value @@ -129,12 +117,8 @@ class User( privacyAgreed: Boolean, ) { require(termsAgreed && privacyAgreed) { "모든 약관에 동의해야 합니다." } - this.termsAgreed = termsAgreed - this.privacyAgreed = privacyAgreed - } - - fun updateProfileImageUrl(url: String?) { - this.profileImageUrl = url?.trim()?.takeIf { it.isNotBlank() } + this.termsAgreed = true + this.privacyAgreed = true } fun accept() { @@ -145,12 +129,6 @@ class User( status = Status.BANNED } - fun updateRole(role: Role) { - this.role = role - } - - fun hasRole(role: Role): Boolean = this.role == role - companion object { fun create( name: String, @@ -160,7 +138,6 @@ class User( school: String = "", department: String = "", status: Status = Status.WAITING, - profileImageUrl: String? = null, ): User = User( name = name, @@ -170,7 +147,6 @@ class User( school = school, department = department, status = status, - profileImageUrl = profileImageUrl, ) } } diff --git a/src/main/kotlin/com/weeth/domain/user/domain/enums/Role.kt b/src/main/kotlin/com/weeth/domain/user/domain/enums/Role.kt deleted file mode 100644 index 340b1eed..00000000 --- a/src/main/kotlin/com/weeth/domain/user/domain/enums/Role.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.weeth.domain.user.domain.enums - -enum class Role { - USER, - ADMIN, -} diff --git a/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt b/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt index ce508d7e..fcaeb773 100644 --- a/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt +++ b/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt @@ -4,7 +4,6 @@ import com.weeth.domain.user.application.dto.request.AgreeTermsRequest import com.weeth.domain.user.application.dto.request.SocialLoginRequest import com.weeth.domain.user.application.dto.request.UpdateUserProfileRequest import com.weeth.domain.user.application.dto.response.SocialLoginResponse -import com.weeth.domain.user.application.dto.response.UserProfileResponse import com.weeth.domain.user.application.dto.response.UserSummaryResponse import com.weeth.domain.user.application.exception.UserErrorCode import com.weeth.domain.user.application.usecase.command.AgreeTermsUseCase @@ -67,13 +66,7 @@ class UserController( ): CommonResponse = CommonResponse.success(UserResponseCode.USER_EMAIL_CHECK_SUCCESS, !getUserQueryService.existsByEmail(email)) - @GetMapping - @Operation(summary = "내 정보 조회") - fun find( - @Parameter(hidden = true) @CurrentUser userId: Long, - ): CommonResponse = - CommonResponse.success(UserResponseCode.USER_FIND_BY_ID_SUCCESS, getUserQueryService.findMyProfile(userId)) - + @Deprecated("WTH-205에서 club-scoped API로 대체 예정") @GetMapping("/info") @Operation(summary = "전역 내 정보 조회 API") fun findMyInfo( diff --git a/src/main/kotlin/com/weeth/global/auth/annotation/CurrentUserRole.kt b/src/main/kotlin/com/weeth/global/auth/annotation/CurrentUserRole.kt deleted file mode 100644 index 90690a12..00000000 --- a/src/main/kotlin/com/weeth/global/auth/annotation/CurrentUserRole.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.weeth.global.auth.annotation - -@Target(AnnotationTarget.VALUE_PARAMETER) -@Retention(AnnotationRetention.RUNTIME) -annotation class CurrentUserRole diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractor.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractor.kt index dc2ee1c5..de308936 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractor.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractor.kt @@ -1,6 +1,5 @@ package com.weeth.global.auth.jwt.application.service -import com.weeth.domain.user.domain.enums.Role import com.weeth.global.auth.jwt.application.exception.TokenNotFoundException import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider import com.weeth.global.config.properties.JwtProperties @@ -19,7 +18,6 @@ class JwtTokenExtractor( data class TokenClaims( val id: Long, val email: String, - val role: Role, ) fun extractRefreshToken(request: HttpServletRequest): String = @@ -46,7 +44,6 @@ class JwtTokenExtractor( TokenClaims( id = claims.get(JwtTokenProvider.ID_CLAIM, Long::class.javaObjectType), email = claims.get(JwtTokenProvider.EMAIL_CLAIM, String::class.java), - role = Role.valueOf(claims.get(JwtTokenProvider.ROLE_CLAIM, String::class.java)), ) }.onFailure { log.error("액세스 토큰이 유효하지 않습니다: {}", it.message) diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt index c04cf927..322da189 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt @@ -1,6 +1,5 @@ package com.weeth.global.auth.jwt.application.usecase -import com.weeth.domain.user.domain.enums.Role import com.weeth.global.auth.jwt.application.dto.JwtDto import com.weeth.global.auth.jwt.application.exception.InvalidTokenException import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor @@ -17,12 +16,11 @@ class JwtManageUseCase( fun create( userId: Long, email: String, - role: Role, ): JwtDto { - val accessToken = jwtTokenProvider.createAccessToken(userId, email, role) + val accessToken = jwtTokenProvider.createAccessToken(userId, email) val refreshToken = jwtTokenProvider.createRefreshToken(userId) - updateToken(userId, refreshToken, role, email) + refreshTokenStore.save(userId, refreshToken, email) return JwtDto(accessToken, refreshToken) } @@ -33,18 +31,8 @@ class JwtManageUseCase( val userId = jwtTokenExtractor.extractId(requestToken) ?: throw InvalidTokenException() refreshTokenStore.validateRefreshToken(userId, requestToken) - val role = refreshTokenStore.getRole(userId) val email = refreshTokenStore.getEmail(userId) - return create(userId, email, role) - } - - private fun updateToken( - userId: Long, - refreshToken: String, - role: Role, - email: String, - ) { - refreshTokenStore.save(userId, refreshToken, role, email) + return create(userId, email) } } diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/domain/port/RefreshTokenStorePort.kt b/src/main/kotlin/com/weeth/global/auth/jwt/domain/port/RefreshTokenStorePort.kt index 9d3c3bac..578cdaf8 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/domain/port/RefreshTokenStorePort.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/domain/port/RefreshTokenStorePort.kt @@ -1,12 +1,9 @@ package com.weeth.global.auth.jwt.domain.port -import com.weeth.domain.user.domain.enums.Role - interface RefreshTokenStorePort { fun save( userId: Long, refreshToken: String, - role: Role, email: String, ) @@ -18,11 +15,4 @@ interface RefreshTokenStorePort { ) fun getEmail(userId: Long): String - - fun getRole(userId: Long): Role - - fun updateRole( - userId: Long, - role: Role, - ) } diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProvider.kt b/src/main/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProvider.kt index f7d6ef15..e9044ef7 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProvider.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProvider.kt @@ -1,6 +1,5 @@ package com.weeth.global.auth.jwt.domain.service -import com.weeth.domain.user.domain.enums.Role import com.weeth.global.auth.jwt.application.exception.InvalidTokenException import com.weeth.global.config.properties.JwtProperties import io.jsonwebtoken.Claims @@ -32,7 +31,6 @@ class JwtTokenProvider( fun createAccessToken( id: Long, email: String, - role: Role, ): String { val now = Date() return Jwts @@ -40,7 +38,6 @@ class JwtTokenProvider( .subject(ACCESS_TOKEN_SUBJECT) .claim(ID_CLAIM, id) .claim(EMAIL_CLAIM, email) - .claim(ROLE_CLAIM, role.name) .issuedAt(now) .expiration(Date(now.time + accessTokenExpirationPeriod)) .signWith(secretKey) @@ -85,6 +82,5 @@ class JwtTokenProvider( private const val REFRESH_TOKEN_SUBJECT = "RefreshToken" internal const val EMAIL_CLAIM = "email" internal const val ID_CLAIM = "id" - internal const val ROLE_CLAIM = "role" } } diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt b/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt index 0613cf15..0f316c50 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt @@ -39,13 +39,13 @@ class JwtAuthenticationProcessingFilter( private fun saveAuthentication(accessToken: String) { val claims = jwtTokenExtractor.extractClaims(accessToken) ?: throw TokenNotFoundException() - val principal = AuthenticatedUser(claims.id, claims.email, claims.role) + val principal = AuthenticatedUser(claims.id, claims.email) val authentication = UsernamePasswordAuthenticationToken( principal, null, - listOf(SimpleGrantedAuthority("ROLE_${claims.role.name}")), + listOf(SimpleGrantedAuthority("ROLE_USER")), ) SecurityContextHolder.getContext().authentication = authentication diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisRefreshTokenStoreAdapter.kt b/src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisRefreshTokenStoreAdapter.kt index d90cdbcf..de1548a9 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisRefreshTokenStoreAdapter.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisRefreshTokenStoreAdapter.kt @@ -1,6 +1,5 @@ package com.weeth.global.auth.jwt.infrastructure -import com.weeth.domain.user.domain.enums.Role import com.weeth.global.auth.jwt.application.exception.InvalidTokenException import com.weeth.global.auth.jwt.application.exception.RedisTokenNotFoundException import com.weeth.global.auth.jwt.domain.port.RefreshTokenStorePort @@ -17,7 +16,6 @@ class RedisRefreshTokenStoreAdapter( override fun save( userId: Long, refreshToken: String, - role: Role, email: String, ) { val key = getKey(userId) @@ -25,7 +23,6 @@ class RedisRefreshTokenStoreAdapter( key, mapOf( TOKEN to refreshToken, - ROLE to role.name, EMAIL to email, ), ) @@ -52,24 +49,6 @@ class RedisRefreshTokenStoreAdapter( ?: throw RedisTokenNotFoundException() } - override fun getRole(userId: Long): Role { - val key = getKey(userId) - val role = - redisTemplate.opsForHash().get(key, ROLE) - ?: throw RedisTokenNotFoundException() - return runCatching { Role.valueOf(role) }.getOrElse { throw InvalidTokenException() } - } - - override fun updateRole( - userId: Long, - role: Role, - ) { - val key = getKey(userId) - if (redisTemplate.hasKey(key) == true) { - redisTemplate.opsForHash().put(key, ROLE, role.name) - } - } - private fun find(userId: Long): String { val key = getKey(userId) return redisTemplate.opsForHash().get(key, TOKEN) @@ -81,7 +60,6 @@ class RedisRefreshTokenStoreAdapter( companion object { private const val PREFIX = "refreshToken:" private const val TOKEN = "token" - private const val ROLE = "role" private const val EMAIL = "email" } } diff --git a/src/main/kotlin/com/weeth/global/auth/model/AuthenticatedUser.kt b/src/main/kotlin/com/weeth/global/auth/model/AuthenticatedUser.kt index 18ff70f9..bb125002 100644 --- a/src/main/kotlin/com/weeth/global/auth/model/AuthenticatedUser.kt +++ b/src/main/kotlin/com/weeth/global/auth/model/AuthenticatedUser.kt @@ -1,12 +1,9 @@ package com.weeth.global.auth.model -import com.weeth.domain.user.domain.enums.Role - /** * Authentication 설정을 위한 model */ data class AuthenticatedUser( val id: Long, val email: String, - val role: Role, ) diff --git a/src/main/kotlin/com/weeth/global/auth/resolver/CurrentUserRoleArgumentResolver.kt b/src/main/kotlin/com/weeth/global/auth/resolver/CurrentUserRoleArgumentResolver.kt deleted file mode 100644 index 11623dec..00000000 --- a/src/main/kotlin/com/weeth/global/auth/resolver/CurrentUserRoleArgumentResolver.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.weeth.global.auth.resolver - -import com.weeth.domain.user.domain.enums.Role -import com.weeth.global.auth.annotation.CurrentUserRole -import com.weeth.global.auth.jwt.application.exception.AnonymousAuthenticationException -import com.weeth.global.auth.model.AuthenticatedUser -import org.springframework.core.MethodParameter -import org.springframework.security.authentication.AnonymousAuthenticationToken -import org.springframework.security.core.context.SecurityContextHolder -import org.springframework.web.bind.support.WebDataBinderFactory -import org.springframework.web.context.request.NativeWebRequest -import org.springframework.web.method.support.HandlerMethodArgumentResolver -import org.springframework.web.method.support.ModelAndViewContainer - -class CurrentUserRoleArgumentResolver : HandlerMethodArgumentResolver { - override fun supportsParameter(parameter: MethodParameter): Boolean { - val hasAnnotation = parameter.hasParameterAnnotation(CurrentUserRole::class.java) - val parameterType = Role::class.java.isAssignableFrom(parameter.parameterType) - return hasAnnotation && parameterType - } - - override fun resolveArgument( - parameter: MethodParameter, - mavContainer: ModelAndViewContainer?, - webRequest: NativeWebRequest, - binderFactory: WebDataBinderFactory?, - ): Any { - val authentication = SecurityContextHolder.getContext().authentication - - if (authentication == null || authentication is AnonymousAuthenticationToken) { - throw AnonymousAuthenticationException() - } - - val principal = authentication.principal - if (principal is AuthenticatedUser) { - return principal.role - } - - val role = - authentication.authorities - .asSequence() - .mapNotNull { authority -> authority.authority } - .filter { it.startsWith("ROLE_") } - .mapNotNull { raw -> - runCatching { Role.valueOf(raw.removePrefix("ROLE_")) }.getOrNull() - }.firstOrNull() - - return role ?: throw AnonymousAuthenticationException() - } -} diff --git a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt index 6d52e098..0ab5d2fb 100644 --- a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt +++ b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt @@ -76,11 +76,11 @@ class SecurityConfig( AuthorizationDecision(allowed) }.requestMatchers("/actuator/health") .permitAll() - // TODO: 전역 User.role 대신 clubMember 기반 권한 검증으로 교체해야 동아리별 ADMIN/LEAD가 admin API를 사용할 수 있다. + // 실제 관리자 권한 검증은 ClubMemberPolicy.requireAdmin()에서 수행 .requestMatchers( "/api/v1/admin/**", "/api/v4/admin/**", - ).hasRole("ADMIN") + ).authenticated() .anyRequest() .authenticated() }.exceptionHandling { exceptionHandling -> diff --git a/src/main/kotlin/com/weeth/global/config/WebMvcConfig.kt b/src/main/kotlin/com/weeth/global/config/WebMvcConfig.kt index 41baf18b..87c9be2c 100644 --- a/src/main/kotlin/com/weeth/global/config/WebMvcConfig.kt +++ b/src/main/kotlin/com/weeth/global/config/WebMvcConfig.kt @@ -1,7 +1,6 @@ package com.weeth.global.config import com.weeth.global.auth.resolver.CurrentUserArgumentResolver -import com.weeth.global.auth.resolver.CurrentUserRoleArgumentResolver import com.weeth.global.common.web.TsidPathVariableArgumentResolver import org.springframework.context.annotation.Configuration import org.springframework.web.method.support.HandlerMethodArgumentResolver @@ -11,7 +10,6 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer class WebMvcConfig : WebMvcConfigurer { override fun addArgumentResolvers(resolvers: MutableList) { resolvers.add(CurrentUserArgumentResolver()) - resolvers.add(CurrentUserRoleArgumentResolver()) resolvers.add(TsidPathVariableArgumentResolver()) } } diff --git a/src/test/kotlin/com/weeth/domain/attendance/fixture/AttendanceTestFixture.kt b/src/test/kotlin/com/weeth/domain/attendance/fixture/AttendanceTestFixture.kt index ce3a0984..0c46d535 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/fixture/AttendanceTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/fixture/AttendanceTestFixture.kt @@ -4,7 +4,6 @@ import com.weeth.domain.attendance.domain.entity.Attendance import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.session.domain.entity.Session import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.enums.Role import org.springframework.test.util.ReflectionTestUtils import java.util.UUID @@ -19,7 +18,7 @@ object AttendanceTestFixture { department = "", ).also { it.accept() } - fun createAdminUser(name: String): User = createActiveUser(name).also { it.updateRole(Role.ADMIN) } + fun createAdminUser(name: String): User = createActiveUser(name) fun createAttendance( session: Session, diff --git a/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt b/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt index 5531f799..fa7e8c0e 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt @@ -1,12 +1,14 @@ package com.weeth.domain.board.application.mapper import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.enums.MemberRole import com.weeth.domain.comment.application.dto.response.CommentResponse import com.weeth.domain.file.application.dto.response.FileResponse import com.weeth.domain.file.domain.enums.FileStatus +import com.weeth.domain.file.domain.port.FileAccessUrlPort import com.weeth.domain.user.application.dto.response.UserInfo import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.enums.Role import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe import io.mockk.every @@ -15,15 +17,18 @@ import java.time.LocalDateTime class PostMapperTest : DescribeSpec({ - val mapper = PostMapper() + val fileAccessUrlPort = mockk() + val mapper = PostMapper(fileAccessUrlPort) val now = LocalDateTime.now() val user = mockk() val post = mockk() + val authorMember = mockk() every { user.id } returns 1L every { user.name } returns "테스터" - every { user.role } returns Role.USER - every { user.profileImageUrl } returns null + + every { authorMember.memberRole } returns MemberRole.USER + every { authorMember.profileImageStorageKey } returns null every { post.id } returns 1L every { post.title } returns "제목" @@ -35,7 +40,7 @@ class PostMapperTest : describe("toListResponse") { it("24시간 이내 생성된 게시글은 isNew=true") { - val response = mapper.toListResponse(post, hasFile = true, now = now) + val response = mapper.toListResponse(post, authorMember, hasFile = true, now = now) response.id shouldBe 1L response.hasFile shouldBe true @@ -49,7 +54,7 @@ class PostMapperTest : listOf( CommentResponse( id = 10L, - author = UserInfo(id = 2L, name = "댓글작성자", profileImageUrl = null, role = Role.USER), + author = UserInfo(id = 2L, name = "댓글작성자", profileImageUrl = null, role = MemberRole.USER), content = "댓글", time = LocalDateTime.now(), fileUrls = emptyList(), @@ -69,7 +74,7 @@ class PostMapperTest : ), ) - val response = mapper.toDetailResponse(post, comments, files) + val response = mapper.toDetailResponse(post, authorMember, comments, files) response.id shouldBe 1L response.commentCount shouldBe 2 diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt index d36d14b0..c2e60f3d 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt @@ -8,10 +8,10 @@ import com.weeth.domain.board.domain.enums.BoardType import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.board.domain.vo.BoardConfig import com.weeth.domain.board.fixture.BoardTestFixture +import com.weeth.domain.club.domain.enums.MemberRole import com.weeth.domain.club.domain.repository.ClubReader import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.club.fixture.ClubTestFixture -import com.weeth.domain.user.domain.enums.Role import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe @@ -45,7 +45,7 @@ class ManageBoardUseCaseTest : name = "운영공지", type = BoardType.NOTICE, commentEnabled = false, - writePermission = Role.ADMIN, + writePermission = MemberRole.ADMIN, isPrivate = true, ) @@ -54,7 +54,7 @@ class ManageBoardUseCaseTest : result.name shouldBe "운영공지" result.type shouldBe BoardType.NOTICE result.commentEnabled shouldBe false - result.writePermission shouldBe Role.ADMIN + result.writePermission shouldBe MemberRole.ADMIN result.isPrivate shouldBe true } } @@ -68,7 +68,7 @@ class ManageBoardUseCaseTest : result.name shouldBe "변경" result.commentEnabled shouldBe true - result.writePermission shouldBe Role.USER + result.writePermission shouldBe MemberRole.USER result.isPrivate shouldBe true } @@ -80,7 +80,7 @@ class ManageBoardUseCaseTest : result.name shouldBe "기존" result.commentEnabled shouldBe true - result.writePermission shouldBe Role.USER + result.writePermission shouldBe MemberRole.USER result.isPrivate shouldBe false } diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt index 60eb7c28..d8644324 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt @@ -15,6 +15,7 @@ import com.weeth.domain.board.domain.repository.PostRepository import com.weeth.domain.board.domain.vo.BoardConfig import com.weeth.domain.board.fixture.BoardTestFixture import com.weeth.domain.board.fixture.PostTestFixture +import com.weeth.domain.club.domain.enums.MemberRole import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.file.application.dto.request.FileSaveRequest import com.weeth.domain.file.application.mapper.FileMapper @@ -23,7 +24,6 @@ import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.repository.FileReader import com.weeth.domain.file.domain.repository.FileRepository import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.enums.Role import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.repository.UserReader import com.weeth.domain.user.domain.vo.Email @@ -75,15 +75,11 @@ class ManagePostUseCaseTest : ownerId = ownerId, ) - fun createUser( - id: Long = 1L, - role: Role = Role.USER, - ): User = + fun createUser(id: Long = 1L): User = User( name = "적순", email = Email.from("test1@test.com"), status = Status.ACTIVE, - role = role, ).apply { ReflectionTestUtils.setField(this, "id", id) } @@ -105,13 +101,13 @@ class ManagePostUseCaseTest : every { fileReader.findAll(any(), any(), any()) } returns emptyList() every { postMapper.toSaveResponse(any()) } returns PostSaveResponse(1L) every { fileRepository.delete(any()) } just runs - // update/delete 공통 기본값: USER 역할의 작성자 - every { userReader.getById(any()) } returns createUser(1L, Role.USER) + // update/delete 공통 기본값: 작성자 + every { userReader.getById(any()) } returns UserTestFixture.createActiveUser1(1L) } describe("save") { it("일반 게시판에서 게시글을 저장한다") { - val user = createUser(1L, Role.USER) + val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val request = CreatePostRequest(title = "제목", content = "내용") @@ -125,12 +121,12 @@ class ManagePostUseCaseTest : } it("ADMIN 전용 게시판에 일반 사용자가 작성하면 예외를 던진다") { - val user = createUser(1L, Role.USER) + val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create( name = "공지", type = BoardType.NOTICE, - config = BoardConfig(writePermission = Role.ADMIN), + config = BoardConfig(writePermission = MemberRole.ADMIN), ) val request = CreatePostRequest(title = "제목", content = "내용") @@ -145,7 +141,7 @@ class ManagePostUseCaseTest : } it("비공개 게시판에 일반 사용자가 작성하면 예외를 던진다") { - val user = createUser(1L, Role.USER) + val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create( name = "비공개", @@ -165,7 +161,7 @@ class ManagePostUseCaseTest : } it("cardinalNumber가 전달되면 게시글에 반영된다") { - val user = createUser(1L, Role.USER) + val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val request = CreatePostRequest( @@ -187,7 +183,7 @@ class ManagePostUseCaseTest : } it("존재하지 않는 boardId면 예외를 던진다") { - val user = createUser(1L, Role.USER) + val user = UserTestFixture.createActiveUser1(1L) val request = CreatePostRequest(title = "제목", content = "내용") every { userReader.getById(1L) } returns user @@ -201,7 +197,7 @@ class ManagePostUseCaseTest : describe("update") { it("files가 null이면 기존 파일을 유지한다") { - val user = createUser(1L, Role.USER) + val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val clubId = board.club.id val post = Post.create("제목", "내용", user, board) @@ -217,7 +213,7 @@ class ManagePostUseCaseTest : } it("files가 있으면 기존 파일을 soft delete 후 교체한다") { - val user = createUser(1L, Role.USER) + val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val clubId = board.club.id val post = PostTestFixture.create(title = "제목", content = "내용", user = user, board = board) @@ -253,7 +249,7 @@ class ManagePostUseCaseTest : } it("title이 null이면 기존 제목을 유지한다") { - val user = createUser(1L, Role.USER) + val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val clubId = board.club.id val post = Post.create("원래 제목", "원래 내용", user, board) @@ -269,7 +265,7 @@ class ManagePostUseCaseTest : } it("content가 null이면 기존 내용을 유지한다") { - val user = createUser(1L, Role.USER) + val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val clubId = board.club.id val post = Post.create("원래 제목", "원래 내용", user, board) @@ -293,12 +289,12 @@ class ManagePostUseCaseTest : } it("게시판이 ADMIN 전용으로 바뀐 후 일반 사용자가 수정하면 예외를 던진다") { - val user = createUser(1L, Role.USER) + val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create( name = "공지", type = BoardType.NOTICE, - config = BoardConfig(writePermission = Role.ADMIN), + config = BoardConfig(writePermission = MemberRole.ADMIN), ) val clubId = board.club.id val post = PostTestFixture.create(title = "제목", content = "내용", user = user, board = board) @@ -312,7 +308,7 @@ class ManagePostUseCaseTest : } it("게시판이 비공개로 바뀐 후 일반 사용자가 수정하면 예외를 던진다") { - val user = createUser(1L, Role.USER) + val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create( name = "비공개", @@ -333,7 +329,7 @@ class ManagePostUseCaseTest : describe("delete") { it("삭제 시 첨부 파일과 게시글을 soft delete한다") { - val user = createUser(1L, Role.USER) + val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val clubId = board.club.id val post = PostTestFixture.create(title = "제목", content = "내용", user = user, board = board) @@ -359,12 +355,12 @@ class ManagePostUseCaseTest : } it("게시판이 ADMIN 전용으로 바뀐 후 일반 사용자가 삭제하면 예외를 던진다") { - val user = createUser(1L, Role.USER) + val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create( name = "공지", type = BoardType.NOTICE, - config = BoardConfig(writePermission = Role.ADMIN), + config = BoardConfig(writePermission = MemberRole.ADMIN), ) val clubId = board.club.id val post = PostTestFixture.create(title = "제목", content = "내용", user = user, board = board) @@ -381,7 +377,7 @@ class ManagePostUseCaseTest : describe("owner validation") { it("작성자가 아니면 수정 시 예외를 던진다") { val owner = UserTestFixture.createActiveUser1(1L) - val otherUser = createUser(2L, Role.USER) + val otherUser = UserTestFixture.createActiveUser1(2L) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val clubId = board.club.id val post = PostTestFixture.create(title = "제목", content = "내용", user = owner, board = board) diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt index 6f9f1d08..86ecda55 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt @@ -6,7 +6,7 @@ import com.weeth.domain.board.domain.enums.BoardType import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.board.fixture.BoardTestFixture import com.weeth.domain.club.domain.service.ClubMemberPolicy -import com.weeth.domain.user.domain.enums.Role +import com.weeth.domain.club.fixture.ClubMemberTestFixture import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.collections.shouldHaveSize @@ -31,11 +31,13 @@ class GetBoardQueryServiceTest : BoardTestFixture.create(name = "운영", type = BoardType.NOTICE).apply { updateConfig(config.copy(isPrivate = true)) } + val member = ClubMemberTestFixture.createActiveMember() + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns member every { boardRepository.findAllByClubIdAndIsDeletedFalseOrderByIdAsc(clubId) } returns listOf(publicBoard, privateBoard) - val result = queryService.findBoards(clubId, userId, Role.USER) + val result = queryService.findBoards(clubId, userId) result shouldHaveSize 1 result.first().name shouldBe "일반" @@ -47,11 +49,13 @@ class GetBoardQueryServiceTest : BoardTestFixture.create(name = "운영", type = BoardType.NOTICE).apply { updateConfig(config.copy(isPrivate = true)) } + val adminMember = ClubMemberTestFixture.createAdminMember() + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns adminMember every { boardRepository.findAllByClubIdAndIsDeletedFalseOrderByIdAsc(clubId) } returns listOf(publicBoard, privateBoard) - val result = queryService.findBoards(clubId, userId, Role.ADMIN) + val result = queryService.findBoards(clubId, userId) result shouldHaveSize 2 result.map { it.name } shouldBe listOf("일반", "운영") diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt index 6cf3e75e..ca374ac7 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt @@ -10,7 +10,10 @@ import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.board.domain.repository.PostRepository import com.weeth.domain.board.fixture.BoardTestFixture import com.weeth.domain.board.fixture.PostTestFixture +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.repository.ClubMemberReader import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.fixture.ClubMemberTestFixture import com.weeth.domain.comment.application.dto.response.CommentResponse import com.weeth.domain.comment.application.usecase.query.GetCommentQueryService import com.weeth.domain.comment.domain.repository.CommentReader @@ -21,7 +24,6 @@ import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.enums.FileStatus import com.weeth.domain.file.domain.repository.FileReader import com.weeth.domain.user.application.dto.response.UserInfo -import com.weeth.domain.user.domain.enums.Role import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec @@ -39,6 +41,7 @@ class GetPostQueryServiceTest : val postRepository = mockk() val boardRepository = mockk() val clubMemberPolicy = mockk(relaxed = true) + val clubMemberReader = mockk() val commentReader = mockk() val getCommentQueryService = mockk() val fileReader = mockk() @@ -50,6 +53,7 @@ class GetPostQueryServiceTest : postRepository, boardRepository, clubMemberPolicy, + clubMemberReader, commentReader, getCommentQueryService, fileReader, @@ -57,7 +61,7 @@ class GetPostQueryServiceTest : postMapper, ) - val clubId = 1L // findPosts/searchPosts 테스트에서 boardRepository mock 인자로 사용 + val clubId = 1L val userId = 1L beforeTest { @@ -65,6 +69,7 @@ class GetPostQueryServiceTest : postRepository, boardRepository, clubMemberPolicy, + clubMemberReader, commentReader, getCommentQueryService, fileReader, @@ -75,17 +80,20 @@ class GetPostQueryServiceTest : describe("findPost") { it("존재하지 않는 게시글이면 예외를 던진다") { + val member = ClubMemberTestFixture.createActiveMember() + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns member every { postRepository.findByIdAndIsDeletedFalse(1L) } returns null shouldThrow { - queryService.findPost(clubId, userId, 1L, Role.USER) + queryService.findPost(clubId, userId, 1L) } } it("댓글/파일을 포함한 상세 응답을 반환한다") { val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) - val actualClubId = board.club.id // TSID 생성된 실제 club id 사용 + val actualClubId = board.club.id + val member = ClubMemberTestFixture.createActiveMember(club = board.club, user = user) val post = PostTestFixture.create( title = "제목", @@ -120,7 +128,7 @@ class GetPostQueryServiceTest : val detail = com.weeth.domain.board.application.dto.response.PostDetailResponse( id = 1L, - author = UserInfo(id = 1L, name = "적순", profileImageUrl = null, role = Role.USER), + author = UserInfo(id = 1L, name = "적순", profileImageUrl = null, role = MemberRole.USER), title = "제목", content = "내용", time = LocalDateTime.now(), @@ -129,25 +137,28 @@ class GetPostQueryServiceTest : fileUrls = fileResponses, ) + every { clubMemberPolicy.getActiveMember(actualClubId, userId) } returns member every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post every { commentReader.findAllByPostId(any()) } returns emptyList() - every { getCommentQueryService.toCommentTreeResponses(any()) } returns comments + every { clubMemberReader.findAllByClubIdAndUserIds(actualClubId, any()) } returns listOf(member) + every { getCommentQueryService.toCommentTreeResponses(any(), any()) } returns comments every { fileReader.findAll(FileOwnerType.POST, any(), any()) } returns files - every { postMapper.toDetailResponse(post, comments, fileResponses) } returns detail + every { postMapper.toDetailResponse(post, member, comments, fileResponses) } returns detail every { fileMapper.toFileResponse(files.first()) } returns fileResponses.first() - val result = queryService.findPost(actualClubId, userId, 1L, Role.USER) + val result = queryService.findPost(actualClubId, userId, 1L) result.id shouldBe 1L result.comments.size shouldBe 1 result.fileUrls.size shouldBe 1 } - it("비공개 게시판 게시글은 일반/익명에게 노출하지 않는다") { + it("비공개 게시판 게시글은 일반 멤버에게 노출하지 않는다") { val user = UserTestFixture.createActiveUser1(1L) val privateBoard = BoardTestFixture.create(name = "비공개", type = BoardType.GENERAL) val actualClubId = privateBoard.club.id privateBoard.updateConfig(privateBoard.config.copy(isPrivate = true)) + val member = ClubMemberTestFixture.createActiveMember(club = privateBoard.club, user = user) val post = PostTestFixture.create( title = "제목", @@ -156,10 +167,11 @@ class GetPostQueryServiceTest : board = privateBoard, ) + every { clubMemberPolicy.getActiveMember(actualClubId, userId) } returns member every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post shouldThrow { - queryService.findPost(actualClubId, userId, 1L, Role.USER) + queryService.findPost(actualClubId, userId, 1L) } } @@ -172,6 +184,7 @@ class GetPostQueryServiceTest : type = BoardType.GENERAL, ).also { it.markDeleted() } val actualClubId = deletedBoard.club.id + val member = ClubMemberTestFixture.createActiveMember(club = deletedBoard.club, user = user) val post = PostTestFixture.create( title = "제목", @@ -180,10 +193,11 @@ class GetPostQueryServiceTest : board = deletedBoard, ) + every { clubMemberPolicy.getActiveMember(actualClubId, userId) } returns member every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post shouldThrow { - queryService.findPost(actualClubId, userId, 1L, Role.USER) + queryService.findPost(actualClubId, userId, 1L) } } } @@ -192,42 +206,52 @@ class GetPostQueryServiceTest : it("검색 결과가 없으면 예외를 던진다") { val pageable = PageRequest.of(0, 10) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val member = ClubMemberTestFixture.createActiveMember() + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns member every { boardRepository.findByIdAndClubIdAndIsDeletedFalse(1L, clubId) } returns board every { postRepository.searchByBoardId(1L, "키워드", any()) } returns SliceImpl(emptyList(), pageable, false) shouldThrow { - queryService.searchPosts(clubId, userId, 1L, "키워드", 0, 10, Role.USER) + queryService.searchPosts(clubId, userId, 1L, "키워드", 0, 10) } } - it("비공개 게시판은 일반/익명이 검색할 수 없다") { + it("비공개 게시판은 일반 멤버가 검색할 수 없다") { val privateBoard = BoardTestFixture.create(name = "비공개", type = BoardType.GENERAL) privateBoard.updateConfig(privateBoard.config.copy(isPrivate = true)) + val member = ClubMemberTestFixture.createActiveMember() + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns member every { boardRepository.findByIdAndClubIdAndIsDeletedFalse(1L, clubId) } returns privateBoard shouldThrow { - queryService.searchPosts(clubId, userId, 1L, "키워드", 0, 10, Role.USER) + queryService.searchPosts(clubId, userId, 1L, "키워드", 0, 10) } } } describe("validatePage") { it("음수 페이지면 예외를 던진다") { + val member = ClubMemberTestFixture.createActiveMember() + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns member shouldThrow { - queryService.findPosts(clubId, userId, 1L, -1, 10, Role.USER) + queryService.findPosts(clubId, userId, 1L, -1, 10) } } it("pageSize가 0이면 예외를 던진다") { + val member = ClubMemberTestFixture.createActiveMember() + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns member shouldThrow { - queryService.findPosts(clubId, userId, 1L, 0, 0, Role.USER) + queryService.findPosts(clubId, userId, 1L, 0, 0) } } it("pageSize가 최대값을 초과하면 예외를 던진다") { + val member = ClubMemberTestFixture.createActiveMember() + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns member shouldThrow { - queryService.findPosts(clubId, userId, 1L, 0, 51, Role.USER) + queryService.findPosts(clubId, userId, 1L, 0, 51) } } } @@ -236,6 +260,7 @@ class GetPostQueryServiceTest : it("목록 조회 시 mapper를 통해 응답으로 변환한다") { val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val member = ClubMemberTestFixture.createActiveMember(user = user) val post = PostTestFixture.create( title = "제목", @@ -248,7 +273,7 @@ class GetPostQueryServiceTest : val response = com.weeth.domain.board.application.dto.response.PostListResponse( id = 10L, - author = UserInfo(id = 1L, name = "적순", profileImageUrl = null, role = Role.USER), + author = UserInfo(id = 1L, name = "적순", profileImageUrl = null, role = MemberRole.USER), title = "제목", content = "내용", time = LocalDateTime.now(), @@ -257,12 +282,14 @@ class GetPostQueryServiceTest : isNew = false, ) + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns member every { boardRepository.findByIdAndClubIdAndIsDeletedFalse(1L, clubId) } returns board every { postRepository.findAllActiveByBoardId(1L, any()) } returns postSlice every { fileReader.findAll(FileOwnerType.POST, any>(), any()) } returns emptyList() - every { postMapper.toListResponse(any(), any(), any()) } returns response + every { clubMemberReader.findAllByClubIdAndUserIds(clubId, any()) } returns listOf(member) + every { postMapper.toListResponse(any(), any(), any(), any()) } returns response - val result = queryService.findPosts(clubId, userId, 1L, 0, 10, Role.USER) + val result = queryService.findPosts(clubId, userId, 1L, 0, 10) result.content.size shouldBe 1 verify(exactly = 1) { fileReader.findAll(FileOwnerType.POST, any>(), any()) } diff --git a/src/test/kotlin/com/weeth/domain/board/domain/converter/BoardConfigConverterTest.kt b/src/test/kotlin/com/weeth/domain/board/domain/converter/BoardConfigConverterTest.kt index 6bf835a9..cd92b628 100644 --- a/src/test/kotlin/com/weeth/domain/board/domain/converter/BoardConfigConverterTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/domain/converter/BoardConfigConverterTest.kt @@ -1,7 +1,7 @@ package com.weeth.domain.board.domain.converter import com.weeth.domain.board.domain.vo.BoardConfig -import com.weeth.domain.user.domain.enums.Role +import com.weeth.domain.club.domain.enums.MemberRole import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.shouldBe @@ -14,7 +14,7 @@ class BoardConfigConverterTest : val config = BoardConfig( commentEnabled = false, - writePermission = Role.ADMIN, + writePermission = MemberRole.ADMIN, isPrivate = true, ) diff --git a/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt b/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt index 750db876..2e487048 100644 --- a/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt @@ -3,7 +3,7 @@ package com.weeth.domain.board.domain.entity import com.weeth.domain.board.domain.enums.BoardType import com.weeth.domain.board.domain.vo.BoardConfig import com.weeth.domain.board.fixture.BoardTestFixture -import com.weeth.domain.user.domain.enums.Role +import com.weeth.domain.club.domain.enums.MemberRole import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.shouldBe @@ -34,13 +34,13 @@ class BoardEntityTest : BoardTestFixture.create( name = "공지", type = BoardType.NOTICE, - config = BoardConfig(writePermission = Role.ADMIN), + config = BoardConfig(writePermission = MemberRole.ADMIN), ) board.isAdminOnly shouldBe true } - "isAccessibleBy는 비공개 게시판을 ADMIN에게만 허용한다" { + "isAccessibleBy는 비공개 게시판을 ADMIN/LEAD에게만 허용한다" { val privateBoard = BoardTestFixture.create( name = "운영", @@ -48,8 +48,9 @@ class BoardEntityTest : config = BoardConfig(isPrivate = true), ) - privateBoard.isAccessibleBy(Role.ADMIN) shouldBe true - privateBoard.isAccessibleBy(Role.USER) shouldBe false + privateBoard.isAccessibleBy(MemberRole.ADMIN) shouldBe true + privateBoard.isAccessibleBy(MemberRole.LEAD) shouldBe true + privateBoard.isAccessibleBy(MemberRole.USER) shouldBe false } "canWriteBy는 비공개/관리자 전용 설정을 모두 고려한다" { @@ -59,15 +60,16 @@ class BoardEntityTest : BoardTestFixture.create( name = "공지", type = BoardType.NOTICE, - config = BoardConfig(writePermission = Role.ADMIN), + config = BoardConfig(writePermission = MemberRole.ADMIN), ) val publicBoard = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL, config = BoardConfig()) - privateBoard.canWriteBy(Role.USER) shouldBe false - privateBoard.canWriteBy(Role.ADMIN) shouldBe true - adminOnlyBoard.canWriteBy(Role.USER) shouldBe false - adminOnlyBoard.canWriteBy(Role.ADMIN) shouldBe true - publicBoard.canWriteBy(Role.USER) shouldBe true + privateBoard.canWriteBy(MemberRole.USER) shouldBe false + privateBoard.canWriteBy(MemberRole.ADMIN) shouldBe true + privateBoard.canWriteBy(MemberRole.LEAD) shouldBe true + adminOnlyBoard.canWriteBy(MemberRole.USER) shouldBe false + adminOnlyBoard.canWriteBy(MemberRole.ADMIN) shouldBe true + publicBoard.canWriteBy(MemberRole.USER) shouldBe true } "updateConfig는 config를 교체한다" { diff --git a/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt b/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt index 1e4bf65f..5cdc4aea 100644 --- a/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt @@ -4,8 +4,8 @@ import com.weeth.domain.board.domain.entity.Board import com.weeth.domain.board.domain.enums.BoardType import com.weeth.domain.board.domain.vo.BoardConfig import com.weeth.domain.club.domain.entity.Club +import com.weeth.domain.club.domain.enums.MemberRole import com.weeth.domain.club.fixture.ClubTestFixture -import com.weeth.domain.user.domain.enums.Role object BoardTestFixture { fun create( @@ -29,6 +29,6 @@ object BoardTestFixture { club = club, name = name, type = BoardType.NOTICE, - config = BoardConfig(writePermission = Role.ADMIN), + config = BoardConfig(writePermission = MemberRole.ADMIN), ) } diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt index 77c9cff7..b042524e 100644 --- a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt @@ -103,8 +103,6 @@ class ManageClubMemberUseCaseTest : ownerType = FileOwnerType.CLUB_MEMBER_PROFILE, ownerId = userId, ) - val resolvedUrl = "https://cdn.example.com/profile.png" - every { clubMemberRepository.findActiveByUserId(userId) } returns listOf(member1, member2) every { fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus( @@ -113,13 +111,11 @@ class ManageClubMemberUseCaseTest : FileStatus.UPLOADED, ) } returns listOf(existingFile) - every { fileAccessUrlPort.resolve(any()) } returns resolvedUrl - useCase.updateProfile(userId, UpdateMemberProfileRequest(profileImage = profileImageRequest)) existingFile.status shouldBe FileStatus.DELETED - member1.profileImageUrl shouldBe resolvedUrl - member2.profileImageUrl shouldBe resolvedUrl + member1.profileImageStorageKey shouldBe profileImageRequest.storageKey + member2.profileImageStorageKey shouldBe profileImageRequest.storageKey verify(exactly = 1) { fileRepository.save(any()) } } } @@ -172,8 +168,8 @@ class ManageClubMemberUseCaseTest : it("모든 활성 ClubMember의 파일을 soft delete하고 URL을 null로 만든다") { val member1 = ClubMemberTestFixture.createActiveMember(id = 1L) val member2 = ClubMemberTestFixture.createActiveMember(id = 2L) - member1.updateProfileImageUrl("https://cdn.example.com/profile.png") - member2.updateProfileImageUrl("https://cdn.example.com/profile.png") + member1.updateProfileImageUrl("CLUB_MEMBER_PROFILE/2026-02/uuid_profile.png") + member2.updateProfileImageUrl("CLUB_MEMBER_PROFILE/2026-02/uuid_profile.png") val existingFile = FileTestFixture.createFile( id = 1L, @@ -194,8 +190,8 @@ class ManageClubMemberUseCaseTest : useCase.deleteProfileImage(userId) existingFile.status shouldBe FileStatus.DELETED - member1.profileImageUrl shouldBe null - member2.profileImageUrl shouldBe null + member1.profileImageStorageKey shouldBe null + member2.profileImageStorageKey shouldBe null } } diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt index 56edc84e..68d38a4c 100644 --- a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt @@ -186,8 +186,8 @@ class ManageClubUseCaseTest : null, null, null, - "https://example.com/profile.png", - "https://example.com/background.png", + "CLUB_PROFILE/2026-02/uuid_profile.png", + "CLUB_BACKGROUND/2026-02/uuid_background.png", ) every { clubMemberPolicy.requireAdmin(1L, 10L) } returns adminMember @@ -207,8 +207,8 @@ class ManageClubUseCaseTest : club.description shouldBe "기존 소개" club.clubContact.email shouldBe "club@example.com" club.clubContact.phoneNumber shouldBe "01099998888" - club.profileImageUrl shouldBe "https://example.com/profile.png" - club.backgroundImageUrl shouldBe "https://example.com/background.png" + club.profileImageStorageKey shouldBe "CLUB_PROFILE/2026-02/uuid_profile.png" + club.backgroundImageStorageKey shouldBe "CLUB_BACKGROUND/2026-02/uuid_background.png" } it("모든 필드가 null이면 기존 값이 유지된다") { @@ -253,8 +253,8 @@ class ManageClubUseCaseTest : null, null, null, - "https://example.com/profile.png", - "https://example.com/background.png", + "CLUB_PROFILE/2026-02/uuid_profile.png", + "CLUB_BACKGROUND/2026-02/uuid_background.png", ) every { clubMemberPolicy.requireAdmin(1L, 10L) } returns adminMember @@ -262,8 +262,8 @@ class ManageClubUseCaseTest : useCase.deleteProfileImage(1L, 10L) - club.profileImageUrl shouldBe null - club.backgroundImageUrl shouldBe "https://example.com/background.png" + club.profileImageStorageKey shouldBe null + club.backgroundImageStorageKey shouldBe "CLUB_BACKGROUND/2026-02/uuid_background.png" } } @@ -285,8 +285,8 @@ class ManageClubUseCaseTest : null, null, null, - "https://example.com/profile.png", - "https://example.com/background.png", + "CLUB_PROFILE/2026-02/uuid_profile.png", + "CLUB_BACKGROUND/2026-02/uuid_background.png", ) every { clubMemberPolicy.requireAdmin(1L, 10L) } returns adminMember @@ -294,8 +294,8 @@ class ManageClubUseCaseTest : useCase.deleteBackgroundImage(1L, 10L) - club.profileImageUrl shouldBe "https://example.com/profile.png" - club.backgroundImageUrl shouldBe null + club.profileImageStorageKey shouldBe "CLUB_PROFILE/2026-02/uuid_profile.png" + club.backgroundImageStorageKey shouldBe null } } }) diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryServiceTest.kt index 3703ad06..893bdb2f 100644 --- a/src/test/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryServiceTest.kt @@ -8,6 +8,7 @@ import com.weeth.domain.club.domain.repository.ClubMemberCardinalReader import com.weeth.domain.club.domain.repository.ClubMemberReader import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.club.fixture.ClubTestFixture +import com.weeth.domain.file.domain.port.FileAccessUrlPort import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.collections.shouldHaveSize @@ -21,7 +22,8 @@ class GetClubMemberQueryServiceTest : val clubMemberReader = mockk() val clubMemberCardinalReader = mockk() val clubMemberPolicy = mockk() - val clubMapper = ClubMapper() + val fileAccessUrlPort = mockk() + val clubMapper = ClubMapper(fileAccessUrlPort) val service = GetClubMemberQueryService( diff --git a/src/test/kotlin/com/weeth/domain/club/domain/entity/ClubMemberTest.kt b/src/test/kotlin/com/weeth/domain/club/domain/entity/ClubMemberTest.kt index 043aae09..94cd84ce 100644 --- a/src/test/kotlin/com/weeth/domain/club/domain/entity/ClubMemberTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/domain/entity/ClubMemberTest.kt @@ -77,13 +77,13 @@ class ClubMemberTest : member.updateRole(MemberRole.ADMIN) member.memberRole shouldBe MemberRole.ADMIN - member.isAdmin() shouldBe true + member.isAdminOrLead() shouldBe true } "isAdmin — USER 역할일 때 false" { val member = ClubMember(club = club, user = user) - member.isAdmin() shouldBe false + member.isAdminOrLead() shouldBe false } "attend/absent — 출석 통계를 올바르게 계산한다" { diff --git a/src/test/kotlin/com/weeth/domain/club/domain/entity/ClubTest.kt b/src/test/kotlin/com/weeth/domain/club/domain/entity/ClubTest.kt index fdd64c0c..c730324e 100644 --- a/src/test/kotlin/com/weeth/domain/club/domain/entity/ClubTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/domain/entity/ClubTest.kt @@ -50,8 +50,8 @@ class ClubTest : contactEmail = null, contactPhoneNumber = null, primaryContact = null, - profileImageUrl = null, - backgroundImageUrl = null, + profileImageStorageKey = null, + backgroundImageStorageKey = null, ) club.name shouldBe "리츠2기" diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt index 5fa31592..f2f4caa1 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt @@ -7,6 +7,8 @@ import com.weeth.domain.board.domain.entity.Post import com.weeth.domain.board.domain.enums.BoardType import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.board.domain.repository.PostRepository +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.repository.ClubMemberRepository import com.weeth.domain.club.domain.repository.ClubRepository import com.weeth.domain.club.fixture.ClubTestFixture import com.weeth.domain.comment.application.dto.response.CommentResponse @@ -19,7 +21,6 @@ import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.port.FileAccessUrlPort import com.weeth.domain.file.domain.repository.FileRepository import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.enums.Role import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.repository.UserRepository import com.weeth.domain.user.domain.vo.Email @@ -44,6 +45,7 @@ class CommentQueryPerformanceTest( private val commentRepository: CommentRepository, private val fileRepository: FileRepository, private val clubRepository: ClubRepository, + private val clubMemberRepository: ClubMemberRepository, private val entityManager: EntityManager, ) : DescribeSpec({ val runPerformanceTests = System.getProperty("runPerformanceTests")?.toBoolean() ?: false @@ -55,7 +57,6 @@ class CommentQueryPerformanceTest( email = Email.from("perf-user@test.com"), department = "컴퓨터공학과", status = Status.ACTIVE, - role = Role.USER, ), ) @@ -84,14 +85,25 @@ class CommentQueryPerformanceTest( ), ) + data class SetupResult( + val commentIds: List, + val memberMap: Map, + ) + fun setupData( rootCount: Int, childrenPerRoot: Int, filesPerComment: Int, - ): List { + ): SetupResult { val user = createUser() val board = createBoard() val post = createPost(user, board) + val clubMember = + clubMemberRepository + .save( + ClubMember.create(club = board.club, user = user), + ).apply { accept() } + val memberMap = mapOf(user.id to clubMember) val commentIds = mutableListOf() repeat(rootCount) { rootIdx -> @@ -134,7 +146,7 @@ class CommentQueryPerformanceTest( } } - return commentIds + return SetupResult(commentIds, memberMap) } describe("comment file query performance") { @@ -144,15 +156,19 @@ class CommentQueryPerformanceTest( childrenPerRoot: Int, filesPerComment: Int, ) { - setupData(rootCount = rootCount, childrenPerRoot = childrenPerRoot, filesPerComment = filesPerComment) - - val fileMapper = - FileMapper( - object : FileAccessUrlPort { - override fun resolve(storageKey: String): String = "https://test.local/$storageKey" - }, + val (_, memberMap) = + setupData( + rootCount = rootCount, + childrenPerRoot = childrenPerRoot, + filesPerComment = filesPerComment, ) - val commentMapper = CommentMapper() + + val fileAccessUrlPort = + object : FileAccessUrlPort { + override fun resolve(storageKey: String): String = "https://test.local/$storageKey" + } + val fileMapper = FileMapper(fileAccessUrlPort) + val commentMapper = CommentMapper(fileAccessUrlPort) val legacyService = LegacyCommentQueryService(fileRepository, fileMapper, commentMapper) val improvedService = GetCommentQueryService(fileRepository, fileMapper, commentMapper) @@ -162,7 +178,7 @@ class CommentQueryPerformanceTest( val legacy = QueryCountUtil.count(entityManager) { val comments = commentRepository.findAll().sortedBy { it.id } - val tree = legacyService.toCommentTreeResponses(comments) + val tree = legacyService.toCommentTreeResponses(comments, memberMap) tree.size shouldBe rootCount } @@ -171,7 +187,7 @@ class CommentQueryPerformanceTest( val improved = QueryCountUtil.count(entityManager) { val comments = commentRepository.findAll().sortedBy { it.id } - val tree = improvedService.toCommentTreeResponses(comments) + val tree = improvedService.toCommentTreeResponses(comments, memberMap) tree.size shouldBe rootCount } @@ -195,7 +211,10 @@ private class LegacyCommentQueryService( private val fileMapper: FileMapper, private val commentMapper: CommentMapper, ) { - fun toCommentTreeResponses(comments: List): List { + fun toCommentTreeResponses( + comments: List, + memberMap: Map, + ): List { if (comments.isEmpty()) { return emptyList() } @@ -207,16 +226,17 @@ private class LegacyCommentQueryService( return comments .filter { it.parent == null } - .map { mapToCommentResponse(it, childrenByParentId) } + .map { mapToCommentResponse(it, childrenByParentId, memberMap) } } private fun mapToCommentResponse( comment: Comment, childrenByParentId: Map>, + memberMap: Map, ): CommentResponse { val children = childrenByParentId[comment.id] - ?.map { mapToCommentResponse(it, childrenByParentId) } + ?.map { mapToCommentResponse(it, childrenByParentId, memberMap) } ?: emptyList() val files = @@ -224,6 +244,7 @@ private class LegacyCommentQueryService( .findAll(FileOwnerType.COMMENT, comment.id) .map(fileMapper::toFileResponse) - return commentMapper.toCommentDto(comment, children, files) + val authorMember = memberMap.getValue(comment.user.id) + return commentMapper.toCommentDto(comment, authorMember, children, files) } } diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt index 4d227382..85dd0fc6 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt @@ -1,6 +1,8 @@ package com.weeth.domain.comment.application.usecase.query import com.weeth.domain.board.fixture.PostTestFixture +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.fixture.ClubMemberTestFixture import com.weeth.domain.comment.application.dto.response.CommentResponse import com.weeth.domain.comment.application.mapper.CommentMapper import com.weeth.domain.comment.fixture.CommentTestFixture @@ -8,7 +10,6 @@ import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.repository.FileReader import com.weeth.domain.user.application.dto.response.UserInfo -import com.weeth.domain.user.domain.enums.Role import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe @@ -27,6 +28,8 @@ class GetCommentQueryServiceTest : val user = UserTestFixture.createActiveUser1(1L) val post = PostTestFixture.create(user = user) + val member = ClubMemberTestFixture.createActiveMember(user = user) + val memberMap = mapOf(user.id to member) beforeTest { clearMocks(fileReader, fileMapper, commentMapper) @@ -37,7 +40,7 @@ class GetCommentQueryServiceTest : children: List = emptyList(), ) = CommentResponse( id = id, - author = UserInfo(id = 1L, name = "테스트유저", profileImageUrl = null, role = Role.USER), + author = UserInfo(id = 1L, name = "테스트유저", profileImageUrl = null, role = MemberRole.USER), content = "content", time = LocalDateTime.now(), fileUrls = emptyList(), @@ -46,7 +49,7 @@ class GetCommentQueryServiceTest : describe("toCommentTreeResponses") { it("빈 리스트면 빈 리스트를 반환하고 파일 조회를 하지 않는다") { - val result = service.toCommentTreeResponses(emptyList()) + val result = service.toCommentTreeResponses(emptyList(), memberMap) result shouldBe emptyList() verify(exactly = 0) { fileReader.findAll(any(), any(), any()) } @@ -58,9 +61,9 @@ class GetCommentQueryServiceTest : val response = stubResponse(1L) every { fileReader.findAll(FileOwnerType.COMMENT, listOf(1L), any()) } returns emptyList() - every { commentMapper.toCommentDto(comment, emptyList(), emptyList()) } returns response + every { commentMapper.toCommentDto(comment, member, emptyList(), emptyList()) } returns response - val result = service.toCommentTreeResponses(listOf(comment)) + val result = service.toCommentTreeResponses(listOf(comment), memberMap) result.size shouldBe 1 result[0].id shouldBe 1L @@ -74,10 +77,12 @@ class GetCommentQueryServiceTest : val parentResponse = stubResponse(10L, children = listOf(childResponse)) every { fileReader.findAll(FileOwnerType.COMMENT, listOf(10L, 11L), any()) } returns emptyList() - every { commentMapper.toCommentDto(child, emptyList(), emptyList()) } returns childResponse - every { commentMapper.toCommentDto(parent, listOf(childResponse), emptyList()) } returns parentResponse + every { commentMapper.toCommentDto(child, member, emptyList(), emptyList()) } returns childResponse + every { + commentMapper.toCommentDto(parent, member, listOf(childResponse), emptyList()) + } returns parentResponse - val result = service.toCommentTreeResponses(listOf(parent, child)) + val result = service.toCommentTreeResponses(listOf(parent, child), memberMap) result.size shouldBe 1 result[0].id shouldBe 10L diff --git a/src/test/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryServiceTest.kt index 73e063ac..7e4bc839 100644 --- a/src/test/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryServiceTest.kt @@ -15,6 +15,7 @@ import com.weeth.domain.dashboard.application.mapper.DashboardMapper import com.weeth.domain.dashboard.domain.enums.ScheduleType import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.port.FileAccessUrlPort import com.weeth.domain.file.domain.repository.FileReader import com.weeth.domain.schedule.domain.repository.EventReader import com.weeth.domain.schedule.fixture.ScheduleTestFixture @@ -44,7 +45,8 @@ class GetDashboardQueryServiceTest : val fileReader = mockk() val userReader = mockk() val fileMapper = mockk() - val dashboardMapper = DashboardMapper(fileMapper) + val fileAccessUrlPort = mockk() + val dashboardMapper = DashboardMapper(fileMapper, fileAccessUrlPort) val queryService = GetDashboardQueryService( @@ -164,14 +166,16 @@ class GetDashboardQueryServiceTest : context("멤버인 경우") { it("공지 제외한 최신 게시글을 반환한다") { val board = BoardTestFixture.create(type = BoardType.GENERAL) - val post = PostTestFixture.create(board = board) + val post = PostTestFixture.create(board = board, user = user) + val memberWithUser = ClubTestFixture.createClubMember(club = club, user = user) val pageable = PageRequest.of(0, 10) val slice = SliceImpl(listOf(post), pageable, false) - every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns clubMember + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns memberWithUser every { postReader.findRecentByClubIdExcludingBoardType(clubId, BoardType.NOTICE, any()) } returns slice every { fileReader.findAll(FileOwnerType.POST, any>()) } returns emptyList() + every { clubMemberReader.findAllByClubIdAndUserIds(clubId, any()) } returns listOf(memberWithUser) val result = queryService.getRecentPosts(clubId, userId, 0, 10) diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCaseTest.kt index 46a038d9..af97f7a8 100644 --- a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCaseTest.kt @@ -1,5 +1,6 @@ package com.weeth.domain.user.application.usecase.command +import com.weeth.domain.file.domain.port.FileAccessUrlPort import com.weeth.domain.user.application.dto.request.SocialLoginRequest import com.weeth.domain.user.application.exception.EmailNotFoundException import com.weeth.domain.user.application.mapper.UserMapper @@ -28,7 +29,8 @@ class SocialLoginUseCaseTest : val socialAuthPortRegistry = mockk() val socialAuthPort = mockk() val jwtManageUseCase = mockk() - val userMapper = UserMapper() + val fileAccessUrlPort = mockk() + val userMapper = UserMapper(fileAccessUrlPort) val useCase = SocialLoginUseCase( @@ -63,7 +65,7 @@ class SocialLoginUseCaseTest : every { userSocialAccountRepository.findByProviderAndProviderUserId(SocialProvider.APPLE, "apple-user-1") } returns Optional.of(account) - every { jwtManageUseCase.create(user.id, user.emailValue, user.role) } returns + every { jwtManageUseCase.create(user.id, user.emailValue) } returns JwtDto("access", "refresh") val result = useCase.socialLoginByApple(request) diff --git a/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt b/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt index 2e3b2d47..a956b16d 100644 --- a/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt @@ -1,6 +1,5 @@ package com.weeth.domain.user.domain.entity -import com.weeth.domain.user.domain.enums.Role import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.vo.Email import com.weeth.global.common.vo.PhoneNumber @@ -23,13 +22,6 @@ class UserTest : user.status shouldBe Status.LEFT } - "updateRole / hasRole" { - val user = User(name = "test", email = Email.from("test@test.com"), studentId = "20200001") - user.updateRole(Role.ADMIN) - - user.hasRole(Role.ADMIN) shouldBe true - } - "User.create 기본 status는 WAITING이다" { val user = User.create(name = "test", email = "test@test.com") @@ -129,55 +121,4 @@ class UserTest : user.agreeTerms(termsAgreed = true, privacyAgreed = false) } } - - "updateProfileImageUrl 정상 설정" { - val user = User(name = "test", email = Email.from("test@test.com")) - - user.updateProfileImageUrl("https://example.com/image.png") - - user.profileImageUrl shouldBe "https://example.com/image.png" - } - - "updateProfileImageUrl null로 초기화" { - val user = - User( - name = "test", - email = Email.from("test@test.com"), - profileImageUrl = "https://example.com/old.png", - ) - - user.updateProfileImageUrl(null) - - user.profileImageUrl shouldBe null - } - - "생성 시 profileImageUrl 공백은 null로 정규화" { - val user = - User( - name = "test", - email = Email.from("test@test.com"), - profileImageUrl = " ", - ) - - user.profileImageUrl shouldBe null - } - - "생성 시 profileImageUrl 앞뒤 공백 제거" { - val user = - User( - name = "test", - email = Email.from("test@test.com"), - profileImageUrl = " https://example.com/image.png ", - ) - - user.profileImageUrl shouldBe "https://example.com/image.png" - } - - "updateProfileImageUrl 앞뒤 공백 제거" { - val user = User(name = "test", email = Email.from("test@test.com")) - - user.updateProfileImageUrl(" https://example.com/image.png ") - - user.profileImageUrl shouldBe "https://example.com/image.png" - } }) diff --git a/src/test/kotlin/com/weeth/domain/user/fixture/UserTestFixture.kt b/src/test/kotlin/com/weeth/domain/user/fixture/UserTestFixture.kt index dfc49380..2669e428 100644 --- a/src/test/kotlin/com/weeth/domain/user/fixture/UserTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/user/fixture/UserTestFixture.kt @@ -1,7 +1,6 @@ package com.weeth.domain.user.fixture import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.enums.Role import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.vo.Email import org.springframework.test.util.ReflectionTestUtils @@ -40,7 +39,6 @@ object UserTestFixture { name = "적순", email = Email.from("admin@test.com"), status = Status.ACTIVE, - role = Role.ADMIN, ).applyId(id) private fun User.applyId(id: Long): User = diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractorTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractorTest.kt index daf1e00d..74fbb652 100644 --- a/src/test/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractorTest.kt +++ b/src/test/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractorTest.kt @@ -1,6 +1,5 @@ package com.weeth.global.auth.jwt.application.service -import com.weeth.domain.user.domain.enums.Role import com.weeth.global.auth.jwt.application.exception.TokenNotFoundException import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider import com.weeth.global.config.properties.JwtProperties @@ -66,19 +65,17 @@ class JwtTokenExtractorTest : } describe("extractClaims") { - it("id, email, role을 함께 반환한다") { + it("id, email을 함께 반환한다") { val token = "sample" val claims = mockk() every { jwtProvider.parseClaims(token) } returns claims every { claims.get("id", Long::class.javaObjectType) } returns 77L every { claims.get("email", String::class.java) } returns "sample@com" - every { claims.get("role", String::class.java) } returns "USER" val tokenClaims = jwtTokenExtractor.extractClaims(token) tokenClaims?.id shouldBe 77L tokenClaims?.email shouldBe "sample@com" - tokenClaims?.role shouldBe Role.USER verify(exactly = 1) { jwtProvider.parseClaims(token) } } } diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCaseTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCaseTest.kt index dfbd54ff..e8f43b4f 100644 --- a/src/test/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCaseTest.kt @@ -1,6 +1,5 @@ package com.weeth.global.auth.jwt.application.usecase -import com.weeth.domain.user.domain.enums.Role import com.weeth.global.auth.jwt.application.dto.JwtDto import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor import com.weeth.global.auth.jwt.domain.port.RefreshTokenStorePort @@ -22,13 +21,13 @@ class JwtManageUseCaseTest : describe("create") { it("access/refresh token을 생성하고 저장한다") { - every { jwtProvider.createAccessToken(1L, "a@weeth.com", Role.USER) } returns "access" + every { jwtProvider.createAccessToken(1L, "a@weeth.com") } returns "access" every { jwtProvider.createRefreshToken(1L) } returns "refresh" - val result = useCase.create(1L, "a@weeth.com", Role.USER) + val result = useCase.create(1L, "a@weeth.com") result shouldBe JwtDto("access", "refresh") - verify(exactly = 1) { refreshTokenStore.save(1L, "refresh", Role.USER, "a@weeth.com") } + verify(exactly = 1) { refreshTokenStore.save(1L, "refresh", "a@weeth.com") } } } @@ -36,16 +35,15 @@ class JwtManageUseCaseTest : it("저장 토큰 검증 후 새 토큰을 재발급한다") { every { jwtProvider.validate("old-refresh") } just runs every { jwtService.extractId("old-refresh") } returns 10L - every { refreshTokenStore.getRole(10L) } returns Role.ADMIN every { refreshTokenStore.getEmail(10L) } returns "admin@weeth.com" - every { jwtProvider.createAccessToken(10L, "admin@weeth.com", Role.ADMIN) } returns "new-access" + every { jwtProvider.createAccessToken(10L, "admin@weeth.com") } returns "new-access" every { jwtProvider.createRefreshToken(10L) } returns "new-refresh" val result = useCase.reIssueToken("old-refresh") result shouldBe JwtDto("new-access", "new-refresh") verify(exactly = 1) { refreshTokenStore.validateRefreshToken(10L, "old-refresh") } - verify(exactly = 1) { refreshTokenStore.save(10L, "new-refresh", Role.ADMIN, "admin@weeth.com") } + verify(exactly = 1) { refreshTokenStore.save(10L, "new-refresh", "admin@weeth.com") } } } }) diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProviderTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProviderTest.kt index 87885924..b3d31298 100644 --- a/src/test/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProviderTest.kt +++ b/src/test/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProviderTest.kt @@ -1,6 +1,5 @@ package com.weeth.global.auth.jwt.domain.service -import com.weeth.domain.user.domain.enums.Role import com.weeth.global.auth.jwt.application.exception.InvalidTokenException import com.weeth.global.config.properties.JwtProperties import io.kotest.assertions.throwables.shouldThrow @@ -19,13 +18,12 @@ class JwtTokenProviderTest : val jwtProvider = JwtTokenProvider(jwtProperties) "access token 생성 후 claims를 파싱할 수 있다" { - val token = jwtProvider.createAccessToken(1L, "test@weeth.com", Role.ADMIN) + val token = jwtProvider.createAccessToken(1L, "test@weeth.com") val claims = jwtProvider.parseClaims(token) claims.get("id", Number::class.java).toLong() shouldBe 1L claims.get("email", String::class.java) shouldBe "test@weeth.com" - claims.get("role", String::class.java) shouldBe "ADMIN" } "유효하지 않은 토큰 검증 시 InvalidTokenException이 발생한다" { diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt index e1062c09..24598407 100644 --- a/src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt +++ b/src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt @@ -1,6 +1,5 @@ package com.weeth.global.auth.jwt.filter -import com.weeth.domain.user.domain.enums.Role import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider import com.weeth.global.auth.model.AuthenticatedUser @@ -41,7 +40,7 @@ class JwtAuthenticationProcessingFilterTest : every { jwtService.extractAccessToken(request) } returns "access-token" every { jwtProvider.validate("access-token") } just runs every { jwtService.extractClaims("access-token") } returns - JwtTokenExtractor.TokenClaims(1L, "admin@weeth.com", Role.ADMIN) + JwtTokenExtractor.TokenClaims(1L, "admin@weeth.com") filter.doFilter(request, response, chain) @@ -51,8 +50,7 @@ class JwtAuthenticationProcessingFilterTest : val principal = authentication.principal as AuthenticatedUser principal.id shouldBe 1L principal.email shouldBe "admin@weeth.com" - principal.role.name shouldBe "ADMIN" - authentication.authorities.any { it.authority == "ROLE_ADMIN" } shouldBe true + authentication.authorities.any { it.authority == "ROLE_USER" } shouldBe true } it("토큰이 없으면 인증을 저장하지 않는다") { diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/infrastructure/store/RedisRefreshTokenStoreAdapterTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/infrastructure/store/RedisRefreshTokenStoreAdapterTest.kt index 6222e907..ebb942a9 100644 --- a/src/test/kotlin/com/weeth/global/auth/jwt/infrastructure/store/RedisRefreshTokenStoreAdapterTest.kt +++ b/src/test/kotlin/com/weeth/global/auth/jwt/infrastructure/store/RedisRefreshTokenStoreAdapterTest.kt @@ -1,7 +1,6 @@ package com.weeth.global.auth.jwt.infrastructure.store import com.weeth.config.TestContainersConfig -import com.weeth.domain.user.domain.enums.Role import com.weeth.global.auth.jwt.application.exception.InvalidTokenException import com.weeth.global.auth.jwt.application.exception.RedisTokenNotFoundException import com.weeth.global.auth.jwt.infrastructure.RedisRefreshTokenStoreAdapter @@ -28,10 +27,9 @@ class RedisRefreshTokenStoreAdapterTest( } describe("save/get") { - it("실제 Redis에 role/email/token을 저장하고 조회한다") { - redisRefreshTokenStoreAdapter.save(1L, "rt", Role.ADMIN, "a@weeth.com") + it("실제 Redis에 email/token을 저장하고 조회한다") { + redisRefreshTokenStoreAdapter.save(1L, "rt", "a@weeth.com") - redisRefreshTokenStoreAdapter.getRole(1L) shouldBe Role.ADMIN redisRefreshTokenStoreAdapter.getEmail(1L) shouldBe "a@weeth.com" redisTemplate.opsForHash().get("refreshToken:1", "token") shouldBe "rt" } @@ -39,13 +37,13 @@ class RedisRefreshTokenStoreAdapterTest( describe("validateRefreshToken") { it("저장된 토큰과 일치하면 예외가 발생하지 않는다") { - redisRefreshTokenStoreAdapter.save(2L, "stored", Role.USER, "u@weeth.com") + redisRefreshTokenStoreAdapter.save(2L, "stored", "u@weeth.com") redisRefreshTokenStoreAdapter.validateRefreshToken(2L, "stored") } it("요청 토큰이 다르면 InvalidTokenException이 발생한다") { - redisRefreshTokenStoreAdapter.save(3L, "stored", Role.USER, "u@weeth.com") + redisRefreshTokenStoreAdapter.save(3L, "stored", "u@weeth.com") shouldThrow { redisRefreshTokenStoreAdapter.validateRefreshToken(3L, "different") @@ -53,35 +51,23 @@ class RedisRefreshTokenStoreAdapterTest( } } - describe("getRole/getEmail") { + describe("getEmail") { it("값이 없으면 RedisTokenNotFoundException이 발생한다") { - shouldThrow { - redisRefreshTokenStoreAdapter.getRole(999L) - } shouldThrow { redisRefreshTokenStoreAdapter.getEmail(999L) } } } - describe("delete/updateRole") { + describe("delete") { it("delete 후 조회 시 예외가 발생한다") { - redisRefreshTokenStoreAdapter.save(4L, "rt", Role.USER, "x@weeth.com") + redisRefreshTokenStoreAdapter.save(4L, "rt", "x@weeth.com") redisRefreshTokenStoreAdapter.delete(4L) shouldThrow { - redisRefreshTokenStoreAdapter.getRole(4L) + redisRefreshTokenStoreAdapter.getEmail(4L) } } - - it("updateRole은 기존 저장 값의 role만 변경한다") { - redisRefreshTokenStoreAdapter.save(5L, "rt", Role.USER, "x@weeth.com") - - redisRefreshTokenStoreAdapter.updateRole(5L, Role.ADMIN) - - redisRefreshTokenStoreAdapter.getRole(5L) shouldBe Role.ADMIN - redisRefreshTokenStoreAdapter.getEmail(5L) shouldBe "x@weeth.com" - } } }) { companion object { diff --git a/src/test/kotlin/com/weeth/global/auth/resolver/CurrentUserArgumentResolverTest.kt b/src/test/kotlin/com/weeth/global/auth/resolver/CurrentUserArgumentResolverTest.kt index 41668d2b..0edeaa7e 100644 --- a/src/test/kotlin/com/weeth/global/auth/resolver/CurrentUserArgumentResolverTest.kt +++ b/src/test/kotlin/com/weeth/global/auth/resolver/CurrentUserArgumentResolverTest.kt @@ -1,6 +1,5 @@ package com.weeth.global.auth.resolver -import com.weeth.domain.user.domain.enums.Role import com.weeth.global.auth.annotation.CurrentUser import com.weeth.global.auth.jwt.application.exception.AnonymousAuthenticationException import com.weeth.global.auth.model.AuthenticatedUser @@ -47,7 +46,7 @@ class CurrentUserArgumentResolverTest : val method = DummyController::class.java.getDeclaredMethod("target", java.lang.Long.TYPE) val parameter = MethodParameter(method, 0) val request = MockHttpServletRequest() - val principal = AuthenticatedUser(id = 99L, email = "test@weeth.com", role = Role.USER) + val principal = AuthenticatedUser(id = 99L, email = "test@weeth.com") SecurityContextHolder.getContext().authentication = UsernamePasswordAuthenticationToken(principal, null, emptyList()) From 23a4334c60394c3174a6f1e1d545a3db5d070268 Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Mon, 23 Mar 2026 18:11:01 +0900 Subject: [PATCH 32/73] =?UTF-8?q?[WTH-200]=20=EB=8F=99=EC=95=84=EB=A6=AC?= =?UTF-8?q?=20=EC=A0=95=EC=B1=85=20=EC=88=98=EC=A0=95=20(#34)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: ClubMember 정책 분리 * feat: 분리 정책 테스트 추가 * refactor: 분리 반영 * refactor: ClubCode 검증 로직 개선 --- .../usecase/command/ManageAccountUseCase.kt | 6 +- .../usecase/command/ManageReceiptUseCase.kt | 10 +- .../usecase/command/GenerateQrTokenUseCase.kt | 6 +- .../command/ManageAttendanceUseCase.kt | 6 +- .../query/GetAttendanceQueryService.kt | 4 +- .../usecase/command/ManageBoardUseCase.kt | 10 +- .../usecase/query/GetBoardQueryService.kt | 6 +- .../usecase/command/ManageCardinalUseCase.kt | 8 +- .../usecase/command/AdminClubMemberUseCase.kt | 10 +- .../command/ManageClubMemberUsecase.kt | 4 +- .../usecase/command/ManageClubUseCase.kt | 16 +- .../query/GetClubMemberQueryService.kt | 4 +- .../usecase/query/GetClubQueryService.kt | 6 +- .../club/domain/service/ClubCodePolicy.kt | 6 +- .../club/domain/service/ClubJoinPolicy.kt | 51 ++++++ .../club/domain/service/ClubMemberPolicy.kt | 55 +------ .../domain/service/ClubPermissionPolicy.kt | 27 ++++ .../usecase/command/DeletePenaltyUseCase.kt | 6 +- .../usecase/command/SavePenaltyUseCase.kt | 4 +- .../usecase/command/UpdatePenaltyUseCase.kt | 6 +- .../usecase/query/GetPenaltyQueryService.kt | 4 +- .../usecase/command/ManageEventUseCase.kt | 10 +- .../usecase/command/ManageSessionUseCase.kt | 10 +- .../usecase/query/GetSessionQueryService.kt | 4 +- .../command/ManageAccountUseCaseTest.kt | 10 +- .../command/ManageReceiptUseCaseTest.kt | 8 +- .../command/GenerateQrTokenUseCaseTest.kt | 10 +- .../command/ManageAttendanceUseCaseTest.kt | 9 +- .../query/GetAttendanceQueryServiceTest.kt | 7 +- .../usecase/command/ManageBoardUseCaseTest.kt | 8 +- .../usecase/query/GetBoardQueryServiceTest.kt | 4 +- .../usecase/command/CardinalUseCaseTest.kt | 13 +- .../command/AdminClubMemberUseCaseTest.kt | 26 ++-- .../command/ManageClubMemberUseCaseTest.kt | 8 +- .../usecase/command/ManageClubUseCaseTest.kt | 26 ++-- .../query/GetClubMemberQueryServiceTest.kt | 7 +- .../club/domain/service/ClubJoinPolicyTest.kt | 107 +++++++++++++ .../domain/service/ClubMemberPolicyTest.kt | 145 +----------------- .../service/ClubPermissionPolicyTest.kt | 80 ++++++++++ 39 files changed, 437 insertions(+), 310 deletions(-) create mode 100644 src/main/kotlin/com/weeth/domain/club/domain/service/ClubJoinPolicy.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/domain/service/ClubPermissionPolicy.kt create mode 100644 src/test/kotlin/com/weeth/domain/club/domain/service/ClubJoinPolicyTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/club/domain/service/ClubPermissionPolicyTest.kt diff --git a/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCase.kt b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCase.kt index 96088a25..ea27267e 100644 --- a/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCase.kt @@ -7,7 +7,7 @@ import com.weeth.domain.account.domain.repository.AccountRepository import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException import com.weeth.domain.cardinal.domain.repository.CardinalReader import com.weeth.domain.club.domain.repository.ClubReader -import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -16,7 +16,7 @@ class ManageAccountUseCase( private val accountRepository: AccountRepository, private val cardinalReader: CardinalReader, private val clubReader: ClubReader, - private val clubMemberPolicy: ClubMemberPolicy, + private val clubPermissionPolicy: ClubPermissionPolicy, ) { @Transactional fun save( @@ -24,7 +24,7 @@ class ManageAccountUseCase( request: AccountSaveRequest, userId: Long, ) { - clubMemberPolicy.requireAdmin(clubId, userId) + clubPermissionPolicy.requireAdmin(clubId, userId) val club = clubReader.getClubById(clubId) if (accountRepository.existsByClubIdAndCardinal(clubId, request.cardinal)) throw AccountExistsException() diff --git a/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCase.kt b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCase.kt index 3fa1150a..1fe46b2d 100644 --- a/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCase.kt @@ -10,7 +10,7 @@ import com.weeth.domain.account.domain.repository.AccountRepository import com.weeth.domain.account.domain.repository.ReceiptRepository import com.weeth.domain.account.domain.vo.Money import com.weeth.domain.cardinal.domain.repository.CardinalReader -import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.repository.FileReader @@ -26,7 +26,7 @@ class ManageReceiptUseCase( private val fileReader: FileReader, private val fileRepository: FileRepository, private val cardinalReader: CardinalReader, - private val clubMemberPolicy: ClubMemberPolicy, + private val clubPermissionPolicy: ClubPermissionPolicy, private val fileMapper: FileMapper, ) { @Transactional @@ -35,7 +35,7 @@ class ManageReceiptUseCase( userId: Long, request: ReceiptSaveRequest, ) { - clubMemberPolicy.requireAdmin(clubId, userId) + clubPermissionPolicy.requireAdmin(clubId, userId) cardinalReader.findByClubIdAndCardinalNumber(clubId, request.cardinal) ?: throw AccountNotFoundException() val account = accountRepository.findByClubIdAndCardinal(clubId, request.cardinal) ?: throw AccountNotFoundException() @@ -57,7 +57,7 @@ class ManageReceiptUseCase( receiptId: Long, request: ReceiptUpdateRequest, ) { - clubMemberPolicy.requireAdmin(clubId, userId) + clubPermissionPolicy.requireAdmin(clubId, userId) cardinalReader.findByClubIdAndCardinalNumber(clubId, request.cardinal) ?: throw AccountNotFoundException() val account = accountRepository.findByClubIdAndCardinal(clubId, request.cardinal) ?: throw AccountNotFoundException() @@ -83,7 +83,7 @@ class ManageReceiptUseCase( userId: Long, receiptId: Long, ) { - clubMemberPolicy.requireAdmin(clubId, userId) + clubPermissionPolicy.requireAdmin(clubId, userId) val receipt = receiptRepository.findByIdOrNull(receiptId) ?: throw ReceiptNotFoundException() if (receipt.account.club.id != clubId) throw ReceiptAccountMismatchException() diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCase.kt b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCase.kt index 1a1d86ea..2e21db7e 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCase.kt @@ -3,7 +3,7 @@ package com.weeth.domain.attendance.application.usecase.command import com.weeth.domain.attendance.application.dto.response.QrTokenResponse import com.weeth.domain.attendance.application.mapper.AttendanceMapper import com.weeth.domain.attendance.domain.port.QrAttendancePort -import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.session.domain.repository.SessionReader import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -14,14 +14,14 @@ class GenerateQrTokenUseCase( private val sessionReader: SessionReader, private val qrAttendancePort: QrAttendancePort, private val attendanceMapper: AttendanceMapper, - private val clubMemberPolicy: ClubMemberPolicy, + private val clubPermissionPolicy: ClubPermissionPolicy, ) { fun execute( sessionId: Long, clubId: Long, userId: Long, ): QrTokenResponse { - clubMemberPolicy.requireAdmin(clubId, userId) + clubPermissionPolicy.requireAdmin(clubId, userId) val session = sessionReader.getById(sessionId) diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCase.kt b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCase.kt index cf70fc17..61e85c56 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCase.kt @@ -11,6 +11,7 @@ import com.weeth.domain.attendance.domain.port.QrAttendancePort import com.weeth.domain.attendance.domain.repository.AttendanceRepository import com.weeth.domain.club.domain.enums.MemberStatus import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.session.application.exception.SessionNotFoundException import com.weeth.domain.session.application.exception.SessionNotInProgressException import com.weeth.domain.session.domain.enums.SessionStatus @@ -23,6 +24,7 @@ import java.time.LocalDateTime @Service class ManageAttendanceUseCase( private val clubMemberPolicy: ClubMemberPolicy, + private val clubPermissionPolicy: ClubPermissionPolicy, private val sessionReader: SessionReader, private val attendanceRepository: AttendanceRepository, private val qrAttendancePort: QrAttendancePort, @@ -60,7 +62,7 @@ class ManageAttendanceUseCase( now: LocalDate, cardinal: Int, ) { - clubMemberPolicy.requireAdmin(clubId, userId) + clubPermissionPolicy.requireAdmin(clubId, userId) val targetSession = sessionReader .findAllByClubIdAndCardinalIn(clubId, listOf(cardinal)) @@ -94,7 +96,7 @@ class ManageAttendanceUseCase( userId: Long, attendanceUpdates: List, ) { - clubMemberPolicy.requireAdmin(clubId, userId) + clubPermissionPolicy.requireAdmin(clubId, userId) attendanceUpdates.forEach { update -> val attendance = attendanceRepository.findByIdWithClubMember(update.attendanceId) diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt index 1e8d5a74..0bcbb726 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt @@ -9,6 +9,7 @@ import com.weeth.domain.attendance.domain.repository.AttendanceRepository import com.weeth.domain.club.domain.enums.MemberStatus import com.weeth.domain.club.domain.service.ClubMemberCardinalPolicy import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.session.domain.repository.SessionReader import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -18,6 +19,7 @@ import java.time.LocalDate @Transactional(readOnly = true) class GetAttendanceQueryService( private val clubMemberPolicy: ClubMemberPolicy, + private val clubPermissionPolicy: ClubPermissionPolicy, private val clubMemberCardinalPolicy: ClubMemberCardinalPolicy, private val sessionReader: SessionReader, private val attendanceRepository: AttendanceRepository, @@ -59,7 +61,7 @@ class GetAttendanceQueryService( userId: Long, sessionId: Long, ): List { - clubMemberPolicy.requireAdmin(clubId, userId) + clubPermissionPolicy.requireAdmin(clubId, userId) val session = sessionReader.getById(sessionId) if (session.club.id != clubId) { diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt index 8beb8c96..07bb8f70 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt @@ -9,7 +9,7 @@ import com.weeth.domain.board.domain.entity.Board import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.board.domain.vo.BoardConfig import com.weeth.domain.club.domain.repository.ClubReader -import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -18,7 +18,7 @@ class ManageBoardUseCase( private val boardRepository: BoardRepository, private val boardMapper: BoardMapper, private val clubReader: ClubReader, - private val clubMemberPolicy: ClubMemberPolicy, + private val clubPermissionPolicy: ClubPermissionPolicy, ) { /** * 게시판 생성 API, 커스텀한 게시판 생성 가능 @@ -30,7 +30,7 @@ class ManageBoardUseCase( request: CreateBoardRequest, userId: Long, ): BoardDetailResponse { - clubMemberPolicy.requireAdmin(clubId, userId) + clubPermissionPolicy.requireAdmin(clubId, userId) val club = clubReader.getClubById(clubId) val board = @@ -57,7 +57,7 @@ class ManageBoardUseCase( request: UpdateBoardRequest, userId: Long, ): BoardDetailResponse { - clubMemberPolicy.requireAdmin(clubId, userId) + clubPermissionPolicy.requireAdmin(clubId, userId) val board = findBoard(boardId) if (board.club.id != clubId) throw BoardNotFoundException() @@ -84,7 +84,7 @@ class ManageBoardUseCase( boardId: Long, userId: Long, ) { - clubMemberPolicy.requireAdmin(clubId, userId) + clubPermissionPolicy.requireAdmin(clubId, userId) val board = findBoard(boardId) if (board.club.id != clubId) throw BoardNotFoundException() diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt index 9ddfc54a..58f9bcd8 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt @@ -6,6 +6,7 @@ import com.weeth.domain.board.application.exception.BoardNotFoundException import com.weeth.domain.board.application.mapper.BoardMapper import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -14,6 +15,7 @@ import org.springframework.transaction.annotation.Transactional class GetBoardQueryService( private val boardRepository: BoardRepository, private val clubMemberPolicy: ClubMemberPolicy, + private val clubPermissionPolicy: ClubPermissionPolicy, private val boardMapper: BoardMapper, ) { fun findBoards( @@ -33,7 +35,7 @@ class GetBoardQueryService( userId: Long, boardId: Long, ): BoardDetailResponse { - clubMemberPolicy.requireAdmin(clubId, userId) + clubPermissionPolicy.requireAdmin(clubId, userId) val board = boardRepository.findByIdAndClubId(boardId, clubId) ?: throw BoardNotFoundException() return boardMapper.toDetailResponseForAdmin(board) @@ -43,7 +45,7 @@ class GetBoardQueryService( clubId: Long, userId: Long, ): List { - clubMemberPolicy.requireAdmin(clubId, userId) + clubPermissionPolicy.requireAdmin(clubId, userId) return boardRepository .findAllByClubIdOrderByIdAsc(clubId) diff --git a/src/main/kotlin/com/weeth/domain/cardinal/application/usecase/command/ManageCardinalUseCase.kt b/src/main/kotlin/com/weeth/domain/cardinal/application/usecase/command/ManageCardinalUseCase.kt index 98286bac..7f153188 100644 --- a/src/main/kotlin/com/weeth/domain/cardinal/application/usecase/command/ManageCardinalUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/application/usecase/command/ManageCardinalUseCase.kt @@ -8,7 +8,7 @@ import com.weeth.domain.cardinal.application.mapper.CardinalMapper import com.weeth.domain.cardinal.domain.repository.CardinalRepository import com.weeth.domain.cardinal.domain.service.CardinalStatusPolicy import com.weeth.domain.club.domain.repository.ClubReader -import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -18,7 +18,7 @@ class ManageCardinalUseCase( private val cardinalMapper: CardinalMapper, private val cardinalStatusPolicy: CardinalStatusPolicy, private val clubReader: ClubReader, - private val clubMemberPolicy: ClubMemberPolicy, + private val clubPermissionPolicy: ClubPermissionPolicy, ) { @Transactional fun save( @@ -26,7 +26,7 @@ class ManageCardinalUseCase( request: CardinalSaveRequest, userId: Long, ) { - clubMemberPolicy.requireAdmin(clubId, userId) + clubPermissionPolicy.requireAdmin(clubId, userId) val club = clubReader.getClubById(clubId) if (cardinalRepository.findByClubIdAndCardinalNumber(clubId, request.cardinalNumber) != null) { @@ -46,7 +46,7 @@ class ManageCardinalUseCase( request: CardinalUpdateRequest, userId: Long, ) { - clubMemberPolicy.requireAdmin(clubId, userId) + clubPermissionPolicy.requireAdmin(clubId, userId) val cardinal = cardinalRepository.findByIdAndClubId(request.id, clubId) ?: throw CardinalNotFoundException() diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt index 55012664..ef9be869 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt @@ -16,6 +16,7 @@ import com.weeth.domain.club.domain.enums.MemberRole import com.weeth.domain.club.domain.repository.ClubMemberCardinalRepository import com.weeth.domain.club.domain.service.ClubMemberCardinalPolicy import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.session.domain.repository.SessionReader import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -26,6 +27,7 @@ import org.springframework.transaction.annotation.Transactional @Service class AdminClubMemberUseCase( private val clubMemberPolicy: ClubMemberPolicy, + private val clubPermissionPolicy: ClubPermissionPolicy, private val clubMemberCardinalPolicy: ClubMemberCardinalPolicy, private val cardinalReader: CardinalReader, private val sessionReader: SessionReader, @@ -38,7 +40,7 @@ class AdminClubMemberUseCase( userId: Long, clubMemberId: Long, ) { - clubMemberPolicy.requireAdmin(clubId, userId) + clubPermissionPolicy.requireAdmin(clubId, userId) val member = clubMemberPolicy.getMemberInClub(clubId, clubMemberId) member.accept() @@ -50,7 +52,7 @@ class AdminClubMemberUseCase( userId: Long, clubMemberId: Long, ) { - clubMemberPolicy.requireAdmin(clubId, userId) + clubPermissionPolicy.requireAdmin(clubId, userId) val member = clubMemberPolicy.getMemberInClub(clubId, clubMemberId) member.ban() @@ -62,7 +64,7 @@ class AdminClubMemberUseCase( userId: Long, request: ClubMemberRoleUpdateRequest, ) { - clubMemberPolicy.requireAdmin(clubId, userId) + clubPermissionPolicy.requireAdmin(clubId, userId) val member = clubMemberPolicy.getMemberInClub(clubId, request.clubMemberId) if (request.memberRole == MemberRole.LEAD) throw LeadTransferOnlyException() @@ -93,7 +95,7 @@ class AdminClubMemberUseCase( userId: Long, requests: List, ) { - clubMemberPolicy.requireAdmin(clubId, userId) + clubPermissionPolicy.requireAdmin(clubId, userId) val uniqueRequests = requests.distinctBy { it.clubMemberId to it.cardinal } if (uniqueRequests.isEmpty()) return diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt index c4fbc63d..80205264 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt @@ -19,6 +19,7 @@ import com.weeth.domain.club.domain.repository.ClubMemberCardinalRepository import com.weeth.domain.club.domain.repository.ClubMemberRepository import com.weeth.domain.club.domain.repository.ClubRepository import com.weeth.domain.club.domain.service.ClubCodePolicy +import com.weeth.domain.club.domain.service.ClubJoinPolicy import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.file.domain.entity.File import com.weeth.domain.file.domain.enums.FileOwnerType @@ -43,6 +44,7 @@ class ManageClubMemberUsecase( private val attendanceRepository: AttendanceRepository, private val userReader: UserReader, private val clubMemberPolicy: ClubMemberPolicy, + private val clubJoinPolicy: ClubJoinPolicy, private val fileRepository: FileRepository, private val fileAccessUrlPort: FileAccessUrlPort, ) { @@ -65,7 +67,7 @@ class ManageClubMemberUsecase( throw AlreadyJoinedException() } - clubMemberPolicy.validateJoinLimit(userId) + clubJoinPolicy.validateJoinLimit(userId) ClubCodePolicy.validate(club.code, request.code) diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt index c6ac8d80..f73de108 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt @@ -15,7 +15,8 @@ import com.weeth.domain.club.domain.repository.ClubMemberCardinalRepository import com.weeth.domain.club.domain.repository.ClubMemberRepository import com.weeth.domain.club.domain.repository.ClubRepository import com.weeth.domain.club.domain.service.ClubCodePolicy -import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubJoinPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.club.domain.vo.ClubContact import com.weeth.domain.user.domain.repository.UserReader import org.springframework.stereotype.Service @@ -32,7 +33,8 @@ class ManageClubUseCase( private val cardinalRepository: CardinalRepository, private val clubMemberCardinalRepository: ClubMemberCardinalRepository, private val userReader: UserReader, - private val clubMemberPolicy: ClubMemberPolicy, + private val clubJoinPolicy: ClubJoinPolicy, + private val clubPermissionPolicy: ClubPermissionPolicy, ) { /** * 새로운 동아리를 생성 @@ -48,7 +50,7 @@ class ManageClubUseCase( val user = userReader.getByIdWithLock(userId) - clubMemberPolicy.validateCreateLimit(userId) + clubJoinPolicy.validateCreateLimit(userId) validatePrimaryContactEmail(request.primaryContact, request.contactEmail) val code = ClubCodePolicy.generateCode() @@ -106,7 +108,7 @@ class ManageClubUseCase( userId: Long, request: ClubUpdateRequest, ) { - clubMemberPolicy.requireAdmin(clubId, userId) + clubPermissionPolicy.requireAdmin(clubId, userId) val club = clubRepository.getClubById(clubId) @@ -134,7 +136,7 @@ class ManageClubUseCase( clubId: Long, userId: Long, ) { - clubMemberPolicy.requireAdmin(clubId, userId) + clubPermissionPolicy.requireAdmin(clubId, userId) val club = clubRepository.getClubById(clubId) val newCode = ClubCodePolicy.generateCode() @@ -146,7 +148,7 @@ class ManageClubUseCase( clubId: Long, userId: Long, ) { - clubMemberPolicy.requireAdmin(clubId, userId) + clubPermissionPolicy.requireAdmin(clubId, userId) val club = clubRepository.getClubById(clubId) club.removeProfileImage() @@ -157,7 +159,7 @@ class ManageClubUseCase( clubId: Long, userId: Long, ) { - clubMemberPolicy.requireAdmin(clubId, userId) + clubPermissionPolicy.requireAdmin(clubId, userId) val club = clubRepository.getClubById(clubId) club.removeBackgroundImage() diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryService.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryService.kt index 7695326a..b16d7eb1 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryService.kt @@ -6,6 +6,7 @@ import com.weeth.domain.club.application.mapper.ClubMapper import com.weeth.domain.club.domain.repository.ClubMemberCardinalReader import com.weeth.domain.club.domain.repository.ClubMemberReader import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -15,13 +16,14 @@ class GetClubMemberQueryService( private val clubMemberReader: ClubMemberReader, private val clubMemberCardinalReader: ClubMemberCardinalReader, private val clubMemberPolicy: ClubMemberPolicy, + private val clubPermissionPolicy: ClubPermissionPolicy, private val clubMapper: ClubMapper, ) { fun findClubMembersForAdmin( clubId: Long, userId: Long, ): List { - clubMemberPolicy.requireAdmin(clubId, userId) + clubPermissionPolicy.requireAdmin(clubId, userId) val members = clubMemberReader.findAllByClubId(clubId) if (members.isEmpty()) { diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubQueryService.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubQueryService.kt index 2051d44f..b9c86ecd 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubQueryService.kt @@ -6,7 +6,7 @@ import com.weeth.domain.club.application.dto.response.ClubPublicResponse import com.weeth.domain.club.application.mapper.ClubMapper import com.weeth.domain.club.domain.repository.ClubMemberReader import com.weeth.domain.club.domain.repository.ClubReader -import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -15,7 +15,7 @@ import org.springframework.transaction.annotation.Transactional class GetClubQueryService( private val clubReader: ClubReader, private val clubMemberReader: ClubMemberReader, - private val clubMemberPolicy: ClubMemberPolicy, + private val clubPermissionPolicy: ClubPermissionPolicy, private val clubMapper: ClubMapper, ) { fun findMyClubs(userId: Long): List { @@ -37,7 +37,7 @@ class GetClubQueryService( clubId: Long, userId: Long, ): ClubDetailResponse { - clubMemberPolicy.requireAdmin(clubId, userId) + clubPermissionPolicy.requireAdmin(clubId, userId) val club = clubReader.getClubById(clubId) return clubMapper.toDetailResponse(club) diff --git a/src/main/kotlin/com/weeth/domain/club/domain/service/ClubCodePolicy.kt b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubCodePolicy.kt index bbf5fd7b..a4ead92a 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/service/ClubCodePolicy.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubCodePolicy.kt @@ -17,10 +17,8 @@ object ClubCodePolicy { clubCode: String, providedCode: String, ) { - if (clubCode != providedCode) { - if (!clubCode.equals(providedCode.trim(), ignoreCase = true)) { - throw InvalidClubCodeException() - } + if (!clubCode.equals(providedCode.trim(), ignoreCase = true)) { + throw InvalidClubCodeException() } } } diff --git a/src/main/kotlin/com/weeth/domain/club/domain/service/ClubJoinPolicy.kt b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubJoinPolicy.kt new file mode 100644 index 00000000..28361f02 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubJoinPolicy.kt @@ -0,0 +1,51 @@ +package com.weeth.domain.club.domain.service + +import com.weeth.domain.club.application.exception.ClubCreateLimitExceededException +import com.weeth.domain.club.application.exception.ClubJoinLimitExceededException +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.repository.ClubMemberReader +import org.springframework.stereotype.Service + +/** + * 동아리 가입/생성 수 제한 검증 정책 + */ +@Service +class ClubJoinPolicy( + private val clubMemberReader: ClubMemberReader, +) { + /** + * 일반 멤버(USER)로 가입 가능한 동아리 수 제한 검증 + */ + fun validateJoinLimit(userId: Long) { + val activeUserCount = + clubMemberReader.countByUserIdAndMemberStatusAndMemberRole( + userId, + MemberStatus.ACTIVE, + MemberRole.USER, + ) + if (activeUserCount >= MAX_USER_CLUBS) { + throw ClubJoinLimitExceededException() + } + } + + /** + * 동아리장(LEAD)으로 생성 가능한 동아리 수 제한 검증 + */ + fun validateCreateLimit(userId: Long) { + val activeLeadCount = + clubMemberReader.countByUserIdAndMemberStatusAndMemberRole( + userId, + MemberStatus.ACTIVE, + MemberRole.LEAD, + ) + if (activeLeadCount >= MAX_LEAD_CLUBS) { + throw ClubCreateLimitExceededException() + } + } + + companion object { + private const val MAX_LEAD_CLUBS = 1 + private const val MAX_USER_CLUBS = 1 + } +} diff --git a/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt index 8b688a17..baba4929 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt @@ -1,19 +1,14 @@ package com.weeth.domain.club.domain.service -import com.weeth.domain.club.application.exception.ClubCreateLimitExceededException -import com.weeth.domain.club.application.exception.ClubJoinLimitExceededException import com.weeth.domain.club.application.exception.ClubMemberNotFoundException import com.weeth.domain.club.application.exception.ClubMemberNotInClubException import com.weeth.domain.club.application.exception.MemberNotActiveException -import com.weeth.domain.club.application.exception.NotClubAdminException import com.weeth.domain.club.domain.entity.ClubMember -import com.weeth.domain.club.domain.enums.MemberRole -import com.weeth.domain.club.domain.enums.MemberStatus import com.weeth.domain.club.domain.repository.ClubMemberReader import org.springframework.stereotype.Service /** - * 동아리 멤버 관련 비즈니스 규칙 및 권한 검증 + * 동아리 멤버 조회 및 상태 검증 * TODO: 캐싱 도입 */ @Service @@ -35,19 +30,6 @@ class ClubMemberPolicy( return member } - /** - * 사용자가 동아리 관리자인지 검증 - * 활성 상태이고 + ADMIN 또는 LEAD 권한 - */ - fun requireAdmin( - clubId: Long, - userId: Long, - ) = getActiveMember(clubId, userId).also { - if (!it.isAdminOrLead()) { - throw NotClubAdminException() - } - } - fun getActiveMemberWithLock( clubId: Long, userId: Long, @@ -70,41 +52,6 @@ class ClubMemberPolicy( return member } - /** - * 일반 멤버(USER)로 가입 가능한 동아리 수 제한 검증 - */ - fun validateJoinLimit(userId: Long) { - val activeUserCount = - clubMemberReader.countByUserIdAndMemberStatusAndMemberRole( - userId, - MemberStatus.ACTIVE, - MemberRole.USER, - ) - if (activeUserCount >= MAX_USER_CLUBS) { - throw ClubJoinLimitExceededException() - } - } - - /** - * 동아리장(LEAD)으로 생성 가능한 동아리 수 제한 검증 - */ - fun validateCreateLimit(userId: Long) { - val activeLeadCount = - clubMemberReader.countByUserIdAndMemberStatusAndMemberRole( - userId, - MemberStatus.ACTIVE, - MemberRole.LEAD, - ) - if (activeLeadCount >= MAX_LEAD_CLUBS) { - throw ClubCreateLimitExceededException() - } - } - - companion object { - private const val MAX_LEAD_CLUBS = 1 - private const val MAX_USER_CLUBS = 1 - } - fun getActiveMemberInClubWithLock( clubId: Long, clubMemberId: Long, diff --git a/src/main/kotlin/com/weeth/domain/club/domain/service/ClubPermissionPolicy.kt b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubPermissionPolicy.kt new file mode 100644 index 00000000..c8af8f3b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubPermissionPolicy.kt @@ -0,0 +1,27 @@ +package com.weeth.domain.club.domain.service + +import com.weeth.domain.club.application.exception.NotClubAdminException +import com.weeth.domain.club.domain.entity.ClubMember +import org.springframework.stereotype.Service + +/** + * 동아리 관리자 권한 검증 정책 + */ +@Service +class ClubPermissionPolicy( + private val clubMemberPolicy: ClubMemberPolicy, +) { + /** + * 사용자가 동아리 관리자인지 검증 + * 활성 상태이고 + ADMIN 또는 LEAD 권한 + */ + fun requireAdmin( + clubId: Long, + userId: Long, + ): ClubMember = + clubMemberPolicy.getActiveMember(clubId, userId).also { + if (!it.isAdminOrLead()) { + throw NotClubAdminException() + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/DeletePenaltyUseCase.kt b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/DeletePenaltyUseCase.kt index 2b02ed5f..55fd13ee 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/DeletePenaltyUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/DeletePenaltyUseCase.kt @@ -1,7 +1,7 @@ package com.weeth.domain.penalty.application.usecase.command import com.weeth.domain.club.domain.repository.ClubMemberRepository -import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.penalty.application.exception.AutoPenaltyDeleteNotAllowedException import com.weeth.domain.penalty.application.exception.PenaltyNotFoundException import com.weeth.domain.penalty.domain.enums.PenaltyType @@ -13,7 +13,7 @@ import org.springframework.transaction.annotation.Transactional class DeletePenaltyUseCase( private val penaltyRepository: PenaltyRepository, private val clubMemberRepository: ClubMemberRepository, - private val clubMemberPolicy: ClubMemberPolicy, + private val clubPermissionPolicy: ClubPermissionPolicy, ) { @Transactional fun delete( @@ -21,7 +21,7 @@ class DeletePenaltyUseCase( userId: Long, penaltyId: Long, ) { - clubMemberPolicy.requireAdmin(clubId, userId) + clubPermissionPolicy.requireAdmin(clubId, userId) val penalty = penaltyRepository.findByIdWithLock(penaltyId) diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/SavePenaltyUseCase.kt b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/SavePenaltyUseCase.kt index 379f236a..7cf4e000 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/SavePenaltyUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/SavePenaltyUseCase.kt @@ -3,6 +3,7 @@ package com.weeth.domain.penalty.application.usecase.command import com.weeth.domain.club.domain.repository.ClubMemberRepository import com.weeth.domain.club.domain.service.ClubMemberCardinalPolicy import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.penalty.application.dto.request.SavePenaltyRequest import com.weeth.domain.penalty.application.exception.PenaltyNotFoundException import com.weeth.domain.penalty.application.mapper.PenaltyMapper @@ -16,6 +17,7 @@ class SavePenaltyUseCase( private val penaltyRepository: PenaltyRepository, private val clubMemberRepository: ClubMemberRepository, private val clubMemberPolicy: ClubMemberPolicy, + private val clubPermissionPolicy: ClubPermissionPolicy, private val clubMemberCardinalPolicy: ClubMemberCardinalPolicy, private val mapper: PenaltyMapper, ) { @@ -25,7 +27,7 @@ class SavePenaltyUseCase( userId: Long, request: SavePenaltyRequest, ) { - clubMemberPolicy.requireAdmin(clubId, userId) + clubPermissionPolicy.requireAdmin(clubId, userId) val clubMember = clubMemberPolicy.getActiveMember(clubId, request.userId) val cardinal = clubMemberCardinalPolicy.getCurrentCardinal(clubMember) diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/UpdatePenaltyUseCase.kt b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/UpdatePenaltyUseCase.kt index f53fea62..8f958d69 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/UpdatePenaltyUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/UpdatePenaltyUseCase.kt @@ -1,6 +1,6 @@ package com.weeth.domain.penalty.application.usecase.command -import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.penalty.application.dto.request.UpdatePenaltyRequest import com.weeth.domain.penalty.application.exception.PenaltyNotFoundException import com.weeth.domain.penalty.domain.repository.PenaltyRepository @@ -11,7 +11,7 @@ import org.springframework.transaction.annotation.Transactional @Service class UpdatePenaltyUseCase( private val penaltyRepository: PenaltyRepository, - private val clubMemberPolicy: ClubMemberPolicy, + private val clubPermissionPolicy: ClubPermissionPolicy, ) { @Transactional fun update( @@ -19,7 +19,7 @@ class UpdatePenaltyUseCase( userId: Long, request: UpdatePenaltyRequest, ) { - clubMemberPolicy.requireAdmin(clubId, userId) + clubPermissionPolicy.requireAdmin(clubId, userId) val penalty = penaltyRepository.findByIdOrNull(request.penaltyId) diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/query/GetPenaltyQueryService.kt b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/query/GetPenaltyQueryService.kt index 24647228..06eb2b52 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/query/GetPenaltyQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/query/GetPenaltyQueryService.kt @@ -4,6 +4,7 @@ import com.weeth.domain.cardinal.domain.repository.CardinalReader import com.weeth.domain.club.domain.repository.ClubMemberCardinalReader import com.weeth.domain.club.domain.service.ClubMemberCardinalPolicy import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.penalty.application.dto.response.PenaltyByCardinalResponse import com.weeth.domain.penalty.application.dto.response.PenaltyResponse import com.weeth.domain.penalty.application.mapper.PenaltyMapper @@ -17,6 +18,7 @@ class GetPenaltyQueryService( private val penaltyRepository: PenaltyRepository, private val clubMemberCardinalReader: ClubMemberCardinalReader, private val clubMemberPolicy: ClubMemberPolicy, + private val clubPermissionPolicy: ClubPermissionPolicy, private val clubMemberCardinalPolicy: ClubMemberCardinalPolicy, private val cardinalReader: CardinalReader, private val mapper: PenaltyMapper, @@ -26,7 +28,7 @@ class GetPenaltyQueryService( userId: Long, cardinalNumber: Int?, ): List { - clubMemberPolicy.requireAdmin(clubId, userId) + clubPermissionPolicy.requireAdmin(clubId, userId) val cardinals = if (cardinalNumber == null) { cardinalReader.findAllByClubIdOrderByCardinalNumberAsc(clubId) diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/usecase/command/ManageEventUseCase.kt b/src/main/kotlin/com/weeth/domain/schedule/application/usecase/command/ManageEventUseCase.kt index 3c264953..4713e241 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/application/usecase/command/ManageEventUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/application/usecase/command/ManageEventUseCase.kt @@ -2,7 +2,7 @@ package com.weeth.domain.schedule.application.usecase.command import com.weeth.domain.cardinal.domain.repository.CardinalReader import com.weeth.domain.club.domain.repository.ClubReader -import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.schedule.application.dto.request.ScheduleSaveRequest import com.weeth.domain.schedule.application.dto.request.ScheduleUpdateRequest import com.weeth.domain.schedule.application.exception.EventNotFoundException @@ -20,7 +20,7 @@ class ManageEventUseCase( private val cardinalReader: CardinalReader, private val eventMapper: EventMapper, private val clubReader: ClubReader, - private val clubMemberPolicy: ClubMemberPolicy, + private val clubPermissionPolicy: ClubPermissionPolicy, ) { @Transactional fun create( @@ -28,7 +28,7 @@ class ManageEventUseCase( request: ScheduleSaveRequest, userId: Long, ) { - clubMemberPolicy.requireAdmin(clubId, userId) + clubPermissionPolicy.requireAdmin(clubId, userId) val club = clubReader.getClubById(clubId) val user = userReader.getById(userId) // TODO: 전역 cardinal 조회 대신 clubId 기준 조회를 사용해야 다른 동아리 기수로 검증이 통과하지 않는다. @@ -43,7 +43,7 @@ class ManageEventUseCase( request: ScheduleUpdateRequest, userId: Long, ) { - clubMemberPolicy.requireAdmin(clubId, userId) + clubPermissionPolicy.requireAdmin(clubId, userId) val user = userReader.getById(userId) val event = eventRepository.findByIdOrNull(eventId) ?: throw EventNotFoundException() if (event.club.id != clubId) throw EventNotFoundException() @@ -56,7 +56,7 @@ class ManageEventUseCase( eventId: Long, userId: Long, ) { - clubMemberPolicy.requireAdmin(clubId, userId) + clubPermissionPolicy.requireAdmin(clubId, userId) val event = eventRepository.findByIdOrNull(eventId) ?: throw EventNotFoundException() if (event.club.id != clubId) throw EventNotFoundException() eventRepository.delete(event) diff --git a/src/main/kotlin/com/weeth/domain/session/application/usecase/command/ManageSessionUseCase.kt b/src/main/kotlin/com/weeth/domain/session/application/usecase/command/ManageSessionUseCase.kt index e58e821a..ca5850f0 100644 --- a/src/main/kotlin/com/weeth/domain/session/application/usecase/command/ManageSessionUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/session/application/usecase/command/ManageSessionUseCase.kt @@ -7,7 +7,7 @@ import com.weeth.domain.cardinal.domain.repository.CardinalReader import com.weeth.domain.club.domain.enums.MemberStatus import com.weeth.domain.club.domain.repository.ClubMemberReader import com.weeth.domain.club.domain.repository.ClubReader -import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.schedule.application.dto.request.ScheduleSaveRequest import com.weeth.domain.schedule.application.dto.request.ScheduleUpdateRequest import com.weeth.domain.schedule.application.mapper.SessionMapper @@ -29,7 +29,7 @@ class ManageSessionUseCase( private val sessionMapper: SessionMapper, private val clubReader: ClubReader, private val clubMemberReader: ClubMemberReader, - private val clubMemberPolicy: ClubMemberPolicy, + private val clubPermissionPolicy: ClubPermissionPolicy, ) { @Transactional fun create( @@ -37,7 +37,7 @@ class ManageSessionUseCase( request: ScheduleSaveRequest, userId: Long, ) { - clubMemberPolicy.requireAdmin(clubId, userId) + clubPermissionPolicy.requireAdmin(clubId, userId) val club = clubReader.getClubById(clubId) val user = userReader.getById(userId) cardinalReader.findByClubIdAndCardinalNumber(clubId, request.cardinal) ?: throw SessionNotFoundException() @@ -57,7 +57,7 @@ class ManageSessionUseCase( request: ScheduleUpdateRequest, userId: Long, ) { - clubMemberPolicy.requireAdmin(clubId, userId) + clubPermissionPolicy.requireAdmin(clubId, userId) val session = sessionRepository.findByIdWithLock(sessionId) ?: throw SessionNotFoundException() if (session.club.id != clubId) throw SessionNotFoundException() val user = userReader.getById(userId) @@ -71,7 +71,7 @@ class ManageSessionUseCase( sessionId: Long, userId: Long, ) { - clubMemberPolicy.requireAdmin(clubId, userId) + clubPermissionPolicy.requireAdmin(clubId, userId) val session = sessionRepository.findByIdWithLock(sessionId) ?: throw SessionNotFoundException() if (session.club.id != clubId) throw SessionNotFoundException() val attendances = diff --git a/src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt b/src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt index c5d9b0ee..d7f66689 100644 --- a/src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt @@ -1,6 +1,7 @@ package com.weeth.domain.session.application.usecase.query import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.schedule.application.dto.response.SessionInfosResponse import com.weeth.domain.schedule.application.dto.response.SessionResponse import com.weeth.domain.schedule.application.mapper.SessionMapper @@ -18,6 +19,7 @@ import java.time.temporal.TemporalAdjusters class GetSessionQueryService( private val sessionRepository: SessionRepository, private val clubMemberPolicy: ClubMemberPolicy, + private val clubPermissionPolicy: ClubPermissionPolicy, private val sessionMapper: SessionMapper, ) { fun findSession( @@ -40,7 +42,7 @@ class GetSessionQueryService( userId: Long, cardinal: Int?, ): SessionInfosResponse { - clubMemberPolicy.requireAdmin(clubId, userId) + clubPermissionPolicy.requireAdmin(clubId, userId) val sessions = if (cardinal == null) { sessionRepository.findAllByClubIdOrderByStartDesc(clubId) diff --git a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCaseTest.kt index 85304506..2c26a79d 100644 --- a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCaseTest.kt @@ -6,7 +6,7 @@ import com.weeth.domain.account.domain.repository.AccountRepository import com.weeth.domain.cardinal.domain.repository.CardinalReader import com.weeth.domain.cardinal.fixture.CardinalTestFixture import com.weeth.domain.club.domain.repository.ClubReader -import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.club.fixture.ClubTestFixture import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec @@ -20,15 +20,15 @@ class ManageAccountUseCaseTest : val accountRepository = mockk(relaxed = true) val cardinalReader = mockk(relaxed = true) val clubReader = mockk(relaxed = true) - val clubMemberPolicy = mockk(relaxed = true) - val useCase = ManageAccountUseCase(accountRepository, cardinalReader, clubReader, clubMemberPolicy) + val clubPermissionPolicy = mockk(relaxed = true) + val useCase = ManageAccountUseCase(accountRepository, cardinalReader, clubReader, clubPermissionPolicy) val clubId = 1L val userId = 100L val club = ClubTestFixture.createClub() beforeTest { - clearMocks(accountRepository, cardinalReader, clubReader, clubMemberPolicy) + clearMocks(accountRepository, cardinalReader, clubReader, clubPermissionPolicy) every { clubReader.getClubById(clubId) } returns club } @@ -52,7 +52,7 @@ class ManageAccountUseCaseTest : useCase.save(clubId, request, userId) - verify(exactly = 1) { clubMemberPolicy.requireAdmin(clubId, userId) } + verify(exactly = 1) { clubPermissionPolicy.requireAdmin(clubId, userId) } verify(exactly = 1) { accountRepository.save(any()) } } } diff --git a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCaseTest.kt index 3a186915..a457c657 100644 --- a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCaseTest.kt @@ -11,7 +11,7 @@ import com.weeth.domain.account.fixture.AccountTestFixture import com.weeth.domain.account.fixture.ReceiptTestFixture import com.weeth.domain.cardinal.domain.repository.CardinalReader import com.weeth.domain.cardinal.fixture.CardinalTestFixture -import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.file.application.dto.request.FileSaveRequest import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.entity.File @@ -34,7 +34,7 @@ class ManageReceiptUseCaseTest : val fileReader = mockk() val fileRepository = mockk(relaxed = true) val cardinalReader = mockk(relaxed = true) - val clubMemberPolicy = mockk(relaxed = true) + val clubPermissionPolicy = mockk(relaxed = true) val fileMapper = mockk() val useCase = ManageReceiptUseCase( @@ -43,7 +43,7 @@ class ManageReceiptUseCaseTest : fileReader, fileRepository, cardinalReader, - clubMemberPolicy, + clubPermissionPolicy, fileMapper, ) @@ -56,7 +56,7 @@ class ManageReceiptUseCaseTest : fileReader, fileRepository, cardinalReader, - clubMemberPolicy, + clubPermissionPolicy, fileMapper, ) } diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCaseTest.kt index c229da1d..3b2e8c09 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCaseTest.kt @@ -3,7 +3,7 @@ package com.weeth.domain.attendance.application.usecase.command import com.weeth.domain.attendance.application.dto.response.QrTokenResponse import com.weeth.domain.attendance.application.mapper.AttendanceMapper import com.weeth.domain.attendance.domain.port.QrAttendancePort -import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.session.application.exception.SessionNotFoundException import com.weeth.domain.session.domain.repository.SessionReader import com.weeth.domain.session.fixture.SessionTestFixture @@ -23,11 +23,11 @@ class GenerateQrTokenUseCaseTest : val sessionReader = mockk() val qrAttendancePort = mockk() val attendanceMapper = mockk() - val clubMemberPolicy = mockk(relaxed = true) + val clubPermissionPolicy = mockk(relaxed = true) - val useCase = GenerateQrTokenUseCase(sessionReader, qrAttendancePort, attendanceMapper, clubMemberPolicy) + val useCase = GenerateQrTokenUseCase(sessionReader, qrAttendancePort, attendanceMapper, clubPermissionPolicy) - beforeTest { clearMocks(sessionReader, qrAttendancePort, attendanceMapper, clubMemberPolicy) } + beforeTest { clearMocks(sessionReader, qrAttendancePort, attendanceMapper, clubPermissionPolicy) } describe("execute") { val sessionId = 1L @@ -50,7 +50,7 @@ class GenerateQrTokenUseCaseTest : val result = useCase.execute(sessionId, 10L, 20L) result shouldBe expectedResponse - verify(exactly = 1) { clubMemberPolicy.requireAdmin(10L, 20L) } + verify(exactly = 1) { clubPermissionPolicy.requireAdmin(10L, 20L) } verify(exactly = 1) { qrAttendancePort.store(sessionId, code) } } } diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCaseTest.kt index f1700abf..03aecf1a 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCaseTest.kt @@ -6,6 +6,7 @@ import com.weeth.domain.attendance.application.exception.AttendanceNotFoundExcep import com.weeth.domain.attendance.domain.port.QrAttendancePort import com.weeth.domain.attendance.domain.repository.AttendanceRepository import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.club.fixture.ClubMemberTestFixture import com.weeth.domain.session.domain.repository.SessionReader import com.weeth.domain.session.fixture.SessionTestFixture @@ -19,6 +20,7 @@ import io.mockk.mockk class ManageAttendanceUseCaseTest : DescribeSpec({ val clubMemberPolicy = mockk() + val clubPermissionPolicy = mockk() val sessionReader = mockk() val attendanceRepository = mockk() val qrAttendancePort = mockk() @@ -26,13 +28,14 @@ class ManageAttendanceUseCaseTest : val useCase = ManageAttendanceUseCase( clubMemberPolicy, + clubPermissionPolicy, sessionReader, attendanceRepository, qrAttendancePort, ) beforeTest { - clearMocks(clubMemberPolicy, sessionReader, attendanceRepository, qrAttendancePort) + clearMocks(clubMemberPolicy, clubPermissionPolicy, sessionReader, attendanceRepository, qrAttendancePort) } describe("checkIn") { @@ -103,7 +106,7 @@ class ManageAttendanceUseCaseTest : member, ) - every { clubMemberPolicy.requireAdmin(admin.club.id, admin.user.id) } returns admin + every { clubPermissionPolicy.requireAdmin(admin.club.id, admin.user.id) } returns admin every { attendanceRepository.findByIdWithClubMember(1L) } returns attendance useCase.updateStatus(admin.club.id, admin.user.id, listOf(UpdateAttendanceStatusRequest(1L, "ATTEND"))) @@ -123,7 +126,7 @@ class ManageAttendanceUseCaseTest : attendance.attend() member.attend() - every { clubMemberPolicy.requireAdmin(admin.club.id, admin.user.id) } returns admin + every { clubPermissionPolicy.requireAdmin(admin.club.id, admin.user.id) } returns admin every { attendanceRepository.findByIdWithClubMember(1L) } returns attendance useCase.updateStatus(admin.club.id, admin.user.id, listOf(UpdateAttendanceStatusRequest(1L, "PENDING"))) diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt index bb72f956..ebca6714 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt @@ -8,6 +8,7 @@ import com.weeth.domain.attendance.domain.repository.AttendanceRepository import com.weeth.domain.cardinal.fixture.CardinalTestFixture import com.weeth.domain.club.domain.service.ClubMemberCardinalPolicy import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.club.fixture.ClubMemberTestFixture import com.weeth.domain.session.domain.repository.SessionReader import com.weeth.domain.session.fixture.SessionTestFixture @@ -22,6 +23,7 @@ import io.mockk.verify class GetAttendanceQueryServiceTest : DescribeSpec({ val clubMemberPolicy = mockk() + val clubPermissionPolicy = mockk() val clubMemberCardinalPolicy = mockk() val sessionReader = mockk() val attendanceRepository = mockk() @@ -30,6 +32,7 @@ class GetAttendanceQueryServiceTest : val queryService = GetAttendanceQueryService( clubMemberPolicy, + clubPermissionPolicy, clubMemberCardinalPolicy, sessionReader, attendanceRepository, @@ -111,7 +114,7 @@ class GetAttendanceQueryServiceTest : val session = SessionTestFixture.createSession(id = 10L, club = admin.club, title = "세션") val attendance = Attendance.create(session, member).also { it.attend() } - every { clubMemberPolicy.requireAdmin(admin.club.id, admin.user.id) } returns admin + every { clubPermissionPolicy.requireAdmin(admin.club.id, admin.user.id) } returns admin every { sessionReader.getById(session.id) } returns session every { attendanceRepository.findAllBySessionAndClubMemberMemberStatus(session, any()) } returns listOf(attendance) @@ -127,7 +130,7 @@ class GetAttendanceQueryServiceTest : val admin = ClubMemberTestFixture.createAdminMember() val otherSession = SessionTestFixture.createSession(id = 10L) - every { clubMemberPolicy.requireAdmin(admin.club.id, admin.user.id) } returns admin + every { clubPermissionPolicy.requireAdmin(admin.club.id, admin.user.id) } returns admin every { sessionReader.getById(otherSession.id) } returns otherSession shouldThrow { diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt index c2e60f3d..6ef33530 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt @@ -10,7 +10,7 @@ import com.weeth.domain.board.domain.vo.BoardConfig import com.weeth.domain.board.fixture.BoardTestFixture import com.weeth.domain.club.domain.enums.MemberRole import com.weeth.domain.club.domain.repository.ClubReader -import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.club.fixture.ClubTestFixture import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec @@ -25,15 +25,15 @@ class ManageBoardUseCaseTest : val boardRepository = mockk() val boardMapper = BoardMapper() val clubReader = mockk() - val clubMemberPolicy = mockk(relaxed = true) - val useCase = ManageBoardUseCase(boardRepository, boardMapper, clubReader, clubMemberPolicy) + val clubPermissionPolicy = mockk(relaxed = true) + val useCase = ManageBoardUseCase(boardRepository, boardMapper, clubReader, clubPermissionPolicy) val club = ClubTestFixture.createClub() val clubId = club.id val userId = 10L beforeTest { - clearMocks(boardRepository, clubReader, clubMemberPolicy) + clearMocks(boardRepository, clubReader, clubPermissionPolicy) every { boardRepository.save(any()) } answers { firstArg() } every { clubReader.getClubById(clubId) } returns club } diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt index 86ecda55..15f94acf 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt @@ -6,6 +6,7 @@ import com.weeth.domain.board.domain.enums.BoardType import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.board.fixture.BoardTestFixture import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.club.fixture.ClubMemberTestFixture import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec @@ -18,8 +19,9 @@ class GetBoardQueryServiceTest : DescribeSpec({ val boardRepository = mockk() val clubMemberPolicy = mockk(relaxed = true) + val clubPermissionPolicy = mockk(relaxed = true) val boardMapper = BoardMapper() - val queryService = GetBoardQueryService(boardRepository, clubMemberPolicy, boardMapper) + val queryService = GetBoardQueryService(boardRepository, clubMemberPolicy, clubPermissionPolicy, boardMapper) val clubId = 1L val userId = 10L diff --git a/src/test/kotlin/com/weeth/domain/cardinal/application/usecase/command/CardinalUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/cardinal/application/usecase/command/CardinalUseCaseTest.kt index b4200b9f..a5ac7c67 100644 --- a/src/test/kotlin/com/weeth/domain/cardinal/application/usecase/command/CardinalUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/cardinal/application/usecase/command/CardinalUseCaseTest.kt @@ -13,6 +13,7 @@ import com.weeth.domain.cardinal.domain.service.CardinalStatusPolicy import com.weeth.domain.cardinal.fixture.CardinalTestFixture import com.weeth.domain.club.domain.repository.ClubReader import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.club.fixture.ClubTestFixture import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.collections.shouldHaveSize @@ -30,6 +31,7 @@ class CardinalUseCaseTest : val cardinalMapper = mockk() val clubReader = mockk() val clubMemberPolicy = mockk(relaxed = true) + val clubPermissionPolicy = mockk(relaxed = true) val cardinalStatusPolicy = CardinalStatusPolicy(cardinalRepository) val manageCardinalUseCase = ManageCardinalUseCase( @@ -37,7 +39,7 @@ class CardinalUseCaseTest : cardinalMapper, cardinalStatusPolicy, clubReader, - clubMemberPolicy, + clubPermissionPolicy, ) val getCardinalQueryService = GetCardinalQueryService(cardinalReader, clubMemberPolicy, cardinalMapper) @@ -47,7 +49,14 @@ class CardinalUseCaseTest : val club = ClubTestFixture.createClub() beforeTest { - clearMocks(cardinalRepository, cardinalReader, cardinalMapper, clubReader, clubMemberPolicy) + clearMocks( + cardinalRepository, + cardinalReader, + cardinalMapper, + clubReader, + clubMemberPolicy, + clubPermissionPolicy, + ) every { clubReader.getClubById(clubId) } returns club every { clubMemberPolicy.getActiveMember( diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCaseTest.kt index b2d282b4..fa352ebb 100644 --- a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCaseTest.kt @@ -16,6 +16,7 @@ import com.weeth.domain.club.domain.enums.MemberStatus import com.weeth.domain.club.domain.repository.ClubMemberCardinalRepository import com.weeth.domain.club.domain.service.ClubMemberCardinalPolicy import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.club.fixture.ClubMemberTestFixture import com.weeth.domain.club.fixture.ClubTestFixture import com.weeth.domain.session.domain.repository.SessionReader @@ -32,6 +33,7 @@ import org.springframework.test.util.ReflectionTestUtils class AdminClubMemberUseCaseTest : DescribeSpec({ val clubMemberPolicy = mockk() + val clubPermissionPolicy = mockk() val clubMemberCardinalPolicy = mockk(relaxed = true) val cardinalReader = mockk(relaxed = true) val sessionReader = mockk(relaxed = true) @@ -40,6 +42,7 @@ class AdminClubMemberUseCaseTest : val useCase = AdminClubMemberUseCase( clubMemberPolicy, + clubPermissionPolicy, clubMemberCardinalPolicy, cardinalReader, sessionReader, @@ -51,6 +54,7 @@ class AdminClubMemberUseCaseTest : beforeTest { clearMocks( clubMemberPolicy, + clubPermissionPolicy, clubMemberCardinalPolicy, cardinalReader, sessionReader, @@ -69,7 +73,7 @@ class AdminClubMemberUseCaseTest : describe("accept") { it("같은 동아리 소속 멤버를 승인한다") { val member = ClubMemberTestFixture.createWaitingMember() - every { clubMemberPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember every { clubMemberPolicy.getMemberInClub(1L, 20L) } returns member useCase.accept(1L, 10L, 20L) @@ -78,7 +82,7 @@ class AdminClubMemberUseCaseTest : } it("다른 동아리 소속 멤버면 예외가 발생한다") { - every { clubMemberPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember every { clubMemberPolicy.getMemberInClub(1L, 20L) } throws ClubMemberNotInClubException() shouldThrow { @@ -90,7 +94,7 @@ class AdminClubMemberUseCaseTest : describe("ban") { it("같은 동아리 소속 멤버를 추방한다") { val member = ClubMemberTestFixture.createActiveMember() - every { clubMemberPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember every { clubMemberPolicy.getMemberInClub(1L, 20L) } returns member useCase.ban(1L, 10L, 20L) @@ -102,7 +106,7 @@ class AdminClubMemberUseCaseTest : describe("updateMemberRole") { it("같은 동아리 소속 멤버의 권한을 변경한다") { val member = ClubMemberTestFixture.createActiveMember(memberRole = MemberRole.USER) - every { clubMemberPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember every { clubMemberPolicy.getMemberInClub(1L, 20L) } returns member useCase.updateMemberRole( @@ -116,7 +120,7 @@ class AdminClubMemberUseCaseTest : it("LEAD로 직접 변경 시도하면 예외가 발생한다") { val member = ClubMemberTestFixture.createActiveMember(memberRole = MemberRole.USER) - every { clubMemberPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember every { clubMemberPolicy.getMemberInClub(1L, 20L) } returns member shouldThrow { @@ -130,7 +134,7 @@ class AdminClubMemberUseCaseTest : it("LEAD 멤버의 역할을 직접 변경 시도하면 예외가 발생한다") { val leadMember = ClubMemberTestFixture.createLeadMember() - every { clubMemberPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember every { clubMemberPolicy.getMemberInClub(1L, 20L) } returns leadMember shouldThrow { @@ -217,7 +221,7 @@ class AdminClubMemberUseCaseTest : semester = 1, ) val session = SessionTestFixture.createSession(club = adminMember.club, cardinal = 8) - every { clubMemberPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember every { clubMemberPolicy.getMemberInClub(1L, 20L) } returns member every { cardinalReader.findByClubIdAndCardinalNumber(1L, 8) } returns cardinal every { clubMemberCardinalPolicy.notContains(member, cardinal) } returns true @@ -242,7 +246,7 @@ class AdminClubMemberUseCaseTest : year = 2026, semester = 1, ) - every { clubMemberPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember every { clubMemberPolicy.getMemberInClub(1L, 20L) } returns member every { cardinalReader.findByClubIdAndCardinalNumber(1L, 8) } returns cardinal every { clubMemberCardinalPolicy.notContains(member, cardinal) } returns false @@ -266,7 +270,7 @@ class AdminClubMemberUseCaseTest : year = 2026, semester = 1, ) - every { clubMemberPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember every { clubMemberPolicy.getMemberInClub(1L, 20L) } returns member every { cardinalReader.findByClubIdAndCardinalNumber(1L, 8) } returns cardinal every { clubMemberCardinalPolicy.notContains(member, cardinal) } returns true @@ -283,7 +287,7 @@ class AdminClubMemberUseCaseTest : it("존재하지 않는 기수면 예외가 발생한다") { val member = ClubMemberTestFixture.createActiveMember(club = adminMember.club) - every { clubMemberPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember every { clubMemberPolicy.getMemberInClub(1L, 20L) } returns member every { cardinalReader.findByClubIdAndCardinalNumber(1L, 8) } returns null @@ -304,7 +308,7 @@ class AdminClubMemberUseCaseTest : ) repeat(2) { member.attend() } repeat(1) { member.absent() } - every { clubMemberPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember every { clubMemberPolicy.getMemberInClub(1L, 20L) } returns member every { cardinalReader.findByClubIdAndCardinalNumber(1L, 8) } returns cardinal every { clubMemberCardinalPolicy.notContains(member, cardinal) } returns true diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt index b042524e..9093ebd9 100644 --- a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt @@ -16,6 +16,7 @@ import com.weeth.domain.club.domain.enums.MemberStatus import com.weeth.domain.club.domain.repository.ClubMemberCardinalRepository import com.weeth.domain.club.domain.repository.ClubMemberRepository import com.weeth.domain.club.domain.repository.ClubRepository +import com.weeth.domain.club.domain.service.ClubJoinPolicy import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.club.fixture.ClubMemberTestFixture import com.weeth.domain.club.fixture.ClubTestFixture @@ -48,6 +49,7 @@ class ManageClubMemberUseCaseTest : val attendanceRepository = mockk(relaxed = true) val userReader = mockk() val clubMemberPolicy = mockk() + val clubJoinPolicy = mockk() val fileRepository = mockk() val fileAccessUrlPort = mockk() @@ -61,6 +63,7 @@ class ManageClubMemberUseCaseTest : attendanceRepository = attendanceRepository, userReader = userReader, clubMemberPolicy = clubMemberPolicy, + clubJoinPolicy = clubJoinPolicy, fileRepository = fileRepository, fileAccessUrlPort = fileAccessUrlPort, ) @@ -75,6 +78,7 @@ class ManageClubMemberUseCaseTest : attendanceRepository, userReader, clubMemberPolicy, + clubJoinPolicy, fileRepository, fileAccessUrlPort, ) @@ -388,7 +392,7 @@ class ManageClubMemberUseCaseTest : every { clubRepository.getClubById(1L) } returns targetClub every { userReader.getByIdWithLock(10L) } returns user every { clubMemberRepository.findByClubIdAndUserId(1L, 10L) } returns null - every { clubMemberPolicy.validateJoinLimit(10L) } throws ClubJoinLimitExceededException() + every { clubJoinPolicy.validateJoinLimit(10L) } throws ClubJoinLimitExceededException() shouldThrow { useCase.join( @@ -410,7 +414,7 @@ class ManageClubMemberUseCaseTest : every { clubRepository.getClubById(1L) } returns targetClub every { userReader.getByIdWithLock(10L) } returns user every { clubMemberRepository.findByClubIdAndUserId(1L, 10L) } returns null - justRun { clubMemberPolicy.validateJoinLimit(10L) } + justRun { clubJoinPolicy.validateJoinLimit(10L) } useCase.join( clubId = 1L, diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt index 68d38a4c..2185ea96 100644 --- a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt @@ -11,7 +11,8 @@ import com.weeth.domain.club.domain.enums.PrimaryContact import com.weeth.domain.club.domain.repository.ClubMemberCardinalRepository import com.weeth.domain.club.domain.repository.ClubMemberRepository import com.weeth.domain.club.domain.repository.ClubRepository -import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubJoinPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.club.domain.vo.ClubContact import com.weeth.domain.club.fixture.ClubTestFixture import com.weeth.domain.user.domain.repository.UserReader @@ -34,7 +35,8 @@ class ManageClubUseCaseTest : val cardinalRepository = mockk() val clubMemberCardinalRepository = mockk() val userReader = mockk() - val clubMemberPolicy = mockk() + val clubJoinPolicy = mockk() + val clubPermissionPolicy = mockk() val useCase = ManageClubUseCase( clubRepository, @@ -42,7 +44,8 @@ class ManageClubUseCaseTest : cardinalRepository, clubMemberCardinalRepository, userReader, - clubMemberPolicy, + clubJoinPolicy, + clubPermissionPolicy, ) val adminMember = com.weeth.domain.club.fixture.ClubMemberTestFixture @@ -55,13 +58,14 @@ class ManageClubUseCaseTest : cardinalRepository, clubMemberCardinalRepository, userReader, - clubMemberPolicy, + clubJoinPolicy, + clubPermissionPolicy, ) every { clubRepository.save(any()) } answers { firstArg() } every { clubMemberRepository.save(any()) } answers { firstArg() } every { cardinalRepository.saveAll(any>()) } answers { firstArg() } every { clubMemberCardinalRepository.save(any()) } answers { firstArg() } - every { clubMemberPolicy.validateCreateLimit(any()) } just Runs + every { clubJoinPolicy.validateCreateLimit(any()) } just Runs } describe("create") { @@ -140,7 +144,7 @@ class ManageClubUseCaseTest : context("이미 LEAD로 1개 동아리를 생성한 사용자가 생성 시도하는 경우") { it("ClubCreateLimitExceededException이 발생하고, 이후 로직이 실행되지 않는다") { every { userReader.getByIdWithLock(13L) } returns user - every { clubMemberPolicy.validateCreateLimit(13L) } throws ClubCreateLimitExceededException() + every { clubJoinPolicy.validateCreateLimit(13L) } throws ClubCreateLimitExceededException() shouldThrow { useCase.create( @@ -157,7 +161,7 @@ class ManageClubUseCaseTest : } verify(exactly = 1) { userReader.getByIdWithLock(13L) } - verify(exactly = 1) { clubMemberPolicy.validateCreateLimit(13L) } + verify(exactly = 1) { clubJoinPolicy.validateCreateLimit(13L) } verify(exactly = 0) { clubRepository.save(any()) } verify(exactly = 0) { clubMemberRepository.save(any()) } verify(exactly = 0) { cardinalRepository.saveAll(any>()) } @@ -190,7 +194,7 @@ class ManageClubUseCaseTest : "CLUB_BACKGROUND/2026-02/uuid_background.png", ) - every { clubMemberPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember every { clubRepository.getClubById(1L) } returns club useCase.update( @@ -222,7 +226,7 @@ class ManageClubUseCaseTest : primaryContact = PrimaryContact.PHONE, ), ) - every { clubMemberPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember every { clubRepository.getClubById(1L) } returns club useCase.update(1L, 10L, ClubUpdateRequest()) @@ -257,7 +261,7 @@ class ManageClubUseCaseTest : "CLUB_BACKGROUND/2026-02/uuid_background.png", ) - every { clubMemberPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember every { clubRepository.getClubById(1L) } returns club useCase.deleteProfileImage(1L, 10L) @@ -289,7 +293,7 @@ class ManageClubUseCaseTest : "CLUB_BACKGROUND/2026-02/uuid_background.png", ) - every { clubMemberPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember every { clubRepository.getClubById(1L) } returns club useCase.deleteBackgroundImage(1L, 10L) diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryServiceTest.kt index 893bdb2f..db7206c6 100644 --- a/src/test/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryServiceTest.kt @@ -7,6 +7,7 @@ import com.weeth.domain.club.domain.enums.MemberRole import com.weeth.domain.club.domain.repository.ClubMemberCardinalReader import com.weeth.domain.club.domain.repository.ClubMemberReader import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.club.fixture.ClubTestFixture import com.weeth.domain.file.domain.port.FileAccessUrlPort import com.weeth.domain.user.fixture.UserTestFixture @@ -22,6 +23,7 @@ class GetClubMemberQueryServiceTest : val clubMemberReader = mockk() val clubMemberCardinalReader = mockk() val clubMemberPolicy = mockk() + val clubPermissionPolicy = mockk() val fileAccessUrlPort = mockk() val clubMapper = ClubMapper(fileAccessUrlPort) @@ -30,6 +32,7 @@ class GetClubMemberQueryServiceTest : clubMemberReader = clubMemberReader, clubMemberCardinalReader = clubMemberCardinalReader, clubMemberPolicy = clubMemberPolicy, + clubPermissionPolicy = clubPermissionPolicy, clubMapper = clubMapper, ) @@ -48,7 +51,7 @@ class GetClubMemberQueryServiceTest : ClubMemberCardinal.create(member, cardinal6), ) - every { clubMemberPolicy.requireAdmin(1L, 99L) } returns admin + every { clubPermissionPolicy.requireAdmin(1L, 99L) } returns admin every { clubMemberReader.findAllByClubId(1L) } returns listOf(member) every { clubMemberCardinalReader.findAllByClubMembers(listOf(member)) } returns memberCardinals @@ -68,7 +71,7 @@ class GetClubMemberQueryServiceTest : response.attendanceRate shouldBe member.attendanceStats.attendanceRate response.penaltyCount shouldBe member.penaltyCount response.cardinals shouldBe listOf(6, 7) - verify(exactly = 1) { clubMemberPolicy.requireAdmin(1L, 99L) } + verify(exactly = 1) { clubPermissionPolicy.requireAdmin(1L, 99L) } verify(exactly = 1) { clubMemberCardinalReader.findAllByClubMembers(listOf(member)) } } } diff --git a/src/test/kotlin/com/weeth/domain/club/domain/service/ClubJoinPolicyTest.kt b/src/test/kotlin/com/weeth/domain/club/domain/service/ClubJoinPolicyTest.kt new file mode 100644 index 00000000..23c336a3 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/club/domain/service/ClubJoinPolicyTest.kt @@ -0,0 +1,107 @@ +package com.weeth.domain.club.domain.service + +import com.weeth.domain.club.application.exception.ClubCreateLimitExceededException +import com.weeth.domain.club.application.exception.ClubJoinLimitExceededException +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.repository.ClubMemberReader +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk + +class ClubJoinPolicyTest : + DescribeSpec({ + val clubMemberReader = mockk() + val policy = ClubJoinPolicy(clubMemberReader) + + beforeTest { + clearMocks(clubMemberReader) + } + + describe("validateJoinLimit") { + context("USER로 가입한 동아리가 없는 경우") { + it("검증을 통과해야 한다") { + every { + clubMemberReader.countByUserIdAndMemberStatusAndMemberRole( + 1L, + MemberStatus.ACTIVE, + MemberRole.USER, + ) + } returns 0L + + shouldNotThrowAny { + policy.validateJoinLimit(1L) + } + } + } + + context("이미 USER로 1개 동아리에 가입한 경우") { + it("ClubJoinLimitExceededException을 발생시켜야 한다") { + every { + clubMemberReader.countByUserIdAndMemberStatusAndMemberRole( + 1L, + MemberStatus.ACTIVE, + MemberRole.USER, + ) + } returns 1L + + shouldThrow { + policy.validateJoinLimit(1L) + } + } + } + + context("LEAD로 1개 동아리를 생성했지만 USER 가입은 없는 경우") { + it("검증을 통과해야 한다 (역할이 다르므로 허용)") { + every { + clubMemberReader.countByUserIdAndMemberStatusAndMemberRole( + 1L, + MemberStatus.ACTIVE, + MemberRole.USER, + ) + } returns 0L + + shouldNotThrowAny { + policy.validateJoinLimit(1L) + } + } + } + } + + describe("validateCreateLimit") { + context("LEAD로 생성한 동아리가 없는 경우") { + it("검증을 통과해야 한다") { + every { + clubMemberReader.countByUserIdAndMemberStatusAndMemberRole( + 1L, + MemberStatus.ACTIVE, + MemberRole.LEAD, + ) + } returns 0L + + shouldNotThrowAny { + policy.validateCreateLimit(1L) + } + } + } + + context("이미 LEAD로 1개 동아리를 생성한 경우") { + it("ClubCreateLimitExceededException을 발생시켜야 한다") { + every { + clubMemberReader.countByUserIdAndMemberStatusAndMemberRole( + 1L, + MemberStatus.ACTIVE, + MemberRole.LEAD, + ) + } returns 1L + + shouldThrow { + policy.validateCreateLimit(1L) + } + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicyTest.kt b/src/test/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicyTest.kt index 32241258..f014cef9 100644 --- a/src/test/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicyTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicyTest.kt @@ -1,18 +1,14 @@ package com.weeth.domain.club.domain.service -import com.weeth.domain.club.application.exception.ClubCreateLimitExceededException -import com.weeth.domain.club.application.exception.ClubJoinLimitExceededException import com.weeth.domain.club.application.exception.ClubMemberNotFoundException import com.weeth.domain.club.application.exception.ClubMemberNotInClubException import com.weeth.domain.club.application.exception.MemberNotActiveException -import com.weeth.domain.club.application.exception.NotClubAdminException -import com.weeth.domain.club.domain.enums.MemberRole import com.weeth.domain.club.domain.enums.MemberStatus import com.weeth.domain.club.domain.repository.ClubMemberReader import com.weeth.domain.club.fixture.ClubTestFixture -import io.kotest.assertions.throwables.shouldNotThrowAny import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk @@ -31,12 +27,12 @@ class ClubMemberPolicyTest : it("활성 멤버를 반환해야 한다") { val activeMember = ClubTestFixture.createClubMember( - memberStatus = com.weeth.domain.club.domain.enums.MemberStatus.ACTIVE, + memberStatus = MemberStatus.ACTIVE, ) every { clubMemberReader.findByClubIdAndUserId(1L, 1L) } returns activeMember val result = policy.getActiveMember(1L, 1L) - assert(result.id == activeMember.id) + result.id shouldBe activeMember.id } } @@ -54,7 +50,7 @@ class ClubMemberPolicyTest : it("MemberNotActiveException을 발생시켜야 한다") { val inactiveMember = ClubTestFixture.createClubMember( - memberStatus = com.weeth.domain.club.domain.enums.MemberStatus.WAITING, + memberStatus = MemberStatus.WAITING, ) every { clubMemberReader.findByClubIdAndUserId(1L, 1L) } returns inactiveMember @@ -65,52 +61,6 @@ class ClubMemberPolicyTest : } } - describe("requireAdmin") { - context("활성 상태의 관리자인 경우") { - it("멤버를 반환해야 한다") { - val adminMember = - ClubTestFixture.createClubMember( - memberStatus = com.weeth.domain.club.domain.enums.MemberStatus.ACTIVE, - memberRole = com.weeth.domain.club.domain.enums.MemberRole.ADMIN, - ) - every { clubMemberReader.findByClubIdAndUserId(1L, 1L) } returns adminMember - - val result = policy.requireAdmin(1L, 1L) - assert(result.id == adminMember.id) - } - } - - context("활성 상태이지만 관리자가 아닌 경우") { - it("NotClubAdminException을 발생시켜야 한다") { - val userMember = - ClubTestFixture.createClubMember( - memberStatus = com.weeth.domain.club.domain.enums.MemberStatus.ACTIVE, - memberRole = com.weeth.domain.club.domain.enums.MemberRole.USER, - ) - every { clubMemberReader.findByClubIdAndUserId(1L, 1L) } returns userMember - - shouldThrow { - policy.requireAdmin(1L, 1L) - } - } - } - - context("비활성 상태인 경우") { - it("MemberNotActiveException을 발생시켜야 한다") { - val inactiveMember = - ClubTestFixture.createClubMember( - memberStatus = com.weeth.domain.club.domain.enums.MemberStatus.WAITING, - memberRole = com.weeth.domain.club.domain.enums.MemberRole.ADMIN, - ) - every { clubMemberReader.findByClubIdAndUserId(1L, 1L) } returns inactiveMember - - shouldThrow { - policy.requireAdmin(1L, 1L) - } - } - } - } - describe("getMemberInClub") { context("해당 동아리에 속한 멤버인 경우") { it("멤버를 반환해야 한다") { @@ -119,7 +69,7 @@ class ClubMemberPolicyTest : val result = policy.getMemberInClub(member.club.id, 1L) - assert(result == member) + result shouldBe member } } @@ -129,7 +79,6 @@ class ClubMemberPolicyTest : every { clubMemberReader.findByIdOrNull(1L) } returns member shouldThrow { - // member.club.id와 다른 clubId를 전달하여 다른 동아리 시나리오를 재현 policy.getMemberInClub(member.club.id + 999L, 1L) } } @@ -145,88 +94,4 @@ class ClubMemberPolicyTest : } } } - - describe("validateJoinLimit") { - context("USER로 가입한 동아리가 없는 경우") { - it("검증을 통과해야 한다") { - every { - clubMemberReader.countByUserIdAndMemberStatusAndMemberRole( - 1L, - MemberStatus.ACTIVE, - MemberRole.USER, - ) - } returns 0L - - shouldNotThrowAny { - policy.validateJoinLimit(1L) - } - } - } - - context("이미 USER로 1개 동아리에 가입한 경우") { - it("ClubJoinLimitExceededException을 발생시켜야 한다") { - every { - clubMemberReader.countByUserIdAndMemberStatusAndMemberRole( - 1L, - MemberStatus.ACTIVE, - MemberRole.USER, - ) - } returns 1L - - shouldThrow { - policy.validateJoinLimit(1L) - } - } - } - - context("LEAD로 1개 동아리를 생성했지만 USER 가입은 없는 경우") { - it("검증을 통과해야 한다 (역할이 다르므로 허용)") { - every { - clubMemberReader.countByUserIdAndMemberStatusAndMemberRole( - 1L, - MemberStatus.ACTIVE, - MemberRole.USER, - ) - } returns 0L - - shouldNotThrowAny { - policy.validateJoinLimit(1L) - } - } - } - } - - describe("validateCreateLimit") { - context("LEAD로 생성한 동아리가 없는 경우") { - it("검증을 통과해야 한다") { - every { - clubMemberReader.countByUserIdAndMemberStatusAndMemberRole( - 1L, - MemberStatus.ACTIVE, - MemberRole.LEAD, - ) - } returns 0L - - shouldNotThrowAny { - policy.validateCreateLimit(1L) - } - } - } - - context("이미 LEAD로 1개 동아리를 생성한 경우") { - it("ClubCreateLimitExceededException을 발생시켜야 한다") { - every { - clubMemberReader.countByUserIdAndMemberStatusAndMemberRole( - 1L, - MemberStatus.ACTIVE, - MemberRole.LEAD, - ) - } returns 1L - - shouldThrow { - policy.validateCreateLimit(1L) - } - } - } - } }) diff --git a/src/test/kotlin/com/weeth/domain/club/domain/service/ClubPermissionPolicyTest.kt b/src/test/kotlin/com/weeth/domain/club/domain/service/ClubPermissionPolicyTest.kt new file mode 100644 index 00000000..cc5a5359 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/club/domain/service/ClubPermissionPolicyTest.kt @@ -0,0 +1,80 @@ +package com.weeth.domain.club.domain.service + +import com.weeth.domain.club.application.exception.MemberNotActiveException +import com.weeth.domain.club.application.exception.NotClubAdminException +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.fixture.ClubTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk + +class ClubPermissionPolicyTest : + DescribeSpec({ + val clubMemberPolicy = mockk() + val policy = ClubPermissionPolicy(clubMemberPolicy) + + beforeTest { + clearMocks(clubMemberPolicy) + } + + describe("requireAdmin") { + context("활성 상태의 관리자인 경우") { + it("멤버를 반환해야 한다") { + val adminMember = + ClubTestFixture.createClubMember( + memberStatus = MemberStatus.ACTIVE, + memberRole = MemberRole.ADMIN, + ) + every { clubMemberPolicy.getActiveMember(1L, 1L) } returns adminMember + + val result = policy.requireAdmin(1L, 1L) + result.id shouldBe adminMember.id + } + } + + context("활성 상태의 LEAD인 경우") { + it("멤버를 반환해야 한다") { + val leadMember = + ClubTestFixture.createClubMember( + memberStatus = MemberStatus.ACTIVE, + memberRole = MemberRole.LEAD, + ) + every { clubMemberPolicy.getActiveMember(1L, 1L) } returns leadMember + + val result = policy.requireAdmin(1L, 1L) + result.id shouldBe leadMember.id + } + } + + context("활성 상태이지만 관리자가 아닌 경우") { + it("NotClubAdminException을 발생시켜야 한다") { + val userMember = + ClubTestFixture.createClubMember( + memberStatus = MemberStatus.ACTIVE, + memberRole = MemberRole.USER, + ) + every { clubMemberPolicy.getActiveMember(1L, 1L) } returns userMember + + shouldThrow { + policy.requireAdmin(1L, 1L) + } + } + } + + context("비활성 상태인 경우") { + it("MemberNotActiveException을 발생시켜야 한다") { + every { + clubMemberPolicy.getActiveMember(1L, 1L) + } throws MemberNotActiveException() + + shouldThrow { + policy.requireAdmin(1L, 1L) + } + } + } + } + }) From 34721dbc399a2b0a0bdf2b8c1c27032897c8eccf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=84=EC=88=98=ED=98=84?= <128474444+soo0711@users.noreply.github.com> Date: Mon, 23 Mar 2026 19:48:11 +0900 Subject: [PATCH 33/73] =?UTF-8?q?[WTH-202]=20=EC=B6=9C=EC=84=9D=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=20=EB=B0=8F=20=EA=B5=AC=EC=A1=B0=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20(#36)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: Session, Attendance private set 적용 * refactor: 출석 자동 마감 스케줄러를 매일 00시로 변경 * refactor: 미사용 API 제거 * refactor: AttendanceSummar.code 예시 6자리로 수정 * refactor: 출석 초기화 트리거 정리 * refactor: 출석 체크인, 마감 락 추가 * refactor: 배치 락 및 데드락 방지 * refactor: 세션 생성 시 출석 쿼리 최적화 * test: 배치 락 변경과 관련된 테스트 수정 * feat: 출석 마감 예외 클래스 추가 * style: 린트 적용 * fix: updateStatus 빈 리스트 예외 방지 * fix: 주석 제거 * refactor: DB 레벨 잠금 순서 보장 * fix: 주석으로 정렬 명시 * refactor: TODO 주석추가 --- .../dto/response/AttendanceSummaryResponse.kt | 2 +- .../AttendanceAlreadyClosedException.kt | 5 ++ .../exception/AttendanceErrorCode.kt | 3 + .../command/ManageAttendanceUseCase.kt | 57 ++++++-------- .../attendance/domain/entity/Attendance.kt | 3 +- .../domain/repository/AttendanceRepository.kt | 11 ++- .../infrastructure/AttendanceScheduler.kt | 2 +- .../presentation/AttendanceAdminController.kt | 15 ---- .../presentation/AttendanceResponseCode.kt | 13 ++-- .../usecase/command/AdminClubMemberUseCase.kt | 14 +++- .../command/ManageClubMemberUsecase.kt | 2 +- .../repository/ClubMemberCardinalReader.kt | 7 ++ .../ClubMemberCardinalRepository.kt | 20 +++++ .../domain/repository/ClubMemberReader.kt | 6 ++ .../domain/repository/ClubMemberRepository.kt | 7 ++ .../club/domain/service/ClubMemberPolicy.kt | 29 ++++--- .../usecase/command/ManageSessionUseCase.kt | 18 ++--- .../domain/session/domain/entity/Session.kt | 56 ++++++++++---- .../command/ManageAttendanceUseCaseTest.kt | 75 +++++++++++-------- .../command/AdminClubMemberUseCaseTest.kt | 37 ++++++--- .../domain/club/fixture/ClubTestFixture.kt | 2 + 21 files changed, 237 insertions(+), 147 deletions(-) create mode 100644 src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceAlreadyClosedException.kt diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceSummaryResponse.kt b/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceSummaryResponse.kt index cbe2d42d..1c51b954 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceSummaryResponse.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceSummaryResponse.kt @@ -11,7 +11,7 @@ data class AttendanceSummaryResponse( val title: String?, @field:Schema(description = "출석 상태", example = "ATTEND") val status: AttendanceStatus?, - @field:Schema(description = "어드민인 경우 출석 코드 노출", example = "1234") + @field:Schema(description = "어드민인 경우 출석 코드 노출", example = "123456") val code: Int?, @field:Schema(description = "정기모임 시작 시간") val start: LocalDateTime?, diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceAlreadyClosedException.kt b/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceAlreadyClosedException.kt new file mode 100644 index 00000000..a35d7515 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceAlreadyClosedException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.attendance.application.exception + +import com.weeth.global.common.exception.BaseException + +class AttendanceAlreadyClosedException : BaseException(AttendanceErrorCode.ATTENDANCE_ALREADY_CLOSED) diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceErrorCode.kt b/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceErrorCode.kt index 7b98d1ed..c56d3bd7 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/exception/AttendanceErrorCode.kt @@ -23,4 +23,7 @@ enum class AttendanceErrorCode( @ExplainError("해당 세션에 이미 출석 처리된 사용자가 다시 출석을 시도할 때 발생합니다.") ALREADY_ATTENDED(20204, HttpStatus.CONFLICT, "이미 출석 처리된 세션입니다."), + + @ExplainError("출석이 자동 마감 처리된 후 체크인을 시도할 때 발생합니다.") + ATTENDANCE_ALREADY_CLOSED(20205, HttpStatus.CONFLICT, "이미 마감된 출석입니다."), } diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCase.kt b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCase.kt index 61e85c56..b8d4e244 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCase.kt @@ -2,6 +2,7 @@ package com.weeth.domain.attendance.application.usecase.command import com.weeth.domain.attendance.application.dto.request.UpdateAttendanceStatusRequest import com.weeth.domain.attendance.application.exception.AlreadyAttendedException +import com.weeth.domain.attendance.application.exception.AttendanceAlreadyClosedException import com.weeth.domain.attendance.application.exception.AttendanceCodeMismatchException import com.weeth.domain.attendance.application.exception.AttendanceNotFoundException import com.weeth.domain.attendance.application.exception.QrTokenExpiredException @@ -12,13 +13,12 @@ import com.weeth.domain.attendance.domain.repository.AttendanceRepository import com.weeth.domain.club.domain.enums.MemberStatus import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.club.domain.service.ClubPermissionPolicy -import com.weeth.domain.session.application.exception.SessionNotFoundException import com.weeth.domain.session.application.exception.SessionNotInProgressException +import com.weeth.domain.session.domain.entity.Session import com.weeth.domain.session.domain.enums.SessionStatus import com.weeth.domain.session.domain.repository.SessionReader import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import java.time.LocalDate import java.time.LocalDateTime @Service @@ -49,45 +49,27 @@ class ManageAttendanceUseCase( attendanceRepository.findBySessionAndClubMemberWithLock(session, clubMember) ?: throw AttendanceNotFoundException() - if (lockedAttendance.status == AttendanceStatus.ATTEND) throw AlreadyAttendedException() + when (lockedAttendance.status) { + AttendanceStatus.ATTEND -> throw AlreadyAttendedException() + AttendanceStatus.ABSENT -> throw AttendanceAlreadyClosedException() + AttendanceStatus.PENDING -> Unit + } lockedAttendance.attend() clubMember.attend() } - @Transactional - fun close( - clubId: Long, - userId: Long, - now: LocalDate, - cardinal: Int, - ) { - clubPermissionPolicy.requireAdmin(clubId, userId) - val targetSession = - sessionReader - .findAllByClubIdAndCardinalIn(clubId, listOf(cardinal)) - .firstOrNull { session -> - session.start.toLocalDate().isEqual(now) && - session.end.toLocalDate().isEqual(now) - } - ?: throw SessionNotFoundException() - - targetSession.close() - val attendances = - attendanceRepository.findAllBySessionAndClubMemberMemberStatus(targetSession, MemberStatus.ACTIVE) - closePendingAttendances(attendances) - } - @Transactional fun autoClose() { val sessions = sessionReader.findAllByStatusAndEndBeforeOrderByEndAsc(SessionStatus.OPEN, LocalDateTime.now()) + sessions.forEach { session -> closeSingleSession(session) } + } - sessions.forEach { session -> - session.close() - val attendances = - attendanceRepository.findAllBySessionAndClubMemberMemberStatus(session, MemberStatus.ACTIVE) - closePendingAttendances(attendances) - } + private fun closeSingleSession(session: Session) { + session.close() + val attendances = + attendanceRepository.findAllBySessionAndClubMemberMemberStatusWithLock(session, MemberStatus.ACTIVE) + closePendingAttendances(attendances) } @Transactional @@ -97,10 +79,13 @@ class ManageAttendanceUseCase( attendanceUpdates: List, ) { clubPermissionPolicy.requireAdmin(clubId, userId) - attendanceUpdates.forEach { update -> - val attendance = - attendanceRepository.findByIdWithClubMember(update.attendanceId) - ?: throw AttendanceNotFoundException() + if (attendanceUpdates.isEmpty()) return + // 데드락 방지: 일관된 순서로 락 획득 + val ids = attendanceUpdates.map { it.attendanceId }.sorted() + val attendanceMap = attendanceRepository.findAllByIdsWithLock(ids).associateBy { it.id } + // 데드락 방지: 처리 순서도 ID 오름차순으로 통일 + attendanceUpdates.sortedBy { it.attendanceId }.forEach { update -> + val attendance = attendanceMap[update.attendanceId] ?: throw AttendanceNotFoundException() if (attendance.clubMember.club.id != clubId) throw AttendanceNotFoundException() val member = attendance.clubMember diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/entity/Attendance.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/entity/Attendance.kt index 0e991b0b..8b21d966 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/domain/entity/Attendance.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/entity/Attendance.kt @@ -31,7 +31,8 @@ class Attendance( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "attendance_id") - val id: Long = 0 + var id: Long = 0L + private set @Enumerated(EnumType.STRING) var status: AttendanceStatus = status diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt index 881a93c8..1d7cdbed 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt @@ -42,8 +42,15 @@ interface AttendanceRepository : JpaRepository { @Param("status") status: MemberStatus, ): List - @Query("SELECT a FROM Attendance a JOIN FETCH a.clubMember cm JOIN FETCH cm.user WHERE a.id = :id") - fun findByIdWithClubMember(id: Long): Attendance? + // 교착 방지: id 오름차순 정렬로 일관된 락 획득 순서 보장 + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query( + "SELECT a FROM Attendance a JOIN FETCH a.clubMember cm JOIN FETCH cm.user JOIN FETCH cm.club WHERE a.id IN :ids ORDER BY a.id ASC", + ) + fun findAllByIdsWithLock( + @Param("ids") ids: List, + ): List @Query( """ diff --git a/src/main/kotlin/com/weeth/domain/attendance/infrastructure/AttendanceScheduler.kt b/src/main/kotlin/com/weeth/domain/attendance/infrastructure/AttendanceScheduler.kt index 9890be6a..44c3a308 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/infrastructure/AttendanceScheduler.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/infrastructure/AttendanceScheduler.kt @@ -8,7 +8,7 @@ import org.springframework.stereotype.Component class AttendanceScheduler( private val manageAttendanceUseCase: ManageAttendanceUseCase, ) { - @Scheduled(cron = "0 0 22 * * THU", zone = "Asia/Seoul") + @Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul") fun autoCloseAttendance() { manageAttendanceUseCase.autoClose() } diff --git a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceAdminController.kt b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceAdminController.kt index da33d194..15a35c3e 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceAdminController.kt @@ -23,9 +23,7 @@ import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController -import java.time.LocalDate @Tag(name = "ATTENDANCE ADMIN", description = "[ADMIN] 출석 어드민 API") @RestController @@ -36,19 +34,6 @@ class AttendanceAdminController( private val getAttendanceQueryService: GetAttendanceQueryService, private val generateQrTokenUseCase: GenerateQrTokenUseCase, ) { - @PatchMapping("/close") - @Operation(summary = "출석 마감") - fun close( - @TsidParam - @TsidPathVariable clubId: Long, - @Parameter(hidden = true) @CurrentUser userId: Long, - @RequestParam now: LocalDate, - @RequestParam cardinal: Int, - ): CommonResponse { - manageAttendanceUseCase.close(clubId, userId, now, cardinal) - return CommonResponse.success(AttendanceResponseCode.ATTENDANCE_CLOSE_SUCCESS) - } - @GetMapping("/{sessionId}") @Operation(summary = "모든 인원 정기모임 출석 정보 조회") fun getAllAttendance( diff --git a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceResponseCode.kt b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceResponseCode.kt index a22bb431..f3d1932b 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceResponseCode.kt @@ -9,15 +9,14 @@ enum class AttendanceResponseCode( override val message: String, ) : ResponseCodeInterface { // AttendanceAdminController 관련 - ATTENDANCE_CLOSE_SUCCESS(10200, HttpStatus.OK, "출석이 성공적으로 마감되었습니다."), - ATTENDANCE_UPDATED_SUCCESS(10201, HttpStatus.OK, "개별 출석 상태가 성공적으로 수정되었습니다."), - ATTENDANCE_FIND_DETAIL_SUCCESS(10202, HttpStatus.OK, "모든 인원의 정기모임 출석 정보가 성공적으로 조회되었습니다."), + ATTENDANCE_UPDATED_SUCCESS(10200, HttpStatus.OK, "개별 출석 상태가 성공적으로 수정되었습니다."), + ATTENDANCE_FIND_DETAIL_SUCCESS(10201, HttpStatus.OK, "모든 인원의 정기모임 출석 정보가 성공적으로 조회되었습니다."), // AttendanceController 관련 - ATTENDANCE_CHECKIN_SUCCESS(10203, HttpStatus.OK, "출석이 성공적으로 처리되었습니다."), - ATTENDANCE_FIND_SUCCESS(10204, HttpStatus.OK, "사용자의 출석 정보가 성공적으로 조회되었습니다."), - ATTENDANCE_FIND_ALL_SUCCESS(10205, HttpStatus.OK, "사용자의 상세 출석 정보가 성공적으로 조회되었습니다."), + ATTENDANCE_CHECKIN_SUCCESS(10202, HttpStatus.OK, "출석이 성공적으로 처리되었습니다."), + ATTENDANCE_FIND_SUCCESS(10203, HttpStatus.OK, "사용자의 출석 정보가 성공적으로 조회되었습니다."), + ATTENDANCE_FIND_ALL_SUCCESS(10204, HttpStatus.OK, "사용자의 상세 출석 정보가 성공적으로 조회되었습니다."), // QR 관련 - QR_TOKEN_GENERATE_SUCCESS(10206, HttpStatus.OK, "QR 코드가 성공적으로 생성되었습니다."), + QR_TOKEN_GENERATE_SUCCESS(10205, HttpStatus.OK, "QR 코드가 성공적으로 생성되었습니다."), } diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt index ef9be869..77a6e292 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt @@ -7,6 +7,8 @@ import com.weeth.domain.cardinal.domain.entity.Cardinal import com.weeth.domain.cardinal.domain.repository.CardinalReader import com.weeth.domain.club.application.dto.request.ClubMemberApplyObRequest import com.weeth.domain.club.application.dto.request.ClubMemberRoleUpdateRequest +import com.weeth.domain.club.application.exception.ClubMemberNotFoundException +import com.weeth.domain.club.application.exception.ClubMemberNotInClubException import com.weeth.domain.club.application.exception.LeadSelfTransferException import com.weeth.domain.club.application.exception.LeadTransferOnlyException import com.weeth.domain.club.application.exception.NotLeadException @@ -14,6 +16,7 @@ import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.club.domain.entity.ClubMemberCardinal import com.weeth.domain.club.domain.enums.MemberRole import com.weeth.domain.club.domain.repository.ClubMemberCardinalRepository +import com.weeth.domain.club.domain.repository.ClubMemberReader import com.weeth.domain.club.domain.service.ClubMemberCardinalPolicy import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.club.domain.service.ClubPermissionPolicy @@ -30,6 +33,7 @@ class AdminClubMemberUseCase( private val clubPermissionPolicy: ClubPermissionPolicy, private val clubMemberCardinalPolicy: ClubMemberCardinalPolicy, private val cardinalReader: CardinalReader, + private val clubMemberReader: ClubMemberReader, private val sessionReader: SessionReader, private val attendanceRepository: AttendanceRepository, private val clubMemberCardinalRepository: ClubMemberCardinalRepository, @@ -100,10 +104,18 @@ class AdminClubMemberUseCase( val uniqueRequests = requests.distinctBy { it.clubMemberId to it.cardinal } if (uniqueRequests.isEmpty()) return + val memberIds = uniqueRequests.map { it.clubMemberId }.distinct().sorted() + val memberMap = + clubMemberReader + .findAllByIdsWithLock(memberIds) + .also { members -> + if (members.any { it.club.id != clubId }) throw ClubMemberNotInClubException() + }.associateBy { it.id } + val cardinalByNumber = mutableMapOf() uniqueRequests.forEach { request -> - val member = clubMemberPolicy.getMemberInClub(clubId, request.clubMemberId) + val member = memberMap[request.clubMemberId] ?: throw ClubMemberNotFoundException() val nextCardinal = cardinalByNumber.getOrPut(request.cardinal) { cardinalReader.findByClubIdAndCardinalNumber(clubId, request.cardinal) diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt index 80205264..1c3adfb1 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt @@ -51,7 +51,7 @@ class ManageClubMemberUsecase( /** * 초대 코드가 일치하면 자동으로 활성 상태로 가입됨 * 역할(LEAD/USER)별 가입 제한 정책 적용 - * TODO: 출석 초기화 + * 출석 초기화는 setInitialCardinals() 호출 시 처리됨 */ @Transactional fun join( diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberCardinalReader.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberCardinalReader.kt index a7acdbab..a733ce97 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberCardinalReader.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberCardinalReader.kt @@ -2,12 +2,19 @@ package com.weeth.domain.club.domain.repository import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.club.domain.entity.ClubMemberCardinal +import com.weeth.domain.club.domain.enums.MemberStatus interface ClubMemberCardinalReader { fun findAllByClubMember(clubMember: ClubMember): List fun findAllByClubMembers(clubMembers: List): List + fun findAllByClubIdAndCardinalNumber( + clubId: Long, + cardinalNumber: Int, + status: MemberStatus, + ): List + fun findLatestCardinalByClubMember(clubMember: ClubMember): ClubMemberCardinal? fun existsByClubMemberAndCardinalId( // todo: 실제 사용처에 따라 파라미터 확정 diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberCardinalRepository.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberCardinalRepository.kt index e31f6aad..861421fc 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberCardinalRepository.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberCardinalRepository.kt @@ -2,6 +2,7 @@ package com.weeth.domain.club.domain.repository import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.club.domain.entity.ClubMemberCardinal +import com.weeth.domain.club.domain.enums.MemberStatus import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param @@ -37,6 +38,25 @@ interface ClubMemberCardinalRepository : override fun findLatestCardinalByClubMember(clubMember: ClubMember): ClubMemberCardinal? = findTopByClubMemberOrderedByCardinalNumberDesc(clubMember) + @Query( + """ + SELECT cmc + FROM ClubMemberCardinal cmc + JOIN FETCH cmc.clubMember cm + JOIN FETCH cm.club + JOIN FETCH cm.user + JOIN FETCH cmc.cardinal + WHERE cm.club.id = :clubId + AND cmc.cardinal.cardinalNumber = :cardinalNumber + AND cm.memberStatus = :status + """, + ) + override fun findAllByClubIdAndCardinalNumber( + @Param("clubId") clubId: Long, + @Param("cardinalNumber") cardinalNumber: Int, + @Param("status") status: MemberStatus, + ): List + override fun existsByClubMemberAndCardinalId( clubMember: ClubMember, cardinalId: Long, diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt index 652f54e7..06f18c2e 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt @@ -7,6 +7,12 @@ import com.weeth.domain.club.domain.enums.MemberStatus interface ClubMemberReader { fun findByIdWithLock(clubMemberId: Long): ClubMember? + /** + * 비관적 쓰기 락(PESSIMISTIC_WRITE)으로 여러 ClubMember를 조회한다. + * 교착 방지를 위해 id 오름차순으로 락을 획득하며, 호출부에서도 [ids]를 정렬하여 전달해야 한다. + */ + fun findAllByIdsWithLock(ids: List): List + fun findByIdOrNull(clubMemberId: Long): ClubMember? fun findByClubIdAndUserId( diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt index 74c08de6..21881293 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt @@ -21,6 +21,13 @@ interface ClubMemberRepository : @Param("clubMemberId") clubMemberId: Long, ): ClubMember? + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query("SELECT cm FROM ClubMember cm JOIN FETCH cm.user JOIN FETCH cm.club WHERE cm.id IN :ids ORDER BY cm.id ASC") + override fun findAllByIdsWithLock( + @Param("ids") ids: List, + ): List + override fun findAllByClubIdAndMemberStatus( clubId: Long, memberStatus: MemberStatus, diff --git a/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt index baba4929..489ca4d8 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubMemberPolicy.kt @@ -22,32 +22,29 @@ class ClubMemberPolicy( fun getActiveMember( clubId: Long, userId: Long, - ): ClubMember { - val member = - clubMemberReader.findByClubIdAndUserId(clubId, userId) - ?: throw ClubMemberNotFoundException() - if (!member.isActive()) throw MemberNotActiveException() - return member - } + ): ClubMember = resolveActiveMember { clubMemberReader.findByClubIdAndUserId(clubId, userId) } fun getActiveMemberWithLock( clubId: Long, userId: Long, - ): ClubMember { - val member = - clubMemberReader.findByClubIdAndUserIdWithLock(clubId, userId) - ?: throw ClubMemberNotFoundException() + ): ClubMember = resolveActiveMember { clubMemberReader.findByClubIdAndUserIdWithLock(clubId, userId) } + + fun getMemberInClub( + clubId: Long, + clubMemberId: Long, + ): ClubMember = resolveMemberInClub(clubId) { clubMemberReader.findByIdOrNull(clubMemberId) } + + private fun resolveActiveMember(reader: () -> ClubMember?): ClubMember { + val member = reader() ?: throw ClubMemberNotFoundException() if (!member.isActive()) throw MemberNotActiveException() return member } - fun getMemberInClub( + private fun resolveMemberInClub( clubId: Long, - clubMemberId: Long, + reader: () -> ClubMember?, ): ClubMember { - val member = - clubMemberReader.findByIdOrNull(clubMemberId) - ?: throw ClubMemberNotFoundException() + val member = reader() ?: throw ClubMemberNotFoundException() if (member.club.id != clubId) throw ClubMemberNotInClubException() return member } diff --git a/src/main/kotlin/com/weeth/domain/session/application/usecase/command/ManageSessionUseCase.kt b/src/main/kotlin/com/weeth/domain/session/application/usecase/command/ManageSessionUseCase.kt index ca5850f0..e883cb47 100644 --- a/src/main/kotlin/com/weeth/domain/session/application/usecase/command/ManageSessionUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/session/application/usecase/command/ManageSessionUseCase.kt @@ -3,9 +3,10 @@ package com.weeth.domain.session.application.usecase.command import com.weeth.domain.attendance.domain.entity.Attendance import com.weeth.domain.attendance.domain.enums.AttendanceStatus import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException import com.weeth.domain.cardinal.domain.repository.CardinalReader import com.weeth.domain.club.domain.enums.MemberStatus -import com.weeth.domain.club.domain.repository.ClubMemberReader +import com.weeth.domain.club.domain.repository.ClubMemberCardinalReader import com.weeth.domain.club.domain.repository.ClubReader import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.schedule.application.dto.request.ScheduleSaveRequest @@ -17,9 +18,6 @@ import com.weeth.domain.user.domain.repository.UserReader import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -/** - * TODO: 출석 생성/삭제 관련해서 출석 초기화 로직과 함께 엣지 케이스나 개선 사항 점검 (join, applyOb) - */ @Service class ManageSessionUseCase( private val sessionRepository: SessionRepository, @@ -28,7 +26,7 @@ class ManageSessionUseCase( private val cardinalReader: CardinalReader, private val sessionMapper: SessionMapper, private val clubReader: ClubReader, - private val clubMemberReader: ClubMemberReader, + private val clubMemberCardinalReader: ClubMemberCardinalReader, private val clubPermissionPolicy: ClubPermissionPolicy, ) { @Transactional @@ -40,14 +38,16 @@ class ManageSessionUseCase( clubPermissionPolicy.requireAdmin(clubId, userId) val club = clubReader.getClubById(clubId) val user = userReader.getById(userId) - cardinalReader.findByClubIdAndCardinalNumber(clubId, request.cardinal) ?: throw SessionNotFoundException() - // TODO: 현재는 동아리 전체 ACTIVE 멤버에게 출석을 만든다. clubMemberCardinal 기준으로 좁히지 않으면 applyOb 초기화와 중복될 수 있다. - val clubMembers = clubMemberReader.findAllByClubIdAndMemberStatus(clubId, MemberStatus.ACTIVE) + cardinalReader.findByClubIdAndCardinalNumber(clubId, request.cardinal) ?: throw CardinalNotFoundException() + val membersWithCardinal = + clubMemberCardinalReader + .findAllByClubIdAndCardinalNumber(clubId, request.cardinal, MemberStatus.ACTIVE) + .map { it.clubMember } val session = sessionMapper.toEntity(club, request, user) sessionRepository.save(session) - attendanceRepository.saveAll(clubMembers.map { Attendance.create(session, it) }) + attendanceRepository.saveAll(membersWithCardinal.map { Attendance.create(session, it) }) } @Transactional diff --git a/src/main/kotlin/com/weeth/domain/session/domain/entity/Session.kt b/src/main/kotlin/com/weeth/domain/session/domain/entity/Session.kt index a258ab4b..f51a6662 100644 --- a/src/main/kotlin/com/weeth/domain/session/domain/entity/Session.kt +++ b/src/main/kotlin/com/weeth/domain/session/domain/entity/Session.kt @@ -23,19 +23,15 @@ import kotlin.random.asKotlinRandom @Table(name = "meeting") // 테이블명 Session으로 수정 class Session( club: Club, - var title: String, - @Column(length = 500) - var content: String? = null, - var location: String? = null, - var cardinal: Int, - var start: LocalDateTime, - var end: LocalDateTime, - var code: Int, - @Enumerated(EnumType.STRING) - var status: SessionStatus = SessionStatus.OPEN, - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id") - var user: User? = null, + title: String, + content: String? = null, + location: String? = null, + cardinal: Int, + start: LocalDateTime, + end: LocalDateTime, + code: Int, + status: SessionStatus = SessionStatus.OPEN, + user: User? = null, ) : BaseEntity() { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "club_id", nullable = false) @@ -44,7 +40,39 @@ class Session( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - val id: Long = 0 + var id: Long = 0L + private set + + var title: String = title + private set + + @Column(length = 500) + var content: String? = content + private set + + var location: String? = location + private set + + var cardinal: Int = cardinal + private set + + var start: LocalDateTime = start + private set + + var end: LocalDateTime = end + private set + + var code: Int = code + private set + + @Enumerated(EnumType.STRING) + var status: SessionStatus = status + private set + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + var user: User? = user + private set fun close() { check(status == SessionStatus.OPEN) { "이미 종료된 세션입니다" } diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCaseTest.kt index 03aecf1a..9cd46bb5 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCaseTest.kt @@ -2,12 +2,18 @@ package com.weeth.domain.attendance.application.usecase.command import com.weeth.domain.attendance.application.dto.request.UpdateAttendanceStatusRequest import com.weeth.domain.attendance.application.exception.AlreadyAttendedException +import com.weeth.domain.attendance.application.exception.AttendanceAlreadyClosedException import com.weeth.domain.attendance.application.exception.AttendanceNotFoundException +import com.weeth.domain.attendance.domain.enums.AttendanceStatus import com.weeth.domain.attendance.domain.port.QrAttendancePort import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.attendance.fixture.AttendanceTestFixture.createAttendance +import com.weeth.domain.attendance.fixture.AttendanceTestFixture.setAttendanceId +import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.club.fixture.ClubMemberTestFixture +import com.weeth.domain.session.domain.entity.Session import com.weeth.domain.session.domain.repository.SessionReader import com.weeth.domain.session.fixture.SessionTestFixture import io.kotest.assertions.throwables.shouldThrow @@ -39,19 +45,22 @@ class ManageAttendanceUseCaseTest : } describe("checkIn") { - val clubMember = ClubMemberTestFixture.createActiveMember() - val session = - SessionTestFixture.createInProgressSession( - cardinal = 1, - code = 123456, - title = "Test Session", - club = clubMember.club, - ) - val attendance = - com.weeth.domain.attendance.domain.entity.Attendance - .create(session, clubMember) + lateinit var clubMember: ClubMember + lateinit var session: Session + + beforeTest { + clubMember = ClubMemberTestFixture.createActiveMember() + session = + SessionTestFixture.createInProgressSession( + cardinal = 1, + code = 123456, + title = "Test Session", + club = clubMember.club, + ) + } it("정상 체크인 시 출석 상태와 멤버 통계를 갱신한다") { + val attendance = createAttendance(session, clubMember) every { qrAttendancePort.getCode(session.id) } returns session.code every { sessionReader.getById(session.id) } returns session every { clubMemberPolicy.getActiveMember(clubMember.club.id, clubMember.user.id) } returns clubMember @@ -60,19 +69,12 @@ class ManageAttendanceUseCaseTest : useCase.checkIn(clubMember.club.id, clubMember.user.id, session.id, session.code) - attendance.status shouldBe com.weeth.domain.attendance.domain.enums.AttendanceStatus.ATTEND + attendance.status shouldBe AttendanceStatus.ATTEND clubMember.attendanceStats.attendanceCount shouldBe 1 } it("이미 출석 처리된 경우 예외를 던진다") { - val attendedAttendance = - com.weeth.domain.attendance.domain.entity.Attendance - .create( - session, - clubMember, - ).also { - it.attend() - } + val attendedAttendance = createAttendance(session, clubMember).also { it.attend() } every { qrAttendancePort.getCode(session.id) } returns session.code every { sessionReader.getById(session.id) } returns session every { clubMemberPolicy.getActiveMember(clubMember.club.id, clubMember.user.id) } returns clubMember @@ -84,6 +86,19 @@ class ManageAttendanceUseCaseTest : } } + it("세션이 이미 마감된 경우(ABSENT) 예외를 던진다") { + val absentAttendance = createAttendance(session, clubMember).also { it.absent() } + every { qrAttendancePort.getCode(session.id) } returns session.code + every { sessionReader.getById(session.id) } returns session + every { clubMemberPolicy.getActiveMember(clubMember.club.id, clubMember.user.id) } returns clubMember + every { attendanceRepository.findBySessionAndClubMemberWithLock(session, clubMember) } returns + absentAttendance + + shouldThrow { + useCase.checkIn(clubMember.club.id, clubMember.user.id, session.id, session.code) + } + } + it("출석 레코드가 없으면 예외를 던진다") { every { qrAttendancePort.getCode(session.id) } returns session.code every { sessionReader.getById(session.id) } returns session @@ -101,17 +116,15 @@ class ManageAttendanceUseCaseTest : val admin = ClubMemberTestFixture.createAdminMember() val member = ClubMemberTestFixture.createActiveMember(club = admin.club) val attendance = - com.weeth.domain.attendance.domain.entity.Attendance.create( - SessionTestFixture.createSession(club = admin.club), - member, - ) + createAttendance(SessionTestFixture.createSession(club = admin.club), member) + .also { setAttendanceId(it, 1L) } every { clubPermissionPolicy.requireAdmin(admin.club.id, admin.user.id) } returns admin - every { attendanceRepository.findByIdWithClubMember(1L) } returns attendance + every { attendanceRepository.findAllByIdsWithLock(listOf(1L)) } returns listOf(attendance) useCase.updateStatus(admin.club.id, admin.user.id, listOf(UpdateAttendanceStatusRequest(1L, "ATTEND"))) - attendance.status shouldBe com.weeth.domain.attendance.domain.enums.AttendanceStatus.ATTEND + attendance.status shouldBe AttendanceStatus.ATTEND member.attendanceStats.attendanceCount shouldBe 1 } @@ -119,19 +132,17 @@ class ManageAttendanceUseCaseTest : val admin = ClubMemberTestFixture.createAdminMember() val member = ClubMemberTestFixture.createActiveMember(club = admin.club) val attendance = - com.weeth.domain.attendance.domain.entity.Attendance.create( - SessionTestFixture.createSession(club = admin.club), - member, - ) + createAttendance(SessionTestFixture.createSession(club = admin.club), member) + .also { setAttendanceId(it, 1L) } attendance.attend() member.attend() every { clubPermissionPolicy.requireAdmin(admin.club.id, admin.user.id) } returns admin - every { attendanceRepository.findByIdWithClubMember(1L) } returns attendance + every { attendanceRepository.findAllByIdsWithLock(listOf(1L)) } returns listOf(attendance) useCase.updateStatus(admin.club.id, admin.user.id, listOf(UpdateAttendanceStatusRequest(1L, "PENDING"))) - attendance.status shouldBe com.weeth.domain.attendance.domain.enums.AttendanceStatus.PENDING + attendance.status shouldBe AttendanceStatus.PENDING member.attendanceStats.attendanceCount shouldBe 0 } } diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCaseTest.kt index fa352ebb..d2ed0a60 100644 --- a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCaseTest.kt @@ -14,6 +14,7 @@ import com.weeth.domain.club.application.exception.NotLeadException import com.weeth.domain.club.domain.enums.MemberRole import com.weeth.domain.club.domain.enums.MemberStatus import com.weeth.domain.club.domain.repository.ClubMemberCardinalRepository +import com.weeth.domain.club.domain.repository.ClubMemberReader import com.weeth.domain.club.domain.service.ClubMemberCardinalPolicy import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.club.domain.service.ClubPermissionPolicy @@ -36,6 +37,7 @@ class AdminClubMemberUseCaseTest : val clubPermissionPolicy = mockk() val clubMemberCardinalPolicy = mockk(relaxed = true) val cardinalReader = mockk(relaxed = true) + val clubMemberReader = mockk(relaxed = true) val sessionReader = mockk(relaxed = true) val attendanceRepository = mockk(relaxed = true) val clubMemberCardinalRepository = mockk(relaxed = true) @@ -45,11 +47,13 @@ class AdminClubMemberUseCaseTest : clubPermissionPolicy, clubMemberCardinalPolicy, cardinalReader, + clubMemberReader, sessionReader, attendanceRepository, clubMemberCardinalRepository, ) - val adminMember = ClubMemberTestFixture.createAdminMember() + val club = ClubTestFixture.createClub(id = 1L) + val adminMember = ClubMemberTestFixture.createAdminMember(club = club) beforeTest { clearMocks( @@ -57,6 +61,7 @@ class AdminClubMemberUseCaseTest : clubPermissionPolicy, clubMemberCardinalPolicy, cardinalReader, + clubMemberReader, sessionReader, attendanceRepository, clubMemberCardinalRepository, @@ -211,7 +216,7 @@ class AdminClubMemberUseCaseTest : describe("applyOb") { it("새 기수를 정상 등록한다") { - val member = ClubMemberTestFixture.createActiveMember(club = adminMember.club) + val member = ClubMemberTestFixture.createActiveMember(id = 20L, club = adminMember.club) val cardinal = CardinalTestFixture.createCardinal( id = 1L, @@ -222,7 +227,7 @@ class AdminClubMemberUseCaseTest : ) val session = SessionTestFixture.createSession(club = adminMember.club, cardinal = 8) every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember - every { clubMemberPolicy.getMemberInClub(1L, 20L) } returns member + every { clubMemberReader.findAllByIdsWithLock(listOf(20L)) } returns listOf(member) every { cardinalReader.findByClubIdAndCardinalNumber(1L, 8) } returns cardinal every { clubMemberCardinalPolicy.notContains(member, cardinal) } returns true every { clubMemberCardinalPolicy.isLatestOrFirstCardinal(member, cardinal) } returns true @@ -237,7 +242,7 @@ class AdminClubMemberUseCaseTest : } it("이미 등록된 기수는 무시한다") { - val member = ClubMemberTestFixture.createActiveMember(club = adminMember.club) + val member = ClubMemberTestFixture.createActiveMember(id = 20L, club = adminMember.club) val cardinal = CardinalTestFixture.createCardinal( id = 1L, @@ -247,7 +252,7 @@ class AdminClubMemberUseCaseTest : semester = 1, ) every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember - every { clubMemberPolicy.getMemberInClub(1L, 20L) } returns member + every { clubMemberReader.findAllByIdsWithLock(listOf(20L)) } returns listOf(member) every { cardinalReader.findByClubIdAndCardinalNumber(1L, 8) } returns cardinal every { clubMemberCardinalPolicy.notContains(member, cardinal) } returns false @@ -261,7 +266,7 @@ class AdminClubMemberUseCaseTest : it("동일한 요청이 중복으로 전달되면 1회만 처리한다") { val session = SessionTestFixture.createSession(club = adminMember.club, cardinal = 8) - val member = ClubMemberTestFixture.createActiveMember(club = adminMember.club) + val member = ClubMemberTestFixture.createActiveMember(id = 20L, club = adminMember.club) val cardinal = CardinalTestFixture.createCardinal( id = 1L, @@ -271,7 +276,7 @@ class AdminClubMemberUseCaseTest : semester = 1, ) every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember - every { clubMemberPolicy.getMemberInClub(1L, 20L) } returns member + every { clubMemberReader.findAllByIdsWithLock(listOf(20L)) } returns listOf(member) every { cardinalReader.findByClubIdAndCardinalNumber(1L, 8) } returns cardinal every { clubMemberCardinalPolicy.notContains(member, cardinal) } returns true every { clubMemberCardinalPolicy.isLatestOrFirstCardinal(member, cardinal) } returns true @@ -286,9 +291,9 @@ class AdminClubMemberUseCaseTest : } it("존재하지 않는 기수면 예외가 발생한다") { - val member = ClubMemberTestFixture.createActiveMember(club = adminMember.club) + val member = ClubMemberTestFixture.createActiveMember(id = 20L, club = adminMember.club) every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember - every { clubMemberPolicy.getMemberInClub(1L, 20L) } returns member + every { clubMemberReader.findAllByIdsWithLock(listOf(20L)) } returns listOf(member) every { cardinalReader.findByClubIdAndCardinalNumber(1L, 8) } returns null shouldThrow { @@ -296,8 +301,18 @@ class AdminClubMemberUseCaseTest : } } + it("다른 클럽 소속 멤버 ID가 포함된 경우 예외가 발생한다") { + val otherClubMember = ClubMemberTestFixture.createActiveMember(id = 20L) + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubMemberReader.findAllByIdsWithLock(listOf(20L)) } returns listOf(otherClubMember) + + shouldThrow { + useCase.applyOb(1L, 10L, listOf(ClubMemberApplyObRequest(20L, 8))) + } + } + it("현재 기수 등록 시 출석 통계를 초기화한다") { - val member = ClubMemberTestFixture.createActiveMember(club = adminMember.club) + val member = ClubMemberTestFixture.createActiveMember(id = 20L, club = adminMember.club) val cardinal = CardinalTestFixture.createCardinal( id = 1L, @@ -309,7 +324,7 @@ class AdminClubMemberUseCaseTest : repeat(2) { member.attend() } repeat(1) { member.absent() } every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember - every { clubMemberPolicy.getMemberInClub(1L, 20L) } returns member + every { clubMemberReader.findAllByIdsWithLock(listOf(20L)) } returns listOf(member) every { cardinalReader.findByClubIdAndCardinalNumber(1L, 8) } returns cardinal every { clubMemberCardinalPolicy.notContains(member, cardinal) } returns true every { clubMemberCardinalPolicy.isLatestOrFirstCardinal(member, cardinal) } returns true diff --git a/src/test/kotlin/com/weeth/domain/club/fixture/ClubTestFixture.kt b/src/test/kotlin/com/weeth/domain/club/fixture/ClubTestFixture.kt index 57f8951c..b3e96ed8 100644 --- a/src/test/kotlin/com/weeth/domain/club/fixture/ClubTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/club/fixture/ClubTestFixture.kt @@ -11,6 +11,7 @@ import org.springframework.test.util.ReflectionTestUtils object ClubTestFixture { fun createClub( + id: Long = 0L, name: String = "테스트 동아리", code: String = "TEST001", description: String? = "테스트 동아리 소개", @@ -30,6 +31,7 @@ object ClubTestFixture { schoolName = schoolName, clubContact = clubContact, ) + if (id != 0L) ReflectionTestUtils.setField(club, "id", id) return club } From e522a4bddd83b662937646b5fb048a497a044eb6 Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Mon, 23 Mar 2026 19:55:06 +0900 Subject: [PATCH 34/73] =?UTF-8?q?[WTH-205]=20=EB=82=B4=20=EB=8F=99?= =?UTF-8?q?=EC=95=84=EB=A6=AC=20=ED=99=9C=EB=8F=99=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20api=20=EC=B6=94=EA=B0=80=20(#35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 컨트롤러 분리 * refactor: dto에 role, status 정보 추가 * refactor: 가입 동아리 여부, 반환 메서드 추가 * refactor: 조회 메서드 책임 이전 * refactor: 응답코드 수정 * refactor: 매퍼로 위임 --- .../dto/response/ClubMemberProfileResponse.kt | 4 + .../response/ClubMemberSummaryResponse.kt} | 10 +- .../response/ClubMembershipStatusResponse.kt | 14 ++ .../club/application/mapper/ClubMapper.kt | 27 ++++ .../query/GetClubMemberQueryService.kt | 11 ++ .../usecase/query/GetClubQueryService.kt | 11 +- .../domain/repository/ClubMemberReader.kt | 2 + .../domain/repository/ClubMemberRepository.kt | 12 ++ .../club/presentation/ClubController.kt | 87 +------------ .../club/presentation/ClubMemberController.kt | 121 ++++++++++++++++++ .../club/presentation/ClubResponseCode.kt | 2 + .../dto/response/UserProfileResponse.kt | 27 ---- .../user/application/mapper/UserMapper.kt | 40 ------ .../usecase/query/GetUserQueryService.kt | 44 ------- .../user/presentation/UserController.kt | 20 --- .../user/presentation/UserResponseCode.kt | 17 +-- 16 files changed, 217 insertions(+), 232 deletions(-) rename src/main/kotlin/com/weeth/domain/{user/application/dto/response/UserSummaryResponse.kt => club/application/dto/response/ClubMemberSummaryResponse.kt} (72%) create mode 100644 src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMembershipStatusResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/presentation/ClubMemberController.kt delete mode 100644 src/main/kotlin/com/weeth/domain/user/application/dto/response/UserProfileResponse.kt delete mode 100644 src/main/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryService.kt diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberProfileResponse.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberProfileResponse.kt index b7432d6f..2e729dbe 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberProfileResponse.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberProfileResponse.kt @@ -26,6 +26,10 @@ data class ClubMemberProfileResponse( val studentId: String, @field:Schema(description = "소속 기수 목록", example = "[6, 7]") val cardinals: List, + @field:Schema(description = "멤버 권한", example = "USER") + val memberRole: MemberRole, + @field:Schema(description = "멤버 상태", example = "ACTIVE") + val memberStatus: MemberStatus, @field:Schema(description = "동아리 프로필 이미지 URL", example = "https://cdn.example.com/profile.jpg") val profileImageUrl: String?, @field:Schema(description = "자기소개") diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserSummaryResponse.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberSummaryResponse.kt similarity index 72% rename from src/main/kotlin/com/weeth/domain/user/application/dto/response/UserSummaryResponse.kt rename to src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberSummaryResponse.kt index 9e8fe4f6..b17ee4b8 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserSummaryResponse.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberSummaryResponse.kt @@ -1,15 +1,15 @@ -package com.weeth.domain.user.application.dto.response +package com.weeth.domain.club.application.dto.response import com.weeth.domain.club.domain.enums.MemberRole import io.swagger.v3.oas.annotations.media.Schema -data class UserSummaryResponse( +data class ClubMemberSummaryResponse( @field:Schema(description = "사용자 ID", example = "1") - val id: Long, + val userId: Long, @field:Schema(description = "이름", example = "홍길동") val name: String, @field:Schema(description = "소속 기수 목록", example = "[6, 7]") val cardinals: List, - @field:Schema(description = "동아리 내 권한", example = "USER", nullable = true) - val role: MemberRole?, + @field:Schema(description = "동아리 내 권한", example = "USER") + val role: MemberRole, ) diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMembershipStatusResponse.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMembershipStatusResponse.kt new file mode 100644 index 00000000..4ff9880f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMembershipStatusResponse.kt @@ -0,0 +1,14 @@ +package com.weeth.domain.club.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class ClubMembershipStatusResponse( + @field:Schema(description = "ACTIVE 상태 동아리 존재 여부", example = "true") + val hasActiveClub: Boolean, + @field:Schema(description = "WAITING 상태 동아리 존재 여부", example = "false") + val hasWaitingClub: Boolean, + @field:Schema(description = "ACTIVE 동아리 정보 (없으면 null)") + val activeClub: ClubInfoResponse?, + @field:Schema(description = "WAITING 동아리 정보 (없으면 null)") + val waitingClub: ClubInfoResponse?, +) diff --git a/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt b/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt index 8ba8055c..857b7059 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt @@ -4,10 +4,13 @@ import com.weeth.domain.club.application.dto.response.ClubDetailResponse import com.weeth.domain.club.application.dto.response.ClubInfoResponse import com.weeth.domain.club.application.dto.response.ClubMemberProfileResponse import com.weeth.domain.club.application.dto.response.ClubMemberResponse +import com.weeth.domain.club.application.dto.response.ClubMemberSummaryResponse +import com.weeth.domain.club.application.dto.response.ClubMembershipStatusResponse import com.weeth.domain.club.application.dto.response.ClubPublicResponse import com.weeth.domain.club.domain.entity.Club import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.club.domain.entity.ClubMemberCardinal +import com.weeth.domain.club.domain.enums.MemberStatus import com.weeth.domain.file.domain.port.FileAccessUrlPort import com.weeth.global.common.id.TsidBase62Encoder import org.springframework.stereotype.Component @@ -84,10 +87,34 @@ class ClubMapper( department = member.user.department, studentId = member.user.studentId, cardinals = toCardinalNumbers(cardinals), + memberRole = member.memberRole, + memberStatus = member.memberStatus, profileImageUrl = member.profileImageStorageKey?.let { fileAccessUrlPort.resolve(it) }, bio = member.bio, ) + fun toMemberSummaryResponse( + member: ClubMember, + cardinals: List, + ) = ClubMemberSummaryResponse( + userId = member.user.id, + name = member.user.name, + cardinals = toCardinalNumbers(cardinals), + role = member.memberRole, + ) + + fun toMembershipStatusResponse(members: List): ClubMembershipStatusResponse { + val activeMember = members.firstOrNull { it.memberStatus == MemberStatus.ACTIVE } + val waitingMember = members.firstOrNull { it.memberStatus == MemberStatus.WAITING } + + return ClubMembershipStatusResponse( + hasActiveClub = activeMember != null, + hasWaitingClub = waitingMember != null, + activeClub = activeMember?.let { toInfoResponse(it.club, it) }, + waitingClub = waitingMember?.let { toInfoResponse(it.club, it) }, + ) + } + private fun resolveClubImage(storageKey: String?): String? = storageKey?.let { fileAccessUrlPort.resolve(it) } private fun toCardinalNumbers(cardinals: List): List { diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryService.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryService.kt index b16d7eb1..f5f7dbe9 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryService.kt @@ -2,6 +2,7 @@ package com.weeth.domain.club.application.usecase.query import com.weeth.domain.club.application.dto.response.ClubMemberProfileResponse import com.weeth.domain.club.application.dto.response.ClubMemberResponse +import com.weeth.domain.club.application.dto.response.ClubMemberSummaryResponse import com.weeth.domain.club.application.mapper.ClubMapper import com.weeth.domain.club.domain.repository.ClubMemberCardinalReader import com.weeth.domain.club.domain.repository.ClubMemberReader @@ -48,4 +49,14 @@ class GetClubMemberQueryService( return clubMapper.toMemberProfileResponse(member, cardinals) } + + fun findMySummary( + clubId: Long, + userId: Long, + ): ClubMemberSummaryResponse { + val member = clubMemberPolicy.getActiveMember(clubId, userId) + val cardinals = clubMemberCardinalReader.findAllByClubMember(member) + + return clubMapper.toMemberSummaryResponse(member, cardinals) + } } diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubQueryService.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubQueryService.kt index b9c86ecd..4116d059 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubQueryService.kt @@ -2,6 +2,7 @@ package com.weeth.domain.club.application.usecase.query import com.weeth.domain.club.application.dto.response.ClubDetailResponse import com.weeth.domain.club.application.dto.response.ClubInfoResponse +import com.weeth.domain.club.application.dto.response.ClubMembershipStatusResponse import com.weeth.domain.club.application.dto.response.ClubPublicResponse import com.weeth.domain.club.application.mapper.ClubMapper import com.weeth.domain.club.domain.repository.ClubMemberReader @@ -19,11 +20,10 @@ class GetClubQueryService( private val clubMapper: ClubMapper, ) { fun findMyClubs(userId: Long): List { - val members = clubMemberReader.findAllByUserId(userId) + val members = clubMemberReader.findAllByUserIdWithClub(userId) return members.map { member -> - val club = clubReader.getClubById(member.club.id) - clubMapper.toInfoResponse(club, member) + clubMapper.toInfoResponse(member.club, member) } } @@ -42,4 +42,9 @@ class GetClubQueryService( return clubMapper.toDetailResponse(club) } + + fun findMembershipStatus(userId: Long): ClubMembershipStatusResponse { + val members = clubMemberReader.findAllByUserIdWithClub(userId) + return clubMapper.toMembershipStatusResponse(members) + } } diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt index 06f18c2e..29b1f853 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt @@ -29,6 +29,8 @@ interface ClubMemberReader { fun findAllByUserId(userId: Long): List + fun findAllByUserIdWithClub(userId: Long): List + fun findActiveByUserId(userId: Long): List fun countActiveByClubId(clubId: Long): Long diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt index 21881293..3a75ed05 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt @@ -62,6 +62,18 @@ interface ClubMemberRepository : override fun findAllByUserId(userId: Long): List + @Query( + """ + SELECT cm + FROM ClubMember cm + JOIN FETCH cm.club + WHERE cm.user.id = :userId + """, + ) + override fun findAllByUserIdWithClub( + @Param("userId") userId: Long, + ): List + @Query( """ SELECT cm diff --git a/src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt b/src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt index fe9f6fb4..4c542f68 100644 --- a/src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt +++ b/src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt @@ -1,16 +1,11 @@ package com.weeth.domain.club.presentation import com.weeth.domain.club.application.dto.request.ClubCreateRequest -import com.weeth.domain.club.application.dto.request.ClubJoinRequest -import com.weeth.domain.club.application.dto.request.ClubMemberCardinalSetRequest -import com.weeth.domain.club.application.dto.request.UpdateMemberProfileRequest import com.weeth.domain.club.application.dto.response.ClubInfoResponse -import com.weeth.domain.club.application.dto.response.ClubMemberProfileResponse +import com.weeth.domain.club.application.dto.response.ClubMembershipStatusResponse import com.weeth.domain.club.application.dto.response.ClubPublicResponse import com.weeth.domain.club.application.exception.ClubErrorCode -import com.weeth.domain.club.application.usecase.command.ManageClubMemberUsecase import com.weeth.domain.club.application.usecase.command.ManageClubUseCase -import com.weeth.domain.club.application.usecase.query.GetClubMemberQueryService import com.weeth.domain.club.application.usecase.query.GetClubQueryService import com.weeth.global.auth.annotation.CurrentUser import com.weeth.global.common.exception.ApiErrorCodeExample @@ -22,9 +17,7 @@ import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.Valid import org.springframework.http.HttpStatus -import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PatchMapping import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping @@ -37,9 +30,7 @@ import org.springframework.web.bind.annotation.RestController @ApiErrorCodeExample(ClubErrorCode::class) class ClubController( private val manageClubUseCase: ManageClubUseCase, - private val manageClubMemberUsecase: ManageClubMemberUsecase, private val getClubQueryService: GetClubQueryService, - private val getClubMemberQueryService: GetClubMemberQueryService, ) { @PostMapping @Operation(summary = "동아리 생성") @@ -74,77 +65,13 @@ class ClubController( return CommonResponse.success(ClubResponseCode.CLUB_FIND_SUCCESS, info) } - @PostMapping("/{clubId}/join") - @Operation(summary = "동아리 가입") - fun join( + @GetMapping("/membership-status") + @Operation(summary = "동아리 가입 여부 조회") + fun getMembershipStatus( @Parameter(hidden = true) @CurrentUser userId: Long, - @TsidParam - @TsidPathVariable clubId: Long, - @Valid @RequestBody request: ClubJoinRequest, - ): CommonResponse { - manageClubMemberUsecase.join(clubId, userId, request) + ): CommonResponse { + val status = getClubQueryService.findMembershipStatus(userId) - return CommonResponse.success(ClubResponseCode.CLUB_JOINED_SUCCESS) + return CommonResponse.success(ClubResponseCode.MEMBERSHIP_STATUS_FIND_SUCCESS, status) } - - @DeleteMapping("/{clubId}/leave") - @Operation(summary = "동아리 탈퇴") - fun leave( - @Parameter(hidden = true) @CurrentUser userId: Long, - @TsidParam - @TsidPathVariable clubId: Long, - ): CommonResponse { - manageClubMemberUsecase.leave(clubId, userId) - - return CommonResponse.success(ClubResponseCode.CLUB_LEFT_SUCCESS) - } - - @GetMapping("/{clubId}/members/me") - @Operation(summary = "내 멤버 정보 조회") - fun getMyMemberInfo( - @Parameter(hidden = true) @CurrentUser userId: Long, - @TsidParam - @TsidPathVariable clubId: Long, - ): CommonResponse { - val meInfo = getClubMemberQueryService.findMyMemberProfile(clubId, userId) - - return CommonResponse.success(ClubResponseCode.MEMBER_FIND_ME_SUCCESS, meInfo) - } - - // TODO: 추후 동아리별 프로필 수정으로 변경 시 clubId 경로 변수 추가 및 단일 ClubMember만 수정하도록 변경 - @PatchMapping("/members/me") - @Operation(summary = "내 클럽 활동 프로필 수정 (프로필 사진, 자기소개)") - fun updateMyProfile( - @Parameter(hidden = true) @CurrentUser userId: Long, - @Valid @RequestBody request: UpdateMemberProfileRequest, - ): CommonResponse { - manageClubMemberUsecase.updateProfile(userId, request) - return CommonResponse.success(ClubResponseCode.MEMBER_PROFILE_UPDATED_SUCCESS) - } - - // TODO: 추후 동아리별 프로필 수정으로 변경 시 clubId 경로 변수 추가 및 단일 ClubMember만 수정하도록 변경 - @DeleteMapping("/members/me/profile-image") - @Operation(summary = "동아리 프로필 사진 삭제") - fun deleteMyProfileImage( - @Parameter(hidden = true) @CurrentUser userId: Long, - ): CommonResponse { - manageClubMemberUsecase.deleteProfileImage(userId) - return CommonResponse.success(ClubResponseCode.MEMBER_PROFILE_IMAGE_DELETED_SUCCESS) - } - - @PostMapping("/{clubId}/members/me/cardinals") - @Operation(summary = "활동 기수 최초 설정 (최초 1회만 가능)") - @ResponseStatus(HttpStatus.CREATED) - fun setInitialCardinals( - @TsidParam - @TsidPathVariable clubId: Long, - @Parameter(hidden = true) @CurrentUser userId: Long, - @Valid @RequestBody request: ClubMemberCardinalSetRequest, - ): CommonResponse { - manageClubMemberUsecase.setInitialCardinals(clubId, userId, request) - - return CommonResponse.success(ClubResponseCode.MEMBER_CARDINAL_SET_SUCCESS) - } - - // TODO: MVP 후 동아리 멤버 조회 기능 구현 } diff --git a/src/main/kotlin/com/weeth/domain/club/presentation/ClubMemberController.kt b/src/main/kotlin/com/weeth/domain/club/presentation/ClubMemberController.kt new file mode 100644 index 00000000..11b530e2 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/presentation/ClubMemberController.kt @@ -0,0 +1,121 @@ +package com.weeth.domain.club.presentation + +import com.weeth.domain.club.application.dto.request.ClubJoinRequest +import com.weeth.domain.club.application.dto.request.ClubMemberCardinalSetRequest +import com.weeth.domain.club.application.dto.request.UpdateMemberProfileRequest +import com.weeth.domain.club.application.dto.response.ClubMemberProfileResponse +import com.weeth.domain.club.application.dto.response.ClubMemberSummaryResponse +import com.weeth.domain.club.application.exception.ClubErrorCode +import com.weeth.domain.club.application.usecase.command.ManageClubMemberUsecase +import com.weeth.domain.club.application.usecase.query.GetClubMemberQueryService +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "CLUB MEMBER", description = "동아리 멤버 API") +@RestController +@RequestMapping("/api/v4/clubs") +@ApiErrorCodeExample(ClubErrorCode::class) +class ClubMemberController( + private val manageClubMemberUsecase: ManageClubMemberUsecase, + private val getClubMemberQueryService: GetClubMemberQueryService, +) { + @PostMapping("/{clubId}/join") + @Operation(summary = "동아리 가입") + fun join( + @Parameter(hidden = true) @CurrentUser userId: Long, + @TsidParam + @TsidPathVariable clubId: Long, + @Valid @RequestBody request: ClubJoinRequest, + ): CommonResponse { + manageClubMemberUsecase.join(clubId, userId, request) + + return CommonResponse.success(ClubResponseCode.CLUB_JOINED_SUCCESS) + } + + @DeleteMapping("/{clubId}/leave") + @Operation(summary = "동아리 탈퇴") + fun leave( + @Parameter(hidden = true) @CurrentUser userId: Long, + @TsidParam + @TsidPathVariable clubId: Long, + ): CommonResponse { + manageClubMemberUsecase.leave(clubId, userId) + + return CommonResponse.success(ClubResponseCode.CLUB_LEFT_SUCCESS) + } + + @GetMapping("/{clubId}/members/me") + @Operation(summary = "내 멤버 정보 조회") + fun getMyMemberInfo( + @Parameter(hidden = true) @CurrentUser userId: Long, + @TsidParam + @TsidPathVariable clubId: Long, + ): CommonResponse { + val meInfo = getClubMemberQueryService.findMyMemberProfile(clubId, userId) + + return CommonResponse.success(ClubResponseCode.MEMBER_FIND_ME_SUCCESS, meInfo) + } + + @GetMapping("/{clubId}/members/me/summary") + @Operation(summary = "내 동아리 활동 요약 정보 조회") + fun getMyMemberSummary( + @Parameter(hidden = true) @CurrentUser userId: Long, + @TsidParam + @TsidPathVariable clubId: Long, + ): CommonResponse { + val summary = getClubMemberQueryService.findMySummary(clubId, userId) + + return CommonResponse.success(ClubResponseCode.MEMBER_SUMMARY_FIND_SUCCESS, summary) + } + + // TODO: 추후 동아리별 프로필 수정으로 변경 시 clubId 경로 변수 추가 및 단일 ClubMember만 수정하도록 변경 + @PatchMapping("/members/me") + @Operation(summary = "내 클럽 활동 프로필 수정 (프로필 사진, 자기소개)") + fun updateMyProfile( + @Parameter(hidden = true) @CurrentUser userId: Long, + @Valid @RequestBody request: UpdateMemberProfileRequest, + ): CommonResponse { + manageClubMemberUsecase.updateProfile(userId, request) + return CommonResponse.success(ClubResponseCode.MEMBER_PROFILE_UPDATED_SUCCESS) + } + + // TODO: 추후 동아리별 프로필 수정으로 변경 시 clubId 경로 변수 추가 및 단일 ClubMember만 수정하도록 변경 + @DeleteMapping("/members/me/profile-image") + @Operation(summary = "동아리 프로필 사진 삭제") + fun deleteMyProfileImage( + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + manageClubMemberUsecase.deleteProfileImage(userId) + return CommonResponse.success(ClubResponseCode.MEMBER_PROFILE_IMAGE_DELETED_SUCCESS) + } + + @PostMapping("/{clubId}/members/me/cardinals") + @Operation(summary = "활동 기수 최초 설정 (최초 1회만 가능)") + @ResponseStatus(HttpStatus.CREATED) + fun setInitialCardinals( + @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + @Valid @RequestBody request: ClubMemberCardinalSetRequest, + ): CommonResponse { + manageClubMemberUsecase.setInitialCardinals(clubId, userId, request) + + return CommonResponse.success(ClubResponseCode.MEMBER_CARDINAL_SET_SUCCESS) + } +} diff --git a/src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt b/src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt index 7dc491b3..eeab8df4 100644 --- a/src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt @@ -28,4 +28,6 @@ enum class ClubResponseCode( MEMBER_PROFILE_IMAGE_DELETED_SUCCESS(11117, HttpStatus.OK, "동아리 프로필 사진이 삭제되었습니다."), MEMBER_PROFILE_UPDATED_SUCCESS(11118, HttpStatus.OK, "프로필이 성공적으로 수정되었습니다."), LEAD_TRANSFERRED_SUCCESS(11119, HttpStatus.OK, "LEAD 권한이 이양되었습니다."), + MEMBERSHIP_STATUS_FIND_SUCCESS(11120, HttpStatus.OK, "동아리 가입 상태를 성공적으로 조회했습니다."), + MEMBER_SUMMARY_FIND_SUCCESS(11121, HttpStatus.OK, "내 요약 정보를 성공적으로 조회했습니다."), } diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserProfileResponse.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserProfileResponse.kt deleted file mode 100644 index 65f17bbf..00000000 --- a/src/main/kotlin/com/weeth/domain/user/application/dto/response/UserProfileResponse.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.weeth.domain.user.application.dto.response - -import com.weeth.domain.club.domain.enums.MemberRole -import io.swagger.v3.oas.annotations.media.Schema - -data class UserProfileResponse( - @field:Schema(description = "사용자 ID", example = "1") - val id: Long, - @field:Schema(description = "이름", example = "홍길동") - val name: String, - @field:Schema(description = "이메일", example = "hong@example.com") - val email: String, - @field:Schema(description = "학번", example = "20201234") - val studentId: String, - @field:Schema(description = "전화번호", example = "01012345678") - val tel: String, - @field:Schema(description = "학교", example = "가천대학교") - val school: String, - @field:Schema(description = "학과", example = "컴퓨터공학과") - val department: String, - @field:Schema(description = "소속 기수 목록", example = "[6, 7]") - val cardinals: List, - @field:Schema(description = "동아리 내 권한", example = "USER", nullable = true) - val role: MemberRole?, - @field:Schema(description = "프로필 이미지 URL") - val profileImageUrl: String?, -) diff --git a/src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt b/src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt index d4c5e43e..47a1d1cc 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt @@ -1,11 +1,7 @@ package com.weeth.domain.user.application.mapper -import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.file.domain.port.FileAccessUrlPort import com.weeth.domain.user.application.dto.response.SocialLoginResponse -import com.weeth.domain.user.application.dto.response.UserProfileResponse -import com.weeth.domain.user.application.dto.response.UserSummaryResponse -import com.weeth.domain.user.domain.entity.User import com.weeth.global.auth.jwt.application.dto.JwtDto import org.springframework.stereotype.Component @@ -22,40 +18,4 @@ class UserMapper( refreshToken = token.refreshToken, isNewUser = isNewUser, ) - - fun toUserProfileResponse( - user: User, - clubMember: ClubMember, - ): UserProfileResponse = - UserProfileResponse( - id = user.id, - name = user.name, - email = user.emailValue, - studentId = user.studentId, - tel = user.telValue, - school = user.school, - department = user.department, - cardinals = emptyList(), - role = clubMember.memberRole, - profileImageUrl = clubMember.profileImageStorageKey?.let { fileAccessUrlPort.resolve(it) }, - ) - - fun toUserSummaryResponse( - user: User, - clubMember: ClubMember, - ): UserSummaryResponse = - UserSummaryResponse( - id = user.id, - name = user.name, - cardinals = emptyList(), - role = clubMember.memberRole, - ) - - fun toUserSummaryResponse(user: User): UserSummaryResponse = - UserSummaryResponse( - id = user.id, - name = user.name, - cardinals = emptyList(), - role = null, - ) } diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryService.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryService.kt deleted file mode 100644 index 65dd7cc0..00000000 --- a/src/main/kotlin/com/weeth/domain/user/application/usecase/query/GetUserQueryService.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.weeth.domain.user.application.usecase.query - -import com.weeth.domain.club.domain.service.ClubMemberPolicy -import com.weeth.domain.user.application.dto.response.UserProfileResponse -import com.weeth.domain.user.application.dto.response.UserSummaryResponse -import com.weeth.domain.user.application.mapper.UserMapper -import com.weeth.domain.user.domain.repository.UserRepository -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional - -@Service -@Transactional(readOnly = true) -class GetUserQueryService( - private val userRepository: UserRepository, - private val clubMemberPolicy: ClubMemberPolicy, - private val mapper: UserMapper, -) { - fun existsByEmail(email: String): Boolean = userRepository.existsByEmailValue(email) - - fun findMyProfile( - clubId: Long, - userId: Long, - ): UserProfileResponse { - val user = userRepository.getById(userId) - val member = clubMemberPolicy.getActiveMember(clubId, userId) - return mapper.toUserProfileResponse(user, member) - } - - // TODO: WTH-205에서 UserClubController에 연결 예정 - fun findMyInfo( - clubId: Long, - userId: Long, - ): UserSummaryResponse { - val user = userRepository.getById(userId) - val member = clubMemberPolicy.getActiveMember(clubId, userId) - return mapper.toUserSummaryResponse(user, member) - } - - @Deprecated("WTH-205에서 club-scoped API로 대체 예정") - fun findMyInfo(userId: Long): UserSummaryResponse { - val user = userRepository.getById(userId) - return mapper.toUserSummaryResponse(user) - } -} diff --git a/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt b/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt index fcaeb773..df83befc 100644 --- a/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt +++ b/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt @@ -4,13 +4,11 @@ import com.weeth.domain.user.application.dto.request.AgreeTermsRequest import com.weeth.domain.user.application.dto.request.SocialLoginRequest import com.weeth.domain.user.application.dto.request.UpdateUserProfileRequest import com.weeth.domain.user.application.dto.response.SocialLoginResponse -import com.weeth.domain.user.application.dto.response.UserSummaryResponse import com.weeth.domain.user.application.exception.UserErrorCode import com.weeth.domain.user.application.usecase.command.AgreeTermsUseCase import com.weeth.domain.user.application.usecase.command.AuthUserUseCase import com.weeth.domain.user.application.usecase.command.SocialLoginUseCase import com.weeth.domain.user.application.usecase.command.UpdateUserProfileUseCase -import com.weeth.domain.user.application.usecase.query.GetUserQueryService import com.weeth.global.auth.annotation.CurrentUser import com.weeth.global.auth.jwt.application.dto.JwtDto import com.weeth.global.auth.jwt.application.exception.JwtErrorCode @@ -21,12 +19,10 @@ import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag import jakarta.servlet.http.HttpServletRequest import jakarta.validation.Valid -import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PatchMapping import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController @Tag(name = "USER", description = "사용자 API") @@ -38,7 +34,6 @@ class UserController( private val socialLoginUseCase: SocialLoginUseCase, private val updateUserProfileUseCase: UpdateUserProfileUseCase, private val agreeTermsUseCase: AgreeTermsUseCase, - private val getUserQueryService: GetUserQueryService, ) { @PostMapping("/social/kakao") @Operation(summary = "카카오 소셜 로그인(auth code flow)") @@ -59,21 +54,6 @@ class UserController( fun refreshToken(request: HttpServletRequest): CommonResponse = CommonResponse.success(UserResponseCode.JWT_REFRESH_SUCCESS, authUserUseCase.refreshToken(request)) - @GetMapping("/email") - @Operation(summary = "이메일 중복 확인") - fun checkEmail( - @RequestParam email: String, - ): CommonResponse = - CommonResponse.success(UserResponseCode.USER_EMAIL_CHECK_SUCCESS, !getUserQueryService.existsByEmail(email)) - - @Deprecated("WTH-205에서 club-scoped API로 대체 예정") - @GetMapping("/info") - @Operation(summary = "전역 내 정보 조회 API") - fun findMyInfo( - @Parameter(hidden = true) @CurrentUser userId: Long, - ): CommonResponse = - CommonResponse.success(UserResponseCode.USER_FIND_BY_ID_SUCCESS, getUserQueryService.findMyInfo(userId)) - @PostMapping("/terms") @Operation(summary = "약관 동의") fun agreeTerms( diff --git a/src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt b/src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt index 435232ae..c0a186ba 100644 --- a/src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt @@ -8,17 +8,8 @@ enum class UserResponseCode( override val status: HttpStatus, override val message: String, ) : ResponseCodeInterface { - USER_FIND_ALL_SUCCESS(10900, HttpStatus.OK, "모든 회원 정보를 성공적으로 조회했습니다."), - USER_DETAILS_SUCCESS(10901, HttpStatus.OK, "특정 회원의 상세 정보를 성공적으로 조회했습니다."), - USER_ACCEPT_SUCCESS(10902, HttpStatus.OK, "회원 가입 승인이 성공적으로 처리되었습니다."), - USER_BAN_SUCCESS(10903, HttpStatus.OK, "회원이 성공적으로 차단되었습니다."), - USER_ROLE_UPDATE_SUCCESS(10904, HttpStatus.OK, "회원의 역할이 성공적으로 수정되었습니다."), - USER_APPLY_OB_SUCCESS(10905, HttpStatus.OK, "OB 신청이 성공적으로 처리되었습니다."), - USER_EMAIL_CHECK_SUCCESS(10906, HttpStatus.OK, "이메일 중복 검사가 성공적으로 처리되었습니다."), - USER_FIND_BY_ID_SUCCESS(10907, HttpStatus.OK, "회원 정보가 성공적으로 조회되었습니다."), - USER_UPDATE_SUCCESS(10908, HttpStatus.OK, "회원 정보가 성공적으로 수정되었습니다."), - USER_LEAVE_SUCCESS(10909, HttpStatus.OK, "회원 탈퇴가 성공적으로 처리되었습니다."), - JWT_REFRESH_SUCCESS(10910, HttpStatus.OK, "토큰 재발급에 성공했습니다."), - SOCIAL_LOGIN_SUCCESS(10911, HttpStatus.OK, "소셜 로그인이 성공적으로 처리되었습니다."), - USER_TERMS_AGREE_SUCCESS(10912, HttpStatus.OK, "약관 동의가 성공적으로 처리되었습니다."), + USER_UPDATE_SUCCESS(10901, HttpStatus.OK, "회원 정보가 성공적으로 수정되었습니다."), + JWT_REFRESH_SUCCESS(10902, HttpStatus.OK, "토큰 재발급에 성공했습니다."), + SOCIAL_LOGIN_SUCCESS(10903, HttpStatus.OK, "소셜 로그인이 성공적으로 처리되었습니다."), + USER_TERMS_AGREE_SUCCESS(10904, HttpStatus.OK, "약관 동의가 성공적으로 처리되었습니다."), } From c7842f830e3956d02806f17e03e64637450c20ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=84=EC=88=98=ED=98=84?= <128474444+soo0711@users.noreply.github.com> Date: Wed, 25 Mar 2026 14:54:54 +0900 Subject: [PATCH 35/73] =?UTF-8?q?[WTH-206]=20=EA=B2=8C=EC=8B=9C=EA=B8=80?= =?UTF-8?q?=20=EC=A2=8B=EC=95=84=EC=9A=94=20api=20(#38)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 게시글 좋아요 도메인 추가 * feat: 게시글 좋아요 DTO 추가 * feat: 게시글 좋아요 토글 유스케이스 추가 * feat: 게시글 좋아요 토글 API 추가 * style: 린트 적용 * test: 관련 테스트 수정 * refactor: 게시글 좋아요 플래그 방식으로 변경 * refactor: 게시글 좋아요 응답 코드 단일화 * refactor: 게시글 좋아요 동시성 스냅샷 해결 * refactor: 비지니스 로직 이동 * refactor: 불필요한 락 제거 * refactor: 락 타임아웃 응답 코드 변경 * test: 게시글 좋아요 테스트 추가 * refactor: 응답 mapper로 위임 * refactor: 좋아요 응답 PostLikeResponse로 통합 --- .../dto/response/PostDetailResponse.kt | 2 + .../dto/response/PostLikeResponse.kt | 10 ++ .../dto/response/PostListResponse.kt | 2 + .../application/exception/BoardErrorCode.kt | 3 + .../exception/PostLikeLockTimeoutException.kt | 5 + .../board/application/mapper/PostMapper.kt | 10 ++ .../usecase/command/TogglePostLikeUseCase.kt | 53 +++++++ .../usecase/query/GetPostQueryService.kt | 23 ++- .../weeth/domain/board/domain/entity/Post.kt | 2 + .../domain/board/domain/entity/PostLike.kt | 45 ++++++ .../domain/repository/PostLikeRepository.kt | 32 ++++ .../board/presentation/BoardResponseCode.kt | 1 + .../board/presentation/PostController.kt | 16 +- .../application/mapper/PostMapperTest.kt | 5 +- .../command/TogglePostLikeUseCaseTest.kt | 145 ++++++++++++++++++ .../usecase/query/GetPostQueryServiceTest.kt | 13 +- .../board/domain/entity/PostLikeEntityTest.kt | 31 ++++ .../board/fixture/PostLikeTestFixture.kt | 16 ++ .../domain/board/fixture/PostTestFixture.kt | 5 +- 19 files changed, 410 insertions(+), 9 deletions(-) create mode 100644 src/main/kotlin/com/weeth/domain/board/application/dto/response/PostLikeResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/exception/PostLikeLockTimeoutException.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/usecase/command/TogglePostLikeUseCase.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/domain/entity/PostLike.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/domain/repository/PostLikeRepository.kt create mode 100644 src/test/kotlin/com/weeth/domain/board/application/usecase/command/TogglePostLikeUseCaseTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/board/domain/entity/PostLikeEntityTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/board/fixture/PostLikeTestFixture.kt diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt index bbcfad18..89918dd3 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt @@ -19,6 +19,8 @@ data class PostDetailResponse( val time: LocalDateTime, @field:Schema(description = "댓글 수") val commentCount: Int, + @field:Schema(description = "좋아요 정보") + val like: PostLikeResponse, @field:Schema(description = "댓글 목록") val comments: List, @field:Schema(description = "첨부 파일 목록") diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostLikeResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostLikeResponse.kt new file mode 100644 index 00000000..4a280ebf --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostLikeResponse.kt @@ -0,0 +1,10 @@ +package com.weeth.domain.board.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class PostLikeResponse( + @field:Schema(description = "좋아요 여부") + val isLiked: Boolean, + @field:Schema(description = "좋아요 수") + val likeCount: Int, +) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt index 5950c173..a0821d8b 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt @@ -17,6 +17,8 @@ data class PostListResponse( val time: LocalDateTime, @field:Schema(description = "댓글 수") val commentCount: Int, + @field:Schema(description = "좋아요 정보") + val like: PostLikeResponse, @field:Schema(description = "파일 첨부 여부") val hasFile: Boolean, @field:Schema(description = "신규 게시글 여부 (24시간 이내)") diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt index ee46e804..e6a9beb2 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt @@ -32,4 +32,7 @@ enum class BoardErrorCode( @ExplainError("경로의 clubId와 게시판의 소속 클럽이 일치하지 않을 때 발생합니다.") BOARD_NOT_IN_CLUB(20407, HttpStatus.FORBIDDEN, "해당 클럽에 속한 게시판이 아닙니다."), + + @ExplainError("좋아요 처리 중 동시 요청이 많아 락 획득에 실패했을 때 발생합니다.") + POST_LIKE_LOCK_TIMEOUT(20408, HttpStatus.TOO_MANY_REQUESTS, "잠시 후 다시 시도해주세요."), } diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/PostLikeLockTimeoutException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/PostLikeLockTimeoutException.kt new file mode 100644 index 00000000..e01bf83a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/PostLikeLockTimeoutException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class PostLikeLockTimeoutException : BaseException(BoardErrorCode.POST_LIKE_LOCK_TIMEOUT) diff --git a/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt b/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt index 23f913f2..5ed127b2 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt @@ -1,6 +1,7 @@ package com.weeth.domain.board.application.mapper import com.weeth.domain.board.application.dto.response.PostDetailResponse +import com.weeth.domain.board.application.dto.response.PostLikeResponse import com.weeth.domain.board.application.dto.response.PostListResponse import com.weeth.domain.board.application.dto.response.PostSaveResponse import com.weeth.domain.board.domain.entity.Post @@ -18,11 +19,17 @@ class PostMapper( ) { fun toSaveResponse(post: Post) = PostSaveResponse(id = post.id) + fun toLikeResponse( + post: Post, + isLiked: Boolean, + ) = PostLikeResponse(isLiked = isLiked, likeCount = post.likeCount) + fun toDetailResponse( post: Post, authorMember: ClubMember, comments: List, files: List, + isLiked: Boolean, ) = PostDetailResponse( id = post.id, author = UserInfo.of(post.user, authorMember.memberRole, resolveProfileImage(authorMember)), @@ -30,6 +37,7 @@ class PostMapper( content = post.content, time = post.modifiedAt, commentCount = post.commentCount, + like = toLikeResponse(post, isLiked), comments = comments, fileUrls = files, ) @@ -39,6 +47,7 @@ class PostMapper( authorMember: ClubMember, hasFile: Boolean, now: LocalDateTime, + isLiked: Boolean, ) = PostListResponse( id = post.id, author = UserInfo.of(post.user, authorMember.memberRole, resolveProfileImage(authorMember)), @@ -46,6 +55,7 @@ class PostMapper( content = post.content, time = post.modifiedAt, commentCount = post.commentCount, + like = toLikeResponse(post, isLiked), hasFile = hasFile, isNew = post.createdAt.isAfter(now.minusHours(24)), ) diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/TogglePostLikeUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/TogglePostLikeUseCase.kt new file mode 100644 index 00000000..c3df21ca --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/TogglePostLikeUseCase.kt @@ -0,0 +1,53 @@ +package com.weeth.domain.board.application.usecase.command + +import com.weeth.domain.board.application.dto.response.PostLikeResponse +import com.weeth.domain.board.application.exception.CategoryAccessDeniedException +import com.weeth.domain.board.application.exception.PostLikeLockTimeoutException +import com.weeth.domain.board.application.exception.PostNotFoundException +import com.weeth.domain.board.application.mapper.PostMapper +import com.weeth.domain.board.domain.entity.PostLike +import com.weeth.domain.board.domain.repository.PostLikeRepository +import com.weeth.domain.board.domain.repository.PostRepository +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import org.springframework.dao.PessimisticLockingFailureException +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class TogglePostLikeUseCase( + private val postRepository: PostRepository, + private val postLikeRepository: PostLikeRepository, + private val clubMemberPolicy: ClubMemberPolicy, + private val postMapper: PostMapper, +) { + @Transactional + fun execute( + clubId: Long, + postId: Long, + userId: Long, + ): PostLikeResponse { + val member = clubMemberPolicy.getActiveMember(clubId, userId) + + val post = + try { + postRepository.findByIdWithLock(postId) ?: throw PostNotFoundException() + } catch (_: PessimisticLockingFailureException) { + throw PostLikeLockTimeoutException() + } + + if (!post.belongsToClub(clubId)) throw PostNotFoundException() + if (!post.board.isAccessibleBy(member.memberRole)) throw CategoryAccessDeniedException() + + val existingLike = postLikeRepository.findByPostAndUserId(post, userId) + + return if (existingLike != null) { + existingLike.toggle() + if (existingLike.isActive) post.increaseLikeCount() else post.decreaseLikeCount() + postMapper.toLikeResponse(post, existingLike.isActive) + } else { + postLikeRepository.save(PostLike(post = post, userId = userId)) + post.increaseLikeCount() + postMapper.toLikeResponse(post, isLiked = true) + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt index bb24eed9..af9aae19 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt @@ -8,6 +8,7 @@ import com.weeth.domain.board.application.exception.PageNotFoundException import com.weeth.domain.board.application.exception.PostNotFoundException import com.weeth.domain.board.application.mapper.PostMapper import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.board.domain.repository.PostLikeRepository import com.weeth.domain.board.domain.repository.PostRepository import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.club.domain.enums.MemberRole @@ -30,6 +31,7 @@ import java.time.LocalDateTime class GetPostQueryService( private val postRepository: PostRepository, private val boardRepository: BoardRepository, + private val postLikeRepository: PostLikeRepository, private val clubMemberPolicy: ClubMemberPolicy, private val clubMemberReader: ClubMemberReader, private val commentReader: CommentReader, @@ -62,8 +64,9 @@ class GetPostQueryService( val memberMap = buildMemberMap(clubId, allAuthorIds) val commentTree = getCommentQueryService.toCommentTreeResponses(comments, memberMap) + val isLiked = postLikeRepository.existsByPostAndUserIdAndIsActiveTrue(post, userId) - return postMapper.toDetailResponse(post, memberMap.getValue(post.user.id), commentTree, files) + return postMapper.toDetailResponse(post, memberMap.getValue(post.user.id), commentTree, files, isLiked) } fun findPosts( @@ -83,10 +86,17 @@ class GetPostQueryService( val postIds = posts.content.map { it.id } val fileExistsByPostId = buildFileExistsMap(postIds) val memberMap = buildMemberMap(clubId, posts.content.map { it.user.id }.distinct()) + val likedPostIds = postLikeRepository.findLikedPostIds(postIds, userId) val now = LocalDateTime.now() return posts.map { post -> - postMapper.toListResponse(post, memberMap.getValue(post.user.id), fileExistsByPostId[post.id] == true, now) + postMapper.toListResponse( + post, + memberMap.getValue(post.user.id), + fileExistsByPostId[post.id] == true, + now, + post.id in likedPostIds, + ) } } @@ -111,10 +121,17 @@ class GetPostQueryService( val postIds = posts.content.map { it.id } val fileExistsByPostId = buildFileExistsMap(postIds) val memberMap = buildMemberMap(clubId, posts.content.map { it.user.id }.distinct()) + val likedPostIds = postLikeRepository.findLikedPostIds(postIds, userId) val now = LocalDateTime.now() return posts.map { post -> - postMapper.toListResponse(post, memberMap.getValue(post.user.id), fileExistsByPostId[post.id] == true, now) + postMapper.toListResponse( + post, + memberMap.getValue(post.user.id), + fileExistsByPostId[post.id] == true, + now, + post.id in likedPostIds, + ) } } diff --git a/src/main/kotlin/com/weeth/domain/board/domain/entity/Post.kt b/src/main/kotlin/com/weeth/domain/board/domain/entity/Post.kt index a74119d8..ff1ec0f3 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/entity/Post.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/entity/Post.kt @@ -80,6 +80,8 @@ class Post( fun isOwnedBy(userId: Long): Boolean = user.id == userId + fun belongsToClub(clubId: Long): Boolean = board.club.id == clubId && !board.isDeleted + fun update( newTitle: String?, newContent: String?, diff --git a/src/main/kotlin/com/weeth/domain/board/domain/entity/PostLike.kt b/src/main/kotlin/com/weeth/domain/board/domain/entity/PostLike.kt new file mode 100644 index 00000000..89282ef1 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/entity/PostLike.kt @@ -0,0 +1,45 @@ +package com.weeth.domain.board.domain.entity + +import com.weeth.global.common.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint + +@Entity +@Table( + name = "post_like", + uniqueConstraints = [UniqueConstraint(columnNames = ["post_id", "user_id"])], +) +class PostLike( + post: Post, + userId: Long, +) : BaseEntity() { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long = 0L + private set + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "post_id", nullable = false) + var post: Post = post + private set + + @Column(nullable = false) + var userId: Long = userId + private set + + @Column(nullable = false) + var isActive: Boolean = true + private set + + fun toggle() { + isActive = !isActive + } +} diff --git a/src/main/kotlin/com/weeth/domain/board/domain/repository/PostLikeRepository.kt b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostLikeRepository.kt new file mode 100644 index 00000000..978e9973 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostLikeRepository.kt @@ -0,0 +1,32 @@ +package com.weeth.domain.board.domain.repository + +import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.board.domain.entity.PostLike +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query + +interface PostLikeRepository : JpaRepository { + fun existsByPostAndUserIdAndIsActiveTrue( + post: Post, + userId: Long, + ): Boolean + + fun findByPostAndUserId( + post: Post, + userId: Long, + ): PostLike? + + @Query( + """ + SELECT pl.post.id + FROM PostLike pl + WHERE pl.post.id IN :postIds + AND pl.userId = :userId + AND pl.isActive = true + """, + ) + fun findLikedPostIds( + postIds: List, + userId: Long, + ): Set +} diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt b/src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt index 16ac57c6..80cd598c 100644 --- a/src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt @@ -20,4 +20,5 @@ enum class BoardResponseCode( BOARD_FIND_ALL_SUCCESS(10409, HttpStatus.OK, "게시판 목록이 성공적으로 조회되었습니다."), BOARD_FIND_BY_ID_SUCCESS(10410, HttpStatus.OK, "게시판이 성공적으로 조회되었습니다."), BOARD_NOTICE_READ_SUCCESS(10411, HttpStatus.OK, "공지를 읽음 처리했습니다."), + POST_LIKE_TOGGLE_SUCCESS(10412, HttpStatus.OK, "게시글 좋아요가 처리되었습니다."), } diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt b/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt index 1681bc0c..ec578e08 100644 --- a/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt +++ b/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt @@ -3,11 +3,13 @@ package com.weeth.domain.board.presentation import com.weeth.domain.board.application.dto.request.CreatePostRequest import com.weeth.domain.board.application.dto.request.UpdatePostRequest import com.weeth.domain.board.application.dto.response.PostDetailResponse +import com.weeth.domain.board.application.dto.response.PostLikeResponse import com.weeth.domain.board.application.dto.response.PostListResponse import com.weeth.domain.board.application.dto.response.PostSaveResponse import com.weeth.domain.board.application.exception.BoardErrorCode import com.weeth.domain.board.application.usecase.command.ManagePostUseCase import com.weeth.domain.board.application.usecase.command.MarkNoticeReadUseCase +import com.weeth.domain.board.application.usecase.command.TogglePostLikeUseCase import com.weeth.domain.board.application.usecase.query.GetPostQueryService import com.weeth.global.auth.annotation.CurrentUser import com.weeth.global.auth.jwt.application.exception.JwtErrorCode @@ -38,6 +40,7 @@ class PostController( private val managePostUseCase: ManagePostUseCase, private val getPostQueryService: GetPostQueryService, private val markNoticeReadUseCase: MarkNoticeReadUseCase, + private val togglePostLikeUseCase: TogglePostLikeUseCase, ) { @PostMapping("/{boardId}/posts") @Operation(summary = "게시글 작성") @@ -135,5 +138,16 @@ class PostController( return CommonResponse.success(BoardResponseCode.BOARD_NOTICE_READ_SUCCESS) } - // todo: 좋아요 관련 API 추가 + @PostMapping("/posts/{postId}/like") + @Operation(summary = "게시글 좋아요 토글", description = "좋아요가 없으면 추가, 있으면 취소합니다.") + fun toggleLike( + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable postId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success( + BoardResponseCode.POST_LIKE_TOGGLE_SUCCESS, + togglePostLikeUseCase.execute(clubId, postId, userId), + ) } diff --git a/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt b/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt index fa7e8c0e..9efc8cbd 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt @@ -35,12 +35,13 @@ class PostMapperTest : every { post.content } returns "내용" every { post.user } returns user every { post.commentCount } returns 2 + every { post.likeCount } returns 0 every { post.createdAt } returns now.minusHours(1) every { post.modifiedAt } returns now describe("toListResponse") { it("24시간 이내 생성된 게시글은 isNew=true") { - val response = mapper.toListResponse(post, authorMember, hasFile = true, now = now) + val response = mapper.toListResponse(post, authorMember, hasFile = true, now = now, isLiked = false) response.id shouldBe 1L response.hasFile shouldBe true @@ -74,7 +75,7 @@ class PostMapperTest : ), ) - val response = mapper.toDetailResponse(post, authorMember, comments, files) + val response = mapper.toDetailResponse(post, authorMember, comments, files, isLiked = false) response.id shouldBe 1L response.commentCount shouldBe 2 diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/TogglePostLikeUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/TogglePostLikeUseCaseTest.kt new file mode 100644 index 00000000..3dba592a --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/TogglePostLikeUseCaseTest.kt @@ -0,0 +1,145 @@ +package com.weeth.domain.board.application.usecase.command + +import com.weeth.domain.board.application.dto.response.PostLikeResponse +import com.weeth.domain.board.application.exception.CategoryAccessDeniedException +import com.weeth.domain.board.application.exception.PostLikeLockTimeoutException +import com.weeth.domain.board.application.exception.PostNotFoundException +import com.weeth.domain.board.application.mapper.PostMapper +import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.board.domain.repository.PostLikeRepository +import com.weeth.domain.board.domain.repository.PostRepository +import com.weeth.domain.board.domain.vo.BoardConfig +import com.weeth.domain.board.fixture.BoardTestFixture +import com.weeth.domain.board.fixture.PostLikeTestFixture +import com.weeth.domain.board.fixture.PostTestFixture +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.fixture.ClubMemberTestFixture +import com.weeth.domain.club.fixture.ClubTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.springframework.dao.PessimisticLockingFailureException + +class TogglePostLikeUseCaseTest : + DescribeSpec({ + val postRepository = mockk() + val postLikeRepository = mockk() + val clubMemberPolicy = mockk() + val postMapper = mockk(relaxed = true) + val useCase = TogglePostLikeUseCase(postRepository, postLikeRepository, clubMemberPolicy, postMapper) + + val clubId = 1L + val userId = 10L + val postId = 100L + + val club = ClubTestFixture.createClub(id = clubId) + val board = BoardTestFixture.create(club = club) + val member = ClubMemberTestFixture.createActiveMember(club = club) + + beforeTest { + clearMocks(postRepository, postLikeRepository, clubMemberPolicy, postMapper) + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns member + every { postLikeRepository.save(any()) } answers { firstArg() } + every { postMapper.toLikeResponse(any(), any()) } answers { + PostLikeResponse(isLiked = secondArg(), likeCount = firstArg().likeCount) + } + } + + describe("execute") { + context("게시글이 존재하지 않을 때") { + it("PostNotFoundException을 던진다") { + every { postRepository.findByIdWithLock(postId) } returns null + + shouldThrow { + useCase.execute(clubId, postId, userId) + } + } + } + + context("락 획득에 실패했을 때") { + it("PostLikeLockTimeoutException을 던진다") { + every { postRepository.findByIdWithLock(postId) } throws + PessimisticLockingFailureException("lock timeout") + + shouldThrow { + useCase.execute(clubId, postId, userId) + } + } + } + + context("다른 클럽의 게시글일 때") { + it("PostNotFoundException을 던진다") { + val otherClub = ClubTestFixture.createClub(id = 99L) + val otherPost = PostTestFixture.create(board = BoardTestFixture.create(club = otherClub)) + every { postRepository.findByIdWithLock(postId) } returns otherPost + + shouldThrow { + useCase.execute(clubId, postId, userId) + } + } + } + + context("접근 권한이 없는 비공개 게시판일 때") { + it("CategoryAccessDeniedException을 던진다") { + val privateBoard = BoardTestFixture.create(club = club, config = BoardConfig(isPrivate = true)) + val privatePost = PostTestFixture.create(board = privateBoard) + val userMember = ClubMemberTestFixture.createActiveMember(club = club, memberRole = MemberRole.USER) + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns userMember + every { postRepository.findByIdWithLock(postId) } returns privatePost + + shouldThrow { + useCase.execute(clubId, postId, userId) + } + } + } + + context("기존 좋아요가 없을 때") { + it("새 PostLike를 생성하고 isLiked=true, likeCount=1을 반환한다") { + val post = PostTestFixture.create(board = board) + every { postRepository.findByIdWithLock(postId) } returns post + every { postLikeRepository.findByPostAndUserId(post, userId) } returns null + + val result = useCase.execute(clubId, postId, userId) + + result.isLiked shouldBe true + result.likeCount shouldBe 1 + verify(exactly = 1) { postLikeRepository.save(any()) } + } + } + + context("기존 좋아요(isActive=true)가 있을 때") { + it("toggle하여 isLiked=false, likeCount를 감소시킨다") { + val post = PostTestFixture.create(board = board, initialLikeCount = 1) + val existingLike = PostLikeTestFixture.createActive(post = post, userId = userId) + every { postRepository.findByIdWithLock(postId) } returns post + every { postLikeRepository.findByPostAndUserId(post, userId) } returns existingLike + + val result = useCase.execute(clubId, postId, userId) + + result.isLiked shouldBe false + result.likeCount shouldBe 0 + verify(exactly = 0) { postLikeRepository.save(any()) } + } + } + + context("기존 좋아요(isActive=false)가 있을 때") { + it("toggle하여 isLiked=true, likeCount를 증가시킨다") { + val post = PostTestFixture.create(board = board) + val existingLike = PostLikeTestFixture.createInactive(post = post, userId = userId) + every { postRepository.findByIdWithLock(postId) } returns post + every { postLikeRepository.findByPostAndUserId(post, userId) } returns existingLike + + val result = useCase.execute(clubId, postId, userId) + + result.isLiked shouldBe true + result.likeCount shouldBe 1 + verify(exactly = 0) { postLikeRepository.save(any()) } + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt index ca374ac7..d1253fb8 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt @@ -1,5 +1,6 @@ package com.weeth.domain.board.application.usecase.query +import com.weeth.domain.board.application.dto.response.PostLikeResponse import com.weeth.domain.board.application.exception.BoardNotFoundException import com.weeth.domain.board.application.exception.NoSearchResultException import com.weeth.domain.board.application.exception.PageNotFoundException @@ -7,6 +8,7 @@ import com.weeth.domain.board.application.exception.PostNotFoundException import com.weeth.domain.board.application.mapper.PostMapper import com.weeth.domain.board.domain.enums.BoardType import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.board.domain.repository.PostLikeRepository import com.weeth.domain.board.domain.repository.PostRepository import com.weeth.domain.board.fixture.BoardTestFixture import com.weeth.domain.board.fixture.PostTestFixture @@ -40,6 +42,7 @@ class GetPostQueryServiceTest : DescribeSpec({ val postRepository = mockk() val boardRepository = mockk() + val postLikeRepository = mockk() val clubMemberPolicy = mockk(relaxed = true) val clubMemberReader = mockk() val commentReader = mockk() @@ -52,6 +55,7 @@ class GetPostQueryServiceTest : GetPostQueryService( postRepository, boardRepository, + postLikeRepository, clubMemberPolicy, clubMemberReader, commentReader, @@ -68,6 +72,7 @@ class GetPostQueryServiceTest : clearMocks( postRepository, boardRepository, + postLikeRepository, clubMemberPolicy, clubMemberReader, commentReader, @@ -133,6 +138,7 @@ class GetPostQueryServiceTest : content = "내용", time = LocalDateTime.now(), commentCount = 1, + like = PostLikeResponse(isLiked = false, likeCount = 0), comments = comments, fileUrls = fileResponses, ) @@ -143,7 +149,8 @@ class GetPostQueryServiceTest : every { clubMemberReader.findAllByClubIdAndUserIds(actualClubId, any()) } returns listOf(member) every { getCommentQueryService.toCommentTreeResponses(any(), any()) } returns comments every { fileReader.findAll(FileOwnerType.POST, any(), any()) } returns files - every { postMapper.toDetailResponse(post, member, comments, fileResponses) } returns detail + every { postLikeRepository.existsByPostAndUserIdAndIsActiveTrue(post, userId) } returns false + every { postMapper.toDetailResponse(post, member, comments, fileResponses, false) } returns detail every { fileMapper.toFileResponse(files.first()) } returns fileResponses.first() val result = queryService.findPost(actualClubId, userId, 1L) @@ -278,6 +285,7 @@ class GetPostQueryServiceTest : content = "내용", time = LocalDateTime.now(), commentCount = 0, + like = PostLikeResponse(isLiked = false, likeCount = 0), hasFile = false, isNew = false, ) @@ -287,7 +295,8 @@ class GetPostQueryServiceTest : every { postRepository.findAllActiveByBoardId(1L, any()) } returns postSlice every { fileReader.findAll(FileOwnerType.POST, any>(), any()) } returns emptyList() every { clubMemberReader.findAllByClubIdAndUserIds(clubId, any()) } returns listOf(member) - every { postMapper.toListResponse(any(), any(), any(), any()) } returns response + every { postLikeRepository.findLikedPostIds(any(), any()) } returns emptySet() + every { postMapper.toListResponse(any(), any(), any(), any(), any()) } returns response val result = queryService.findPosts(clubId, userId, 1L, 0, 10) diff --git a/src/test/kotlin/com/weeth/domain/board/domain/entity/PostLikeEntityTest.kt b/src/test/kotlin/com/weeth/domain/board/domain/entity/PostLikeEntityTest.kt new file mode 100644 index 00000000..c0b0e352 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/domain/entity/PostLikeEntityTest.kt @@ -0,0 +1,31 @@ +package com.weeth.domain.board.domain.entity + +import com.weeth.domain.board.fixture.PostLikeTestFixture +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe + +class PostLikeEntityTest : + StringSpec({ + "초기 생성 시 isActive는 true이다" { + val like = PostLikeTestFixture.createActive() + + like.isActive shouldBe true + } + + "toggle은 isActive를 true에서 false로 반전시킨다" { + val like = PostLikeTestFixture.createActive() + + like.toggle() + + like.isActive shouldBe false + } + + "toggle을 두 번 호출하면 isActive가 다시 true가 된다" { + val like = PostLikeTestFixture.createActive() + + like.toggle() + like.toggle() + + like.isActive shouldBe true + } + }) diff --git a/src/test/kotlin/com/weeth/domain/board/fixture/PostLikeTestFixture.kt b/src/test/kotlin/com/weeth/domain/board/fixture/PostLikeTestFixture.kt new file mode 100644 index 00000000..f9aaa6e2 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/fixture/PostLikeTestFixture.kt @@ -0,0 +1,16 @@ +package com.weeth.domain.board.fixture + +import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.board.domain.entity.PostLike + +object PostLikeTestFixture { + fun createActive( + post: Post = PostTestFixture.create(), + userId: Long = 1L, + ): PostLike = PostLike(post = post, userId = userId) + + fun createInactive( + post: Post = PostTestFixture.create(), + userId: Long = 1L, + ): PostLike = PostLike(post = post, userId = userId).also { it.toggle() } +} diff --git a/src/test/kotlin/com/weeth/domain/board/fixture/PostTestFixture.kt b/src/test/kotlin/com/weeth/domain/board/fixture/PostTestFixture.kt index 52f1ce29..bb64a142 100644 --- a/src/test/kotlin/com/weeth/domain/board/fixture/PostTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/board/fixture/PostTestFixture.kt @@ -12,6 +12,7 @@ object PostTestFixture { user: User = UserTestFixture.createActiveUser1(1L), board: Board = BoardTestFixture.create(), cardinalNumber: Int? = null, + initialLikeCount: Int = 0, ): Post = Post( title = title, @@ -19,5 +20,7 @@ object PostTestFixture { user = user, board = board, cardinalNumber = cardinalNumber, - ) + ).also { post -> + repeat(initialLikeCount) { post.increaseLikeCount() } + } } From fe269a7729a7453d06b558e935649e093c32d4f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=84=EC=88=98=ED=98=84?= <128474444+soo0711@users.noreply.github.com> Date: Wed, 25 Mar 2026 21:18:42 +0900 Subject: [PATCH 36/73] =?UTF-8?q?[WTH-207]=20=EA=B2=8C=EC=8B=9C=ED=8C=90?= =?UTF-8?q?=20=EA=B8=B0=EB=B3=B8=20=EA=B8=B0=EB=8A=A5=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20(#37)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 게시글 작성 시 현재 기수 자동 반영 * feat: 전체 게시글 조회 API 추가 * feat: 게시판 순서 수정 기능 추가 * refactor: 게시판 순서 변경 시 중복 boardId 검증 추가 * feat: 게시판 생성 시 displayOrder 자동 설정 * refactor: 게시판 생성 시 reorder apply 블록 제거 * style: 린트 적용 * refactor: 게시판 이름 중복 확인 로직 추가 * test: 게시판 이름 중복 검증 테스트 추가 * feat: 동아리 생성 시 공지사항 게시판 자동 생성 * style: 린트 적용 * refactor: 공지사항 게시판 생성 시 순서 명시 * test: 게시판 재정렬 후 검증 테스트 추가 * feat: 게시판 정보 추가 * refactor: 게시판 관련 코드 정리 * style: 가독성 개선 * style: 린트 적용 * fix: board EntityGraph 누락 수정 * refactor: cardinal 조회 예외 방지 * docs: 게시판 순서 변경 요청 DTO 설명 추가 * refactor: 대시보드 최신글 권한별 필터링 적용 * refactor: 미사용 매서드 제거 및 테스트 보완 * refactor: 공지사항 순서 고정 및 가상 전체 게시판 추가 * test: 게시판 고정, 전체 관련 테스트 추가 * refactor: 어드민 게시판 postCount 및 가상 전체 게시판 추가 * refactor: 게시판 이름, 순서 변경 제한 및 삭제 시 displayOrder 재정렬 * fix: 미팅 테이블명 세션으로 수정 * refactor: 세션 조회 시 기수 검증 추가 * refactor: 전체 게시글 목록 조회 시 좋아요 추가 * refactor: 좋아요와 관련된 테스트 수정 * test: 기수 검증 테스트 추가 --- .../dto/request/CreatePostRequest.kt | 2 - .../dto/request/ReorderBoardsRequest.kt | 15 ++ .../dto/request/UpdatePostRequest.kt | 2 - .../dto/response/BoardDetailResponse.kt | 20 +- .../dto/response/BoardListResponse.kt | 4 +- .../dto/response/PostDetailResponse.kt | 4 + .../dto/response/PostListResponse.kt | 4 + .../application/exception/BoardErrorCode.kt | 17 +- .../DeletedBoardNotReorderableException.kt | 5 + .../exception/DuplicateBoardIdException.kt | 5 + .../exception/DuplicateBoardNameException.kt | 5 + .../FixedBoardNotRenamableException.kt | 5 + .../FixedBoardNotReorderableException.kt | 5 + .../board/application/mapper/BoardMapper.kt | 25 +- .../board/application/mapper/PostMapper.kt | 4 + .../usecase/command/ManageBoardUseCase.kt | 67 ++++- .../usecase/command/ManagePostUseCase.kt | 6 +- .../usecase/query/GetBoardQueryService.kt | 61 ++++- .../usecase/query/GetPostQueryService.kt | 40 +++ .../weeth/domain/board/domain/entity/Board.kt | 10 + .../weeth/domain/board/domain/entity/Post.kt | 2 - .../domain/board/domain/enums/BoardType.kt | 1 + .../board/domain/repository/BoardPostCount.kt | 6 + .../board/domain/repository/BoardReader.kt | 7 + .../domain/repository/BoardRepository.kt | 29 +- .../board/domain/repository/PostReader.kt | 5 +- .../board/domain/repository/PostRepository.kt | 56 ++-- .../presentation/BoardAdminController.kt | 14 + .../board/presentation/BoardResponseCode.kt | 4 +- .../board/presentation/PostController.kt | 14 + .../domain/repository/CardinalReader.kt | 2 + .../domain/repository/CardinalRepository.kt | 8 + .../usecase/command/ManageClubUseCase.kt | 15 ++ .../usecase/query/GetDashboardQueryService.kt | 24 +- .../usecase/query/GetSessionQueryService.kt | 7 + .../domain/session/domain/entity/Session.kt | 2 +- .../application/mapper/PostMapperTest.kt | 6 + .../usecase/command/ManageBoardUseCaseTest.kt | 252 +++++++++++++++++- .../usecase/command/ManagePostUseCaseTest.kt | 37 ++- .../usecase/query/GetBoardQueryServiceTest.kt | 101 +++++-- .../usecase/query/GetPostQueryServiceTest.kt | 56 ++++ .../board/domain/entity/BoardEntityTest.kt | 25 ++ .../board/domain/entity/PostEntityTest.kt | 3 - .../domain/board/fixture/BoardTestFixture.kt | 20 +- .../usecase/command/ManageClubUseCaseTest.kt | 31 +++ .../query/GetDashboardQueryServiceTest.kt | 77 +++++- .../query/GetSessionQueryServiceTest.kt | 123 +++++++++ 47 files changed, 1117 insertions(+), 116 deletions(-) create mode 100644 src/main/kotlin/com/weeth/domain/board/application/dto/request/ReorderBoardsRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/exception/DeletedBoardNotReorderableException.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/exception/DuplicateBoardIdException.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/exception/DuplicateBoardNameException.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/exception/FixedBoardNotRenamableException.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/exception/FixedBoardNotReorderableException.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/domain/repository/BoardPostCount.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/domain/repository/BoardReader.kt create mode 100644 src/test/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryServiceTest.kt diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/request/CreatePostRequest.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/request/CreatePostRequest.kt index e1f5805a..296d8ffd 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/dto/request/CreatePostRequest.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/request/CreatePostRequest.kt @@ -15,8 +15,6 @@ data class CreatePostRequest( @field:Schema(description = "게시글 내용", example = "내용입니다.") @field:NotBlank val content: String, - @field:Schema(description = "기수", nullable = true) - val cardinalNumber: Int? = null, @field:Schema(description = "첨부 파일 목록", nullable = true) @field:Valid val files: List<@NotNull FileSaveRequest>? = null, diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/request/ReorderBoardsRequest.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/request/ReorderBoardsRequest.kt new file mode 100644 index 00000000..61cc978f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/request/ReorderBoardsRequest.kt @@ -0,0 +1,15 @@ +package com.weeth.domain.board.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotEmpty + +data class ReorderBoardsRequest( + @field:Schema( + description = + "표시할 순서대로 게시판 ID를 담아 보내주세요. " + + "공지사항과 전체 게시판은 고정이므로 제외하고 나머지 게시판 ID만 포함해야 합니다.", + example = "[3, 1, 2]", + ) + @field:NotEmpty + val boardIds: List, +) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdatePostRequest.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdatePostRequest.kt index c60e41c3..b0207aeb 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdatePostRequest.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdatePostRequest.kt @@ -12,8 +12,6 @@ data class UpdatePostRequest( val title: String? = null, @field:Schema(description = "게시글 내용 (null=변경 안 함)") val content: String? = null, - @field:Schema(description = "기수 (null=변경 안 함)", nullable = true) - val cardinalNumber: Int? = null, @field:Schema(description = "첨부 파일 변경 규약: null=변경 안 함, []=전체 삭제, 배열 전달=해당 목록으로 교체", nullable = true) @field:Valid val files: List<@NotNull FileSaveRequest>? = null, diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardDetailResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardDetailResponse.kt index ba6d2b7e..c4608d03 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardDetailResponse.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardDetailResponse.kt @@ -7,18 +7,22 @@ import io.swagger.v3.oas.annotations.media.Schema @JsonInclude(JsonInclude.Include.NON_NULL) data class BoardDetailResponse( - @field:Schema(description = "게시판 ID") - val id: Long, + @field:Schema(description = "게시판 ID (전체 게시판은 null)") + val id: Long?, @field:Schema(description = "게시판 이름") val name: String, @field:Schema(description = "게시판 타입") val type: BoardType, - @field:Schema(description = "댓글 허용 여부") - val commentEnabled: Boolean, - @field:Schema(description = "게시글 작성 권한") - val writePermission: MemberRole, - @field:Schema(description = "비공개 게시판 여부") - val isPrivate: Boolean, + @field:Schema(description = "댓글 허용 여부 (전체 게시판은 null)") + val commentEnabled: Boolean?, + @field:Schema(description = "게시글 작성 권한 (전체 게시판은 null)") + val writePermission: MemberRole?, + @field:Schema(description = "비공개 게시판 여부 (전체 게시판은 null)") + val isPrivate: Boolean?, + @field:Schema(description = "표시 순서 (전체 게시판은 null)") + val displayOrder: Int?, + @field:Schema(description = "게시글 수 (관리자 페이지에서만 값 존재)") + val postCount: Int? = null, @field:Schema(description = "삭제 여부 (관리자 페이지에서만 값 존재)") val isDeleted: Boolean?, ) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardListResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardListResponse.kt index f5f6ec40..082af45b 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardListResponse.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardListResponse.kt @@ -4,8 +4,8 @@ import com.weeth.domain.board.domain.enums.BoardType import io.swagger.v3.oas.annotations.media.Schema data class BoardListResponse( - @field:Schema(description = "게시판 ID") - val id: Long, + @field:Schema(description = "게시판 ID (전체 게시판은 null)") + val id: Long?, @field:Schema(description = "게시판 이름") val name: String, @field:Schema(description = "게시판 타입") diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt index 89918dd3..a553f00f 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt @@ -9,6 +9,10 @@ import java.time.LocalDateTime data class PostDetailResponse( @field:Schema(description = "게시글 ID") val id: Long, + @field:Schema(description = "게시판 ID") + val boardId: Long, + @field:Schema(description = "게시판 이름") + val boardName: String, @field:Schema(description = "작성자 정보") val author: UserInfo, @field:Schema(description = "제목") diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt index a0821d8b..37777e47 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt @@ -9,6 +9,10 @@ data class PostListResponse( val id: Long, @field:Schema(description = "작성자 정보") val author: UserInfo, + @field:Schema(description = "게시판 ID") + val boardId: Long, + @field:Schema(description = "게시판 이름") + val boardName: String, @field:Schema(description = "제목") val title: String, @field:Schema(description = "내용") diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt index e6a9beb2..3718c4ec 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt @@ -33,6 +33,21 @@ enum class BoardErrorCode( @ExplainError("경로의 clubId와 게시판의 소속 클럽이 일치하지 않을 때 발생합니다.") BOARD_NOT_IN_CLUB(20407, HttpStatus.FORBIDDEN, "해당 클럽에 속한 게시판이 아닙니다."), + @ExplainError("순서 변경 요청에 중복된 게시판 ID가 포함되어 있을 때 발생합니다.") + DUPLICATE_BOARD_ID(20408, HttpStatus.BAD_REQUEST, "중복된 게시판 ID가 포함되어 있습니다."), + + @ExplainError("동일한 클럽 내에 같은 이름의 게시판이 이미 존재할 때 발생합니다.") + DUPLICATE_BOARD_NAME(20409, HttpStatus.CONFLICT, "이미 존재하는 게시판 이름입니다."), + + @ExplainError("공지사항 등 고정 게시판을 순서 변경 요청에 포함할 때 발생합니다.") + FIXED_BOARD_NOT_REORDERABLE(20410, HttpStatus.BAD_REQUEST, "고정 게시판은 순서를 변경할 수 없습니다."), + + @ExplainError("공지사항 등 고정 게시판의 이름 변경을 시도할 때 발생합니다.") + FIXED_BOARD_NOT_RENAMABLE(20411, HttpStatus.BAD_REQUEST, "고정 게시판의 이름은 변경할 수 없습니다."), + + @ExplainError("삭제된 게시판을 순서 변경 요청에 포함할 때 발생합니다.") + DELETED_BOARD_NOT_REORDERABLE(20412, HttpStatus.BAD_REQUEST, "삭제된 게시판의 순서는 변경할 수 없습니다."), + @ExplainError("좋아요 처리 중 동시 요청이 많아 락 획득에 실패했을 때 발생합니다.") - POST_LIKE_LOCK_TIMEOUT(20408, HttpStatus.TOO_MANY_REQUESTS, "잠시 후 다시 시도해주세요."), + POST_LIKE_LOCK_TIMEOUT(20413, HttpStatus.TOO_MANY_REQUESTS, "잠시 후 다시 시도해주세요."), } diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/DeletedBoardNotReorderableException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/DeletedBoardNotReorderableException.kt new file mode 100644 index 00000000..21f12c3f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/DeletedBoardNotReorderableException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class DeletedBoardNotReorderableException : BaseException(BoardErrorCode.DELETED_BOARD_NOT_REORDERABLE) diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/DuplicateBoardIdException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/DuplicateBoardIdException.kt new file mode 100644 index 00000000..c5abc650 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/DuplicateBoardIdException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class DuplicateBoardIdException : BaseException(BoardErrorCode.DUPLICATE_BOARD_ID) diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/DuplicateBoardNameException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/DuplicateBoardNameException.kt new file mode 100644 index 00000000..d96ef422 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/DuplicateBoardNameException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class DuplicateBoardNameException : BaseException(BoardErrorCode.DUPLICATE_BOARD_NAME) diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/FixedBoardNotRenamableException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/FixedBoardNotRenamableException.kt new file mode 100644 index 00000000..e4c9e8cb --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/FixedBoardNotRenamableException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class FixedBoardNotRenamableException : BaseException(BoardErrorCode.FIXED_BOARD_NOT_RENAMABLE) diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/FixedBoardNotReorderableException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/FixedBoardNotReorderableException.kt new file mode 100644 index 00000000..b13f8ac1 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/FixedBoardNotReorderableException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class FixedBoardNotReorderableException : BaseException(BoardErrorCode.FIXED_BOARD_NOT_REORDERABLE) diff --git a/src/main/kotlin/com/weeth/domain/board/application/mapper/BoardMapper.kt b/src/main/kotlin/com/weeth/domain/board/application/mapper/BoardMapper.kt index 08c7934d..4e2a8510 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/mapper/BoardMapper.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/mapper/BoardMapper.kt @@ -22,17 +22,22 @@ class BoardMapper { commentEnabled = board.config.commentEnabled, writePermission = board.config.writePermission, isPrivate = board.config.isPrivate, + displayOrder = board.displayOrder, isDeleted = null, // public api에서 삭제 여부는 보여주지 않음 ) - fun toDetailResponseForAdmin(board: Board) = - BoardDetailResponse( - id = board.id, - name = board.name, - type = board.type, - commentEnabled = board.config.commentEnabled, - writePermission = board.config.writePermission, - isPrivate = board.config.isPrivate, - isDeleted = board.isDeleted, - ) + fun toDetailResponseForAdmin( + board: Board, + postCount: Int? = null, + ) = BoardDetailResponse( + id = board.id, + name = board.name, + type = board.type, + commentEnabled = board.config.commentEnabled, + writePermission = board.config.writePermission, + isPrivate = board.config.isPrivate, + displayOrder = board.displayOrder, + postCount = postCount, + isDeleted = board.isDeleted, + ) } diff --git a/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt b/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt index 5ed127b2..62a63ffe 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt @@ -32,6 +32,8 @@ class PostMapper( isLiked: Boolean, ) = PostDetailResponse( id = post.id, + boardId = post.board.id, + boardName = post.board.name, author = UserInfo.of(post.user, authorMember.memberRole, resolveProfileImage(authorMember)), title = post.title, content = post.content, @@ -51,6 +53,8 @@ class PostMapper( ) = PostListResponse( id = post.id, author = UserInfo.of(post.user, authorMember.memberRole, resolveProfileImage(authorMember)), + boardId = post.board.id, + boardName = post.board.name, title = post.title, content = post.content, time = post.modifiedAt, diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt index 07bb8f70..11612d86 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt @@ -1,11 +1,19 @@ package com.weeth.domain.board.application.usecase.command import com.weeth.domain.board.application.dto.request.CreateBoardRequest +import com.weeth.domain.board.application.dto.request.ReorderBoardsRequest import com.weeth.domain.board.application.dto.request.UpdateBoardRequest import com.weeth.domain.board.application.dto.response.BoardDetailResponse import com.weeth.domain.board.application.exception.BoardNotFoundException +import com.weeth.domain.board.application.exception.BoardNotInClubException +import com.weeth.domain.board.application.exception.DeletedBoardNotReorderableException +import com.weeth.domain.board.application.exception.DuplicateBoardIdException +import com.weeth.domain.board.application.exception.DuplicateBoardNameException +import com.weeth.domain.board.application.exception.FixedBoardNotRenamableException +import com.weeth.domain.board.application.exception.FixedBoardNotReorderableException import com.weeth.domain.board.application.mapper.BoardMapper import com.weeth.domain.board.domain.entity.Board +import com.weeth.domain.board.domain.enums.BoardType import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.board.domain.vo.BoardConfig import com.weeth.domain.club.domain.repository.ClubReader @@ -33,6 +41,15 @@ class ManageBoardUseCase( clubPermissionPolicy.requireAdmin(clubId, userId) val club = clubReader.getClubById(clubId) + if (boardRepository.existsByClubIdAndNameAndIsDeletedFalse( + clubId, + request.name, + ) + ) { + throw DuplicateBoardNameException() + } + + val nextOrder = boardRepository.findMaxActiveDisplayOrderByClubId(clubId) + 1 val board = Board( club = club, @@ -45,6 +62,7 @@ class ManageBoardUseCase( isPrivate = request.isPrivate, ), ) + board.reorder(nextOrder) val savedBoard = boardRepository.save(board) return boardMapper.toDetailResponse(savedBoard) @@ -61,7 +79,18 @@ class ManageBoardUseCase( val board = findBoard(boardId) if (board.club.id != clubId) throw BoardNotFoundException() - request.name?.let { board.rename(it) } + request.name?.let { + if (board.type == BoardType.NOTICE) throw FixedBoardNotRenamableException() + if (boardRepository.existsByClubIdAndNameAndIsDeletedFalseAndIdNot( + clubId, + it, + boardId, + ) + ) { + throw DuplicateBoardNameException() + } + board.rename(it) + } // BoardConfig는 불변 VO이므로 개별 필드 수정이 불가능하여 copy()로 새 객체를 만들어 통째로 교체한다. null이면 기존 값을 명시적으로 채운다. // 바깥 if 문은 config 관련 필드가 전부 null인 요청에서 불필요한 VO 생성을 방지한다. @@ -88,7 +117,43 @@ class ManageBoardUseCase( val board = findBoard(boardId) if (board.club.id != clubId) throw BoardNotFoundException() + val maxOrder = boardRepository.findMaxDisplayOrderByClubId(clubId) board.markDeleted() + board.reorder(maxOrder + 1) + } + + @Transactional + fun reorder( + clubId: Long, + request: ReorderBoardsRequest, + userId: Long, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + + val uniqueIds = request.boardIds.toSet() + if (uniqueIds.size != request.boardIds.size) throw DuplicateBoardIdException() + + val allBoards = boardRepository.findAllByClubIdOrderByDisplayOrderAscIdAsc(clubId) + val (activeBoards, deletedBoards) = allBoards.partition { !it.isDeleted } + + // 삭제된 게시판 ID가 요청에 포함되면 명확한 에러 반환 + val deletedIds = deletedBoards.mapTo(mutableSetOf()) { it.id } + if (uniqueIds.any { it in deletedIds }) throw DeletedBoardNotReorderableException() + + val (fixedBoards, reorderableBoards) = activeBoards.partition { it.type == BoardType.NOTICE } + + // 고정 게시판 ID가 요청에 포함되면 명확한 에러 반환 + val fixedIds = fixedBoards.mapTo(mutableSetOf()) { it.id } + if (uniqueIds.any { it in fixedIds }) throw FixedBoardNotReorderableException() + + val boardById = reorderableBoards.associateBy { it.id } + if (uniqueIds.any { it !in boardById }) throw BoardNotInClubException() + + // 요청된 게시판들의 현재 displayOrder 슬롯을 정렬 후 재배분 (부분 재정렬 시 충돌 방지) + val slots = request.boardIds.map { boardById.getValue(it).displayOrder }.sorted() + request.boardIds.forEachIndexed { index, boardId -> + boardById.getValue(boardId).reorder(slots[index]) + } } private fun findBoard(boardId: Long): Board = diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt index 6c9bef10..48e792d6 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt @@ -12,6 +12,7 @@ import com.weeth.domain.board.domain.entity.Board import com.weeth.domain.board.domain.entity.Post import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.board.domain.repository.PostRepository +import com.weeth.domain.cardinal.domain.repository.CardinalReader import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.file.application.dto.request.FileSaveRequest @@ -29,6 +30,7 @@ class ManagePostUseCase( private val boardRepository: BoardRepository, private val userReader: UserReader, private val clubMemberPolicy: ClubMemberPolicy, + private val cardinalReader: CardinalReader, private val fileRepository: FileRepository, private val fileReader: FileReader, private val fileMapper: FileMapper, @@ -46,13 +48,14 @@ class ManagePostUseCase( val board = findBoardInClub(boardId, clubId) validateWritePermission(board, member) + val currentCardinalNumber = cardinalReader.findInProgressByClubId(clubId)?.cardinalNumber val post = Post.create( title = request.title, content = request.content, user = user, board = board, - cardinalNumber = request.cardinalNumber, // TODO: 백엔드에서 최신 기수 넣어주기 + cardinalNumber = currentCardinalNumber, ) val savedPost = postRepository.save(post) @@ -77,7 +80,6 @@ class ManagePostUseCase( post.update( newTitle = request.title, newContent = request.content, - newCardinalNumber = request.cardinalNumber, ) replacePostFiles(post, request.files) diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt index 58f9bcd8..ebc82c2a 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt @@ -4,7 +4,9 @@ import com.weeth.domain.board.application.dto.response.BoardDetailResponse import com.weeth.domain.board.application.dto.response.BoardListResponse import com.weeth.domain.board.application.exception.BoardNotFoundException import com.weeth.domain.board.application.mapper.BoardMapper +import com.weeth.domain.board.domain.enums.BoardType import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.board.domain.repository.PostRepository import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.club.domain.service.ClubPermissionPolicy import org.springframework.stereotype.Service @@ -14,6 +16,7 @@ import org.springframework.transaction.annotation.Transactional @Transactional(readOnly = true) class GetBoardQueryService( private val boardRepository: BoardRepository, + private val postRepository: PostRepository, private val clubMemberPolicy: ClubMemberPolicy, private val clubPermissionPolicy: ClubPermissionPolicy, private val boardMapper: BoardMapper, @@ -24,10 +27,17 @@ class GetBoardQueryService( ): List { val member = clubMemberPolicy.getActiveMember(clubId, userId) - return boardRepository - .findAllByClubIdAndIsDeletedFalseOrderByIdAsc(clubId) - .filter { it.isAccessibleBy(member.memberRole) } - .map(boardMapper::toListResponse) + val realBoards = + boardRepository + .findAllByClubIdAndIsDeletedFalseOrderByDisplayOrderAscIdAsc(clubId) + .filter { it.isAccessibleBy(member.memberRole) } + + // 공지사항 고정 첫 번째, 전체(가상) 두 번째, 나머지는 displayOrder 순 + val (noticeList, otherList) = realBoards.partition { it.type == BoardType.NOTICE } + val noticeBoards = noticeList.map(boardMapper::toListResponse) + val otherBoards = otherList.map(boardMapper::toListResponse) + + return noticeBoards + VIRTUAL_ALL_BOARD + otherBoards } fun findBoardDetailForAdmin( @@ -37,8 +47,14 @@ class GetBoardQueryService( ): BoardDetailResponse { clubPermissionPolicy.requireAdmin(clubId, userId) val board = boardRepository.findByIdAndClubId(boardId, clubId) ?: throw BoardNotFoundException() + val postCount = + postRepository + .countActivePostsByBoardIds(listOf(boardId)) + .firstOrNull() + ?.postCount + ?.toInt() ?: 0 - return boardMapper.toDetailResponseForAdmin(board) + return boardMapper.toDetailResponseForAdmin(board, postCount) } fun findAllBoardsForAdmin( @@ -47,8 +63,37 @@ class GetBoardQueryService( ): List { clubPermissionPolicy.requireAdmin(clubId, userId) - return boardRepository - .findAllByClubIdOrderByIdAsc(clubId) - .map(boardMapper::toDetailResponseForAdmin) + val boards = boardRepository.findAllByClubIdOrderByDisplayOrderAscIdAsc(clubId) + val boardIds = boards.map { it.id } + val postCountMap = + if (boardIds.isEmpty()) { + emptyMap() + } else { + postRepository.countActivePostsByBoardIds(boardIds).associate { it.boardId to it.postCount.toInt() } + } + + val (noticeList, otherList) = boards.partition { it.type == BoardType.NOTICE } + val noticeBoards = noticeList.map { boardMapper.toDetailResponseForAdmin(it, postCountMap[it.id] ?: 0) } + val otherBoards = otherList.map { boardMapper.toDetailResponseForAdmin(it, postCountMap[it.id] ?: 0) } + val totalPostCount = postCountMap.values.sum() + + return noticeBoards + virtualAllBoardForAdmin(totalPostCount) + otherBoards + } + + companion object { + private val VIRTUAL_ALL_BOARD = BoardListResponse(id = null, name = "전체", type = BoardType.ALL) + + private fun virtualAllBoardForAdmin(totalPostCount: Int) = + BoardDetailResponse( + id = null, + name = "전체", + type = BoardType.ALL, + commentEnabled = null, + writePermission = null, + isPrivate = null, + displayOrder = null, + postCount = totalPostCount, + isDeleted = null, + ) } } diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt index af9aae19..2f868ca9 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt @@ -21,6 +21,7 @@ import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.repository.FileReader import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Slice +import org.springframework.data.domain.SliceImpl import org.springframework.data.domain.Sort import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -69,6 +70,45 @@ class GetPostQueryService( return postMapper.toDetailResponse(post, memberMap.getValue(post.user.id), commentTree, files, isLiked) } + fun findAllPosts( + clubId: Long, + userId: Long, + pageNumber: Int, + pageSize: Int, + ): Slice { + val member = clubMemberPolicy.getActiveMember(clubId, userId) + validatePage(pageNumber, pageSize) + + val accessibleBoardIds = + boardRepository + .findAllByClubIdAndIsDeletedFalseOrderByDisplayOrderAscIdAsc(clubId) + .filter { it.isAccessibleBy(member.memberRole) } + .map { it.id } + + val pageable = PageRequest.of(pageNumber, pageSize) + + if (accessibleBoardIds.isEmpty()) { + return SliceImpl(emptyList(), pageable, false) + } + + val posts = postRepository.findAllActiveByBoardIds(accessibleBoardIds, pageable) + val postIds = posts.content.map { it.id } + val memberMap = buildMemberMap(clubId, posts.content.map { it.user.id }.distinct()) + val fileExistsByPostId = buildFileExistsMap(postIds) + val likedPostIds = postLikeRepository.findLikedPostIds(postIds, userId) + val now = LocalDateTime.now() + + return posts.map { post -> + postMapper.toListResponse( + post, + memberMap.getValue(post.user.id), + fileExistsByPostId[post.id] == true, + now, + post.id in likedPostIds, + ) + } + } + fun findPosts( clubId: Long, userId: Long, diff --git a/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt b/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt index 9906279e..aa985dd4 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt @@ -29,6 +29,7 @@ class Board( ) : BaseEntity() { init { require(name.isNotBlank()) { "게시판 이름은 공백이 될 수 없습니다" } + require(type != BoardType.ALL) { "ALL은 가상 타입으로 게시판을 생성할 수 없습니다" } } @ManyToOne(fetch = FetchType.LAZY) @@ -55,6 +56,10 @@ class Board( var config: BoardConfig = config private set + @Column(nullable = false) + var displayOrder: Int = 0 + private set + @Column(nullable = false) var isDeleted: Boolean = false private set @@ -86,4 +91,9 @@ class Board( fun restore() { isDeleted = false } + + fun reorder(newOrder: Int) { + require(newOrder >= 0) { "순서는 0 이상이어야 합니다." } + displayOrder = newOrder + } } diff --git a/src/main/kotlin/com/weeth/domain/board/domain/entity/Post.kt b/src/main/kotlin/com/weeth/domain/board/domain/entity/Post.kt index ff1ec0f3..32d886f8 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/entity/Post.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/entity/Post.kt @@ -85,7 +85,6 @@ class Post( fun update( newTitle: String?, newContent: String?, - newCardinalNumber: Int?, ) { newTitle?.let { require(it.isNotBlank()) { "제목은 비어 있을 수 없습니다" } @@ -95,7 +94,6 @@ class Post( require(it.isNotBlank()) { "내용은 비어 있을 수 없습니다" } content = it } - newCardinalNumber?.let { cardinalNumber = it } } fun markDeleted() { diff --git a/src/main/kotlin/com/weeth/domain/board/domain/enums/BoardType.kt b/src/main/kotlin/com/weeth/domain/board/domain/enums/BoardType.kt index c27ebe15..ab17de71 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/enums/BoardType.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/enums/BoardType.kt @@ -1,6 +1,7 @@ package com.weeth.domain.board.domain.enums enum class BoardType { + ALL, // 가상 전체 게시판 (DB에 저장되지 않음) NOTICE, GALLERY, GENERAL, diff --git a/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardPostCount.kt b/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardPostCount.kt new file mode 100644 index 00000000..66416359 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardPostCount.kt @@ -0,0 +1,6 @@ +package com.weeth.domain.board.domain.repository + +data class BoardPostCount( + val boardId: Long, + val postCount: Long, +) diff --git a/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardReader.kt b/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardReader.kt new file mode 100644 index 00000000..62b26aac --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardReader.kt @@ -0,0 +1,7 @@ +package com.weeth.domain.board.domain.repository + +import com.weeth.domain.board.domain.entity.Board + +interface BoardReader { + fun findAllActiveByClubId(clubId: Long): List +} diff --git a/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardRepository.kt b/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardRepository.kt index 6a6bdb99..19ace2b7 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardRepository.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardRepository.kt @@ -2,8 +2,11 @@ package com.weeth.domain.board.domain.repository import com.weeth.domain.board.domain.entity.Board import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query -interface BoardRepository : JpaRepository { +interface BoardRepository : + JpaRepository, + BoardReader { fun findByIdAndIsDeletedFalse(id: Long): Board? fun findByIdAndClubId( @@ -11,12 +14,32 @@ interface BoardRepository : JpaRepository { clubId: Long, ): Board? - fun findAllByClubIdAndIsDeletedFalseOrderByIdAsc(clubId: Long): List + fun findAllByClubIdAndIsDeletedFalseOrderByDisplayOrderAscIdAsc(clubId: Long): List + + override fun findAllActiveByClubId(clubId: Long): List = + findAllByClubIdAndIsDeletedFalseOrderByDisplayOrderAscIdAsc(clubId) fun findByIdAndClubIdAndIsDeletedFalse( boardId: Long, clubId: Long, ): Board? - fun findAllByClubIdOrderByIdAsc(clubId: Long): List + fun findAllByClubIdOrderByDisplayOrderAscIdAsc(clubId: Long): List + + @Query("SELECT COALESCE(MAX(b.displayOrder), -1) FROM Board b WHERE b.club.id = :clubId AND b.isDeleted = false") + fun findMaxActiveDisplayOrderByClubId(clubId: Long): Int + + @Query("SELECT COALESCE(MAX(b.displayOrder), -1) FROM Board b WHERE b.club.id = :clubId") + fun findMaxDisplayOrderByClubId(clubId: Long): Int + + fun existsByClubIdAndNameAndIsDeletedFalse( + clubId: Long, + name: String, + ): Boolean + + fun existsByClubIdAndNameAndIsDeletedFalseAndIdNot( + clubId: Long, + name: String, + id: Long, + ): Boolean } diff --git a/src/main/kotlin/com/weeth/domain/board/domain/repository/PostReader.kt b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostReader.kt index 6c000ef9..0cac837d 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/repository/PostReader.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostReader.kt @@ -27,9 +27,8 @@ interface PostReader { pageable: Pageable, ): Slice - fun findRecentByClubIdExcludingBoardType( - clubId: Long, - excludedType: BoardType, + fun findRecentByBoardIds( + boardIds: List, pageable: Pageable, ): Slice diff --git a/src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt index 13b15464..b2aef8ff 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt @@ -19,7 +19,23 @@ import java.time.LocalDateTime interface PostRepository : JpaRepository, PostReader { - @EntityGraph(attributePaths = ["user"]) + @EntityGraph(attributePaths = ["user", "board"]) + @Query( + """ + SELECT p + FROM Post p + WHERE p.board.id IN :boardIds + AND p.isDeleted = false + AND p.board.isDeleted = false + ORDER BY p.createdAt DESC, p.id DESC + """, + ) + fun findAllActiveByBoardIds( + @Param("boardIds") boardIds: List, + pageable: Pageable, + ): Slice + + @EntityGraph(attributePaths = ["user", "board"]) @Query( """ SELECT p @@ -64,7 +80,7 @@ interface PostRepository : @Param("id") id: Long, ): Post? - @EntityGraph(attributePaths = ["user"]) + @EntityGraph(attributePaths = ["user", "board"]) @Query( """ SELECT p @@ -85,6 +101,11 @@ interface PostRepository : override fun findActiveById(postId: Long): Post? = findActivePostById(postId) + override fun findRecentByBoardIds( + boardIds: List, + pageable: Pageable, + ): Slice = findAllActiveByBoardIds(boardIds, pageable) + @EntityGraph(attributePaths = ["user"]) @Query( """ @@ -135,24 +156,6 @@ interface PostRepository : pageable: Pageable, ): Slice - @EntityGraph(attributePaths = ["user"]) - @Query( - """ - SELECT p - FROM Post p - WHERE p.board.club.id = :clubId - AND p.board.type <> :excludedType - AND p.isDeleted = false - AND p.board.isDeleted = false - ORDER BY p.createdAt DESC, p.id DESC - """, - ) - override fun findRecentByClubIdExcludingBoardType( - @Param("clubId") clubId: Long, - @Param("excludedType") excludedType: BoardType, - pageable: Pageable, - ): Slice - @EntityGraph(attributePaths = ["user"]) @Query( """ @@ -182,4 +185,17 @@ interface PostRepository : boardType: BoardType, since: LocalDateTime, ): Post? = findUnreadNoticeSince(clubId, userId, boardType, since, PageRequest.of(0, 1)).firstOrNull() + + @Query( + """ + SELECT new com.weeth.domain.board.domain.repository.BoardPostCount(p.board.id, COUNT(p)) + FROM Post p + WHERE p.board.id IN :boardIds + AND p.isDeleted = false + GROUP BY p.board.id + """, + ) + fun countActivePostsByBoardIds( + @Param("boardIds") boardIds: List, + ): List } diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt b/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt index a3df40b3..5f7102a3 100644 --- a/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt @@ -1,6 +1,7 @@ package com.weeth.domain.board.presentation import com.weeth.domain.board.application.dto.request.CreateBoardRequest +import com.weeth.domain.board.application.dto.request.ReorderBoardsRequest import com.weeth.domain.board.application.dto.request.UpdateBoardRequest import com.weeth.domain.board.application.dto.response.BoardDetailResponse import com.weeth.domain.board.application.exception.BoardErrorCode @@ -20,6 +21,7 @@ import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PatchMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @@ -84,6 +86,18 @@ class BoardAdminController( manageBoardUseCase.update(clubId, boardId, request, userId), ) + @PutMapping("/order") + @Operation(summary = "게시판 순서 변경", description = "boardIds 배열의 순서대로 게시판 표시 순서를 저장합니다.") + fun reorderBoards( + @TsidParam + @TsidPathVariable clubId: Long, + @RequestBody @Valid request: ReorderBoardsRequest, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + manageBoardUseCase.reorder(clubId, request, userId) + return CommonResponse.success(BoardResponseCode.BOARD_REORDERED_SUCCESS) + } + @DeleteMapping("/{boardId}") @Operation(summary = "게시판 삭제") fun deleteBoard( diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt b/src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt index 80cd598c..d8454f83 100644 --- a/src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt @@ -20,5 +20,7 @@ enum class BoardResponseCode( BOARD_FIND_ALL_SUCCESS(10409, HttpStatus.OK, "게시판 목록이 성공적으로 조회되었습니다."), BOARD_FIND_BY_ID_SUCCESS(10410, HttpStatus.OK, "게시판이 성공적으로 조회되었습니다."), BOARD_NOTICE_READ_SUCCESS(10411, HttpStatus.OK, "공지를 읽음 처리했습니다."), - POST_LIKE_TOGGLE_SUCCESS(10412, HttpStatus.OK, "게시글 좋아요가 처리되었습니다."), + POST_FIND_ALL_BY_CLUB_SUCCESS(10412, HttpStatus.OK, "전체 게시글 목록이 성공적으로 조회되었습니다."), + BOARD_REORDERED_SUCCESS(10413, HttpStatus.OK, "게시판 순서가 성공적으로 변경되었습니다."), + POST_LIKE_TOGGLE_SUCCESS(10414, HttpStatus.OK, "게시글 좋아요가 처리되었습니다."), } diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt b/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt index ec578e08..1135a9a8 100644 --- a/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt +++ b/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt @@ -56,6 +56,20 @@ class PostController( managePostUseCase.save(clubId, boardId, request, userId), ) + @GetMapping("/posts") + @Operation(summary = "전체 게시글 조회", description = "클럽 내 접근 가능한 모든 게시판의 게시글을 최신순으로 조회합니다.") + fun findAllPosts( + @TsidParam + @TsidPathVariable clubId: Long, + @RequestParam(defaultValue = "0") pageNumber: Int, + @RequestParam(defaultValue = "10") pageSize: Int, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse> = + CommonResponse.success( + BoardResponseCode.POST_FIND_ALL_BY_CLUB_SUCCESS, + getPostQueryService.findAllPosts(clubId, userId, pageNumber, pageSize), + ) + @GetMapping("/{boardId}/posts") @Operation(summary = "게시글 목록 조회") fun findPosts( diff --git a/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalReader.kt b/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalReader.kt index e6feeef7..81899291 100644 --- a/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalReader.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalReader.kt @@ -32,4 +32,6 @@ interface CardinalReader { ): List fun findAllByClubIdOrderByCardinalNumberAsc(clubId: Long): List + + fun findInProgressByClubId(clubId: Long): Cardinal? } diff --git a/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalRepository.kt b/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalRepository.kt index 4429a4df..99420b26 100644 --- a/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalRepository.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalRepository.kt @@ -51,6 +51,14 @@ interface CardinalRepository : override fun findAllByClubIdOrderByCardinalNumberAsc(clubId: Long): List + fun findFirstByClubIdAndStatusOrderByCardinalNumberDesc( + clubId: Long, + status: CardinalStatus, + ): Cardinal? + + override fun findInProgressByClubId(clubId: Long): Cardinal? = + findFirstByClubIdAndStatusOrderByCardinalNumberDesc(clubId, CardinalStatus.IN_PROGRESS) + override fun getByCardinalNumber(cardinalNumber: Int): Cardinal = findByCardinalNumber(cardinalNumber).orElseThrow { CardinalNotFoundException() } diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt index f73de108..2724712b 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt @@ -1,5 +1,9 @@ package com.weeth.domain.club.application.usecase.command +import com.weeth.domain.board.domain.entity.Board +import com.weeth.domain.board.domain.enums.BoardType +import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.board.domain.vo.BoardConfig import com.weeth.domain.cardinal.domain.entity.Cardinal import com.weeth.domain.cardinal.domain.enums.CardinalStatus import com.weeth.domain.cardinal.domain.repository.CardinalRepository @@ -32,6 +36,7 @@ class ManageClubUseCase( private val clubMemberRepository: ClubMemberRepository, private val cardinalRepository: CardinalRepository, private val clubMemberCardinalRepository: ClubMemberCardinalRepository, + private val boardRepository: BoardRepository, private val userReader: UserReader, private val clubJoinPolicy: ClubJoinPolicy, private val clubPermissionPolicy: ClubPermissionPolicy, @@ -74,6 +79,16 @@ class ManageClubUseCase( clubRepository.save(club) + // 공지사항 게시판 자동 생성 (관리자만 작성 가능, displayOrder=0) + val noticeBoard = + Board( + club = club, + name = "공지사항", + type = BoardType.NOTICE, + config = BoardConfig(writePermission = MemberRole.ADMIN), + ) + boardRepository.save(noticeBoard) + val leadMember = ClubMember .create( diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryService.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryService.kt index 2d06a620..c6e56108 100644 --- a/src/main/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryService.kt @@ -1,6 +1,7 @@ package com.weeth.domain.dashboard.application.usecase.query import com.weeth.domain.board.domain.enums.BoardType +import com.weeth.domain.board.domain.repository.BoardReader import com.weeth.domain.board.domain.repository.PostReader import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.club.domain.repository.ClubMemberReader @@ -19,6 +20,7 @@ import com.weeth.domain.session.domain.repository.SessionReader import com.weeth.domain.user.domain.repository.UserReader import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Slice +import org.springframework.data.domain.SliceImpl import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.LocalDate @@ -27,6 +29,7 @@ import java.time.LocalDateTime @Service @Transactional(readOnly = true) class GetDashboardQueryService( + private val boardReader: BoardReader, private val clubReader: ClubReader, private val clubMemberReader: ClubMemberReader, private val clubMemberPolicy: ClubMemberPolicy, @@ -69,14 +72,21 @@ class GetDashboardQueryService( pageNumber: Int, pageSize: Int, ): Slice { - clubMemberPolicy.getActiveMember(clubId, userId) + val member = clubMemberPolicy.getActiveMember(clubId, userId) - val posts = - postReader.findRecentByClubIdExcludingBoardType( - clubId, - BoardType.NOTICE, - PageRequest.of(pageNumber, pageSize), - ) + val accessibleBoardIds = + boardReader + .findAllActiveByClubId(clubId) + .filter { it.isAccessibleBy(member.memberRole) && it.type != BoardType.NOTICE } + .map { it.id } + + val pageable = PageRequest.of(pageNumber, pageSize) + + if (accessibleBoardIds.isEmpty()) { + return SliceImpl(emptyList(), pageable, false) + } + + val posts = postReader.findRecentByBoardIds(accessibleBoardIds, pageable) val now = LocalDateTime.now() val postIds = posts.content.map { it.id } val filesByPostId = fileReader.findAll(FileOwnerType.POST, postIds).groupBy { it.ownerId } diff --git a/src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt b/src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt index d7f66689..b72ffde8 100644 --- a/src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt @@ -1,5 +1,7 @@ package com.weeth.domain.session.application.usecase.query +import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException +import com.weeth.domain.cardinal.domain.repository.CardinalReader import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.schedule.application.dto.response.SessionInfosResponse @@ -18,6 +20,7 @@ import java.time.temporal.TemporalAdjusters @Transactional(readOnly = true) class GetSessionQueryService( private val sessionRepository: SessionRepository, + private val cardinalReader: CardinalReader, private val clubMemberPolicy: ClubMemberPolicy, private val clubPermissionPolicy: ClubPermissionPolicy, private val sessionMapper: SessionMapper, @@ -43,6 +46,10 @@ class GetSessionQueryService( cardinal: Int?, ): SessionInfosResponse { clubPermissionPolicy.requireAdmin(clubId, userId) + if (cardinal != null) { + cardinalReader.findByClubIdAndCardinalNumber(clubId, cardinal) + ?: throw CardinalNotFoundException() + } val sessions = if (cardinal == null) { sessionRepository.findAllByClubIdOrderByStartDesc(clubId) diff --git a/src/main/kotlin/com/weeth/domain/session/domain/entity/Session.kt b/src/main/kotlin/com/weeth/domain/session/domain/entity/Session.kt index f51a6662..64569335 100644 --- a/src/main/kotlin/com/weeth/domain/session/domain/entity/Session.kt +++ b/src/main/kotlin/com/weeth/domain/session/domain/entity/Session.kt @@ -20,7 +20,7 @@ import java.time.LocalDateTime import kotlin.random.asKotlinRandom @Entity -@Table(name = "meeting") // 테이블명 Session으로 수정 +@Table(name = "session") class Session( club: Club, title: String, diff --git a/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt b/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt index 9efc8cbd..2ea39cfc 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt @@ -1,5 +1,6 @@ package com.weeth.domain.board.application.mapper +import com.weeth.domain.board.domain.entity.Board import com.weeth.domain.board.domain.entity.Post import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.club.domain.enums.MemberRole @@ -21,12 +22,16 @@ class PostMapperTest : val mapper = PostMapper(fileAccessUrlPort) val now = LocalDateTime.now() val user = mockk() + val board = mockk() val post = mockk() val authorMember = mockk() every { user.id } returns 1L every { user.name } returns "테스터" + every { board.id } returns 10L + every { board.name } returns "일반 게시판" + every { authorMember.memberRole } returns MemberRole.USER every { authorMember.profileImageStorageKey } returns null @@ -34,6 +39,7 @@ class PostMapperTest : every { post.title } returns "제목" every { post.content } returns "내용" every { post.user } returns user + every { post.board } returns board every { post.commentCount } returns 2 every { post.likeCount } returns 0 every { post.createdAt } returns now.minusHours(1) diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt index 6ef33530..2782be9f 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt @@ -1,8 +1,15 @@ package com.weeth.domain.board.application.usecase.command import com.weeth.domain.board.application.dto.request.CreateBoardRequest +import com.weeth.domain.board.application.dto.request.ReorderBoardsRequest import com.weeth.domain.board.application.dto.request.UpdateBoardRequest import com.weeth.domain.board.application.exception.BoardNotFoundException +import com.weeth.domain.board.application.exception.BoardNotInClubException +import com.weeth.domain.board.application.exception.DeletedBoardNotReorderableException +import com.weeth.domain.board.application.exception.DuplicateBoardIdException +import com.weeth.domain.board.application.exception.DuplicateBoardNameException +import com.weeth.domain.board.application.exception.FixedBoardNotRenamableException +import com.weeth.domain.board.application.exception.FixedBoardNotReorderableException import com.weeth.domain.board.application.mapper.BoardMapper import com.weeth.domain.board.domain.enums.BoardType import com.weeth.domain.board.domain.repository.BoardRepository @@ -36,6 +43,10 @@ class ManageBoardUseCaseTest : clearMocks(boardRepository, clubReader, clubPermissionPolicy) every { boardRepository.save(any()) } answers { firstArg() } every { clubReader.getClubById(clubId) } returns club + every { boardRepository.findMaxActiveDisplayOrderByClubId(clubId) } returns -1 + every { boardRepository.findMaxDisplayOrderByClubId(clubId) } returns -1 + every { boardRepository.existsByClubIdAndNameAndIsDeletedFalse(any(), any()) } returns false + every { boardRepository.existsByClubIdAndNameAndIsDeletedFalseAndIdNot(any(), any(), any()) } returns false } describe("create") { @@ -57,6 +68,54 @@ class ManageBoardUseCaseTest : result.writePermission shouldBe MemberRole.ADMIN result.isPrivate shouldBe true } + + it("기존 게시판이 없으면 displayOrder 0으로 생성한다") { + every { boardRepository.findMaxActiveDisplayOrderByClubId(clubId) } returns -1 + val request = + CreateBoardRequest( + name = "첫 게시판", + type = BoardType.GENERAL, + commentEnabled = true, + writePermission = MemberRole.USER, + isPrivate = false, + ) + + val result = useCase.create(clubId, request, userId) + + result.displayOrder shouldBe 0 + } + + it("기존 게시판이 있으면 마지막 순서 다음으로 생성한다") { + every { boardRepository.findMaxActiveDisplayOrderByClubId(clubId) } returns 2 + val request = + CreateBoardRequest( + name = "새 게시판", + type = BoardType.GENERAL, + commentEnabled = true, + writePermission = MemberRole.USER, + isPrivate = false, + ) + + val result = useCase.create(clubId, request, userId) + + result.displayOrder shouldBe 3 + } + + it("같은 클럽에 동일한 이름의 게시판이 이미 있으면 예외를 던진다") { + every { boardRepository.existsByClubIdAndNameAndIsDeletedFalse(clubId, "중복 이름") } returns true + val request = + CreateBoardRequest( + name = "중복 이름", + type = BoardType.GENERAL, + commentEnabled = true, + writePermission = MemberRole.USER, + isPrivate = false, + ) + + shouldThrow { + useCase.create(clubId, request, userId) + } + } } describe("update") { @@ -91,17 +150,208 @@ class ManageBoardUseCaseTest : useCase.update(clubId, 999L, UpdateBoardRequest(name = "변경"), userId) } } + + it("변경할 이름이 같은 클럽의 다른 게시판 이름과 중복되면 예외를 던진다") { + val board = BoardTestFixture.create(id = 1L, club = club, name = "기존", type = BoardType.GENERAL) + every { boardRepository.findByIdAndIsDeletedFalse(1L) } returns board + every { boardRepository.existsByClubIdAndNameAndIsDeletedFalseAndIdNot(clubId, "중복 이름", 1L) } returns + true + + shouldThrow { + useCase.update(clubId, 1L, UpdateBoardRequest(name = "중복 이름"), userId) + } + } + + it("공지사항 게시판의 이름을 변경하면 예외를 던진다") { + val noticeBoard = BoardTestFixture.create(id = 1L, club = club, name = "공지사항", type = BoardType.NOTICE) + every { boardRepository.findByIdAndIsDeletedFalse(1L) } returns noticeBoard + + shouldThrow { + useCase.update(clubId, 1L, UpdateBoardRequest(name = "새 이름"), userId) + } + } } describe("delete") { - it("게시판을 soft delete 처리한다") { + it("게시판을 soft delete 처리하고 displayOrder를 맨 아래로 이동한다") { val board = BoardTestFixture.create(club = club, name = "일반", type = BoardType.GENERAL) every { boardRepository.findByIdAndIsDeletedFalse(1L) } returns board + every { boardRepository.findMaxDisplayOrderByClubId(clubId) } returns 2 useCase.delete(clubId, 1L, userId) board.isDeleted shouldBe true + board.displayOrder shouldBe 3 verify(exactly = 0) { boardRepository.delete(any()) } } } + + describe("reorder") { + it("요청 순서대로 displayOrder를 저장한다") { + val board1 = + BoardTestFixture + .create( + id = 1L, + club = club, + name = "A", + type = BoardType.GENERAL, + ).also { it.reorder(0) } + val board2 = + BoardTestFixture + .create( + id = 2L, + club = club, + name = "B", + type = BoardType.GENERAL, + ).also { it.reorder(1) } + val board3 = + BoardTestFixture + .create( + id = 3L, + club = club, + name = "C", + type = BoardType.GENERAL, + ).also { it.reorder(2) } + every { + boardRepository.findAllByClubIdOrderByDisplayOrderAscIdAsc(clubId) + } returns listOf(board1, board2, board3) + + useCase.reorder(clubId, ReorderBoardsRequest(boardIds = listOf(2L, 3L, 1L)), userId) + + board2.displayOrder shouldBe 0 + board3.displayOrder shouldBe 1 + board1.displayOrder shouldBe 2 + } + + it("클럽 게시판 일부만 요청해도 해당 게시판끼리 순서를 교환한다") { + val board1 = + BoardTestFixture + .create( + id = 1L, + club = club, + name = "A", + type = BoardType.GENERAL, + ).also { it.reorder(0) } + val board2 = + BoardTestFixture + .create( + id = 2L, + club = club, + name = "B", + type = BoardType.GENERAL, + ).also { it.reorder(1) } + val board3 = + BoardTestFixture + .create( + id = 3L, + club = club, + name = "C", + type = BoardType.GENERAL, + ).also { it.reorder(2) } + every { + boardRepository.findAllByClubIdOrderByDisplayOrderAscIdAsc(clubId) + } returns listOf(board1, board2, board3) + + // board1(0)과 board3(2)만 swap — board2는 변경 없음 + useCase.reorder(clubId, ReorderBoardsRequest(boardIds = listOf(3L, 1L)), userId) + + board3.displayOrder shouldBe 0 + board1.displayOrder shouldBe 2 + board2.displayOrder shouldBe 1 + } + + it("다른 클럽 게시판 ID가 포함되면 예외를 던진다") { + val board1 = BoardTestFixture.create(id = 1L, club = club, name = "A", type = BoardType.GENERAL) + val board2 = BoardTestFixture.create(id = 2L, club = club, name = "B", type = BoardType.GENERAL) + // 클럽에 2개 게시판이 있는데 존재하지 않는 ID(99L) 요청 + every { + boardRepository.findAllByClubIdOrderByDisplayOrderAscIdAsc(clubId) + } returns listOf(board1, board2) + + shouldThrow { + useCase.reorder(clubId, ReorderBoardsRequest(boardIds = listOf(1L, 99L)), userId) + } + } + + it("중복된 boardId가 포함되면 예외를 던진다") { + // DB 조회 전에 중복 체크로 예외 발생 → repository 호출 없음 + shouldThrow { + useCase.reorder(clubId, ReorderBoardsRequest(boardIds = listOf(1L, 1L, 2L)), userId) + } + verify(exactly = 0) { + boardRepository.findAllByClubIdOrderByDisplayOrderAscIdAsc(any()) + } + } + + it("공지사항 ID를 요청에 포함하면 예외를 던진다") { + val noticeBoard = BoardTestFixture.create(id = 1L, club = club, name = "공지사항", type = BoardType.NOTICE) + val board2 = BoardTestFixture.create(id = 2L, club = club, name = "B", type = BoardType.GENERAL) + every { + boardRepository.findAllByClubIdOrderByDisplayOrderAscIdAsc(clubId) + } returns listOf(noticeBoard, board2) + + shouldThrow { + useCase.reorder(clubId, ReorderBoardsRequest(boardIds = listOf(1L, 2L)), userId) + } + } + + it("삭제된 게시판 ID를 요청에 포함하면 예외를 던진다") { + val board1 = BoardTestFixture.create(id = 1L, club = club, name = "A", type = BoardType.GENERAL) + val deletedBoard = + BoardTestFixture + .create( + id = 2L, + club = club, + name = "B", + type = BoardType.GENERAL, + ).also { + it.markDeleted() + } + every { + boardRepository.findAllByClubIdOrderByDisplayOrderAscIdAsc(clubId) + } returns listOf(board1, deletedBoard) + + shouldThrow { + useCase.reorder(clubId, ReorderBoardsRequest(boardIds = listOf(1L, 2L)), userId) + } + } + + it("요청한 게시판끼리 슬롯을 교환하고 나머지는 그대로 유지한다") { + val noticeBoard = BoardTestFixture.create(id = 1L, club = club, name = "공지사항", type = BoardType.NOTICE) + val board2 = + BoardTestFixture + .create( + id = 2L, + club = club, + name = "B", + type = BoardType.GENERAL, + ).also { it.reorder(0) } + val board3 = + BoardTestFixture + .create( + id = 3L, + club = club, + name = "C", + type = BoardType.GENERAL, + ).also { it.reorder(1) } + val board4 = + BoardTestFixture + .create( + id = 4L, + club = club, + name = "D", + type = BoardType.GENERAL, + ).also { it.reorder(2) } + every { + boardRepository.findAllByClubIdOrderByDisplayOrderAscIdAsc(clubId) + } returns listOf(noticeBoard, board2, board3, board4) + + // board3(1)과 board2(0)를 swap — board4는 변경 없음 + useCase.reorder(clubId, ReorderBoardsRequest(boardIds = listOf(3L, 2L)), userId) + + board3.displayOrder shouldBe 0 + board2.displayOrder shouldBe 1 + board4.displayOrder shouldBe 2 + } + } }) diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt index d8644324..9c5812bc 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt @@ -15,6 +15,8 @@ import com.weeth.domain.board.domain.repository.PostRepository import com.weeth.domain.board.domain.vo.BoardConfig import com.weeth.domain.board.fixture.BoardTestFixture import com.weeth.domain.board.fixture.PostTestFixture +import com.weeth.domain.cardinal.domain.repository.CardinalReader +import com.weeth.domain.cardinal.fixture.CardinalTestFixture import com.weeth.domain.club.domain.enums.MemberRole import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.file.application.dto.request.FileSaveRequest @@ -45,6 +47,7 @@ class ManagePostUseCaseTest : val boardRepository = mockk() val userReader = mockk() val clubMemberPolicy = mockk(relaxed = true) + val cardinalReader = mockk() val fileRepository = mockk() val fileReader = mockk() val fileMapper = mockk() @@ -56,6 +59,7 @@ class ManagePostUseCaseTest : boardRepository, userReader, clubMemberPolicy, + cardinalReader, fileRepository, fileReader, fileMapper, @@ -90,6 +94,7 @@ class ManagePostUseCaseTest : boardRepository, userReader, clubMemberPolicy, + cardinalReader, fileRepository, fileReader, fileMapper, @@ -101,6 +106,7 @@ class ManagePostUseCaseTest : every { fileReader.findAll(any(), any(), any()) } returns emptyList() every { postMapper.toSaveResponse(any()) } returns PostSaveResponse(1L) every { fileRepository.delete(any()) } just runs + every { cardinalReader.findInProgressByClubId(any()) } returns null // update/delete 공통 기본값: 작성자 every { userReader.getById(any()) } returns UserTestFixture.createActiveUser1(1L) } @@ -160,25 +166,40 @@ class ManagePostUseCaseTest : verify(exactly = 0) { postRepository.save(any()) } } - it("cardinalNumber가 전달되면 게시글에 반영된다") { + it("IN_PROGRESS 기수가 존재하면 게시글에 자동 반영된다") { val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) - val request = - CreatePostRequest( - title = "게시글", - content = "내용", + val cardinal = + CardinalTestFixture.createCardinalInProgress( cardinalNumber = 6, + year = 2026, + semester = 1, ) + val request = CreatePostRequest(title = "게시글", content = "내용") every { userReader.getById(1L) } returns user every { boardRepository.findByIdAndClubIdAndIsDeletedFalse(11L, 1L) } returns board + every { cardinalReader.findInProgressByClubId(1L) } returns cardinal useCase.save(1L, 11L, request, 1L) verify { - postRepository.save( - match { it.cardinalNumber == 6 }, - ) + postRepository.save(match { it.cardinalNumber == 6 }) + } + } + + it("IN_PROGRESS 기수가 없으면 cardinalNumber가 null로 저장된다") { + val user = UserTestFixture.createActiveUser1(1L) + val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val request = CreatePostRequest(title = "게시글", content = "내용") + + every { userReader.getById(1L) } returns user + every { boardRepository.findByIdAndClubIdAndIsDeletedFalse(11L, 1L) } returns board + + useCase.save(1L, 11L, request, 1L) + + verify { + postRepository.save(match { it.cardinalNumber == null }) } } diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt index 15f94acf..40274aea 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt @@ -3,7 +3,9 @@ package com.weeth.domain.board.application.usecase.query import com.weeth.domain.board.application.exception.BoardNotFoundException import com.weeth.domain.board.application.mapper.BoardMapper import com.weeth.domain.board.domain.enums.BoardType +import com.weeth.domain.board.domain.repository.BoardPostCount import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.board.domain.repository.PostRepository import com.weeth.domain.board.fixture.BoardTestFixture import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.club.domain.service.ClubPermissionPolicy @@ -12,55 +14,82 @@ import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe +import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk +import io.mockk.verify class GetBoardQueryServiceTest : DescribeSpec({ val boardRepository = mockk() + val postRepository = mockk() val clubMemberPolicy = mockk(relaxed = true) val clubPermissionPolicy = mockk(relaxed = true) val boardMapper = BoardMapper() - val queryService = GetBoardQueryService(boardRepository, clubMemberPolicy, clubPermissionPolicy, boardMapper) + val queryService = + GetBoardQueryService(boardRepository, postRepository, clubMemberPolicy, clubPermissionPolicy, boardMapper) val clubId = 1L val userId = 10L + beforeTest { + clearMocks(boardRepository, postRepository, clubMemberPolicy, clubPermissionPolicy) + every { postRepository.countActivePostsByBoardIds(any()) } returns emptyList() + } + describe("findBoards") { - it("일반 사용자에게는 공개 게시판만 반환한다") { + it("일반 사용자에게는 공개 게시판만 반환하고 전체 게시판은 항상 포함한다") { + val noticeBoard = BoardTestFixture.create(name = "공지사항", type = BoardType.NOTICE) val publicBoard = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val privateBoard = - BoardTestFixture.create(name = "운영", type = BoardType.NOTICE).apply { + BoardTestFixture.create(name = "운영", type = BoardType.GENERAL).apply { updateConfig(config.copy(isPrivate = true)) } val member = ClubMemberTestFixture.createActiveMember() every { clubMemberPolicy.getActiveMember(clubId, userId) } returns member - every { boardRepository.findAllByClubIdAndIsDeletedFalseOrderByIdAsc(clubId) } returns - listOf(publicBoard, privateBoard) + every { boardRepository.findAllByClubIdAndIsDeletedFalseOrderByDisplayOrderAscIdAsc(clubId) } returns + listOf(noticeBoard, publicBoard, privateBoard) val result = queryService.findBoards(clubId, userId) - result shouldHaveSize 1 - result.first().name shouldBe "일반" + // 공지사항, 전체(가상), 일반 — 비공개 운영은 제외 + result shouldHaveSize 3 + result.map { it.name } shouldBe listOf("공지사항", "전체", "일반") } - it("관리자에게는 비공개 게시판도 포함해 반환한다") { + it("관리자에게는 비공개 게시판도 포함하고 순서는 공지사항 → 전체 → 나머지다") { + val noticeBoard = BoardTestFixture.create(name = "공지사항", type = BoardType.NOTICE) val publicBoard = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val privateBoard = - BoardTestFixture.create(name = "운영", type = BoardType.NOTICE).apply { + BoardTestFixture.create(name = "운영", type = BoardType.GENERAL).apply { updateConfig(config.copy(isPrivate = true)) } val adminMember = ClubMemberTestFixture.createAdminMember() every { clubMemberPolicy.getActiveMember(clubId, userId) } returns adminMember - every { boardRepository.findAllByClubIdAndIsDeletedFalseOrderByIdAsc(clubId) } returns - listOf(publicBoard, privateBoard) + every { boardRepository.findAllByClubIdAndIsDeletedFalseOrderByDisplayOrderAscIdAsc(clubId) } returns + listOf(noticeBoard, publicBoard, privateBoard) + + val result = queryService.findBoards(clubId, userId) + + result shouldHaveSize 4 + result.map { it.name } shouldBe listOf("공지사항", "전체", "일반", "운영") + } + + it("전체 게시판은 항상 id가 null이고 type이 ALL이다") { + val noticeBoard = BoardTestFixture.create(name = "공지사항", type = BoardType.NOTICE) + val member = ClubMemberTestFixture.createActiveMember() + + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns member + every { boardRepository.findAllByClubIdAndIsDeletedFalseOrderByDisplayOrderAscIdAsc(clubId) } returns + listOf(noticeBoard) val result = queryService.findBoards(clubId, userId) - result shouldHaveSize 2 - result.map { it.name } shouldBe listOf("일반", "운영") + val virtualAll = result.first { it.type == BoardType.ALL } + virtualAll.id shouldBe null + virtualAll.name shouldBe "전체" } } @@ -72,12 +101,14 @@ class GetBoardQueryServiceTest : markDeleted() } - every { boardRepository.findAllByClubIdOrderByIdAsc(clubId) } returns listOf(activeBoard, deletedBoard) + every { boardRepository.findAllByClubIdOrderByDisplayOrderAscIdAsc(clubId) } returns + listOf(activeBoard, deletedBoard) val result = queryService.findAllBoardsForAdmin(clubId, userId) - result shouldHaveSize 2 - result.map { it.name } shouldBe listOf("일반", "삭제됨") + // 가상 전체 게시판 포함: 전체, 일반, 삭제됨 + result shouldHaveSize 3 + result.map { it.name } shouldBe listOf("전체", "일반", "삭제됨") } it("활성 게시판과 비공개 게시판도 모두 포함해 반환한다") { @@ -87,12 +118,33 @@ class GetBoardQueryServiceTest : updateConfig(config.copy(isPrivate = true)) } - every { boardRepository.findAllByClubIdOrderByIdAsc(clubId) } returns listOf(publicBoard, privateBoard) + every { boardRepository.findAllByClubIdOrderByDisplayOrderAscIdAsc(clubId) } returns + listOf(publicBoard, privateBoard) val result = queryService.findAllBoardsForAdmin(clubId, userId) - result shouldHaveSize 2 - result.map { it.name } shouldBe listOf("일반", "운영") + // NOTICE 타입인 운영 → noticeBoards 먼저, 가상 전체 다음, 나머지 순 + result shouldHaveSize 3 + result.map { it.name } shouldBe listOf("운영", "전체", "일반") + } + + it("게시판별 활성 게시글 수를 포함해 반환한다") { + val board = BoardTestFixture.create(id = 1L, name = "일반", type = BoardType.GENERAL) + every { boardRepository.findAllByClubIdOrderByDisplayOrderAscIdAsc(clubId) } returns listOf(board) + every { postRepository.countActivePostsByBoardIds(listOf(board.id)) } returns + listOf(BoardPostCount(boardId = board.id, postCount = 5L)) + + val result = queryService.findAllBoardsForAdmin(clubId, userId) + + result.first().postCount shouldBe 5 + } + + it("게시판이 없으면 postRepository를 호출하지 않는다") { + every { boardRepository.findAllByClubIdOrderByDisplayOrderAscIdAsc(clubId) } returns emptyList() + + queryService.findAllBoardsForAdmin(clubId, userId) + + verify(exactly = 0) { postRepository.countActivePostsByBoardIds(any()) } } } @@ -128,5 +180,16 @@ class GetBoardQueryServiceTest : queryService.findBoardDetailForAdmin(clubId, userId, 999L) } } + + it("게시글 수가 postCount에 반영된다") { + val board = BoardTestFixture.create(id = 1L, name = "일반", type = BoardType.GENERAL) + every { boardRepository.findByIdAndClubId(board.id, clubId) } returns board + every { postRepository.countActivePostsByBoardIds(listOf(board.id)) } returns + listOf(BoardPostCount(boardId = board.id, postCount = 3L)) + + val result = queryService.findBoardDetailForAdmin(clubId, userId, board.id) + + result.postCount shouldBe 3 + } } }) diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt index d1253fb8..efcb5479 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt @@ -133,6 +133,8 @@ class GetPostQueryServiceTest : val detail = com.weeth.domain.board.application.dto.response.PostDetailResponse( id = 1L, + boardId = 1L, + boardName = "일반 게시판", author = UserInfo(id = 1L, name = "적순", profileImageUrl = null, role = MemberRole.USER), title = "제목", content = "내용", @@ -263,6 +265,58 @@ class GetPostQueryServiceTest : } } + describe("findAllPosts") { + it("접근 가능한 게시판의 게시글을 최신순으로 반환한다") { + val user = UserTestFixture.createActiveUser1(1L) + val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val member = ClubMemberTestFixture.createActiveMember(club = board.club, user = user) + val post = PostTestFixture.create(title = "제목", content = "내용", user = user, board = board) + val pageable = PageRequest.of(0, 10) + val postSlice = SliceImpl(listOf(post), pageable, false) + val response = + com.weeth.domain.board.application.dto.response.PostListResponse( + id = post.id, + author = UserInfo(id = 1L, name = "적순", profileImageUrl = null, role = MemberRole.USER), + boardId = board.id, + boardName = "일반", + title = "제목", + content = "내용", + time = LocalDateTime.now(), + commentCount = 0, + like = PostLikeResponse(isLiked = false, likeCount = 0), + hasFile = false, + isNew = true, + ) + + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns member + every { boardRepository.findAllByClubIdAndIsDeletedFalseOrderByDisplayOrderAscIdAsc(clubId) } returns + listOf(board) + every { postRepository.findAllActiveByBoardIds(any(), any()) } returns postSlice + every { fileReader.findAll(FileOwnerType.POST, any>(), any()) } returns emptyList() + every { clubMemberReader.findAllByClubIdAndUserIds(clubId, any()) } returns listOf(member) + every { postLikeRepository.findLikedPostIds(any(), any()) } returns emptySet() + every { postMapper.toListResponse(any(), any(), any(), any(), any()) } returns response + + val result = queryService.findAllPosts(clubId, userId, 0, 10) + + result.content.size shouldBe 1 + } + + it("접근 가능한 게시판이 없으면 빈 슬라이스를 반환한다") { + val board = BoardTestFixture.create(name = "비공개", type = BoardType.GENERAL) + board.updateConfig(board.config.copy(isPrivate = true)) + val member = ClubMemberTestFixture.createActiveMember(club = board.club) + + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns member + every { boardRepository.findAllByClubIdAndIsDeletedFalseOrderByDisplayOrderAscIdAsc(clubId) } returns + listOf(board) + + val result = queryService.findAllPosts(clubId, userId, 0, 10) + + result.content shouldBe emptyList() + } + } + describe("findPosts") { it("목록 조회 시 mapper를 통해 응답으로 변환한다") { val user = UserTestFixture.createActiveUser1(1L) @@ -281,6 +335,8 @@ class GetPostQueryServiceTest : com.weeth.domain.board.application.dto.response.PostListResponse( id = 10L, author = UserInfo(id = 1L, name = "적순", profileImageUrl = null, role = MemberRole.USER), + boardId = board.id, + boardName = "일반 게시판", title = "제목", content = "내용", time = LocalDateTime.now(), diff --git a/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt b/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt index 2e487048..bc343a30 100644 --- a/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt @@ -4,6 +4,7 @@ import com.weeth.domain.board.domain.enums.BoardType import com.weeth.domain.board.domain.vo.BoardConfig import com.weeth.domain.board.fixture.BoardTestFixture import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.fixture.ClubTestFixture import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.shouldBe @@ -90,4 +91,28 @@ class BoardEntityTest : board.restore() board.isDeleted shouldBe false } + + "reorder는 displayOrder를 변경한다" { + val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + + board.reorder(3) + + board.displayOrder shouldBe 3 + } + + "reorder는 음수 순서이면 예외를 던진다" { + val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + + shouldThrow { + board.reorder(-1) + } + } + + "ALL 타입으로 게시판을 생성하면 예외를 던진다" { + val club = ClubTestFixture.createClub() + + shouldThrow { + Board(club = club, name = "전체", type = BoardType.ALL) + } + } }) diff --git a/src/test/kotlin/com/weeth/domain/board/domain/entity/PostEntityTest.kt b/src/test/kotlin/com/weeth/domain/board/domain/entity/PostEntityTest.kt index 944623b2..aff973cf 100644 --- a/src/test/kotlin/com/weeth/domain/board/domain/entity/PostEntityTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/domain/entity/PostEntityTest.kt @@ -29,12 +29,10 @@ class PostEntityTest : post.update( newTitle = "변경", newContent = "변경 내용", - newCardinalNumber = 7, ) post.title shouldBe "변경" post.content shouldBe "변경 내용" - post.cardinalNumber shouldBe 7 } "update는 content가 공백이면 예외를 던진다" { @@ -44,7 +42,6 @@ class PostEntityTest : post.update( newTitle = "변경", newContent = " ", - newCardinalNumber = null, ) } } diff --git a/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt b/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt index 5cdc4aea..5703238c 100644 --- a/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt @@ -6,20 +6,26 @@ import com.weeth.domain.board.domain.vo.BoardConfig import com.weeth.domain.club.domain.entity.Club import com.weeth.domain.club.domain.enums.MemberRole import com.weeth.domain.club.fixture.ClubTestFixture +import org.springframework.test.util.ReflectionTestUtils object BoardTestFixture { fun create( + id: Long = 0L, club: Club = ClubTestFixture.createClub(), name: String = "일반 게시판", type: BoardType = BoardType.GENERAL, config: BoardConfig = BoardConfig(), - ): Board = - Board( - club = club, - name = name, - type = type, - config = config, - ) + ): Board { + val board = + Board( + club = club, + name = name, + type = type, + config = config, + ) + if (id != 0L) ReflectionTestUtils.setField(board, "id", id) + return board + } fun createNoticeBoard( club: Club = ClubTestFixture.createClub(), diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt index 2185ea96..f61f6fbe 100644 --- a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt @@ -1,5 +1,7 @@ package com.weeth.domain.club.application.usecase.command +import com.weeth.domain.board.domain.enums.BoardType +import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.cardinal.domain.entity.Cardinal import com.weeth.domain.cardinal.domain.enums.CardinalStatus import com.weeth.domain.cardinal.domain.repository.CardinalRepository @@ -34,6 +36,7 @@ class ManageClubUseCaseTest : val clubMemberRepository = mockk() val cardinalRepository = mockk() val clubMemberCardinalRepository = mockk() + val boardRepository = mockk() val userReader = mockk() val clubJoinPolicy = mockk() val clubPermissionPolicy = mockk() @@ -43,6 +46,7 @@ class ManageClubUseCaseTest : clubMemberRepository, cardinalRepository, clubMemberCardinalRepository, + boardRepository, userReader, clubJoinPolicy, clubPermissionPolicy, @@ -57,6 +61,7 @@ class ManageClubUseCaseTest : clubMemberRepository, cardinalRepository, clubMemberCardinalRepository, + boardRepository, userReader, clubJoinPolicy, clubPermissionPolicy, @@ -65,6 +70,7 @@ class ManageClubUseCaseTest : every { clubMemberRepository.save(any()) } answers { firstArg() } every { cardinalRepository.saveAll(any>()) } answers { firstArg() } every { clubMemberCardinalRepository.save(any()) } answers { firstArg() } + every { boardRepository.save(any()) } answers { firstArg() } every { clubJoinPolicy.validateCreateLimit(any()) } just Runs } @@ -141,6 +147,31 @@ class ManageClubUseCaseTest : } } + it("클럽 생성 시 공지사항 게시판이 displayOrder=0으로 자동 생성된다") { + every { userReader.getByIdWithLock(10L) } returns user + + useCase.create( + 10L, + ClubCreateRequest( + name = "테스트", + schoolName = "가천대", + currentCardinal = 1, + contactPhoneNumber = "01000000000", + primaryContact = PrimaryContact.PHONE, + ), + ) + + verify(exactly = 1) { + boardRepository.save( + match { board -> + board.type == BoardType.NOTICE && + board.name == "공지사항" && + board.displayOrder == 0 + }, + ) + } + } + context("이미 LEAD로 1개 동아리를 생성한 사용자가 생성 시도하는 경우") { it("ClubCreateLimitExceededException이 발생하고, 이후 로직이 실행되지 않는다") { every { userReader.getByIdWithLock(13L) } returns user diff --git a/src/test/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryServiceTest.kt index 7e4bc839..9b530e28 100644 --- a/src/test/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryServiceTest.kt @@ -1,11 +1,14 @@ package com.weeth.domain.dashboard.application.usecase.query import com.weeth.domain.board.domain.enums.BoardType +import com.weeth.domain.board.domain.repository.BoardReader import com.weeth.domain.board.domain.repository.PostReader +import com.weeth.domain.board.domain.vo.BoardConfig import com.weeth.domain.board.fixture.BoardTestFixture import com.weeth.domain.board.fixture.PostTestFixture import com.weeth.domain.club.application.exception.ClubMemberNotFoundException import com.weeth.domain.club.application.exception.MemberNotActiveException +import com.weeth.domain.club.domain.enums.MemberRole import com.weeth.domain.club.domain.enums.MemberStatus import com.weeth.domain.club.domain.repository.ClubMemberReader import com.weeth.domain.club.domain.repository.ClubReader @@ -36,6 +39,7 @@ import java.time.LocalDateTime class GetDashboardQueryServiceTest : DescribeSpec({ + val boardReader = mockk() val clubReader = mockk() val clubMemberReader = mockk() val clubMemberPolicy = ClubMemberPolicy(clubMemberReader) @@ -50,6 +54,7 @@ class GetDashboardQueryServiceTest : val queryService = GetDashboardQueryService( + boardReader = boardReader, clubReader = clubReader, clubMemberReader = clubMemberReader, clubMemberPolicy = clubMemberPolicy, @@ -69,6 +74,7 @@ class GetDashboardQueryServiceTest : beforeTest { clearMocks( + boardReader, clubReader, clubMemberReader, eventReader, @@ -163,17 +169,18 @@ class GetDashboardQueryServiceTest : } describe("getRecentPosts") { + val memberWithUser = ClubTestFixture.createClubMember(club = club, user = user) + context("멤버인 경우") { - it("공지 제외한 최신 게시글을 반환한다") { - val board = BoardTestFixture.create(type = BoardType.GENERAL) + it("공지 제외한 접근 가능한 게시판의 최신 게시글을 반환한다") { + val board = BoardTestFixture.create(id = 10L, type = BoardType.GENERAL) val post = PostTestFixture.create(board = board, user = user) - val memberWithUser = ClubTestFixture.createClubMember(club = club, user = user) val pageable = PageRequest.of(0, 10) val slice = SliceImpl(listOf(post), pageable, false) every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns memberWithUser - every { postReader.findRecentByClubIdExcludingBoardType(clubId, BoardType.NOTICE, any()) } returns - slice + every { boardReader.findAllActiveByClubId(clubId) } returns listOf(board) + every { postReader.findRecentByBoardIds(listOf(board.id), any()) } returns slice every { fileReader.findAll(FileOwnerType.POST, any>()) } returns emptyList() every { clubMemberReader.findAllByClubIdAndUserIds(clubId, any()) } returns listOf(memberWithUser) @@ -184,6 +191,66 @@ class GetDashboardQueryServiceTest : } } + context("비공개 게시판이 있는 경우") { + val privateBoard = + BoardTestFixture.create( + id = 11L, + type = BoardType.GENERAL, + config = BoardConfig(isPrivate = true), + ) + + it("일반 멤버에게는 비공개 게시판 글이 포함되지 않는다") { + val publicBoard = BoardTestFixture.create(id = 10L, type = BoardType.GENERAL) + val post = PostTestFixture.create(board = publicBoard, user = user) + val pageable = PageRequest.of(0, 10) + val slice = SliceImpl(listOf(post), pageable, false) + + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns memberWithUser + every { boardReader.findAllActiveByClubId(clubId) } returns listOf(publicBoard, privateBoard) + every { postReader.findRecentByBoardIds(listOf(publicBoard.id), any()) } returns slice + every { fileReader.findAll(FileOwnerType.POST, any>()) } returns emptyList() + every { clubMemberReader.findAllByClubIdAndUserIds(clubId, any()) } returns listOf(memberWithUser) + + val result = queryService.getRecentPosts(clubId, userId, 0, 10) + + result.content.size shouldBe 1 + } + + it("ADMIN 멤버에게는 비공개 게시판 글이 포함된다") { + val adminMember = + ClubTestFixture.createClubMember( + club = club, + user = user, + memberRole = MemberRole.ADMIN, + ) + val post = PostTestFixture.create(board = privateBoard, user = user) + val pageable = PageRequest.of(0, 10) + val slice = SliceImpl(listOf(post), pageable, false) + + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns adminMember + every { boardReader.findAllActiveByClubId(clubId) } returns listOf(privateBoard) + every { postReader.findRecentByBoardIds(listOf(privateBoard.id), any()) } returns slice + every { fileReader.findAll(FileOwnerType.POST, any>()) } returns emptyList() + every { clubMemberReader.findAllByClubIdAndUserIds(clubId, any()) } returns listOf(adminMember) + + val result = queryService.getRecentPosts(clubId, userId, 0, 10) + + result.content.size shouldBe 1 + } + } + + context("접근 가능한 게시판이 없는 경우") { + it("빈 Slice를 반환한다") { + every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns memberWithUser + every { boardReader.findAllActiveByClubId(clubId) } returns emptyList() + + val result = queryService.getRecentPosts(clubId, userId, 0, 10) + + result.content.isEmpty() shouldBe true + result.hasNext() shouldBe false + } + } + context("멤버가 아닌 경우") { it("ClubMemberNotFoundException을 던진다") { every { clubMemberReader.findByClubIdAndUserId(clubId, userId) } returns null diff --git a/src/test/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryServiceTest.kt new file mode 100644 index 00000000..cdeed328 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryServiceTest.kt @@ -0,0 +1,123 @@ +package com.weeth.domain.session.application.usecase.query + +import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException +import com.weeth.domain.cardinal.domain.repository.CardinalReader +import com.weeth.domain.cardinal.fixture.CardinalTestFixture +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.club.fixture.ClubMemberTestFixture +import com.weeth.domain.schedule.application.dto.response.SessionInfosResponse +import com.weeth.domain.schedule.application.dto.response.SessionResponse +import com.weeth.domain.schedule.application.mapper.SessionMapper +import com.weeth.domain.session.application.exception.SessionNotFoundException +import com.weeth.domain.session.domain.repository.SessionRepository +import com.weeth.domain.session.fixture.SessionTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify + +class GetSessionQueryServiceTest : + DescribeSpec({ + val sessionRepository = mockk() + val cardinalReader = mockk() + val clubMemberPolicy = mockk(relaxed = true) + val clubPermissionPolicy = mockk(relaxed = true) + val sessionMapper = mockk() + val queryService = + GetSessionQueryService( + sessionRepository, + cardinalReader, + clubMemberPolicy, + clubPermissionPolicy, + sessionMapper, + ) + + val clubId = 1L + val userId = 10L + + beforeTest { + clearMocks(sessionRepository, cardinalReader, clubMemberPolicy, clubPermissionPolicy, sessionMapper) + } + + describe("findSession") { + it("존재하지 않는 세션이면 예외를 던진다") { + every { sessionRepository.findByIdAndClubId(99L, clubId) } returns null + + shouldThrow { + queryService.findSession(clubId, userId, 99L) + } + } + + it("어드민/리드는 admin 응답을 반환한다") { + val session = SessionTestFixture.createSession() + val adminMember = ClubMemberTestFixture.createAdminMember() + val response = mockk() + + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns adminMember + every { sessionRepository.findByIdAndClubId(1L, clubId) } returns session + every { sessionMapper.toAdminResponse(session) } returns response + + val result = queryService.findSession(clubId, userId, 1L) + + result shouldBe response + verify(exactly = 0) { sessionMapper.toResponse(any()) } + } + + it("일반 멤버는 일반 응답을 반환한다") { + val session = SessionTestFixture.createSession() + val member = ClubMemberTestFixture.createActiveMember() + val response = mockk() + + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns member + every { sessionRepository.findByIdAndClubId(1L, clubId) } returns session + every { sessionMapper.toResponse(session) } returns response + + val result = queryService.findSession(clubId, userId, 1L) + + result shouldBe response + verify(exactly = 0) { sessionMapper.toAdminResponse(any()) } + } + } + + describe("findSessionInfos") { + it("cardinal이 null이면 클럽 전체 세션을 반환한다") { + val sessions = listOf(SessionTestFixture.createSession()) + val response = mockk() + + every { sessionRepository.findAllByClubIdOrderByStartDesc(clubId) } returns sessions + every { sessionMapper.toInfos(any(), sessions) } returns response + + val result = queryService.findSessionInfos(clubId, userId, null) + + result shouldBe response + verify(exactly = 0) { cardinalReader.findByClubIdAndCardinalNumber(any(), any()) } + } + + it("cardinal이 지정되면 해당 기수의 세션만 반환한다") { + val cardinal = CardinalTestFixture.createCardinal(cardinalNumber = 3, year = 2026, semester = 1) + val sessions = listOf(SessionTestFixture.createSession(cardinal = 3)) + val response = mockk() + + every { cardinalReader.findByClubIdAndCardinalNumber(clubId, 3) } returns cardinal + every { sessionRepository.findAllByClubIdAndCardinalOrderByStartDesc(clubId, 3) } returns sessions + every { sessionMapper.toInfos(any(), sessions) } returns response + + val result = queryService.findSessionInfos(clubId, userId, 3) + + result shouldBe response + } + + it("존재하지 않는 기수를 요청하면 예외를 던진다") { + every { cardinalReader.findByClubIdAndCardinalNumber(clubId, 99) } returns null + + shouldThrow { + queryService.findSessionInfos(clubId, userId, 99) + } + verify(exactly = 0) { sessionRepository.findAllByClubIdAndCardinalOrderByStartDesc(any(), any()) } + } + } + }) From 3b71d369993a68a864972c433477c0def18de1df Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Wed, 25 Mar 2026 21:26:01 +0900 Subject: [PATCH 37/73] =?UTF-8?q?[WTH-221]=20=EC=95=A1=EC=84=B8=EC=8A=A4?= =?UTF-8?q?=20=ED=86=A0=ED=81=B0=20=EB=A6=AC=ED=94=84=EB=A0=88=EC=8B=9C=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EC=BF=A0=ED=82=A4=20=EB=B0=98=ED=99=98=20?= =?UTF-8?q?(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 쿠키 관련 환경변수 설정 * feat: 쿠키 유틸 추가 * refactor: 쿠키에서 토큰 추출 로직 추가 * refactor: 쿠키에서 토큰 추출 로직 추가 * refactor: 쿠키를 반환하고 받도록 인증 관련 API 수정 * refactor: 약관 동의 시 가입 승인 & 토큰 발급하도록 로직 수정 * refactor: 토큰 타입 추가 (임시, 액세스) * refactor: 인증 API 수정 * refactor: 가입 미완료시 예외처리 * test: 테스트 환경변수 추가 * feat: token type enum 추가 * feat: 동아리 활동 정보 API 추가 * refactor: public 겨로 저리 * feat: 유저 프로필 완성 여부 확인 API 추가 --- .../dto/response/ClubInfoResponse.kt | 6 ++ .../dto/response/ProfileStatusResponse.kt | 12 +++ .../club/application/mapper/ClubMapper.kt | 42 ++++++++- .../query/GetClubMemberQueryService.kt | 14 +++ .../usecase/query/GetClubQueryService.kt | 29 +++++- .../club/presentation/ClubController.kt | 2 +- .../club/presentation/ClubMemberController.kt | 13 +++ .../club/presentation/ClubResponseCode.kt | 1 + .../dto/response/SocialLoginResponse.kt | 4 +- .../user/application/mapper/UserMapper.kt | 4 +- .../usecase/command/AgreeTermsUseCase.kt | 9 +- .../usecase/command/SocialLoginUseCase.kt | 17 ++-- .../weeth/domain/user/domain/entity/User.kt | 17 ++-- .../user/presentation/UserController.kt | 57 +++++++++--- .../CustomAccessDeniedHandler.kt | 36 +++++++- .../jwt/application/exception/JwtErrorCode.kt | 3 + .../application/service/JwtTokenExtractor.kt | 18 +++- .../service/TokenCookieProvider.kt | 48 ++++++++++ .../application/usecase/JwtManageUseCase.kt | 9 +- .../global/auth/jwt/domain/enums/TokenType.kt | 6 ++ .../jwt/domain/port/RefreshTokenStorePort.kt | 5 ++ .../jwt/domain/service/JwtTokenProvider.kt | 4 + .../JwtAuthenticationProcessingFilter.kt | 9 +- .../RedisRefreshTokenStoreAdapter.kt | 12 +++ .../com/weeth/global/config/SecurityConfig.kt | 19 +--- .../config/properties/CookieProperties.kt | 20 +++++ src/main/resources/application-dev.yml | 8 +- src/main/resources/application-local.yml | 6 ++ src/main/resources/application-prod.yml | 6 ++ .../query/GetClubMemberQueryServiceTest.kt | 88 +++++++++++++++++++ .../usecase/command/AgreeTermsUseCaseTest.kt | 20 +++-- .../usecase/command/SocialLoginUseCaseTest.kt | 84 +++++++++++++++++- .../domain/user/domain/entity/UserTest.kt | 35 ++++++++ .../domain/user/fixture/UserTestFixture.kt | 9 ++ .../service/JwtTokenExtractorTest.kt | 55 +++++++++++- .../service/TokenCookieProviderTest.kt | 86 ++++++++++++++++++ .../usecase/JwtManageUseCaseTest.kt | 47 ++++++++-- .../domain/service/JwtTokenProviderTest.kt | 12 ++- .../JwtAuthenticationProcessingFilterTest.kt | 23 ++++- .../RedisRefreshTokenStoreAdapterTest.kt | 32 +++++-- src/test/resources/application-test.yml | 5 ++ 41 files changed, 844 insertions(+), 88 deletions(-) create mode 100644 src/main/kotlin/com/weeth/domain/club/application/dto/response/ProfileStatusResponse.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/jwt/application/service/TokenCookieProvider.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/jwt/domain/enums/TokenType.kt create mode 100644 src/main/kotlin/com/weeth/global/config/properties/CookieProperties.kt create mode 100644 src/test/kotlin/com/weeth/global/auth/jwt/application/service/TokenCookieProviderTest.kt diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubInfoResponse.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubInfoResponse.kt index 40dd8723..e656bf52 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubInfoResponse.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubInfoResponse.kt @@ -13,6 +13,12 @@ data class ClubInfoResponse( val schoolName: String, @field:Schema(description = "동아리 설명", example = "함께 배우고 성장하는 개발자 커뮤니티") val description: String?, + @field:Schema(description = "동아리 프로필 이미지 URL") + val profileImageUrl: String?, + @field:Schema(description = "활동 부원 수", example = "368") + val memberCount: Long, + @field:Schema(description = "활동 기수 목록", example = "[31, 32]") + val cardinals: List, @field:Schema(description = "나의 권한", example = "USER") val memberRole: MemberRole, @field:Schema(description = "나의 멤버 상태", example = "ACTIVE") diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/response/ProfileStatusResponse.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ProfileStatusResponse.kt new file mode 100644 index 00000000..6eb90465 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ProfileStatusResponse.kt @@ -0,0 +1,12 @@ +package com.weeth.domain.club.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class ProfileStatusResponse( + @field:Schema(description = "기수 등록 여부") + val cardinalAssigned: Boolean, + @field:Schema(description = "프로필 완성 여부 (이름, 학번, 전화번호, 학교, 학과)") + val profileCompleted: Boolean, + @field:Schema(description = "미완성 필드 목록", example = "[\"studentId\", \"tel\"]") + val missingFields: List, +) diff --git a/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt b/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt index 857b7059..20c499de 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt @@ -7,11 +7,13 @@ import com.weeth.domain.club.application.dto.response.ClubMemberResponse import com.weeth.domain.club.application.dto.response.ClubMemberSummaryResponse import com.weeth.domain.club.application.dto.response.ClubMembershipStatusResponse import com.weeth.domain.club.application.dto.response.ClubPublicResponse +import com.weeth.domain.club.application.dto.response.ProfileStatusResponse import com.weeth.domain.club.domain.entity.Club import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.club.domain.entity.ClubMemberCardinal import com.weeth.domain.club.domain.enums.MemberStatus import com.weeth.domain.file.domain.port.FileAccessUrlPort +import com.weeth.domain.user.domain.entity.User import com.weeth.global.common.id.TsidBase62Encoder import org.springframework.stereotype.Component @@ -22,11 +24,16 @@ class ClubMapper( fun toInfoResponse( club: Club, member: ClubMember, + cardinals: List, + memberCount: Long, ) = ClubInfoResponse( id = TsidBase62Encoder.encode(club.id), name = club.name, schoolName = club.schoolName, description = club.description, + profileImageUrl = resolveClubImage(club.profileImageStorageKey), + memberCount = memberCount, + cardinals = toCardinalNumbers(cardinals), memberRole = member.memberRole, memberStatus = member.memberStatus, ) @@ -103,18 +110,47 @@ class ClubMapper( role = member.memberRole, ) - fun toMembershipStatusResponse(members: List): ClubMembershipStatusResponse { + fun toMembershipStatusResponse( + members: List, + cardinalsByMemberId: Map>, + memberCountByClubId: Map, + ): ClubMembershipStatusResponse { val activeMember = members.firstOrNull { it.memberStatus == MemberStatus.ACTIVE } val waitingMember = members.firstOrNull { it.memberStatus == MemberStatus.WAITING } return ClubMembershipStatusResponse( hasActiveClub = activeMember != null, hasWaitingClub = waitingMember != null, - activeClub = activeMember?.let { toInfoResponse(it.club, it) }, - waitingClub = waitingMember?.let { toInfoResponse(it.club, it) }, + activeClub = + activeMember?.let { + toInfoResponse( + it.club, + it, + cardinalsByMemberId[it.id] ?: emptyList(), + memberCountByClubId[it.club.id] ?: 0, + ) + }, + waitingClub = + waitingMember?.let { + toInfoResponse( + it.club, + it, + cardinalsByMemberId[it.id] ?: emptyList(), + memberCountByClubId[it.club.id] ?: 0, + ) + }, ) } + fun toProfileStatusResponse( + user: User, + cardinalAssigned: Boolean, + ) = ProfileStatusResponse( + profileCompleted = user.isProfileCompleted(), + cardinalAssigned = cardinalAssigned, + missingFields = user.missingProfileFields(), + ) + private fun resolveClubImage(storageKey: String?): String? = storageKey?.let { fileAccessUrlPort.resolve(it) } private fun toCardinalNumbers(cardinals: List): List { diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryService.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryService.kt index f5f7dbe9..c24fe23d 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryService.kt @@ -3,11 +3,13 @@ package com.weeth.domain.club.application.usecase.query import com.weeth.domain.club.application.dto.response.ClubMemberProfileResponse import com.weeth.domain.club.application.dto.response.ClubMemberResponse import com.weeth.domain.club.application.dto.response.ClubMemberSummaryResponse +import com.weeth.domain.club.application.dto.response.ProfileStatusResponse import com.weeth.domain.club.application.mapper.ClubMapper import com.weeth.domain.club.domain.repository.ClubMemberCardinalReader import com.weeth.domain.club.domain.repository.ClubMemberReader import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.user.domain.repository.UserReader import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -19,6 +21,7 @@ class GetClubMemberQueryService( private val clubMemberPolicy: ClubMemberPolicy, private val clubPermissionPolicy: ClubPermissionPolicy, private val clubMapper: ClubMapper, + private val userReader: UserReader, ) { fun findClubMembersForAdmin( clubId: Long, @@ -50,6 +53,17 @@ class GetClubMemberQueryService( return clubMapper.toMemberProfileResponse(member, cardinals) } + fun findProfileStatus( + clubId: Long, + userId: Long, + ): ProfileStatusResponse { + val member = clubMemberPolicy.getActiveMember(clubId, userId) + val user = userReader.getById(userId) + val cardinalAssigned = clubMemberCardinalReader.findLatestCardinalByClubMember(member) != null + + return clubMapper.toProfileStatusResponse(user, cardinalAssigned) + } + fun findMySummary( clubId: Long, userId: Long, diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubQueryService.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubQueryService.kt index 4116d059..6ef178e8 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubQueryService.kt @@ -5,6 +5,7 @@ import com.weeth.domain.club.application.dto.response.ClubInfoResponse import com.weeth.domain.club.application.dto.response.ClubMembershipStatusResponse import com.weeth.domain.club.application.dto.response.ClubPublicResponse import com.weeth.domain.club.application.mapper.ClubMapper +import com.weeth.domain.club.domain.repository.ClubMemberCardinalReader import com.weeth.domain.club.domain.repository.ClubMemberReader import com.weeth.domain.club.domain.repository.ClubReader import com.weeth.domain.club.domain.service.ClubPermissionPolicy @@ -16,14 +17,23 @@ import org.springframework.transaction.annotation.Transactional class GetClubQueryService( private val clubReader: ClubReader, private val clubMemberReader: ClubMemberReader, + private val clubMemberCardinalReader: ClubMemberCardinalReader, private val clubPermissionPolicy: ClubPermissionPolicy, private val clubMapper: ClubMapper, ) { fun findMyClubs(userId: Long): List { val members = clubMemberReader.findAllByUserIdWithClub(userId) + if (members.isEmpty()) return emptyList() + + val cardinalsByMemberId = + clubMemberCardinalReader + .findAllByClubMembers(members) + .groupBy { it.clubMember.id } return members.map { member -> - clubMapper.toInfoResponse(member.club, member) + val cardinals = cardinalsByMemberId[member.id] ?: emptyList() + val memberCount = clubMemberReader.countActiveByClubId(member.club.id) + clubMapper.toInfoResponse(member.club, member, cardinals, memberCount) } } @@ -45,6 +55,21 @@ class GetClubQueryService( fun findMembershipStatus(userId: Long): ClubMembershipStatusResponse { val members = clubMemberReader.findAllByUserIdWithClub(userId) - return clubMapper.toMembershipStatusResponse(members) + if (members.isEmpty()) { + return clubMapper.toMembershipStatusResponse(members, emptyMap(), emptyMap()) + } + + val cardinalsByMemberId = + clubMemberCardinalReader + .findAllByClubMembers(members) + .groupBy { it.clubMember.id } + + val memberCountByClubId = + members + .map { it.club.id } + .distinct() + .associateWith { clubMemberReader.countActiveByClubId(it) } + + return clubMapper.toMembershipStatusResponse(members, cardinalsByMemberId, memberCountByClubId) } } diff --git a/src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt b/src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt index 4c542f68..008a32c8 100644 --- a/src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt +++ b/src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt @@ -45,7 +45,7 @@ class ClubController( } @GetMapping - @Operation(summary = "내가 가입한 동아리 목록 조회 (MVP 미사용)", deprecated = true) + @Operation(summary = "내가 가입한 동아리 목록 조회") fun getMyClubs( @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse> { diff --git a/src/main/kotlin/com/weeth/domain/club/presentation/ClubMemberController.kt b/src/main/kotlin/com/weeth/domain/club/presentation/ClubMemberController.kt index 11b530e2..1c94f392 100644 --- a/src/main/kotlin/com/weeth/domain/club/presentation/ClubMemberController.kt +++ b/src/main/kotlin/com/weeth/domain/club/presentation/ClubMemberController.kt @@ -5,6 +5,7 @@ import com.weeth.domain.club.application.dto.request.ClubMemberCardinalSetReques import com.weeth.domain.club.application.dto.request.UpdateMemberProfileRequest import com.weeth.domain.club.application.dto.response.ClubMemberProfileResponse import com.weeth.domain.club.application.dto.response.ClubMemberSummaryResponse +import com.weeth.domain.club.application.dto.response.ProfileStatusResponse import com.weeth.domain.club.application.exception.ClubErrorCode import com.weeth.domain.club.application.usecase.command.ManageClubMemberUsecase import com.weeth.domain.club.application.usecase.query.GetClubMemberQueryService @@ -105,6 +106,18 @@ class ClubMemberController( return CommonResponse.success(ClubResponseCode.MEMBER_PROFILE_IMAGE_DELETED_SUCCESS) } + @GetMapping("/{clubId}/members/me/profile-status") + @Operation(summary = "프로필 완성 상태 조회", description = "로그인 후 혹은 홈 접속시 최초 1회만 호출하고 상태를 저장해서 처리해주세요.") + fun getProfileStatus( + @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + val status = getClubMemberQueryService.findProfileStatus(clubId, userId) + + return CommonResponse.success(ClubResponseCode.PROFILE_STATUS_FIND_SUCCESS, status) + } + @PostMapping("/{clubId}/members/me/cardinals") @Operation(summary = "활동 기수 최초 설정 (최초 1회만 가능)") @ResponseStatus(HttpStatus.CREATED) diff --git a/src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt b/src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt index eeab8df4..9d125b0d 100644 --- a/src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt @@ -30,4 +30,5 @@ enum class ClubResponseCode( LEAD_TRANSFERRED_SUCCESS(11119, HttpStatus.OK, "LEAD 권한이 이양되었습니다."), MEMBERSHIP_STATUS_FIND_SUCCESS(11120, HttpStatus.OK, "동아리 가입 상태를 성공적으로 조회했습니다."), MEMBER_SUMMARY_FIND_SUCCESS(11121, HttpStatus.OK, "내 요약 정보를 성공적으로 조회했습니다."), + PROFILE_STATUS_FIND_SUCCESS(11122, HttpStatus.OK, "프로필 완성 상태를 성공적으로 조회했습니다."), } diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/response/SocialLoginResponse.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/response/SocialLoginResponse.kt index 65ad7266..43fcbc3b 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/dto/response/SocialLoginResponse.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/response/SocialLoginResponse.kt @@ -7,6 +7,6 @@ data class SocialLoginResponse( val accessToken: String, @field:Schema(description = "리프레시 토큰") val refreshToken: String, - @field:Schema(description = "신규 회원 여부", example = "true") - val isNewUser: Boolean, + @field:Schema(description = "약관 동의 완료 여부 (true: 약관 동의 완료, false: 약관 동의 필요)", example = "true") + val registered: Boolean, ) diff --git a/src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt b/src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt index 47a1d1cc..deee9cc8 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt @@ -11,11 +11,11 @@ class UserMapper( ) { fun toSocialLoginResponse( token: JwtDto, - isNewUser: Boolean, + registered: Boolean, ): SocialLoginResponse = SocialLoginResponse( accessToken = token.accessToken, refreshToken = token.refreshToken, - isNewUser = isNewUser, + registered = registered, ) } diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AgreeTermsUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AgreeTermsUseCase.kt index 593574e6..aef8bec5 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AgreeTermsUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AgreeTermsUseCase.kt @@ -2,19 +2,26 @@ package com.weeth.domain.user.application.usecase.command import com.weeth.domain.user.application.dto.request.AgreeTermsRequest import com.weeth.domain.user.domain.repository.UserRepository +import com.weeth.global.auth.jwt.application.dto.JwtDto +import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase +import com.weeth.global.auth.jwt.domain.enums.TokenType import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @Service class AgreeTermsUseCase( private val userRepository: UserRepository, + private val jwtManageUseCase: JwtManageUseCase, ) { @Transactional fun execute( userId: Long, request: AgreeTermsRequest, - ) { + ): JwtDto { val user = userRepository.getById(userId) user.agreeTerms(request.termsAgreed, request.privacyAgreed) + user.accept() // 약관 동의시 회원가입 승인 + + return jwtManageUseCase.create(userId, user.emailValue, TokenType.ACCESS) } } diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCase.kt index 760e12d9..c16ce401 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCase.kt @@ -14,6 +14,7 @@ import com.weeth.domain.user.domain.repository.UserSocialAccountRepository import com.weeth.domain.user.domain.vo.SocialAuthResult import com.weeth.domain.user.infrastructure.SocialAuthPortRegistry import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase +import com.weeth.global.auth.jwt.domain.enums.TokenType import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -37,24 +38,24 @@ class SocialLoginUseCase( provider: SocialProvider, request: SocialLoginRequest, ): SocialLoginResponse { - val authResult = socialAuthPortRegistry.get(provider).authenticate(request.authCode) - val (user, isNewUser) = findOrCreateUser(authResult) + val user = findOrCreateUser(authResult = socialAuthPortRegistry.get(provider).authenticate(request.authCode)) if (user.isBannedOrLeft()) throw UserInActiveException() - val token = jwtManageUseCase.create(user.id, user.emailValue) + val tokenType = if (user.isRegistered()) TokenType.ACCESS else TokenType.TEMPORARY + val token = jwtManageUseCase.create(user.id, user.emailValue, tokenType) - return userMapper.toSocialLoginResponse(token, isNewUser) + return userMapper.toSocialLoginResponse(token, user.isRegistered()) } // TODO: 실제 서비스 출시 시 이메일 기반 기존 사용자 연동 및 유저 알림 기능 필요 - private fun findOrCreateUser(authResult: SocialAuthResult): Pair { + private fun findOrCreateUser(authResult: SocialAuthResult): User { val existing = userSocialAccountRepository .findByProviderAndProviderUserId(authResult.provider, authResult.providerUserId) .orElse(null) - if (existing != null) return existing.user to false + if (existing != null) return existing.user val email = authResult.email.takeIf { authResult.emailVerified && it.isNotBlank() } ?: throw EmailNotFoundException() @@ -64,7 +65,7 @@ class SocialLoginUseCase( User.create( name = authResult.name?.takeIf { it.isNotBlank() } ?: email.substringBefore("@"), email = email, - status = Status.ACTIVE, // 소셜 로그인으로 회원가입 한 경우 바로 가입 승인 + status = Status.WAITING, // 소셜 로그인으로 회원가입 한 경우 WAITING으로 초기화 -> 동의 완료시 ACTIVE ), ) @@ -76,6 +77,6 @@ class SocialLoginUseCase( ), ) - return user to true + return user } } diff --git a/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt b/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt index 9fda6d55..99fd8835 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt @@ -88,12 +88,17 @@ class User( fun isBannedOrLeft(): Boolean = status == Status.BANNED || status == Status.LEFT - fun isProfileCompleted(): Boolean = - name.isNotBlank() && - studentId.isNotBlank() && - telValue.isNotBlank() && - school.isNotBlank() && - department.isNotBlank() + fun isRegistered(): Boolean = status == Status.ACTIVE && termsAgreed && privacyAgreed + + fun isProfileCompleted(): Boolean = missingProfileFields().isEmpty() + + fun missingProfileFields(): List = + buildList { + if (studentId.isBlank()) add("studentId") + if (telValue.isBlank()) add("tel") + if (school.isBlank()) add("school") + if (department.isBlank()) add("department") + } fun update( name: String, diff --git a/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt b/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt index df83befc..489a1c98 100644 --- a/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt +++ b/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt @@ -12,6 +12,7 @@ import com.weeth.domain.user.application.usecase.command.UpdateUserProfileUseCas import com.weeth.global.auth.annotation.CurrentUser import com.weeth.global.auth.jwt.application.dto.JwtDto import com.weeth.global.auth.jwt.application.exception.JwtErrorCode +import com.weeth.global.auth.jwt.application.service.TokenCookieProvider import com.weeth.global.common.exception.ApiErrorCodeExample import com.weeth.global.common.response.CommonResponse import io.swagger.v3.oas.annotations.Operation @@ -19,6 +20,8 @@ import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag import jakarta.servlet.http.HttpServletRequest import jakarta.validation.Valid +import org.springframework.http.HttpHeaders +import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PatchMapping import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody @@ -34,34 +37,57 @@ class UserController( private val socialLoginUseCase: SocialLoginUseCase, private val updateUserProfileUseCase: UpdateUserProfileUseCase, private val agreeTermsUseCase: AgreeTermsUseCase, + private val tokenCookieProvider: TokenCookieProvider, ) { @PostMapping("/social/kakao") @Operation(summary = "카카오 소셜 로그인(auth code flow)") fun socialLoginByKakao( @RequestBody @Valid request: SocialLoginRequest, - ): CommonResponse = - CommonResponse.success(UserResponseCode.SOCIAL_LOGIN_SUCCESS, socialLoginUseCase.socialLoginByKakao(request)) + ): ResponseEntity> { + val response = socialLoginUseCase.socialLoginByKakao(request) + return buildTokenResponse( + CommonResponse.success(UserResponseCode.SOCIAL_LOGIN_SUCCESS, response), + response.accessToken, + response.refreshToken, + ) + } @PostMapping("/social/apple") @Operation(summary = "애플 소셜 로그인(auth code flow)") fun socialLoginByApple( @RequestBody @Valid request: SocialLoginRequest, - ): CommonResponse = - CommonResponse.success(UserResponseCode.SOCIAL_LOGIN_SUCCESS, socialLoginUseCase.socialLoginByApple(request)) + ): ResponseEntity> { + val response = socialLoginUseCase.socialLoginByApple(request) + return buildTokenResponse( + CommonResponse.success(UserResponseCode.SOCIAL_LOGIN_SUCCESS, response), + response.accessToken, + response.refreshToken, + ) + } @PostMapping("/social/refresh") - @Operation(summary = "토큰 재발급") - fun refreshToken(request: HttpServletRequest): CommonResponse = - CommonResponse.success(UserResponseCode.JWT_REFRESH_SUCCESS, authUserUseCase.refreshToken(request)) + @Operation(summary = "토큰 재발급", description = "쿠키를 사용해 토큰을 재발급합니다.") + fun refreshToken(request: HttpServletRequest): ResponseEntity> { + val jwtDto = authUserUseCase.refreshToken(request) + return buildTokenResponse( + CommonResponse.success(UserResponseCode.JWT_REFRESH_SUCCESS, jwtDto), + jwtDto.accessToken, + jwtDto.refreshToken, + ) + } @PostMapping("/terms") @Operation(summary = "약관 동의") fun agreeTerms( @RequestBody @Valid request: AgreeTermsRequest, @Parameter(hidden = true) @CurrentUser userId: Long, - ): CommonResponse { - agreeTermsUseCase.execute(userId, request) - return CommonResponse.success(UserResponseCode.USER_TERMS_AGREE_SUCCESS) + ): ResponseEntity> { + val jwtDto = agreeTermsUseCase.execute(userId, request) + return buildTokenResponse( + CommonResponse.success(UserResponseCode.USER_TERMS_AGREE_SUCCESS, jwtDto), + jwtDto.accessToken, + jwtDto.refreshToken, + ) } @PatchMapping @@ -73,4 +99,15 @@ class UserController( updateUserProfileUseCase.updateProfile(request, userId) return CommonResponse.success(UserResponseCode.USER_UPDATE_SUCCESS) } + + private fun buildTokenResponse( + body: CommonResponse, + accessToken: String, + refreshToken: String, + ): ResponseEntity> = + ResponseEntity + .ok() + .header(HttpHeaders.SET_COOKIE, tokenCookieProvider.createAccessTokenCookie(accessToken).toString()) + .header(HttpHeaders.SET_COOKIE, tokenCookieProvider.createRefreshTokenCookie(refreshToken).toString()) + .body(body) } diff --git a/src/main/kotlin/com/weeth/global/auth/authentication/CustomAccessDeniedHandler.kt b/src/main/kotlin/com/weeth/global/auth/authentication/CustomAccessDeniedHandler.kt index 5e0318e0..a3f27c53 100644 --- a/src/main/kotlin/com/weeth/global/auth/authentication/CustomAccessDeniedHandler.kt +++ b/src/main/kotlin/com/weeth/global/auth/authentication/CustomAccessDeniedHandler.kt @@ -1,11 +1,13 @@ package com.weeth.global.auth.authentication import com.fasterxml.jackson.databind.ObjectMapper +import com.weeth.global.auth.jwt.application.exception.JwtErrorCode import com.weeth.global.common.response.CommonResponse import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import org.slf4j.LoggerFactory import org.springframework.security.access.AccessDeniedException +import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.web.access.AccessDeniedHandler import org.springframework.stereotype.Component @@ -20,26 +22,52 @@ class CustomAccessDeniedHandler( response: HttpServletResponse, accessDeniedException: AccessDeniedException, ) { - setResponse(response) log.error( "ExceptionClass: {}, Message: {}", accessDeniedException::class.simpleName, accessDeniedException.message, ) + + if (isTemporaryUser()) { + setRegistrationIncompleteResponse(response) + } else { + setForbiddenResponse(response) + } + } + + private fun isTemporaryUser(): Boolean = + SecurityContextHolder + .getContext() + .authentication + ?.authorities + ?.any { it.authority == "ROLE_TEMPORARY" } + ?: false + + private fun setRegistrationIncompleteResponse(response: HttpServletResponse) { + val errorCode = JwtErrorCode.REGISTRATION_INCOMPLETE + response.status = errorCode.status.value() + response.contentType = "application/json" + response.characterEncoding = "UTF-8" + + val body = + objectMapper.writeValueAsString( + CommonResponse.error(errorCode), + ) + response.writer.write(body) } - private fun setResponse(response: HttpServletResponse) { + private fun setForbiddenResponse(response: HttpServletResponse) { response.status = HttpServletResponse.SC_FORBIDDEN response.contentType = "application/json" response.characterEncoding = "UTF-8" - val message = + val body = objectMapper.writeValueAsString( CommonResponse.createFailure( ErrorMessage.FORBIDDEN.code, ErrorMessage.FORBIDDEN.message, ), ) - response.writer.write(message) + response.writer.write(body) } } diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/JwtErrorCode.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/JwtErrorCode.kt index 5b88435e..1ed765c7 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/JwtErrorCode.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/JwtErrorCode.kt @@ -23,4 +23,7 @@ enum class JwtErrorCode( @ExplainError("Apple 인증 과정에서 토큰 교환 또는 검증에 실패했을 때 발생합니다.") APPLE_AUTHENTICATION_FAILED(29004, HttpStatus.UNAUTHORIZED, "애플 로그인에 실패했습니다."), + + @ExplainError("약관 동의가 완료되지 않은 사용자(TEMPORARY 토큰)가 서비스 API에 접근을 시도했을 때 발생합니다.") + REGISTRATION_INCOMPLETE(29005, HttpStatus.FORBIDDEN, "회원가입이 완료되지 않았습니다. 약관 동의를 진행해주세요."), } diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractor.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractor.kt index de308936..9e1698a3 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractor.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractor.kt @@ -1,7 +1,9 @@ package com.weeth.global.auth.jwt.application.service import com.weeth.global.auth.jwt.application.exception.TokenNotFoundException +import com.weeth.global.auth.jwt.domain.enums.TokenType import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider +import com.weeth.global.config.properties.CookieProperties import com.weeth.global.config.properties.JwtProperties import io.jsonwebtoken.Claims import jakarta.servlet.http.HttpServletRequest @@ -12,20 +14,32 @@ import org.springframework.stereotype.Service class JwtTokenExtractor( private val jwtProperties: JwtProperties, private val jwtTokenProvider: JwtTokenProvider, + private val cookieProperties: CookieProperties, ) { private val log = LoggerFactory.getLogger(javaClass) data class TokenClaims( val id: Long, val email: String, + val tokenType: TokenType, ) fun extractRefreshToken(request: HttpServletRequest): String = + extractRefreshTokenFromCookie(request) + ?: extractRefreshTokenFromHeader(request) + ?: throw TokenNotFoundException() + + private fun extractRefreshTokenFromCookie(request: HttpServletRequest): String? = + request.cookies + ?.firstOrNull { it.name == cookieProperties.refreshTokenName } + ?.value + ?.takeIf { it.isNotBlank() } + + private fun extractRefreshTokenFromHeader(request: HttpServletRequest): String? = request .getHeader(jwtProperties.refresh.header) ?.takeIf { it.startsWith(BEARER) } ?.removePrefix(BEARER) - ?: throw TokenNotFoundException() fun extractAccessToken(request: HttpServletRequest): String? = request @@ -41,9 +55,11 @@ class JwtTokenExtractor( fun extractClaims(token: String): TokenClaims? = runCatching { val claims: Claims = jwtTokenProvider.parseClaims(token) + val tokenTypeStr = claims.get(JwtTokenProvider.TOKEN_TYPE_CLAIM, String::class.java) TokenClaims( id = claims.get(JwtTokenProvider.ID_CLAIM, Long::class.javaObjectType), email = claims.get(JwtTokenProvider.EMAIL_CLAIM, String::class.java), + tokenType = tokenTypeStr?.let { TokenType.valueOf(it) } ?: TokenType.ACCESS, ) }.onFailure { log.error("액세스 토큰이 유효하지 않습니다: {}", it.message) diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/service/TokenCookieProvider.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/service/TokenCookieProvider.kt new file mode 100644 index 00000000..20ed71a1 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/service/TokenCookieProvider.kt @@ -0,0 +1,48 @@ +package com.weeth.global.auth.jwt.application.service + +import com.weeth.global.config.properties.CookieProperties +import com.weeth.global.config.properties.JwtProperties +import org.springframework.http.ResponseCookie +import org.springframework.stereotype.Service +import java.time.Duration + +@Service +class TokenCookieProvider( + private val cookieProperties: CookieProperties, + private val jwtProperties: JwtProperties, +) { + fun createAccessTokenCookie(token: String): ResponseCookie = + buildCookie( + name = cookieProperties.accessTokenName, + value = token, + maxAge = Duration.ofMillis(jwtProperties.access.expiration), + path = cookieProperties.path, + ) + + fun createRefreshTokenCookie(token: String): ResponseCookie = + buildCookie( + name = cookieProperties.refreshTokenName, + value = token, + maxAge = Duration.ofMillis(jwtProperties.refresh.expiration), + path = cookieProperties.refreshPath, + ) + + private fun buildCookie( + name: String, + value: String, + maxAge: Duration, + path: String, + ): ResponseCookie = + ResponseCookie + .from(name, value) + .httpOnly(cookieProperties.httpOnly) + .secure(cookieProperties.secure) + .path(path) + .maxAge(maxAge) + .sameSite(cookieProperties.sameSite) + .apply { + if (cookieProperties.domain.isNotBlank()) { + domain(cookieProperties.domain) + } + }.build() +} diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt index 322da189..e8439ebb 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt @@ -3,6 +3,7 @@ package com.weeth.global.auth.jwt.application.usecase import com.weeth.global.auth.jwt.application.dto.JwtDto import com.weeth.global.auth.jwt.application.exception.InvalidTokenException import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor +import com.weeth.global.auth.jwt.domain.enums.TokenType import com.weeth.global.auth.jwt.domain.port.RefreshTokenStorePort import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider import org.springframework.stereotype.Service @@ -16,11 +17,12 @@ class JwtManageUseCase( fun create( userId: Long, email: String, + tokenType: TokenType, ): JwtDto { - val accessToken = jwtTokenProvider.createAccessToken(userId, email) + val accessToken = jwtTokenProvider.createAccessToken(userId, email, tokenType) val refreshToken = jwtTokenProvider.createRefreshToken(userId) - refreshTokenStore.save(userId, refreshToken, email) + refreshTokenStore.save(userId, refreshToken, email, tokenType) return JwtDto(accessToken, refreshToken) } @@ -32,7 +34,8 @@ class JwtManageUseCase( refreshTokenStore.validateRefreshToken(userId, requestToken) val email = refreshTokenStore.getEmail(userId) + val tokenType = refreshTokenStore.getTokenType(userId) - return create(userId, email) + return create(userId, email, tokenType) } } diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/domain/enums/TokenType.kt b/src/main/kotlin/com/weeth/global/auth/jwt/domain/enums/TokenType.kt new file mode 100644 index 00000000..a85d0db7 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/domain/enums/TokenType.kt @@ -0,0 +1,6 @@ +package com.weeth.global.auth.jwt.domain.enums + +enum class TokenType { + TEMPORARY, // 약관 미동의 사용자용 (약관 동의 API만 접근 가능) + ACCESS, // 정상 사용자용 +} diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/domain/port/RefreshTokenStorePort.kt b/src/main/kotlin/com/weeth/global/auth/jwt/domain/port/RefreshTokenStorePort.kt index 578cdaf8..db9466c6 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/domain/port/RefreshTokenStorePort.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/domain/port/RefreshTokenStorePort.kt @@ -1,10 +1,13 @@ package com.weeth.global.auth.jwt.domain.port +import com.weeth.global.auth.jwt.domain.enums.TokenType + interface RefreshTokenStorePort { fun save( userId: Long, refreshToken: String, email: String, + tokenType: TokenType, ) fun delete(userId: Long) @@ -15,4 +18,6 @@ interface RefreshTokenStorePort { ) fun getEmail(userId: Long): String + + fun getTokenType(userId: Long): TokenType } diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProvider.kt b/src/main/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProvider.kt index e9044ef7..b5cad298 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProvider.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProvider.kt @@ -1,6 +1,7 @@ package com.weeth.global.auth.jwt.domain.service import com.weeth.global.auth.jwt.application.exception.InvalidTokenException +import com.weeth.global.auth.jwt.domain.enums.TokenType import com.weeth.global.config.properties.JwtProperties import io.jsonwebtoken.Claims import io.jsonwebtoken.JwtException @@ -31,6 +32,7 @@ class JwtTokenProvider( fun createAccessToken( id: Long, email: String, + tokenType: TokenType, ): String { val now = Date() return Jwts @@ -38,6 +40,7 @@ class JwtTokenProvider( .subject(ACCESS_TOKEN_SUBJECT) .claim(ID_CLAIM, id) .claim(EMAIL_CLAIM, email) + .claim(TOKEN_TYPE_CLAIM, tokenType.name) .issuedAt(now) .expiration(Date(now.time + accessTokenExpirationPeriod)) .signWith(secretKey) @@ -82,5 +85,6 @@ class JwtTokenProvider( private const val REFRESH_TOKEN_SUBJECT = "RefreshToken" internal const val EMAIL_CLAIM = "email" internal const val ID_CLAIM = "id" + internal const val TOKEN_TYPE_CLAIM = "tokenType" } } diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt b/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt index 0f316c50..95cf87d1 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt @@ -2,6 +2,7 @@ package com.weeth.global.auth.jwt.filter import com.weeth.global.auth.jwt.application.exception.TokenNotFoundException import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor +import com.weeth.global.auth.jwt.domain.enums.TokenType import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider import com.weeth.global.auth.model.AuthenticatedUser import jakarta.servlet.FilterChain @@ -41,11 +42,17 @@ class JwtAuthenticationProcessingFilter( val claims = jwtTokenExtractor.extractClaims(accessToken) ?: throw TokenNotFoundException() val principal = AuthenticatedUser(claims.id, claims.email) + val role = + when (claims.tokenType) { + TokenType.TEMPORARY -> "ROLE_TEMPORARY" + TokenType.ACCESS -> "ROLE_USER" + } + val authentication = UsernamePasswordAuthenticationToken( principal, null, - listOf(SimpleGrantedAuthority("ROLE_USER")), + listOf(SimpleGrantedAuthority(role)), ) SecurityContextHolder.getContext().authentication = authentication diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisRefreshTokenStoreAdapter.kt b/src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisRefreshTokenStoreAdapter.kt index de1548a9..9ccb9542 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisRefreshTokenStoreAdapter.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisRefreshTokenStoreAdapter.kt @@ -2,6 +2,7 @@ package com.weeth.global.auth.jwt.infrastructure import com.weeth.global.auth.jwt.application.exception.InvalidTokenException import com.weeth.global.auth.jwt.application.exception.RedisTokenNotFoundException +import com.weeth.global.auth.jwt.domain.enums.TokenType import com.weeth.global.auth.jwt.domain.port.RefreshTokenStorePort import com.weeth.global.config.properties.JwtProperties import org.springframework.data.redis.core.RedisTemplate @@ -17,6 +18,7 @@ class RedisRefreshTokenStoreAdapter( userId: Long, refreshToken: String, email: String, + tokenType: TokenType, ) { val key = getKey(userId) redisTemplate.opsForHash().putAll( @@ -24,6 +26,7 @@ class RedisRefreshTokenStoreAdapter( mapOf( TOKEN to refreshToken, EMAIL to email, + TOKEN_TYPE to tokenType.name, ), ) redisTemplate.expire(key, jwtProperties.refresh.expiration, TimeUnit.MINUTES) @@ -49,6 +52,14 @@ class RedisRefreshTokenStoreAdapter( ?: throw RedisTokenNotFoundException() } + override fun getTokenType(userId: Long): TokenType { + val key = getKey(userId) + val value = + redisTemplate.opsForHash().get(key, TOKEN_TYPE) + ?: return TokenType.ACCESS // 기존 토큰 호환성을 위한 기본값 + return TokenType.valueOf(value) + } + private fun find(userId: Long): String { val key = getKey(userId) return redisTemplate.opsForHash().get(key, TOKEN) @@ -61,5 +72,6 @@ class RedisRefreshTokenStoreAdapter( private const val PREFIX = "refreshToken:" private const val TOKEN = "token" private const val EMAIL = "email" + private const val TOKEN_TYPE = "tokenType" } } diff --git a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt index 0ab5d2fb..d5e9cb97 100644 --- a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt +++ b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt @@ -42,11 +42,9 @@ class SecurityConfig( .authorizeHttpRequests { authorize -> authorize .requestMatchers( - "/api/v4/users/email", "/api/v4/users/social/kakao", "/api/v4/users/social/apple", "/api/v4/users/social/refresh", - "/api/v1/users/email", ).permitAll() .requestMatchers("/health-check") .permitAll() @@ -54,14 +52,6 @@ class SecurityConfig( .permitAll() .requestMatchers(HttpMethod.GET, "/api/v4/university/*") .permitAll() - .requestMatchers( - "/admin", - "/admin/login", - "/admin/account", - "/admin/meeting", - "/admin/member", - "/admin/penalty", - ).permitAll() .requestMatchers( "/v3/api-docs", "/v3/api-docs/**", @@ -76,13 +66,10 @@ class SecurityConfig( AuthorizationDecision(allowed) }.requestMatchers("/actuator/health") .permitAll() - // 실제 관리자 권한 검증은 ClubMemberPolicy.requireAdmin()에서 수행 - .requestMatchers( - "/api/v1/admin/**", - "/api/v4/admin/**", - ).authenticated() + .requestMatchers("/api/v4/users/terms") + .hasAnyRole("TEMPORARY", "USER") .anyRequest() - .authenticated() + .hasRole("USER") }.exceptionHandling { exceptionHandling -> exceptionHandling .authenticationEntryPoint(customAuthenticationEntryPoint) diff --git a/src/main/kotlin/com/weeth/global/config/properties/CookieProperties.kt b/src/main/kotlin/com/weeth/global/config/properties/CookieProperties.kt new file mode 100644 index 00000000..d679d30d --- /dev/null +++ b/src/main/kotlin/com/weeth/global/config/properties/CookieProperties.kt @@ -0,0 +1,20 @@ +package com.weeth.global.config.properties + +import jakarta.validation.constraints.NotBlank +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.validation.annotation.Validated + +@Validated +@ConfigurationProperties(prefix = "weeth.cookie") +data class CookieProperties( + @field:NotBlank + val accessTokenName: String, + @field:NotBlank + val refreshTokenName: String, + val domain: String = "", + val path: String = "/", + val refreshPath: String = "/api/v4/users/social/refresh", + val sameSite: String = "Lax", + val secure: Boolean = true, + val httpOnly: Boolean = true, +) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index b8135b57..9a96e8d6 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -27,6 +27,12 @@ weeth: refresh: expiration: ${REFRESH_EXP} header: ${REFRESH_HEAD} + cookie: + access-token-name: ${ACCESS_HEAD} + refresh-token-name: ${REFRESH_HEAD} + domain: ${COOKIE_DOMAIN:} + same-site: ${COOKIE_SAME_SITE:Lax} + secure: false cloud: aws: s3: @@ -38,4 +44,4 @@ cloud: static: ap-northeast-2 auto: false stack: - auto: false \ No newline at end of file + auto: false diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 4d47ca69..fe67cc3d 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -27,6 +27,12 @@ weeth: refresh: expiration: ${REFRESH_EXP} header: ${REFRESH_HEAD} + cookie: + access-token-name: ${ACCESS_HEAD} + refresh-token-name: ${REFRESH_HEAD} + domain: ${COOKIE_DOMAIN:} + same-site: ${COOKIE_SAME_SITE:Lax} + secure: false cloud: aws: diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 8d89246b..93d3783d 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -32,6 +32,12 @@ weeth: refresh: expiration: ${REFRESH_EXP} header: ${REFRESH_HEAD} + cookie: + access-token-name: ${ACCESS_HEAD} + refresh-token-name: ${REFRESH_HEAD} + domain: ${COOKIE_DOMAIN:} + same-site: ${COOKIE_SAME_SITE:Lax} + secure: true cloud: aws: s3: diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryServiceTest.kt index db7206c6..65f2e975 100644 --- a/src/test/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/query/GetClubMemberQueryServiceTest.kt @@ -8,12 +8,18 @@ import com.weeth.domain.club.domain.repository.ClubMemberCardinalReader import com.weeth.domain.club.domain.repository.ClubMemberReader import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.club.fixture.ClubMemberTestFixture import com.weeth.domain.club.fixture.ClubTestFixture import com.weeth.domain.file.domain.port.FileAccessUrlPort +import com.weeth.domain.user.domain.entity.User +import com.weeth.domain.user.domain.repository.UserReader import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe +import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -25,6 +31,7 @@ class GetClubMemberQueryServiceTest : val clubMemberPolicy = mockk() val clubPermissionPolicy = mockk() val fileAccessUrlPort = mockk() + val userReader = mockk() val clubMapper = ClubMapper(fileAccessUrlPort) val service = @@ -34,8 +41,13 @@ class GetClubMemberQueryServiceTest : clubMemberPolicy = clubMemberPolicy, clubPermissionPolicy = clubPermissionPolicy, clubMapper = clubMapper, + userReader = userReader, ) + beforeTest { + clearMocks(clubMemberReader, clubMemberCardinalReader, clubMemberPolicy, clubPermissionPolicy, userReader) + } + describe("findClubMembersForAdmin") { context("관리자가 멤버 목록을 조회하는 경우") { it("각 멤버의 소속 기수 정보를 함께 반환한다") { @@ -76,4 +88,80 @@ class GetClubMemberQueryServiceTest : } } } + + describe("findProfileStatus") { + val club = ClubTestFixture.createClub() + val clubId = 1L + val userId = 1L + + context("프로필이 완성되고 기수가 등록된 경우") { + it("profileCompleted=true, cardinalAssigned=true, missingFields 비어있음") { + val user = + User.create( + name = "test", + email = "test@test.com", + studentId = "20200001", + tel = "01012345678", + school = "가천대학교", + department = "CS", + ) + val member = ClubMemberTestFixture.createActiveMember(club = club, user = user) + val cardinal = Cardinal.create(club = club, cardinalNumber = 7) + val memberCardinal = ClubMemberCardinal.create(member, cardinal) + + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns member + every { userReader.getById(userId) } returns user + every { clubMemberCardinalReader.findLatestCardinalByClubMember(member) } returns memberCardinal + + val result = service.findProfileStatus(clubId, userId) + + result.profileCompleted shouldBe true + result.cardinalAssigned shouldBe true + result.missingFields.shouldBeEmpty() + } + } + + context("프로필이 미완성이고 기수가 미등록인 경우") { + it("profileCompleted=false, cardinalAssigned=false, missingFields에 비어있는 필드 반환") { + val user = User.create(name = "test", email = "test@test.com") + val member = ClubMemberTestFixture.createActiveMember(club = club, user = user) + + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns member + every { userReader.getById(userId) } returns user + every { clubMemberCardinalReader.findLatestCardinalByClubMember(member) } returns null + + val result = service.findProfileStatus(clubId, userId) + + result.profileCompleted shouldBe false + result.cardinalAssigned shouldBe false + result.missingFields shouldContainExactlyInAnyOrder + listOf("studentId", "tel", "school", "department") + } + } + + context("프로필은 완성이나 기수가 미등록인 경우") { + it("profileCompleted=true, cardinalAssigned=false") { + val user = + User.create( + name = "test", + email = "test@test.com", + studentId = "20200001", + tel = "01012345678", + school = "가천대학교", + department = "CS", + ) + val member = ClubMemberTestFixture.createActiveMember(club = club, user = user) + + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns member + every { userReader.getById(userId) } returns user + every { clubMemberCardinalReader.findLatestCardinalByClubMember(member) } returns null + + val result = service.findProfileStatus(clubId, userId) + + result.profileCompleted shouldBe true + result.cardinalAssigned shouldBe false + result.missingFields.shouldBeEmpty() + } + } + } }) diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AgreeTermsUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AgreeTermsUseCaseTest.kt index fc71702f..4a45a679 100644 --- a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AgreeTermsUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AgreeTermsUseCaseTest.kt @@ -3,30 +3,40 @@ package com.weeth.domain.user.application.usecase.command import com.weeth.domain.user.application.dto.request.AgreeTermsRequest import com.weeth.domain.user.domain.repository.UserRepository import com.weeth.domain.user.fixture.UserTestFixture +import com.weeth.global.auth.jwt.application.dto.JwtDto +import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase +import com.weeth.global.auth.jwt.domain.enums.TokenType import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk +import io.mockk.verify class AgreeTermsUseCaseTest : DescribeSpec({ val userRepository = mockk() - val useCase = AgreeTermsUseCase(userRepository) + val jwtManageUseCase = mockk() + val useCase = AgreeTermsUseCase(userRepository, jwtManageUseCase) - beforeTest { clearMocks(userRepository) } + beforeTest { clearMocks(userRepository, jwtManageUseCase) } describe("execute") { context("모든 약관에 동의한 경우") { - it("약관 동의 상태를 true로 변경한다") { - val user = UserTestFixture.createActiveUser1(1L) + it("약관 동의 후 ACCESS 토큰을 발급한다") { + val user = UserTestFixture.createWaitingUser1(1L) every { userRepository.getById(1L) } returns user + every { jwtManageUseCase.create(1L, user.emailValue, TokenType.ACCESS) } returns + JwtDto("access", "refresh") - useCase.execute(1L, AgreeTermsRequest(termsAgreed = true, privacyAgreed = true)) + val result = useCase.execute(1L, AgreeTermsRequest(termsAgreed = true, privacyAgreed = true)) user.termsAgreed shouldBe true user.privacyAgreed shouldBe true + result.accessToken shouldBe "access" + result.refreshToken shouldBe "refresh" + verify(exactly = 1) { jwtManageUseCase.create(1L, user.emailValue, TokenType.ACCESS) } } } diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCaseTest.kt index af97f7a8..bbc853ca 100644 --- a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCaseTest.kt @@ -4,8 +4,10 @@ import com.weeth.domain.file.domain.port.FileAccessUrlPort import com.weeth.domain.user.application.dto.request.SocialLoginRequest import com.weeth.domain.user.application.exception.EmailNotFoundException import com.weeth.domain.user.application.mapper.UserMapper +import com.weeth.domain.user.domain.entity.User import com.weeth.domain.user.domain.entity.UserSocialAccount import com.weeth.domain.user.domain.enums.SocialProvider +import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.port.SocialAuthPort import com.weeth.domain.user.domain.repository.UserRepository import com.weeth.domain.user.domain.repository.UserSocialAccountRepository @@ -14,9 +16,11 @@ import com.weeth.domain.user.fixture.UserTestFixture import com.weeth.domain.user.infrastructure.SocialAuthPortRegistry import com.weeth.global.auth.jwt.application.dto.JwtDto import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase +import com.weeth.global.auth.jwt.domain.enums.TokenType import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe +import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -41,10 +45,20 @@ class SocialLoginUseCaseTest : userMapper = userMapper, ) + beforeTest { + clearMocks( + userRepository, + userSocialAccountRepository, + socialAuthPortRegistry, + socialAuthPort, + jwtManageUseCase, + ) + } + describe("socialLoginByApple") { - it("기존 연동 계정은 이메일이 없어도 로그인된다") { + it("약관 동의 완료된 기존 유저는 ACCESS 토큰과 registered=true를 반환한다") { val request = SocialLoginRequest(authCode = "apple-auth-code") - val user = UserTestFixture.createActiveUser1(1L) + val user = UserTestFixture.createRegisteredUser(1L) val account = UserSocialAccount( provider = SocialProvider.APPLE, @@ -65,19 +79,81 @@ class SocialLoginUseCaseTest : every { userSocialAccountRepository.findByProviderAndProviderUserId(SocialProvider.APPLE, "apple-user-1") } returns Optional.of(account) - every { jwtManageUseCase.create(user.id, user.emailValue) } returns + every { jwtManageUseCase.create(user.id, user.emailValue, TokenType.ACCESS) } returns JwtDto("access", "refresh") val result = useCase.socialLoginByApple(request) result.accessToken shouldBe "access" result.refreshToken shouldBe "refresh" - result.isNewUser shouldBe false + result.registered shouldBe true verify(exactly = 0) { userRepository.save(any()) } verify(exactly = 0) { userSocialAccountRepository.save(any()) } } + it("약관 미동의 기존 유저는 TEMPORARY 토큰과 registered=false를 반환한다") { + val request = SocialLoginRequest(authCode = "apple-auth-code") + val user = UserTestFixture.createActiveUser1(1L) // ACTIVE이지만 약관 미동의 + val account = + UserSocialAccount( + provider = SocialProvider.APPLE, + providerUserId = "apple-user-1", + user = user, + ) + val authResult = + SocialAuthResult( + provider = SocialProvider.APPLE, + providerUserId = "apple-user-1", + email = "", + emailVerified = false, + name = null, + ) + + every { socialAuthPortRegistry.get(SocialProvider.APPLE) } returns socialAuthPort + every { socialAuthPort.authenticate("apple-auth-code") } returns authResult + every { + userSocialAccountRepository.findByProviderAndProviderUserId(SocialProvider.APPLE, "apple-user-1") + } returns Optional.of(account) + every { jwtManageUseCase.create(user.id, user.emailValue, TokenType.TEMPORARY) } returns + JwtDto("temp-access", "refresh") + + val result = useCase.socialLoginByApple(request) + + result.accessToken shouldBe "temp-access" + result.registered shouldBe false + } + + it("신규 유저는 TEMPORARY 토큰과 registered=false를 반환한다") { + val request = SocialLoginRequest(authCode = "apple-auth-code") + val authResult = + SocialAuthResult( + provider = SocialProvider.APPLE, + providerUserId = "apple-user-new", + email = "new@test.com", + emailVerified = true, + name = "신규유저", + ) + + every { socialAuthPortRegistry.get(SocialProvider.APPLE) } returns socialAuthPort + every { socialAuthPort.authenticate("apple-auth-code") } returns authResult + every { + userSocialAccountRepository.findByProviderAndProviderUserId(SocialProvider.APPLE, "apple-user-new") + } returns Optional.empty() + every { userRepository.save(any()) } answers { + val saved = firstArg() + saved + } + every { userSocialAccountRepository.save(any()) } answers { firstArg() } + every { jwtManageUseCase.create(any(), any(), TokenType.TEMPORARY) } returns + JwtDto("temp-access", "refresh") + + val result = useCase.socialLoginByApple(request) + + result.registered shouldBe false + verify(exactly = 1) { jwtManageUseCase.create(any(), any(), TokenType.TEMPORARY) } + } + it("신규 연동 계정은 이메일이 없으면 예외가 발생한다") { val request = SocialLoginRequest(authCode = "apple-auth-code") val authResult = diff --git a/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt b/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt index a956b16d..1f6a84de 100644 --- a/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt @@ -5,6 +5,8 @@ import com.weeth.domain.user.domain.vo.Email import com.weeth.global.common.vo.PhoneNumber import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder import io.kotest.matchers.shouldBe class UserTest : @@ -75,6 +77,39 @@ class UserTest : user.isProfileCompleted() shouldBe true } + "missingProfileFields — 기본 생성 시 비어있는 필드 목록 반환" { + val user = User.create(name = "test", email = "test@test.com") + + user.missingProfileFields() shouldContainExactlyInAnyOrder + listOf("studentId", "tel", "school", "department") + } + + "missingProfileFields — 모든 필드 채워졌을 때 빈 리스트 반환" { + val user = + User.create( + name = "test", + email = "test@test.com", + studentId = "20200001", + tel = "01012345678", + school = "가천대학교", + department = "CS", + ) + + user.missingProfileFields().shouldBeEmpty() + } + + "missingProfileFields — 일부 필드만 비어있을 때 해당 필드만 반환" { + val user = + User.create( + name = "test", + email = "test@test.com", + studentId = "20200001", + tel = "01012345678", + ) + + user.missingProfileFields() shouldContainExactlyInAnyOrder listOf("school", "department") + } + "isActive / isInactive 동작" { val user = User(name = "test", email = Email.from("test@test.com")) user.isActive() shouldBe false diff --git a/src/test/kotlin/com/weeth/domain/user/fixture/UserTestFixture.kt b/src/test/kotlin/com/weeth/domain/user/fixture/UserTestFixture.kt index 2669e428..7445c9cb 100644 --- a/src/test/kotlin/com/weeth/domain/user/fixture/UserTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/user/fixture/UserTestFixture.kt @@ -34,6 +34,15 @@ object UserTestFixture { status = Status.WAITING, ).applyId(id) + fun createRegisteredUser(id: Long = 0L): User = + User( + name = "등록완료", + email = Email.from("registered@test.com"), + status = Status.ACTIVE, + ).apply { + agreeTerms(termsAgreed = true, privacyAgreed = true) + }.applyId(id) + fun createAdmin(id: Long = 0L): User = User( name = "적순", diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractorTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractorTest.kt index 74fbb652..5722d1a3 100644 --- a/src/test/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractorTest.kt +++ b/src/test/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractorTest.kt @@ -1,7 +1,9 @@ package com.weeth.global.auth.jwt.application.service import com.weeth.global.auth.jwt.application.exception.TokenNotFoundException +import com.weeth.global.auth.jwt.domain.enums.TokenType import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider +import com.weeth.global.config.properties.CookieProperties import com.weeth.global.config.properties.JwtProperties import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec @@ -10,6 +12,7 @@ import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk import io.mockk.verify +import jakarta.servlet.http.Cookie import jakarta.servlet.http.HttpServletRequest class JwtTokenExtractorTest : @@ -21,8 +24,9 @@ class JwtTokenExtractorTest : refresh = JwtProperties.TokenProperties(expiration = 120_000L, header = "Refresh"), ) + val cookieProperties = CookieProperties(accessTokenName = "access_token", refreshTokenName = "refresh_token") val jwtProvider = mockk() - val jwtTokenExtractor = JwtTokenExtractor(jwtProperties, jwtProvider) + val jwtTokenExtractor = JwtTokenExtractor(jwtProperties, jwtProvider, cookieProperties) beforeTest { clearMocks(jwtProvider) @@ -40,8 +44,38 @@ class JwtTokenExtractorTest : } describe("extractRefreshToken") { - it("헤더가 없으면 TokenNotFoundException이 발생한다") { + it("Cookie에서 refresh token을 우선 추출한다") { val request = mockk() + every { request.cookies } returns arrayOf(Cookie("refresh_token", "cookie-refresh-token")) + + val token = jwtTokenExtractor.extractRefreshToken(request) + + token shouldBe "cookie-refresh-token" + } + + it("Cookie가 없으면 Header에서 refresh token을 추출한다") { + val request = mockk() + every { request.cookies } returns null + every { request.getHeader("Refresh") } returns "Bearer header-refresh-token" + + val token = jwtTokenExtractor.extractRefreshToken(request) + + token shouldBe "header-refresh-token" + } + + it("Cookie가 빈 값이면 Header에서 refresh token을 추출한다") { + val request = mockk() + every { request.cookies } returns arrayOf(Cookie("refresh_token", "")) + every { request.getHeader("Refresh") } returns "Bearer header-refresh-token" + + val token = jwtTokenExtractor.extractRefreshToken(request) + + token shouldBe "header-refresh-token" + } + + it("Cookie와 Header 둘 다 없으면 TokenNotFoundException이 발생한다") { + val request = mockk() + every { request.cookies } returns null every { request.getHeader("Refresh") } returns null shouldThrow { @@ -65,18 +99,33 @@ class JwtTokenExtractorTest : } describe("extractClaims") { - it("id, email을 함께 반환한다") { + it("id, email, tokenType을 함께 반환한다") { val token = "sample" val claims = mockk() every { jwtProvider.parseClaims(token) } returns claims every { claims.get("id", Long::class.javaObjectType) } returns 77L every { claims.get("email", String::class.java) } returns "sample@com" + every { claims.get("tokenType", String::class.java) } returns "ACCESS" val tokenClaims = jwtTokenExtractor.extractClaims(token) tokenClaims?.id shouldBe 77L tokenClaims?.email shouldBe "sample@com" + tokenClaims?.tokenType shouldBe TokenType.ACCESS verify(exactly = 1) { jwtProvider.parseClaims(token) } } + + it("tokenType 클레임이 없으면 기본값 ACCESS를 반환한다") { + val token = "sample" + val claims = mockk() + every { jwtProvider.parseClaims(token) } returns claims + every { claims.get("id", Long::class.javaObjectType) } returns 77L + every { claims.get("email", String::class.java) } returns "sample@com" + every { claims.get("tokenType", String::class.java) } returns null + + val tokenClaims = jwtTokenExtractor.extractClaims(token) + + tokenClaims?.tokenType shouldBe TokenType.ACCESS + } } }) diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/application/service/TokenCookieProviderTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/application/service/TokenCookieProviderTest.kt new file mode 100644 index 00000000..459ab50b --- /dev/null +++ b/src/test/kotlin/com/weeth/global/auth/jwt/application/service/TokenCookieProviderTest.kt @@ -0,0 +1,86 @@ +package com.weeth.global.auth.jwt.application.service + +import com.weeth.global.config.properties.CookieProperties +import com.weeth.global.config.properties.JwtProperties +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain + +class TokenCookieProviderTest : + DescribeSpec({ + val jwtProperties = + JwtProperties( + key = "test-key", + access = JwtProperties.TokenProperties(expiration = 3_600_000L, header = "Authorization"), + refresh = JwtProperties.TokenProperties(expiration = 604_800_000L, header = "Authorization_refresh"), + ) + + describe("createAccessTokenCookie") { + it("설정값대로 access token 쿠키를 생성한다") { + val cookieProperties = + CookieProperties( + accessTokenName = "access_token", + refreshTokenName = "refresh_token", + secure = false, + ) + val provider = TokenCookieProvider(cookieProperties, jwtProperties) + + val cookie = provider.createAccessTokenCookie("test-access-token") + + cookie.name shouldBe "access_token" + cookie.value shouldBe "test-access-token" + cookie.maxAge.seconds shouldBe 3600L + cookie.path shouldBe "/" + cookie.isHttpOnly shouldBe true + cookie.isSecure shouldBe false + cookie.sameSite shouldBe "Lax" + } + + it("domain이 설정되면 쿠키에 도메인이 포함된다") { + val cookieProperties = + CookieProperties( + accessTokenName = "access_token", + refreshTokenName = "refresh_token", + domain = "example.com", + ) + val provider = TokenCookieProvider(cookieProperties, jwtProperties) + + val cookie = provider.createAccessTokenCookie("test-token") + + cookie.toString() shouldContain "Domain=example.com" + } + + it("domain이 빈 문자열이면 쿠키에 도메인이 포함되지 않는다") { + val cookieProperties = + CookieProperties(accessTokenName = "access_token", refreshTokenName = "refresh_token", domain = "") + val provider = TokenCookieProvider(cookieProperties, jwtProperties) + + val cookie = provider.createAccessTokenCookie("test-token") + + cookie.toString().contains("Domain=") shouldBe false + } + } + + describe("createRefreshTokenCookie") { + it("설정값대로 refresh token 쿠키를 생성한다") { + val cookieProperties = + CookieProperties( + accessTokenName = "access_token", + refreshTokenName = "refresh_token", + secure = true, + sameSite = "None", + ) + val provider = TokenCookieProvider(cookieProperties, jwtProperties) + + val cookie = provider.createRefreshTokenCookie("test-refresh-token") + + cookie.name shouldBe "refresh_token" + cookie.value shouldBe "test-refresh-token" + cookie.maxAge.seconds shouldBe 604_800L + cookie.path shouldBe "/api/v4/users/social/refresh" + cookie.isHttpOnly shouldBe true + cookie.isSecure shouldBe true + cookie.sameSite shouldBe "None" + } + } + }) diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCaseTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCaseTest.kt index e8f43b4f..6f854d3c 100644 --- a/src/test/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCaseTest.kt @@ -2,10 +2,12 @@ package com.weeth.global.auth.jwt.application.usecase import com.weeth.global.auth.jwt.application.dto.JwtDto import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor +import com.weeth.global.auth.jwt.domain.enums.TokenType import com.weeth.global.auth.jwt.domain.port.RefreshTokenStorePort import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe +import io.mockk.clearMocks import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -19,31 +21,62 @@ class JwtManageUseCaseTest : val refreshTokenStore = mockk(relaxUnitFun = true) val useCase = JwtManageUseCase(jwtProvider, jwtService, refreshTokenStore) + beforeTest { clearMocks(jwtProvider, jwtService, refreshTokenStore) } + describe("create") { - it("access/refresh token을 생성하고 저장한다") { - every { jwtProvider.createAccessToken(1L, "a@weeth.com") } returns "access" + it("ACCESS 타입으로 토큰을 생성하고 저장한다") { + every { jwtProvider.createAccessToken(1L, "a@weeth.com", TokenType.ACCESS) } returns "access" every { jwtProvider.createRefreshToken(1L) } returns "refresh" - val result = useCase.create(1L, "a@weeth.com") + val result = useCase.create(1L, "a@weeth.com", TokenType.ACCESS) result shouldBe JwtDto("access", "refresh") - verify(exactly = 1) { refreshTokenStore.save(1L, "refresh", "a@weeth.com") } + verify(exactly = 1) { refreshTokenStore.save(1L, "refresh", "a@weeth.com", TokenType.ACCESS) } + } + + it("TEMPORARY 타입으로 토큰을 생성하고 저장한다") { + every { jwtProvider.createAccessToken(1L, "a@weeth.com", TokenType.TEMPORARY) } returns "temp-access" + every { jwtProvider.createRefreshToken(1L) } returns "refresh" + + val result = useCase.create(1L, "a@weeth.com", TokenType.TEMPORARY) + + result shouldBe JwtDto("temp-access", "refresh") + verify(exactly = 1) { refreshTokenStore.save(1L, "refresh", "a@weeth.com", TokenType.TEMPORARY) } } } describe("reIssueToken") { - it("저장 토큰 검증 후 새 토큰을 재발급한다") { + it("저장된 tokenType으로 새 토큰을 재발급한다") { every { jwtProvider.validate("old-refresh") } just runs every { jwtService.extractId("old-refresh") } returns 10L every { refreshTokenStore.getEmail(10L) } returns "admin@weeth.com" - every { jwtProvider.createAccessToken(10L, "admin@weeth.com") } returns "new-access" + every { refreshTokenStore.getTokenType(10L) } returns TokenType.ACCESS + every { jwtProvider.createAccessToken(10L, "admin@weeth.com", TokenType.ACCESS) } returns "new-access" every { jwtProvider.createRefreshToken(10L) } returns "new-refresh" val result = useCase.reIssueToken("old-refresh") result shouldBe JwtDto("new-access", "new-refresh") verify(exactly = 1) { refreshTokenStore.validateRefreshToken(10L, "old-refresh") } - verify(exactly = 1) { refreshTokenStore.save(10L, "new-refresh", "admin@weeth.com") } + verify(exactly = 1) { refreshTokenStore.save(10L, "new-refresh", "admin@weeth.com", TokenType.ACCESS) } + } + + it("TEMPORARY tokenType이면 TEMPORARY 토큰으로 재발급한다") { + every { jwtProvider.validate("old-refresh") } just runs + every { jwtService.extractId("old-refresh") } returns 10L + every { refreshTokenStore.getEmail(10L) } returns "new@weeth.com" + every { refreshTokenStore.getTokenType(10L) } returns TokenType.TEMPORARY + every { + jwtProvider.createAccessToken(10L, "new@weeth.com", TokenType.TEMPORARY) + } returns "temp-access" + every { jwtProvider.createRefreshToken(10L) } returns "new-refresh" + + val result = useCase.reIssueToken("old-refresh") + + result shouldBe JwtDto("temp-access", "new-refresh") + verify(exactly = 1) { + refreshTokenStore.save(10L, "new-refresh", "new@weeth.com", TokenType.TEMPORARY) + } } } }) diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProviderTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProviderTest.kt index b3d31298..5765fd2f 100644 --- a/src/test/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProviderTest.kt +++ b/src/test/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProviderTest.kt @@ -1,6 +1,7 @@ package com.weeth.global.auth.jwt.domain.service import com.weeth.global.auth.jwt.application.exception.InvalidTokenException +import com.weeth.global.auth.jwt.domain.enums.TokenType import com.weeth.global.config.properties.JwtProperties import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.StringSpec @@ -18,12 +19,21 @@ class JwtTokenProviderTest : val jwtProvider = JwtTokenProvider(jwtProperties) "access token 생성 후 claims를 파싱할 수 있다" { - val token = jwtProvider.createAccessToken(1L, "test@weeth.com") + val token = jwtProvider.createAccessToken(1L, "test@weeth.com", TokenType.ACCESS) val claims = jwtProvider.parseClaims(token) claims.get("id", Number::class.java).toLong() shouldBe 1L claims.get("email", String::class.java) shouldBe "test@weeth.com" + claims.get("tokenType", String::class.java) shouldBe "ACCESS" + } + + "TEMPORARY 토큰은 tokenType 클레임이 TEMPORARY이다" { + val token = jwtProvider.createAccessToken(1L, "test@weeth.com", TokenType.TEMPORARY) + + val claims = jwtProvider.parseClaims(token) + + claims.get("tokenType", String::class.java) shouldBe "TEMPORARY" } "유효하지 않은 토큰 검증 시 InvalidTokenException이 발생한다" { diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt index 24598407..4278102a 100644 --- a/src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt +++ b/src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt @@ -1,6 +1,7 @@ package com.weeth.global.auth.jwt.filter import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor +import com.weeth.global.auth.jwt.domain.enums.TokenType import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider import com.weeth.global.auth.model.AuthenticatedUser import io.kotest.core.spec.style.DescribeSpec @@ -32,7 +33,7 @@ class JwtAuthenticationProcessingFilterTest : } describe("doFilterInternal") { - it("유효한 토큰이면 SecurityContext에 인증을 저장한다") { + it("ACCESS 토큰이면 ROLE_USER 권한을 부여한다") { val request = MockHttpServletRequest().apply { requestURI = "/api/v1/users" } val response = MockHttpServletResponse() val chain = MockFilterChain() @@ -40,7 +41,7 @@ class JwtAuthenticationProcessingFilterTest : every { jwtService.extractAccessToken(request) } returns "access-token" every { jwtProvider.validate("access-token") } just runs every { jwtService.extractClaims("access-token") } returns - JwtTokenExtractor.TokenClaims(1L, "admin@weeth.com") + JwtTokenExtractor.TokenClaims(1L, "admin@weeth.com", TokenType.ACCESS) filter.doFilter(request, response, chain) @@ -53,6 +54,24 @@ class JwtAuthenticationProcessingFilterTest : authentication.authorities.any { it.authority == "ROLE_USER" } shouldBe true } + it("TEMPORARY 토큰이면 ROLE_TEMPORARY 권한을 부여한다") { + val request = MockHttpServletRequest().apply { requestURI = "/api/v4/users/terms" } + val response = MockHttpServletResponse() + val chain = MockFilterChain() + + every { jwtService.extractAccessToken(request) } returns "temp-token" + every { jwtProvider.validate("temp-token") } just runs + every { jwtService.extractClaims("temp-token") } returns + JwtTokenExtractor.TokenClaims(2L, "new@weeth.com", TokenType.TEMPORARY) + + filter.doFilter(request, response, chain) + + val authentication = SecurityContextHolder.getContext().authentication + (authentication == null) shouldBe false + authentication.authorities.any { it.authority == "ROLE_TEMPORARY" } shouldBe true + authentication.authorities.any { it.authority == "ROLE_USER" } shouldBe false + } + it("토큰이 없으면 인증을 저장하지 않는다") { val request = MockHttpServletRequest().apply { requestURI = "/api/v1/users" } val response = MockHttpServletResponse() diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/infrastructure/store/RedisRefreshTokenStoreAdapterTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/infrastructure/store/RedisRefreshTokenStoreAdapterTest.kt index ebb942a9..ec292bc4 100644 --- a/src/test/kotlin/com/weeth/global/auth/jwt/infrastructure/store/RedisRefreshTokenStoreAdapterTest.kt +++ b/src/test/kotlin/com/weeth/global/auth/jwt/infrastructure/store/RedisRefreshTokenStoreAdapterTest.kt @@ -3,6 +3,7 @@ package com.weeth.global.auth.jwt.infrastructure.store import com.weeth.config.TestContainersConfig import com.weeth.global.auth.jwt.application.exception.InvalidTokenException import com.weeth.global.auth.jwt.application.exception.RedisTokenNotFoundException +import com.weeth.global.auth.jwt.domain.enums.TokenType import com.weeth.global.auth.jwt.infrastructure.RedisRefreshTokenStoreAdapter import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec @@ -27,23 +28,31 @@ class RedisRefreshTokenStoreAdapterTest( } describe("save/get") { - it("실제 Redis에 email/token을 저장하고 조회한다") { - redisRefreshTokenStoreAdapter.save(1L, "rt", "a@weeth.com") + it("실제 Redis에 email/token/tokenType을 저장하고 조회한다") { + redisRefreshTokenStoreAdapter.save(1L, "rt", "a@weeth.com", TokenType.ACCESS) redisRefreshTokenStoreAdapter.getEmail(1L) shouldBe "a@weeth.com" + redisRefreshTokenStoreAdapter.getTokenType(1L) shouldBe TokenType.ACCESS redisTemplate.opsForHash().get("refreshToken:1", "token") shouldBe "rt" + redisTemplate.opsForHash().get("refreshToken:1", "tokenType") shouldBe "ACCESS" + } + + it("TEMPORARY tokenType을 저장하고 조회한다") { + redisRefreshTokenStoreAdapter.save(5L, "rt", "new@weeth.com", TokenType.TEMPORARY) + + redisRefreshTokenStoreAdapter.getTokenType(5L) shouldBe TokenType.TEMPORARY } } describe("validateRefreshToken") { it("저장된 토큰과 일치하면 예외가 발생하지 않는다") { - redisRefreshTokenStoreAdapter.save(2L, "stored", "u@weeth.com") + redisRefreshTokenStoreAdapter.save(2L, "stored", "u@weeth.com", TokenType.ACCESS) redisRefreshTokenStoreAdapter.validateRefreshToken(2L, "stored") } it("요청 토큰이 다르면 InvalidTokenException이 발생한다") { - redisRefreshTokenStoreAdapter.save(3L, "stored", "u@weeth.com") + redisRefreshTokenStoreAdapter.save(3L, "stored", "u@weeth.com", TokenType.ACCESS) shouldThrow { redisRefreshTokenStoreAdapter.validateRefreshToken(3L, "different") @@ -59,9 +68,22 @@ class RedisRefreshTokenStoreAdapterTest( } } + describe("getTokenType") { + it("값이 없으면 기본값 ACCESS를 반환한다") { + // tokenType 필드가 없는 기존 데이터 시뮬레이션 + val key = "refreshToken:998" + redisTemplate.opsForHash().putAll( + key, + mapOf("token" to "rt", "email" to "old@weeth.com"), + ) + + redisRefreshTokenStoreAdapter.getTokenType(998L) shouldBe TokenType.ACCESS + } + } + describe("delete") { it("delete 후 조회 시 예외가 발생한다") { - redisRefreshTokenStoreAdapter.save(4L, "rt", "x@weeth.com") + redisRefreshTokenStoreAdapter.save(4L, "rt", "x@weeth.com", TokenType.ACCESS) redisRefreshTokenStoreAdapter.delete(4L) shouldThrow { diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 318c2cfd..fb88f77e 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -23,6 +23,11 @@ weeth: refresh: expiration: 1440 header: Refresh + cookie: + access-token-name: access_token + refresh-token-name: refresh_token + domain: "" + secure: false auth: providers: From ac5faec475f25f6803bd587f8b08326c544aabbc Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Wed, 25 Mar 2026 21:33:53 +0900 Subject: [PATCH 38/73] =?UTF-8?q?[WTH-220]=20=EB=8F=99=EC=95=84=EB=A6=AC?= =?UTF-8?q?=20=EC=9D=B4=EB=AF=B8=EC=A7=80=EB=8F=84=20File=EC=9D=84=20?= =?UTF-8?q?=ED=83=80=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 동아리 이미지도 File을 타도록 수정 * refactor: lint 설정 * feat: 추방 멤버 복구 API 추가 * feat: 추방 멤버 복구 API 추가 * refactor: 페널티 타입 삭제 * refactor: 경로 파라미터로 수정 * refactor: UUID 예시 수정 * refactor: 학교 + 학번으로 중복 검사하도록 수정 * refactor: 페널티 타입 제거 * refactor: 스웨거 자물쇠 제거 * refactor: 스웨거 자물쇠 제거 * docs: 주석 추가 * refactor: 생성시간을 반환하도록 수정 --- .../dto/request/ClubCreateRequest.kt | 14 +- .../request/ClubMemberRoleUpdateRequest.kt | 3 - .../dto/request/ClubUpdateRequest.kt | 19 +- .../application/exception/ClubErrorCode.kt | 6 + .../exception/SelfBanNotAllowedException.kt | 5 + .../SelfRoleChangeNotAllowedException.kt | 5 + .../usecase/command/AdminClubMemberUseCase.kt | 26 ++- .../usecase/command/ManageClubUseCase.kt | 64 ++++++- .../weeth/domain/club/domain/entity/Club.kt | 2 - .../domain/club/domain/entity/ClubMember.kt | 10 + .../club/presentation/ClubAdminController.kt | 16 +- .../club/presentation/ClubController.kt | 2 + .../club/presentation/ClubResponseCode.kt | 1 + .../dto/request/FileSaveRequest.kt | 2 +- .../domain/file/domain/enums/FileOwnerType.kt | 2 + .../dto/request/SavePenaltyRequest.kt | 3 - .../dto/response/PenaltyDetailResponse.kt | 3 - .../application/mapper/PenaltyMapper.kt | 17 +- .../usecase/command/DeletePenaltyUseCase.kt | 15 +- .../usecase/command/SavePenaltyUseCase.kt | 11 +- .../domain/penalty/domain/entity/Penalty.kt | 31 ++- .../penalty/domain/enums/PenaltyType.kt | 2 - .../usecase/query/GetScheduleQueryService.kt | 2 +- .../presentation/UniversityController.kt | 3 + .../command/UpdateUserProfileUseCase.kt | 2 +- .../user/domain/repository/UserRepository.kt | 3 +- .../user/presentation/UserController.kt | 4 + .../command/AdminClubMemberUseCaseTest.kt | 51 ++++- .../usecase/command/ManageClubUseCaseTest.kt | 178 ++++++++++++++++++ .../club/fixture/ClubMemberTestFixture.kt | 13 ++ 30 files changed, 423 insertions(+), 92 deletions(-) create mode 100644 src/main/kotlin/com/weeth/domain/club/application/exception/SelfBanNotAllowedException.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/application/exception/SelfRoleChangeNotAllowedException.kt diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubCreateRequest.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubCreateRequest.kt index 3c5e43e8..95ede1a0 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubCreateRequest.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubCreateRequest.kt @@ -1,7 +1,9 @@ package com.weeth.domain.club.application.dto.request import com.weeth.domain.club.domain.enums.PrimaryContact +import com.weeth.domain.file.application.dto.request.FileSaveRequest import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.Valid import jakarta.validation.constraints.Email import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.Positive @@ -30,10 +32,10 @@ data class ClubCreateRequest( @field:Schema(description = "가장 최근 기수 번호", example = "7") @field:Positive val currentCardinal: Int, - // TODO: FileSaveRequest로 전환 (ClubMember 프로필과 동일 패턴) - @field:Schema(description = "프로필 사진 storageKey", example = "CLUB_PROFILE/2026-02/uuid_profile.png") - val profileImageStorageKey: String? = null, - // TODO: FileSaveRequest로 전환 (ClubMember 프로필과 동일 패턴) - @field:Schema(description = "배경 사진 storageKey", example = "CLUB_BACKGROUND/2026-02/uuid_background.png") - val backgroundImageStorageKey: String? = null, + @field:Schema(description = "프로필 사진") + @field:Valid + val profileImage: FileSaveRequest? = null, + @field:Schema(description = "배경 사진") + @field:Valid + val backgroundImage: FileSaveRequest? = null, ) diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubMemberRoleUpdateRequest.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubMemberRoleUpdateRequest.kt index 4fec742f..ff8be705 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubMemberRoleUpdateRequest.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubMemberRoleUpdateRequest.kt @@ -5,9 +5,6 @@ import io.swagger.v3.oas.annotations.media.Schema import jakarta.validation.constraints.Positive data class ClubMemberRoleUpdateRequest( - @field:Schema(description = "멤버 ID", example = "1") - @field:Positive - val clubMemberId: Long, @field:Schema(description = "변경할 권한 (LEAD는 별도 API로 요청해주세요. 또한 LEAD는 사용자 뷰에 보이지 않게 해주세요)", example = "ADMIN") val memberRole: MemberRole, ) diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubUpdateRequest.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubUpdateRequest.kt index 7f215f4b..3958e099 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubUpdateRequest.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/request/ClubUpdateRequest.kt @@ -1,7 +1,9 @@ package com.weeth.domain.club.application.dto.request import com.weeth.domain.club.domain.enums.PrimaryContact +import com.weeth.domain.file.application.dto.request.FileSaveRequest import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.Valid import jakarta.validation.constraints.Email import jakarta.validation.constraints.Size @@ -23,15 +25,10 @@ data class ClubUpdateRequest( val contactPhoneNumber: String? = null, @field:Schema(description = "주 연락처 (null=변경 안 함)", example = "PHONE") val primaryContact: PrimaryContact? = null, - // TODO: FileSaveRequest로 전환 (ClubMember 프로필과 동일 패턴) - @field:Size(max = 500) - @field:Schema(description = "프로필 사진 storageKey (null=변경 안 함)", example = "CLUB_PROFILE/2026-02/uuid_profile.png") - val profileImageStorageKey: String? = null, - // TODO: FileSaveRequest로 전환 (ClubMember 프로필과 동일 패턴) - @field:Size(max = 500) - @field:Schema( - description = "배경 사진 storageKey (null=변경 안 함)", - example = "CLUB_BACKGROUND/2026-02/uuid_background.png", - ) - val backgroundImageStorageKey: String? = null, + @field:Schema(description = "프로필 사진 (null=변경 안 함)") + @field:Valid + val profileImage: FileSaveRequest? = null, + @field:Schema(description = "배경 사진 (null=변경 안 함)") + @field:Valid + val backgroundImage: FileSaveRequest? = null, ) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt index 7ba252d9..2e742254 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt @@ -53,4 +53,10 @@ enum class ClubErrorCode( @ExplainError("자기 자신에게 LEAD 권한을 이양하려 할 때 발생합니다.") LEAD_SELF_TRANSFER(21115, HttpStatus.BAD_REQUEST, "자기 자신에게 LEAD를 이양할 수 없습니다."), + + @ExplainError("관리자가 자기 자신을 추방하려 할 때 발생합니다.") + SELF_BAN_NOT_ALLOWED(21116, HttpStatus.BAD_REQUEST, "자기 자신은 추방할 수 없습니다."), + + @ExplainError("관리자가 자기 자신의 권한을 변경하려 할 때 발생합니다.") + SELF_ROLE_CHANGE_NOT_ALLOWED(21117, HttpStatus.BAD_REQUEST, "자기 자신의 권한은 변경할 수 없습니다."), } diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/SelfBanNotAllowedException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/SelfBanNotAllowedException.kt new file mode 100644 index 00000000..17d523e3 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/SelfBanNotAllowedException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class SelfBanNotAllowedException : BaseException(ClubErrorCode.SELF_BAN_NOT_ALLOWED) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/SelfRoleChangeNotAllowedException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/SelfRoleChangeNotAllowedException.kt new file mode 100644 index 00000000..76077ac2 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/SelfRoleChangeNotAllowedException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class SelfRoleChangeNotAllowedException : BaseException(ClubErrorCode.SELF_ROLE_CHANGE_NOT_ALLOWED) diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt index 77a6e292..3e32c3ed 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt @@ -12,6 +12,8 @@ import com.weeth.domain.club.application.exception.ClubMemberNotInClubException import com.weeth.domain.club.application.exception.LeadSelfTransferException import com.weeth.domain.club.application.exception.LeadTransferOnlyException import com.weeth.domain.club.application.exception.NotLeadException +import com.weeth.domain.club.application.exception.SelfBanNotAllowedException +import com.weeth.domain.club.application.exception.SelfRoleChangeNotAllowedException import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.club.domain.entity.ClubMemberCardinal import com.weeth.domain.club.domain.enums.MemberRole @@ -56,22 +58,37 @@ class AdminClubMemberUseCase( userId: Long, clubMemberId: Long, ) { - clubPermissionPolicy.requireAdmin(clubId, userId) + val adminMember = clubPermissionPolicy.requireAdmin(clubId, userId) val member = clubMemberPolicy.getMemberInClub(clubId, clubMemberId) + if (adminMember.id == member.id) throw SelfBanNotAllowedException() member.ban() } + @Transactional + fun restore( + clubId: Long, + userId: Long, + clubMemberId: Long, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + + val member = clubMemberPolicy.getMemberInClub(clubId, clubMemberId) + member.restore() + } + @Transactional fun updateMemberRole( clubId: Long, userId: Long, + clubMemberId: Long, request: ClubMemberRoleUpdateRequest, ) { - clubPermissionPolicy.requireAdmin(clubId, userId) + val adminMember = clubPermissionPolicy.requireAdmin(clubId, userId) - val member = clubMemberPolicy.getMemberInClub(clubId, request.clubMemberId) + val member = clubMemberPolicy.getMemberInClub(clubId, clubMemberId) if (request.memberRole == MemberRole.LEAD) throw LeadTransferOnlyException() + if (adminMember.id == member.id) throw SelfRoleChangeNotAllowedException() if (member.isLead()) throw LeadTransferOnlyException() member.updateRole(request.memberRole) } @@ -124,7 +141,8 @@ class AdminClubMemberUseCase( if (clubMemberCardinalPolicy.notContains(member, nextCardinal)) { if (clubMemberCardinalPolicy.isLatestOrFirstCardinal(member, nextCardinal)) { - member.resetAttendanceStats() // TODO: 페널티 카운트도 초기화 + member.resetAttendanceStats() + member.resetPenaltyCount() initializeAttendances(clubId, member, nextCardinal) } diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt index 2724712b..374b5b9e 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt @@ -22,6 +22,11 @@ import com.weeth.domain.club.domain.service.ClubCodePolicy import com.weeth.domain.club.domain.service.ClubJoinPolicy import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.club.domain.vo.ClubContact +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import com.weeth.domain.file.domain.entity.File +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.enums.FileStatus +import com.weeth.domain.file.domain.repository.FileRepository import com.weeth.domain.user.domain.repository.UserReader import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -40,12 +45,12 @@ class ManageClubUseCase( private val userReader: UserReader, private val clubJoinPolicy: ClubJoinPolicy, private val clubPermissionPolicy: ClubPermissionPolicy, + private val fileRepository: FileRepository, ) { /** * 새로운 동아리를 생성 * 생성자는 자동으로 LEAD 권한 설정 * 1기부터 currentCardinal기까지 Cardinal을 자동 생성하고, LEAD를 최신 기수에 배정 - * TODO: CDN 도입을 위해 File로 저장하기. */ @Transactional fun create( @@ -73,12 +78,15 @@ class ManageClubUseCase( schoolName = request.schoolName, clubContact = clubContact, description = request.description, - profileImageStorageKey = request.profileImageStorageKey, - backgroundImageStorageKey = request.backgroundImageStorageKey, + profileImageStorageKey = request.profileImage?.storageKey, + backgroundImageStorageKey = request.backgroundImage?.storageKey, ) clubRepository.save(club) + saveFileIfPresent(request.profileImage, FileOwnerType.CLUB_PROFILE, club.id) + saveFileIfPresent(request.backgroundImage, FileOwnerType.CLUB_BACKGROUND, club.id) + // 공지사항 게시판 자동 생성 (관리자만 작성 가능, displayOrder=0) val noticeBoard = Board( @@ -134,6 +142,16 @@ class ManageClubUseCase( } } + request.profileImage?.let { image -> + markExistingFilesDeleted(FileOwnerType.CLUB_PROFILE, clubId) + saveFile(image, FileOwnerType.CLUB_PROFILE, clubId) + } + + request.backgroundImage?.let { image -> + markExistingFilesDeleted(FileOwnerType.CLUB_BACKGROUND, clubId) + saveFile(image, FileOwnerType.CLUB_BACKGROUND, clubId) + } + club.update( name = request.name, schoolName = request.schoolName, @@ -141,8 +159,8 @@ class ManageClubUseCase( contactEmail = request.contactEmail, contactPhoneNumber = request.contactPhoneNumber, primaryContact = request.primaryContact, - profileImageStorageKey = request.profileImageStorageKey, - backgroundImageStorageKey = request.backgroundImageStorageKey, + profileImageStorageKey = request.profileImage?.storageKey, + backgroundImageStorageKey = request.backgroundImage?.storageKey, ) } @@ -166,6 +184,7 @@ class ManageClubUseCase( clubPermissionPolicy.requireAdmin(clubId, userId) val club = clubRepository.getClubById(clubId) + markExistingFilesDeleted(FileOwnerType.CLUB_PROFILE, clubId) club.removeProfileImage() } @@ -177,9 +196,44 @@ class ManageClubUseCase( clubPermissionPolicy.requireAdmin(clubId, userId) val club = clubRepository.getClubById(clubId) + markExistingFilesDeleted(FileOwnerType.CLUB_BACKGROUND, clubId) club.removeBackgroundImage() } + private fun saveFileIfPresent( + request: FileSaveRequest?, + ownerType: FileOwnerType, + ownerId: Long, + ) { + request?.let { saveFile(it, ownerType, ownerId) } + } + + private fun saveFile( + request: FileSaveRequest, + ownerType: FileOwnerType, + ownerId: Long, + ) { + val file = + File.createUploaded( + fileName = request.fileName, + storageKey = request.storageKey, + fileSize = request.fileSize, + contentType = request.contentType, + ownerType = ownerType, + ownerId = ownerId, + ) + fileRepository.save(file) + } + + private fun markExistingFilesDeleted( + ownerType: FileOwnerType, + ownerId: Long, + ) { + fileRepository + .findAllByOwnerTypeAndOwnerIdAndStatus(ownerType, ownerId, FileStatus.UPLOADED) + .forEach { it.markDeleted() } + } + private fun validatePrimaryContactEmail( primaryContact: PrimaryContact, contactEmail: String?, diff --git a/src/main/kotlin/com/weeth/domain/club/domain/entity/Club.kt b/src/main/kotlin/com/weeth/domain/club/domain/entity/Club.kt index 7a4fa298..6bcaa508 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/entity/Club.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/entity/Club.kt @@ -58,12 +58,10 @@ class Club( var clubContact: ClubContact = clubContact private set - // TODO: FileSaveRequest + File 도메인 연동 필요 (ClubMember 프로필과 동일 패턴으로 전환) @Column(name = "profile_image_url", length = 500) var profileImageStorageKey: String? = profileImageStorageKey private set - // TODO: FileSaveRequest + File 도메인 연동 필요 (ClubMember 프로필과 동일 패턴으로 전환) @Column(name = "background_image_url", length = 500) var backgroundImageStorageKey: String? = backgroundImageStorageKey private set diff --git a/src/main/kotlin/com/weeth/domain/club/domain/entity/ClubMember.kt b/src/main/kotlin/com/weeth/domain/club/domain/entity/ClubMember.kt index ab7f37cc..b43ce117 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/entity/ClubMember.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/entity/ClubMember.kt @@ -74,6 +74,7 @@ class ClubMember( fun accept() { check(memberStatus == MemberStatus.WAITING) { "대기 상태인 멤버만 승인할 수 있습니다." } memberStatus = MemberStatus.ACTIVE + // TODO: BANNED 복구가 필요해지면 accept()에 섞지 말고 별도 unban/restore 정책과 API로 분리 } fun ban() { @@ -82,6 +83,11 @@ class ClubMember( memberStatus = MemberStatus.BANNED } + fun restore() { + check(memberStatus == MemberStatus.BANNED) { "차단된 멤버만 복구할 수 있습니다." } + memberStatus = MemberStatus.ACTIVE + } + fun leave() { check(memberStatus == MemberStatus.ACTIVE) { "활동 중인 멤버만 탈퇴할 수 있습니다." } memberStatus = MemberStatus.LEFT @@ -132,6 +138,10 @@ class ClubMember( penaltyCount++ } + fun resetPenaltyCount() { + penaltyCount = 0 + } + fun updateProfileImageUrl(storageKey: String?) { val trimmed = storageKey?.trim()?.takeIf { it.isNotBlank() } require((trimmed?.length ?: 0) <= 500) { "프로필 이미지 storageKey는 500자 이하여야 합니다." } diff --git a/src/main/kotlin/com/weeth/domain/club/presentation/ClubAdminController.kt b/src/main/kotlin/com/weeth/domain/club/presentation/ClubAdminController.kt index 028b63b4..a219eb69 100644 --- a/src/main/kotlin/com/weeth/domain/club/presentation/ClubAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/club/presentation/ClubAdminController.kt @@ -106,7 +106,7 @@ class ClubAdminController( } @PatchMapping("/members/{clubMemberId}/accept") - @Operation(summary = "멤버 승인") + @Operation(summary = "멤버 승인", deprecated = true) fun acceptMember( @Parameter(hidden = true) @CurrentUser userId: Long, @TsidParam @@ -129,6 +129,18 @@ class ClubAdminController( return CommonResponse.success(ClubResponseCode.MEMBER_BANNED_SUCCESS) } + @PatchMapping("/members/{clubMemberId}/restore") + @Operation(summary = "추방 멤버 복구") + fun restoreMember( + @Parameter(hidden = true) @CurrentUser userId: Long, + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable clubMemberId: Long, + ): CommonResponse { + adminClubMemberUseCase.restore(clubId, userId, clubMemberId) + return CommonResponse.success(ClubResponseCode.MEMBER_RESTORED_SUCCESS) + } + @PatchMapping("/members/{clubMemberId}/role") @Operation(summary = "멤버 권한 변경") fun updateMemberRole( @@ -138,7 +150,7 @@ class ClubAdminController( @PathVariable clubMemberId: Long, @Valid @RequestBody request: ClubMemberRoleUpdateRequest, ): CommonResponse { - adminClubMemberUseCase.updateMemberRole(clubId, userId, request) + adminClubMemberUseCase.updateMemberRole(clubId, userId, clubMemberId, request) return CommonResponse.success(ClubResponseCode.MEMBER_ROLE_UPDATED_SUCCESS) } diff --git a/src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt b/src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt index 008a32c8..1bb760e6 100644 --- a/src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt +++ b/src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt @@ -14,6 +14,7 @@ import com.weeth.global.common.web.TsidParam import com.weeth.global.common.web.TsidPathVariable import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.security.SecurityRequirements import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.Valid import org.springframework.http.HttpStatus @@ -56,6 +57,7 @@ class ClubController( @GetMapping("/{clubId}") @Operation(summary = "동아리 공개 정보 조회 (이름, 소개, 프로필 사진) - 인증 불필요") + @SecurityRequirements fun getClubPublicInfo( @TsidParam @TsidPathVariable clubId: Long, diff --git a/src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt b/src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt index 9d125b0d..e93bcbb3 100644 --- a/src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt @@ -31,4 +31,5 @@ enum class ClubResponseCode( MEMBERSHIP_STATUS_FIND_SUCCESS(11120, HttpStatus.OK, "동아리 가입 상태를 성공적으로 조회했습니다."), MEMBER_SUMMARY_FIND_SUCCESS(11121, HttpStatus.OK, "내 요약 정보를 성공적으로 조회했습니다."), PROFILE_STATUS_FIND_SUCCESS(11122, HttpStatus.OK, "프로필 완성 상태를 성공적으로 조회했습니다."), + MEMBER_RESTORED_SUCCESS(11123, HttpStatus.OK, "멤버가 복구되었습니다."), } diff --git a/src/main/kotlin/com/weeth/domain/file/application/dto/request/FileSaveRequest.kt b/src/main/kotlin/com/weeth/domain/file/application/dto/request/FileSaveRequest.kt index 23220589..93cf515a 100644 --- a/src/main/kotlin/com/weeth/domain/file/application/dto/request/FileSaveRequest.kt +++ b/src/main/kotlin/com/weeth/domain/file/application/dto/request/FileSaveRequest.kt @@ -10,7 +10,7 @@ data class FileSaveRequest( val fileName: String, @field:Schema( description = "저장소 키. `Type/YY-MM/UUID_원본파일명` 형식", - example = "POST/2026-02/58400-e29b-44-a716-44665000_profile-image.png", + example = "POST/2026-02/2c0a4d45-ec94-4ec0-85e1-b489c2eaf9c3_profile-image.png", ) @field:NotBlank val storageKey: String, diff --git a/src/main/kotlin/com/weeth/domain/file/domain/enums/FileOwnerType.kt b/src/main/kotlin/com/weeth/domain/file/domain/enums/FileOwnerType.kt index aec2909f..6d515432 100644 --- a/src/main/kotlin/com/weeth/domain/file/domain/enums/FileOwnerType.kt +++ b/src/main/kotlin/com/weeth/domain/file/domain/enums/FileOwnerType.kt @@ -5,4 +5,6 @@ enum class FileOwnerType { COMMENT, RECEIPT, CLUB_MEMBER_PROFILE, + CLUB_PROFILE, + CLUB_BACKGROUND, } diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/dto/request/SavePenaltyRequest.kt b/src/main/kotlin/com/weeth/domain/penalty/application/dto/request/SavePenaltyRequest.kt index 5acd0f5b..192d78d4 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/application/dto/request/SavePenaltyRequest.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/application/dto/request/SavePenaltyRequest.kt @@ -1,13 +1,10 @@ package com.weeth.domain.penalty.application.dto.request -import com.weeth.domain.penalty.domain.enums.PenaltyType import io.swagger.v3.oas.annotations.media.Schema data class SavePenaltyRequest( @field:Schema(description = "패널티 대상 사용자 ID", example = "1") val userId: Long, - @field:Schema(description = "패널티 유형", example = "WARNING") - val penaltyType: PenaltyType, @field:Schema(description = "패널티 사유", example = "정기모임 무단 불참") val penaltyDescription: String?, ) diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/dto/response/PenaltyDetailResponse.kt b/src/main/kotlin/com/weeth/domain/penalty/application/dto/response/PenaltyDetailResponse.kt index b6e1ab5e..e0123541 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/application/dto/response/PenaltyDetailResponse.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/application/dto/response/PenaltyDetailResponse.kt @@ -1,14 +1,11 @@ package com.weeth.domain.penalty.application.dto.response -import com.weeth.domain.penalty.domain.enums.PenaltyType import io.swagger.v3.oas.annotations.media.Schema import java.time.LocalDateTime data class PenaltyDetailResponse( @field:Schema(description = "패널티 ID", example = "1") val penaltyId: Long, - @field:Schema(description = "패널티 유형", example = "WARNING") - val penaltyType: PenaltyType, @field:Schema(description = "기수 번호", example = "4") val cardinal: Int?, @field:Schema(description = "패널티 사유", example = "정기모임 무단 불참") diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/mapper/PenaltyMapper.kt b/src/main/kotlin/com/weeth/domain/penalty/application/mapper/PenaltyMapper.kt index b8d387fd..2f0420b5 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/application/mapper/PenaltyMapper.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/application/mapper/PenaltyMapper.kt @@ -8,7 +8,6 @@ import com.weeth.domain.penalty.application.dto.response.PenaltyByCardinalRespon import com.weeth.domain.penalty.application.dto.response.PenaltyDetailResponse import com.weeth.domain.penalty.application.dto.response.PenaltyResponse import com.weeth.domain.penalty.domain.entity.Penalty -import com.weeth.domain.penalty.domain.enums.PenaltyType import org.springframework.stereotype.Component @Component @@ -21,22 +20,9 @@ class PenaltyMapper { Penalty( clubMember = clubMember, cardinal = cardinal, - penaltyType = request.penaltyType, penaltyDescription = request.penaltyDescription ?: "", ) - fun toAutoPenalty( - penaltyDescription: String, - clubMember: ClubMember, - cardinal: Cardinal, - ): Penalty = - Penalty( - clubMember = clubMember, - cardinal = cardinal, - penaltyType = PenaltyType.AUTO_PENALTY, - penaltyDescription = penaltyDescription, - ) - fun toResponse( clubMember: ClubMember, penalties: List, @@ -53,10 +39,9 @@ class PenaltyMapper { fun toDetailResponse(penalty: Penalty): PenaltyDetailResponse = PenaltyDetailResponse( penaltyId = penalty.id, - penaltyType = penalty.penaltyType, cardinal = penalty.cardinal.cardinalNumber, penaltyDescription = penalty.penaltyDescription, - time = penalty.modifiedAt, + time = penalty.createdAt, ) fun toByCardinalResponse( diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/DeletePenaltyUseCase.kt b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/DeletePenaltyUseCase.kt index 55fd13ee..0f44ff9c 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/DeletePenaltyUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/DeletePenaltyUseCase.kt @@ -4,7 +4,6 @@ import com.weeth.domain.club.domain.repository.ClubMemberRepository import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.penalty.application.exception.AutoPenaltyDeleteNotAllowedException import com.weeth.domain.penalty.application.exception.PenaltyNotFoundException -import com.weeth.domain.penalty.domain.enums.PenaltyType import com.weeth.domain.penalty.domain.repository.PenaltyRepository import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -28,16 +27,10 @@ class DeletePenaltyUseCase( ?: throw PenaltyNotFoundException() if (penalty.clubMember.club.id != clubId) throw PenaltyNotFoundException() - if (penalty.penaltyType == PenaltyType.AUTO_PENALTY) { - throw AutoPenaltyDeleteNotAllowedException() - } - - if (penalty.penaltyType == PenaltyType.PENALTY) { - val lockedMember = - clubMemberRepository.findByIdWithLock(penalty.clubMember.id) - ?: throw PenaltyNotFoundException() - lockedMember.decrementPenaltyCount() - } + val lockedMember = + clubMemberRepository.findByIdWithLock(penalty.clubMember.id) + ?: throw PenaltyNotFoundException() + lockedMember.decrementPenaltyCount() penaltyRepository.delete(penalty) } diff --git a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/SavePenaltyUseCase.kt b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/SavePenaltyUseCase.kt index 7cf4e000..eadc558f 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/SavePenaltyUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/application/usecase/command/SavePenaltyUseCase.kt @@ -7,7 +7,6 @@ import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.penalty.application.dto.request.SavePenaltyRequest import com.weeth.domain.penalty.application.exception.PenaltyNotFoundException import com.weeth.domain.penalty.application.mapper.PenaltyMapper -import com.weeth.domain.penalty.domain.enums.PenaltyType import com.weeth.domain.penalty.domain.repository.PenaltyRepository import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -34,11 +33,9 @@ class SavePenaltyUseCase( val penalty = mapper.toEntity(request, clubMember, cardinal) penaltyRepository.save(penalty) - if (penalty.penaltyType == PenaltyType.PENALTY) { - val lockedMember = - clubMemberRepository.findByIdWithLock(clubMember.id) - ?: throw PenaltyNotFoundException() - lockedMember.incrementPenaltyCount() - } + val lockedMember = + clubMemberRepository.findByIdWithLock(clubMember.id) + ?: throw PenaltyNotFoundException() + lockedMember.incrementPenaltyCount() } } diff --git a/src/main/kotlin/com/weeth/domain/penalty/domain/entity/Penalty.kt b/src/main/kotlin/com/weeth/domain/penalty/domain/entity/Penalty.kt index b86327b7..8159ede2 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/domain/entity/Penalty.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/domain/entity/Penalty.kt @@ -17,20 +17,33 @@ import jakarta.persistence.ManyToOne @Entity class Penalty( + clubMember: ClubMember, + cardinal: Cardinal, + penaltyDescription: String, +) : BaseEntity() { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "penalty_id") + var id: Long = 0L + private set + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "club_member_id") - val clubMember: ClubMember, + var clubMember: ClubMember = clubMember + private set + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "cardinal_id") - val cardinal: Cardinal, + var cardinal: Cardinal = cardinal + private set + @Enumerated(EnumType.STRING) - val penaltyType: PenaltyType, - var penaltyDescription: String, - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "penalty_id") - val id: Long = 0, -) : BaseEntity() { + var penaltyType: PenaltyType = PenaltyType.PENALTY + private set + + var penaltyDescription: String = penaltyDescription + private set + fun update(penaltyDescription: String) { this.penaltyDescription = penaltyDescription } diff --git a/src/main/kotlin/com/weeth/domain/penalty/domain/enums/PenaltyType.kt b/src/main/kotlin/com/weeth/domain/penalty/domain/enums/PenaltyType.kt index 8822b075..359fd771 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/domain/enums/PenaltyType.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/domain/enums/PenaltyType.kt @@ -2,6 +2,4 @@ package com.weeth.domain.penalty.domain.enums enum class PenaltyType { PENALTY, - AUTO_PENALTY, - WARNING, } diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt b/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt index 5b0ef000..e98ef936 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt @@ -58,7 +58,7 @@ class GetScheduleQueryService( return (events + sessions).sortedBy { it.start } } - fun findYearly( + fun findYearly( // TODO: 기수가 1학기라는 보장이 없음. 기수 말고 날짜 기준으로 받아오기. (MVP 후) clubId: Long, userId: Long, year: Int, diff --git a/src/main/kotlin/com/weeth/domain/university/presentation/UniversityController.kt b/src/main/kotlin/com/weeth/domain/university/presentation/UniversityController.kt index deccc825..9af6ef96 100644 --- a/src/main/kotlin/com/weeth/domain/university/presentation/UniversityController.kt +++ b/src/main/kotlin/com/weeth/domain/university/presentation/UniversityController.kt @@ -9,6 +9,7 @@ import com.weeth.domain.university.presentation.UniversityResponseCode.SCHOOL_FI import com.weeth.global.common.exception.ApiErrorCodeExample import com.weeth.global.common.response.CommonResponse import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.security.SecurityRequirements import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestMapping @@ -23,11 +24,13 @@ class UniversityController( ) { @GetMapping("/schools") @Operation(summary = "학교 목록 조회") + @SecurityRequirements fun getSchools(): CommonResponse> = CommonResponse.success(SCHOOL_FIND_ALL_SUCCESS, getUniversityQueryService.getSchools()) @GetMapping("/majors") @Operation(summary = "학과 목록 조회") + @SecurityRequirements fun getMajors(): CommonResponse> = CommonResponse.success(MAJOR_FIND_ALL_SUCCESS, getUniversityQueryService.getMajors()) } diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/UpdateUserProfileUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/UpdateUserProfileUseCase.kt index 867fb514..8c598fba 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/UpdateUserProfileUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/UpdateUserProfileUseCase.kt @@ -34,7 +34,7 @@ class UpdateUserProfileUseCase( request: UpdateUserProfileRequest, userId: Long, ) { - if (userRepository.existsByStudentIdAndIdIsNot(request.studentId, userId)) { + if (userRepository.existsBySchoolAndStudentIdAndIdIsNot(request.school, request.studentId, userId)) { throw StudentIdExistsException() } if (userRepository.existsByTelAndIdIsNotValue(request.tel, userId)) { diff --git a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserRepository.kt b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserRepository.kt index a0f6098c..fe85e91a 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/repository/UserRepository.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/repository/UserRepository.kt @@ -31,7 +31,8 @@ interface UserRepository : fun existsByTel(tel: PhoneNumber): Boolean - fun existsByStudentIdAndIdIsNot( + fun existsBySchoolAndStudentIdAndIdIsNot( + school: String, studentId: String, id: Long, ): Boolean diff --git a/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt b/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt index 489a1c98..f46018e3 100644 --- a/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt +++ b/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt @@ -17,6 +17,7 @@ import com.weeth.global.common.exception.ApiErrorCodeExample import com.weeth.global.common.response.CommonResponse import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.security.SecurityRequirements import io.swagger.v3.oas.annotations.tags.Tag import jakarta.servlet.http.HttpServletRequest import jakarta.validation.Valid @@ -41,6 +42,7 @@ class UserController( ) { @PostMapping("/social/kakao") @Operation(summary = "카카오 소셜 로그인(auth code flow)") + @SecurityRequirements fun socialLoginByKakao( @RequestBody @Valid request: SocialLoginRequest, ): ResponseEntity> { @@ -54,6 +56,7 @@ class UserController( @PostMapping("/social/apple") @Operation(summary = "애플 소셜 로그인(auth code flow)") + @SecurityRequirements fun socialLoginByApple( @RequestBody @Valid request: SocialLoginRequest, ): ResponseEntity> { @@ -67,6 +70,7 @@ class UserController( @PostMapping("/social/refresh") @Operation(summary = "토큰 재발급", description = "쿠키를 사용해 토큰을 재발급합니다.") + @SecurityRequirements fun refreshToken(request: HttpServletRequest): ResponseEntity> { val jwtDto = authUserUseCase.refreshToken(request) return buildTokenResponse( diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCaseTest.kt index d2ed0a60..ee4c8706 100644 --- a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCaseTest.kt @@ -11,6 +11,8 @@ import com.weeth.domain.club.application.exception.LeadSelfTransferException import com.weeth.domain.club.application.exception.LeadTransferOnlyException import com.weeth.domain.club.application.exception.MemberNotActiveException import com.weeth.domain.club.application.exception.NotLeadException +import com.weeth.domain.club.application.exception.SelfBanNotAllowedException +import com.weeth.domain.club.application.exception.SelfRoleChangeNotAllowedException import com.weeth.domain.club.domain.enums.MemberRole import com.weeth.domain.club.domain.enums.MemberStatus import com.weeth.domain.club.domain.repository.ClubMemberCardinalRepository @@ -98,7 +100,8 @@ class AdminClubMemberUseCaseTest : describe("ban") { it("같은 동아리 소속 멤버를 추방한다") { - val member = ClubMemberTestFixture.createActiveMember() + ReflectionTestUtils.setField(adminMember, "id", 10L) + val member = ClubMemberTestFixture.createActiveMember(id = 20L) every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember every { clubMemberPolicy.getMemberInClub(1L, 20L) } returns member @@ -106,6 +109,28 @@ class AdminClubMemberUseCaseTest : member.memberStatus shouldBe MemberStatus.BANNED } + + it("자기 자신은 추방할 수 없다") { + ReflectionTestUtils.setField(adminMember, "id", 10L) + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubMemberPolicy.getMemberInClub(1L, 10L) } returns adminMember + + shouldThrow { + useCase.ban(1L, 10L, 10L) + } + } + } + + describe("restore") { + it("추방된 멤버를 복구한다") { + val member = ClubMemberTestFixture.createBannedMember(club = club) + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubMemberPolicy.getMemberInClub(1L, 20L) } returns member + + useCase.restore(1L, 10L, 20L) + + member.memberStatus shouldBe MemberStatus.ACTIVE + } } describe("updateMemberRole") { @@ -117,7 +142,8 @@ class AdminClubMemberUseCaseTest : useCase.updateMemberRole( 1L, 10L, - ClubMemberRoleUpdateRequest(clubMemberId = 20L, memberRole = MemberRole.ADMIN), + 20L, + ClubMemberRoleUpdateRequest(memberRole = MemberRole.ADMIN), ) member.memberRole shouldBe MemberRole.ADMIN @@ -132,7 +158,8 @@ class AdminClubMemberUseCaseTest : useCase.updateMemberRole( 1L, 10L, - ClubMemberRoleUpdateRequest(clubMemberId = 20L, memberRole = MemberRole.LEAD), + 20L, + ClubMemberRoleUpdateRequest(memberRole = MemberRole.LEAD), ) } } @@ -146,7 +173,23 @@ class AdminClubMemberUseCaseTest : useCase.updateMemberRole( 1L, 10L, - ClubMemberRoleUpdateRequest(clubMemberId = 20L, memberRole = MemberRole.ADMIN), + 20L, + ClubMemberRoleUpdateRequest(memberRole = MemberRole.ADMIN), + ) + } + } + + it("자기 자신의 권한은 변경할 수 없다") { + ReflectionTestUtils.setField(adminMember, "id", 10L) + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubMemberPolicy.getMemberInClub(1L, 10L) } returns adminMember + + shouldThrow { + useCase.updateMemberRole( + 1L, + 10L, + 10L, + ClubMemberRoleUpdateRequest(memberRole = MemberRole.USER), ) } } diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt index f61f6fbe..4163e24e 100644 --- a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt @@ -17,6 +17,11 @@ import com.weeth.domain.club.domain.service.ClubJoinPolicy import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.club.domain.vo.ClubContact import com.weeth.domain.club.fixture.ClubTestFixture +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import com.weeth.domain.file.domain.entity.File +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.enums.FileStatus +import com.weeth.domain.file.domain.repository.FileRepository import com.weeth.domain.user.domain.repository.UserReader import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.assertions.throwables.shouldThrow @@ -40,6 +45,7 @@ class ManageClubUseCaseTest : val userReader = mockk() val clubJoinPolicy = mockk() val clubPermissionPolicy = mockk() + val fileRepository = mockk() val useCase = ManageClubUseCase( clubRepository, @@ -50,6 +56,7 @@ class ManageClubUseCaseTest : userReader, clubJoinPolicy, clubPermissionPolicy, + fileRepository, ) val adminMember = com.weeth.domain.club.fixture.ClubMemberTestFixture @@ -65,6 +72,7 @@ class ManageClubUseCaseTest : userReader, clubJoinPolicy, clubPermissionPolicy, + fileRepository, ) every { clubRepository.save(any()) } answers { firstArg() } every { clubMemberRepository.save(any()) } answers { firstArg() } @@ -72,6 +80,10 @@ class ManageClubUseCaseTest : every { clubMemberCardinalRepository.save(any()) } answers { firstArg() } every { boardRepository.save(any()) } answers { firstArg() } every { clubJoinPolicy.validateCreateLimit(any()) } just Runs + every { fileRepository.save(any()) } answers { firstArg() } + every { + fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus(any(), any(), any()) + } returns emptyList() } describe("create") { @@ -172,6 +184,56 @@ class ManageClubUseCaseTest : } } + context("이미지와 함께 동아리를 개설하는 경우") { + it("프로필/배경 이미지에 대한 File 레코드가 각각 생성된다") { + every { userReader.getByIdWithLock(10L) } returns user + + useCase.create( + 10L, + ClubCreateRequest( + name = "테스트", + schoolName = "가천대", + currentCardinal = 1, + contactPhoneNumber = "01000000000", + primaryContact = PrimaryContact.PHONE, + profileImage = + FileSaveRequest( + fileName = "profile.png", + storageKey = "CLUB_PROFILE/2026-03/550e8400-e29b-41d4-a716-446655440000_pf.png", + fileSize = 1024, + contentType = "image/png", + ), + backgroundImage = + FileSaveRequest( + fileName = "bg.png", + storageKey = "CLUB_BACKGROUND/2026-03/550e8400-e29b-41d4-a716-446655440001_bg.png", + fileSize = 2048, + contentType = "image/png", + ), + ), + ) + + verify(exactly = 2) { fileRepository.save(any()) } + } + + it("이미지 없이 개설하면 File 레코드가 생성되지 않는다") { + every { userReader.getByIdWithLock(10L) } returns user + + useCase.create( + 10L, + ClubCreateRequest( + name = "테스트", + schoolName = "가천대", + currentCardinal = 1, + contactPhoneNumber = "01000000000", + primaryContact = PrimaryContact.PHONE, + ), + ) + + verify(exactly = 0) { fileRepository.save(any()) } + } + } + context("이미 LEAD로 1개 동아리를 생성한 사용자가 생성 시도하는 경우") { it("ClubCreateLimitExceededException이 발생하고, 이후 로직이 실행되지 않는다") { every { userReader.getByIdWithLock(13L) } returns user @@ -246,6 +308,68 @@ class ManageClubUseCaseTest : club.backgroundImageStorageKey shouldBe "CLUB_BACKGROUND/2026-02/uuid_background.png" } + it("프로필 이미지를 변경하면 기존 File이 DELETED 처리되고 새 File이 생성된다") { + val existingFile = mockk(relaxed = true) + val club = + ClubTestFixture.createClub( + clubContact = + ClubContact.from( + email = "club@example.com", + phoneNumber = "01011112222", + primaryContact = PrimaryContact.PHONE, + ), + ) + + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubRepository.getClubById(1L) } returns club + every { + fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus( + FileOwnerType.CLUB_PROFILE, + 1L, + FileStatus.UPLOADED, + ) + } returns listOf(existingFile) + + useCase.update( + 1L, + 10L, + ClubUpdateRequest( + profileImage = + FileSaveRequest( + fileName = "new_profile.png", + storageKey = "CLUB_PROFILE/2026-03/550e8400-e29b-41d4-a716-446655440002_new.png", + fileSize = 1024, + contentType = "image/png", + ), + ), + ) + + verify(exactly = 1) { existingFile.markDeleted() } + verify(exactly = 1) { fileRepository.save(any()) } + club.profileImageStorageKey shouldBe "CLUB_PROFILE/2026-03/550e8400-e29b-41d4-a716-446655440002_new.png" + } + + it("이미지 필드가 null이면 File 관련 작업이 실행되지 않는다") { + val club = + ClubTestFixture.createClub( + clubContact = + ClubContact.from( + email = "club@example.com", + phoneNumber = "01000000000", + primaryContact = PrimaryContact.PHONE, + ), + ) + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubRepository.getClubById(1L) } returns club + + useCase.update(1L, 10L, ClubUpdateRequest(name = "새 이름")) + + verify(exactly = 0) { + fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus(any(), any(), any()) + } + verify(exactly = 0) { fileRepository.save(any()) } + } + it("모든 필드가 null이면 기존 값이 유지된다") { val club = ClubTestFixture.createClub( @@ -300,6 +424,33 @@ class ManageClubUseCaseTest : club.profileImageStorageKey shouldBe null club.backgroundImageStorageKey shouldBe "CLUB_BACKGROUND/2026-02/uuid_background.png" } + + it("기존 File 레코드가 DELETED 처리된다") { + val existingFile = mockk(relaxed = true) + val club = + ClubTestFixture.createClub( + clubContact = + ClubContact.from( + email = "club@example.com", + phoneNumber = "01011112222", + primaryContact = PrimaryContact.PHONE, + ), + ) + + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubRepository.getClubById(1L) } returns club + every { + fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus( + FileOwnerType.CLUB_PROFILE, + 1L, + FileStatus.UPLOADED, + ) + } returns listOf(existingFile) + + useCase.deleteProfileImage(1L, 10L) + + verify(exactly = 1) { existingFile.markDeleted() } + } } describe("deleteBackgroundImage") { @@ -332,5 +483,32 @@ class ManageClubUseCaseTest : club.profileImageStorageKey shouldBe "CLUB_PROFILE/2026-02/uuid_profile.png" club.backgroundImageStorageKey shouldBe null } + + it("기존 File 레코드가 DELETED 처리된다") { + val existingFile = mockk(relaxed = true) + val club = + ClubTestFixture.createClub( + clubContact = + ClubContact.from( + email = "club@example.com", + phoneNumber = "01011112222", + primaryContact = PrimaryContact.PHONE, + ), + ) + + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubRepository.getClubById(1L) } returns club + every { + fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus( + FileOwnerType.CLUB_BACKGROUND, + 1L, + FileStatus.UPLOADED, + ) + } returns listOf(existingFile) + + useCase.deleteBackgroundImage(1L, 10L) + + verify(exactly = 1) { existingFile.markDeleted() } + } } }) diff --git a/src/test/kotlin/com/weeth/domain/club/fixture/ClubMemberTestFixture.kt b/src/test/kotlin/com/weeth/domain/club/fixture/ClubMemberTestFixture.kt index 7bac729f..4b6dccda 100644 --- a/src/test/kotlin/com/weeth/domain/club/fixture/ClubMemberTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/club/fixture/ClubMemberTestFixture.kt @@ -44,6 +44,19 @@ object ClubMemberTestFixture { memberRole = MemberRole.ADMIN, ) + fun createBannedMember( + id: Long = 0L, + club: Club = ClubTestFixture.createClub(), + user: User = UserTestFixture.createActiveUser1(), + memberRole: MemberRole = MemberRole.USER, + ): ClubMember = + ClubMember( + club = club, + user = user, + memberStatus = MemberStatus.BANNED, + memberRole = memberRole, + ).also { if (id != 0L) ReflectionTestUtils.setField(it, "id", id) } + fun createLeadMember( club: Club = ClubTestFixture.createClub(), user: User = UserTestFixture.createActiveUser1(), From 97b389457fa7ec0a773c7b3692a3da5510fcb2e6 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Wed, 25 Mar 2026 21:45:27 +0900 Subject: [PATCH 39/73] =?UTF-8?q?HOTFIX:=20cors=20=EC=A3=BC=EC=86=8C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/com/weeth/global/config/SecurityConfig.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt index d5e9cb97..d1c02a2b 100644 --- a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt +++ b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt @@ -81,7 +81,13 @@ class SecurityConfig( fun corsConfigurationSource(): CorsConfigurationSource { val configuration = CorsConfiguration().apply { - allowedOriginPatterns = listOf("http://localhost:*", "http://127.0.0.1:*") + allowedOriginPatterns = + listOf( + "http://localhost:*", + "http://127.0.0.1:*", + "https://13.124.170.169.nip.io", + "https://develop.d2o3vlabneheuu.amplifyapp.com", + ) allowedMethods = listOf("GET", "POST", "PATCH", "DELETE", "OPTIONS") allowedHeaders = listOf("*") exposedHeaders = listOf("Authorization", "Authorization_refresh") From 723ec7c5d86fc8d14703ab576fb6af5e0de86d62 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Wed, 25 Mar 2026 21:59:02 +0900 Subject: [PATCH 40/73] =?UTF-8?q?HOTFIX:=20PATCH=20=EB=A7=A4=ED=95=91?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/weeth/domain/board/presentation/BoardAdminController.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt b/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt index 5f7102a3..2710a048 100644 --- a/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt @@ -86,7 +86,7 @@ class BoardAdminController( manageBoardUseCase.update(clubId, boardId, request, userId), ) - @PutMapping("/order") + @PatchMapping("/order") @Operation(summary = "게시판 순서 변경", description = "boardIds 배열의 순서대로 게시판 표시 순서를 저장합니다.") fun reorderBoards( @TsidParam From a75545f4d010744495e0f028bc827cfc01bd549e Mon Sep 17 00:00:00 2001 From: Jeon Soo Hyeon <128474444+soo0711@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:12:17 +0900 Subject: [PATCH 41/73] =?UTF-8?q?[WTH-228]=20=EA=B3=B5=EC=A7=80=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EC=82=AD=EC=A0=9C=20=EA=B0=80=EB=8A=A5=20(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 공지사항 삭제 방어 로직 추가 * test: 공지 게시판 삭제 테스트 추가 * refactor: 게시글 생성 제한 추가 * fix: 게시글 작성 작성자의 현재 최신기수로 변경 * fix: countBy 반환 타입 Int → Long 수정 * test: ClubMemberCardinalTestFixture 추가 * style: 린트 적용 * refactor: 게시판 생성 동시성 이슈 해결 * fix: countBy 반환 타입 Int로 수정 * refactor: 공지 게시판 생성 방어 로직 * refactor: 모든 게시판으로 생성 개수 제한 --- .../BoardCreateLockTimeoutException.kt | 5 ++ .../application/exception/BoardErrorCode.kt | 9 +++ .../exception/BoardLimitExceededException.kt | 5 ++ .../FixedBoardNotDeletableException.kt | 5 ++ .../usecase/command/ManageBoardUseCase.kt | 28 ++++++++-- .../usecase/command/ManagePostUseCase.kt | 10 +++- .../domain/repository/BoardRepository.kt | 2 + .../club/domain/repository/ClubReader.kt | 2 + .../club/domain/repository/ClubRepository.kt | 12 ++++ .../usecase/command/ManageBoardUseCaseTest.kt | 56 +++++++++++++++++-- .../usecase/command/ManagePostUseCaseTest.kt | 18 +++--- .../fixture/ClubMemberCardinalTestFixture.kt | 17 ++++++ 12 files changed, 148 insertions(+), 21 deletions(-) create mode 100644 src/main/kotlin/com/weeth/domain/board/application/exception/BoardCreateLockTimeoutException.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/exception/BoardLimitExceededException.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/exception/FixedBoardNotDeletableException.kt create mode 100644 src/test/kotlin/com/weeth/domain/club/fixture/ClubMemberCardinalTestFixture.kt diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/BoardCreateLockTimeoutException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardCreateLockTimeoutException.kt new file mode 100644 index 00000000..150ce725 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardCreateLockTimeoutException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class BoardCreateLockTimeoutException : BaseException(BoardErrorCode.BOARD_CREATE_LOCK_TIMEOUT) diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt index 3718c4ec..fb1fc39b 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt @@ -50,4 +50,13 @@ enum class BoardErrorCode( @ExplainError("좋아요 처리 중 동시 요청이 많아 락 획득에 실패했을 때 발생합니다.") POST_LIKE_LOCK_TIMEOUT(20413, HttpStatus.TOO_MANY_REQUESTS, "잠시 후 다시 시도해주세요."), + + @ExplainError("공지사항 게시판은 필수 게시판으로 삭제할 수 없을 때 발생합니다.") + FIXED_BOARD_NOT_DELETABLE(20414, HttpStatus.BAD_REQUEST, "공지사항 게시판은 삭제할 수 없습니다."), + + @ExplainError("동아리 게시판 생성 가능 개수를 초과했을 때 발생합니다.") + BOARD_LIMIT_EXCEEDED(20415, HttpStatus.BAD_REQUEST, "게시판 생성 가능한 횟수를 초과했습니다."), + + @ExplainError("게시판 생성 중 동시 요청이 많아 락 획득에 실패했을 때 발생합니다.") + BOARD_CREATE_LOCK_TIMEOUT(20416, HttpStatus.TOO_MANY_REQUESTS, "잠시 후 다시 시도해주세요."), } diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/BoardLimitExceededException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardLimitExceededException.kt new file mode 100644 index 00000000..cace1ca0 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardLimitExceededException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class BoardLimitExceededException : BaseException(BoardErrorCode.BOARD_LIMIT_EXCEEDED) diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/FixedBoardNotDeletableException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/FixedBoardNotDeletableException.kt new file mode 100644 index 00000000..3d74aed8 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/FixedBoardNotDeletableException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class FixedBoardNotDeletableException : BaseException(BoardErrorCode.FIXED_BOARD_NOT_DELETABLE) diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt index 11612d86..60e157f9 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt @@ -4,11 +4,14 @@ import com.weeth.domain.board.application.dto.request.CreateBoardRequest import com.weeth.domain.board.application.dto.request.ReorderBoardsRequest import com.weeth.domain.board.application.dto.request.UpdateBoardRequest import com.weeth.domain.board.application.dto.response.BoardDetailResponse +import com.weeth.domain.board.application.exception.BoardCreateLockTimeoutException +import com.weeth.domain.board.application.exception.BoardLimitExceededException import com.weeth.domain.board.application.exception.BoardNotFoundException import com.weeth.domain.board.application.exception.BoardNotInClubException import com.weeth.domain.board.application.exception.DeletedBoardNotReorderableException import com.weeth.domain.board.application.exception.DuplicateBoardIdException import com.weeth.domain.board.application.exception.DuplicateBoardNameException +import com.weeth.domain.board.application.exception.FixedBoardNotDeletableException import com.weeth.domain.board.application.exception.FixedBoardNotRenamableException import com.weeth.domain.board.application.exception.FixedBoardNotReorderableException import com.weeth.domain.board.application.mapper.BoardMapper @@ -18,6 +21,7 @@ import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.board.domain.vo.BoardConfig import com.weeth.domain.club.domain.repository.ClubReader import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import org.springframework.dao.PessimisticLockingFailureException import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -28,10 +32,6 @@ class ManageBoardUseCase( private val clubReader: ClubReader, private val clubPermissionPolicy: ClubPermissionPolicy, ) { - /** - * 게시판 생성 API, 커스텀한 게시판 생성 가능 - * TODO: MVP, 무료의 경우엔 개수 제한. 공지사항 제외 - */ @Transactional fun create( clubId: Long, @@ -39,7 +39,20 @@ class ManageBoardUseCase( userId: Long, ): BoardDetailResponse { clubPermissionPolicy.requireAdmin(clubId, userId) - val club = clubReader.getClubById(clubId) + + val club = + try { + clubReader.getClubByIdForUpdate(clubId) + } catch (_: PessimisticLockingFailureException) { + throw BoardCreateLockTimeoutException() + } + + // TODO: MVP 제약 — 공지사항은 클럽 생성 시 자동 제공되므로 직접 생성 불가. 다중 NOTICE 지원 시 제거 + if (request.type == BoardType.NOTICE) throw BoardLimitExceededException() + + if (boardRepository.countByClubIdAndIsDeletedFalse(clubId) >= MAX_BOARD_COUNT) { + throw BoardLimitExceededException() + } if (boardRepository.existsByClubIdAndNameAndIsDeletedFalse( clubId, @@ -117,6 +130,7 @@ class ManageBoardUseCase( val board = findBoard(boardId) if (board.club.id != clubId) throw BoardNotFoundException() + if (board.type == BoardType.NOTICE) throw FixedBoardNotDeletableException() val maxOrder = boardRepository.findMaxDisplayOrderByClubId(clubId) board.markDeleted() board.reorder(maxOrder + 1) @@ -158,4 +172,8 @@ class ManageBoardUseCase( private fun findBoard(boardId: Long): Board = boardRepository.findByIdAndIsDeletedFalse(boardId) ?: throw BoardNotFoundException() + + companion object { + private const val MAX_BOARD_COUNT = 4 + } } diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt index 48e792d6..7868d60d 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt @@ -12,8 +12,8 @@ import com.weeth.domain.board.domain.entity.Board import com.weeth.domain.board.domain.entity.Post import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.board.domain.repository.PostRepository -import com.weeth.domain.cardinal.domain.repository.CardinalReader import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.repository.ClubMemberCardinalReader import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.file.application.dto.request.FileSaveRequest import com.weeth.domain.file.application.mapper.FileMapper @@ -30,7 +30,7 @@ class ManagePostUseCase( private val boardRepository: BoardRepository, private val userReader: UserReader, private val clubMemberPolicy: ClubMemberPolicy, - private val cardinalReader: CardinalReader, + private val clubMemberCardinalReader: ClubMemberCardinalReader, private val fileRepository: FileRepository, private val fileReader: FileReader, private val fileMapper: FileMapper, @@ -48,7 +48,11 @@ class ManagePostUseCase( val board = findBoardInClub(boardId, clubId) validateWritePermission(board, member) - val currentCardinalNumber = cardinalReader.findInProgressByClubId(clubId)?.cardinalNumber + val currentCardinalNumber = + clubMemberCardinalReader + .findLatestCardinalByClubMember(member) + ?.cardinal + ?.cardinalNumber val post = Post.create( title = request.title, diff --git a/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardRepository.kt b/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardRepository.kt index 19ace2b7..a552dbc2 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardRepository.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardRepository.kt @@ -32,6 +32,8 @@ interface BoardRepository : @Query("SELECT COALESCE(MAX(b.displayOrder), -1) FROM Board b WHERE b.club.id = :clubId") fun findMaxDisplayOrderByClubId(clubId: Long): Int + fun countByClubIdAndIsDeletedFalse(clubId: Long): Int + fun existsByClubIdAndNameAndIsDeletedFalse( clubId: Long, name: String, diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubReader.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubReader.kt index 31a8f5ac..3939502d 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubReader.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubReader.kt @@ -5,6 +5,8 @@ import com.weeth.domain.club.domain.entity.Club interface ClubReader { fun getClubById(clubId: Long): Club + fun getClubByIdForUpdate(clubId: Long): Club + fun findByIdOrNull(clubId: Long): Club? fun findClubByCode(code: String): Club? diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubRepository.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubRepository.kt index a85f8e00..5cc777bb 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubRepository.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubRepository.kt @@ -2,7 +2,12 @@ package com.weeth.domain.club.domain.repository import com.weeth.domain.club.application.exception.ClubNotFoundException import com.weeth.domain.club.domain.entity.Club +import jakarta.persistence.LockModeType +import jakarta.persistence.QueryHint import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Lock +import org.springframework.data.jpa.repository.Query +import org.springframework.data.jpa.repository.QueryHints import java.util.Optional interface ClubRepository : @@ -12,8 +17,15 @@ interface ClubRepository : fun findByName(name: String): Optional + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query("SELECT c FROM Club c WHERE c.id = :clubId") + fun findByIdWithLock(clubId: Long): Club? + override fun getClubById(clubId: Long): Club = findById(clubId).orElseThrow { ClubNotFoundException() } + override fun getClubByIdForUpdate(clubId: Long): Club = findByIdWithLock(clubId) ?: throw ClubNotFoundException() + override fun findByIdOrNull(clubId: Long): Club? = findById(clubId).orElse(null) override fun findClubByCode(code: String): Club? = findByCode(code).orElse(null) diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt index 2782be9f..4dafd299 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt @@ -3,11 +3,14 @@ package com.weeth.domain.board.application.usecase.command import com.weeth.domain.board.application.dto.request.CreateBoardRequest import com.weeth.domain.board.application.dto.request.ReorderBoardsRequest import com.weeth.domain.board.application.dto.request.UpdateBoardRequest +import com.weeth.domain.board.application.exception.BoardCreateLockTimeoutException +import com.weeth.domain.board.application.exception.BoardLimitExceededException import com.weeth.domain.board.application.exception.BoardNotFoundException import com.weeth.domain.board.application.exception.BoardNotInClubException import com.weeth.domain.board.application.exception.DeletedBoardNotReorderableException import com.weeth.domain.board.application.exception.DuplicateBoardIdException import com.weeth.domain.board.application.exception.DuplicateBoardNameException +import com.weeth.domain.board.application.exception.FixedBoardNotDeletableException import com.weeth.domain.board.application.exception.FixedBoardNotRenamableException import com.weeth.domain.board.application.exception.FixedBoardNotReorderableException import com.weeth.domain.board.application.mapper.BoardMapper @@ -26,6 +29,7 @@ import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk import io.mockk.verify +import org.springframework.dao.PessimisticLockingFailureException class ManageBoardUseCaseTest : DescribeSpec({ @@ -42,19 +46,20 @@ class ManageBoardUseCaseTest : beforeTest { clearMocks(boardRepository, clubReader, clubPermissionPolicy) every { boardRepository.save(any()) } answers { firstArg() } - every { clubReader.getClubById(clubId) } returns club + every { clubReader.getClubByIdForUpdate(clubId) } returns club every { boardRepository.findMaxActiveDisplayOrderByClubId(clubId) } returns -1 every { boardRepository.findMaxDisplayOrderByClubId(clubId) } returns -1 every { boardRepository.existsByClubIdAndNameAndIsDeletedFalse(any(), any()) } returns false every { boardRepository.existsByClubIdAndNameAndIsDeletedFalseAndIdNot(any(), any(), any()) } returns false + every { boardRepository.countByClubIdAndIsDeletedFalse(any()) } returns 0 } describe("create") { it("요청값으로 게시판과 설정을 생성한다") { val request = CreateBoardRequest( - name = "운영공지", - type = BoardType.NOTICE, + name = "운영 게시판", + type = BoardType.GENERAL, commentEnabled = false, writePermission = MemberRole.ADMIN, isPrivate = true, @@ -62,8 +67,8 @@ class ManageBoardUseCaseTest : val result = useCase.create(clubId, request, userId) - result.name shouldBe "운영공지" - result.type shouldBe BoardType.NOTICE + result.name shouldBe "운영 게시판" + result.type shouldBe BoardType.GENERAL result.commentEnabled shouldBe false result.writePermission shouldBe MemberRole.ADMIN result.isPrivate shouldBe true @@ -101,6 +106,38 @@ class ManageBoardUseCaseTest : result.displayOrder shouldBe 3 } + it("Club 락 획득 타임아웃 시 BoardCreateLockTimeoutException을 던진다") { + every { clubReader.getClubByIdForUpdate(clubId) } throws PessimisticLockingFailureException("") + val request = + CreateBoardRequest( + name = "새 게시판", + type = BoardType.GENERAL, + commentEnabled = true, + writePermission = MemberRole.USER, + isPrivate = false, + ) + + shouldThrow { + useCase.create(clubId, request, userId) + } + } + + it("총 게시판 수가 4개 이상이면 예외를 던진다") { + every { boardRepository.countByClubIdAndIsDeletedFalse(clubId) } returns 4 + val request = + CreateBoardRequest( + name = "초과 게시판", + type = BoardType.GENERAL, + commentEnabled = true, + writePermission = MemberRole.USER, + isPrivate = false, + ) + + shouldThrow { + useCase.create(clubId, request, userId) + } + } + it("같은 클럽에 동일한 이름의 게시판이 이미 있으면 예외를 던진다") { every { boardRepository.existsByClubIdAndNameAndIsDeletedFalse(clubId, "중복 이름") } returns true val request = @@ -184,6 +221,15 @@ class ManageBoardUseCaseTest : board.displayOrder shouldBe 3 verify(exactly = 0) { boardRepository.delete(any()) } } + + it("공지사항 게시판을 삭제하면 예외를 던진다") { + val noticeBoard = BoardTestFixture.create(id = 1L, club = club, name = "공지사항", type = BoardType.NOTICE) + every { boardRepository.findByIdAndIsDeletedFalse(1L) } returns noticeBoard + + shouldThrow { + useCase.delete(clubId, 1L, userId) + } + } } describe("reorder") { diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt index 9c5812bc..eec2946c 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt @@ -15,10 +15,11 @@ import com.weeth.domain.board.domain.repository.PostRepository import com.weeth.domain.board.domain.vo.BoardConfig import com.weeth.domain.board.fixture.BoardTestFixture import com.weeth.domain.board.fixture.PostTestFixture -import com.weeth.domain.cardinal.domain.repository.CardinalReader import com.weeth.domain.cardinal.fixture.CardinalTestFixture import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.repository.ClubMemberCardinalReader import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.fixture.ClubMemberCardinalTestFixture import com.weeth.domain.file.application.dto.request.FileSaveRequest import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.entity.File @@ -47,7 +48,7 @@ class ManagePostUseCaseTest : val boardRepository = mockk() val userReader = mockk() val clubMemberPolicy = mockk(relaxed = true) - val cardinalReader = mockk() + val clubMemberCardinalReader = mockk() val fileRepository = mockk() val fileReader = mockk() val fileMapper = mockk() @@ -59,7 +60,7 @@ class ManagePostUseCaseTest : boardRepository, userReader, clubMemberPolicy, - cardinalReader, + clubMemberCardinalReader, fileRepository, fileReader, fileMapper, @@ -94,7 +95,7 @@ class ManagePostUseCaseTest : boardRepository, userReader, clubMemberPolicy, - cardinalReader, + clubMemberCardinalReader, fileRepository, fileReader, fileMapper, @@ -106,7 +107,7 @@ class ManagePostUseCaseTest : every { fileReader.findAll(any(), any(), any()) } returns emptyList() every { postMapper.toSaveResponse(any()) } returns PostSaveResponse(1L) every { fileRepository.delete(any()) } just runs - every { cardinalReader.findInProgressByClubId(any()) } returns null + every { clubMemberCardinalReader.findLatestCardinalByClubMember(any()) } returns null // update/delete 공통 기본값: 작성자 every { userReader.getById(any()) } returns UserTestFixture.createActiveUser1(1L) } @@ -166,7 +167,7 @@ class ManagePostUseCaseTest : verify(exactly = 0) { postRepository.save(any()) } } - it("IN_PROGRESS 기수가 존재하면 게시글에 자동 반영된다") { + it("사용자의 최신 기수가 존재하면 게시글에 자동 반영된다") { val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val cardinal = @@ -175,11 +176,12 @@ class ManagePostUseCaseTest : year = 2026, semester = 1, ) + val clubMemberCardinal = ClubMemberCardinalTestFixture.create(cardinal = cardinal) val request = CreatePostRequest(title = "게시글", content = "내용") every { userReader.getById(1L) } returns user every { boardRepository.findByIdAndClubIdAndIsDeletedFalse(11L, 1L) } returns board - every { cardinalReader.findInProgressByClubId(1L) } returns cardinal + every { clubMemberCardinalReader.findLatestCardinalByClubMember(any()) } returns clubMemberCardinal useCase.save(1L, 11L, request, 1L) @@ -188,7 +190,7 @@ class ManagePostUseCaseTest : } } - it("IN_PROGRESS 기수가 없으면 cardinalNumber가 null로 저장된다") { + it("사용자의 기수 정보가 없으면 cardinalNumber가 null로 저장된다") { val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val request = CreatePostRequest(title = "게시글", content = "내용") diff --git a/src/test/kotlin/com/weeth/domain/club/fixture/ClubMemberCardinalTestFixture.kt b/src/test/kotlin/com/weeth/domain/club/fixture/ClubMemberCardinalTestFixture.kt new file mode 100644 index 00000000..19ae8ec8 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/club/fixture/ClubMemberCardinalTestFixture.kt @@ -0,0 +1,17 @@ +package com.weeth.domain.club.fixture + +import com.weeth.domain.cardinal.domain.entity.Cardinal +import com.weeth.domain.cardinal.fixture.CardinalTestFixture +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.entity.ClubMemberCardinal + +object ClubMemberCardinalTestFixture { + fun create( + clubMember: ClubMember = ClubMemberTestFixture.createActiveMember(), + cardinal: Cardinal = CardinalTestFixture.createCardinal(cardinalNumber = 1, year = 2026, semester = 1), + ): ClubMemberCardinal = + ClubMemberCardinal( + clubMember = clubMember, + cardinal = cardinal, + ) +} From 1ebc7dddd6d5840c5dc0419955dc2a71b506be3b Mon Sep 17 00:00:00 2001 From: Jeon Soo Hyeon <128474444+soo0711@users.noreply.github.com> Date: Sun, 29 Mar 2026 22:40:42 +0900 Subject: [PATCH 42/73] =?UTF-8?q?[WTH-229]=20=EC=9C=A0=EC=A0=80=20?= =?UTF-8?q?=EA=B8=B0=EC=88=98=20=EC=88=98=EC=A0=95=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 기수 필드 연도, 학기 제거 * refactor: 현재 진행 기수 지정 API로 변경 * refactor: 기수 정책 동아리로 범위 제한 * feat: 기수 수정 api 추가 * style: 린트 적용 * refactor: 기수 연도, 학기 제거 * test: club id 불일치 수정 * refactor: 기수 출석 카운트 복구 로직 추가 * refactor: 출석 복구 최신기수로 제한 * refactor: 출석 통계 및 패널티 리셋 수정 * test: 기수 테스트 픽스처 수정 * refactor: 페널티 복구 로직 추가 --- .../attendance/domain/entity/Attendance.kt | 7 + .../domain/repository/AttendanceRepository.kt | 13 ++ .../dto/request/CardinalSaveRequest.kt | 5 - .../dto/request/CardinalUpdateRequest.kt | 15 -- .../dto/response/CardinalResponse.kt | 4 - .../application/mapper/CardinalMapper.kt | 4 - .../usecase/command/ManageCardinalUseCase.kt | 13 +- .../domain/cardinal/domain/entity/Cardinal.kt | 31 --- .../domain/repository/CardinalReader.kt | 16 +- .../domain/repository/CardinalRepository.kt | 36 +-- .../domain/service/CardinalStatusPolicy.kt | 3 +- .../presentation/CardinalAdminController.kt | 12 +- .../request/UpdateMemberCardinalRequest.kt | 16 ++ .../CardinalRemovalHasAttendanceException.kt | 5 + .../application/exception/ClubErrorCode.kt | 7 + .../usecase/command/AdminClubMemberUseCase.kt | 95 +++++++- .../domain/club/domain/entity/ClubMember.kt | 12 + .../club/domain/vo/ClubAttendanceStats.kt | 9 + .../club/presentation/ClubAdminController.kt | 15 ++ .../club/presentation/ClubResponseCode.kt | 1 + .../domain/repository/PenaltyReader.kt | 8 + .../domain/repository/PenaltyRepository.kt | 10 +- .../usecase/query/GetScheduleQueryService.kt | 13 +- .../presentation/ScheduleController.kt | 3 +- .../command/ManageAccountUseCaseTest.kt | 2 +- .../command/ManageReceiptUseCaseTest.kt | 2 +- .../query/GetAttendanceQueryServiceTest.kt | 2 - .../usecase/command/ManagePostUseCaseTest.kt | 2 - .../usecase/command/CardinalUseCaseTest.kt | 52 ++--- .../cardinal/domain/entity/CardinalTest.kt | 10 +- .../repository/CardinalRepositoryTest.kt | 7 +- .../cardinal/fixture/CardinalTestFixture.kt | 8 - .../command/AdminClubMemberUseCaseTest.kt | 215 +++++++++++++++++- .../command/ManageClubMemberUseCaseTest.kt | 8 - .../service/ClubMemberCardinalPolicyTest.kt | 16 -- .../fixture/ClubMemberCardinalTestFixture.kt | 2 +- .../query/GetSessionQueryServiceTest.kt | 2 +- 37 files changed, 459 insertions(+), 222 deletions(-) delete mode 100644 src/main/kotlin/com/weeth/domain/cardinal/application/dto/request/CardinalUpdateRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/application/dto/request/UpdateMemberCardinalRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/application/exception/CardinalRemovalHasAttendanceException.kt create mode 100644 src/main/kotlin/com/weeth/domain/penalty/domain/repository/PenaltyReader.kt diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/entity/Attendance.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/entity/Attendance.kt index 8b21d966..3dc71ee3 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/domain/entity/Attendance.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/entity/Attendance.kt @@ -14,10 +14,17 @@ import jakarta.persistence.GenerationType import jakarta.persistence.Id import jakarta.persistence.JoinColumn import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint import org.hibernate.annotations.OnDelete import org.hibernate.annotations.OnDeleteAction @Entity +@Table( + uniqueConstraints = [ + UniqueConstraint(name = "uk_attendance_session_member", columnNames = ["meeting_id", "club_member_id"]), + ], +) class Attendance( @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "meeting_id") diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt index 1d7cdbed..96530daf 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt @@ -104,4 +104,17 @@ interface AttendanceRepository : JpaRepository { @Modifying(flushAutomatically = true, clearAutomatically = true) @Query("DELETE FROM Attendance a WHERE a.session = :session") fun deleteAllBySession(session: Session) + + @Query("SELECT a FROM Attendance a JOIN a.session s WHERE a.clubMember = :clubMember AND s.club.id = :clubId") + fun findAllByClubMemberAndClubId( + @Param("clubMember") clubMember: ClubMember, + @Param("clubId") clubId: Long, + ): List + + // NOTE: session, clubMember는 lazy 로딩 — attendance.status 접근 전용. 연관 필드 접근 시 JOIN FETCH 추가 필요 + @Query("SELECT a FROM Attendance a WHERE a.clubMember = :clubMember AND a.session IN :sessions") + fun findAllByClubMemberAndSessionIn( + @Param("clubMember") clubMember: ClubMember, + @Param("sessions") sessions: List, + ): List } diff --git a/src/main/kotlin/com/weeth/domain/cardinal/application/dto/request/CardinalSaveRequest.kt b/src/main/kotlin/com/weeth/domain/cardinal/application/dto/request/CardinalSaveRequest.kt index ae424b0a..13563ead 100644 --- a/src/main/kotlin/com/weeth/domain/cardinal/application/dto/request/CardinalSaveRequest.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/application/dto/request/CardinalSaveRequest.kt @@ -1,15 +1,10 @@ package com.weeth.domain.cardinal.application.dto.request import io.swagger.v3.oas.annotations.media.Schema -import jakarta.validation.constraints.NotNull data class CardinalSaveRequest( @field:Schema(description = "기수", example = "4") val cardinalNumber: Int, - @field:Schema(description = "년도", example = "2024") - val year: Int, - @field:Schema(description = "학기", example = "2") - val semester: Int, @field:Schema(description = "현재 진행중 여부", example = "false") val inProgress: Boolean, ) diff --git a/src/main/kotlin/com/weeth/domain/cardinal/application/dto/request/CardinalUpdateRequest.kt b/src/main/kotlin/com/weeth/domain/cardinal/application/dto/request/CardinalUpdateRequest.kt deleted file mode 100644 index 37cc8f19..00000000 --- a/src/main/kotlin/com/weeth/domain/cardinal/application/dto/request/CardinalUpdateRequest.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.weeth.domain.cardinal.application.dto.request - -import io.swagger.v3.oas.annotations.media.Schema -import jakarta.validation.constraints.NotNull - -data class CardinalUpdateRequest( - @field:Schema(description = "기수 ID", example = "1") - val id: Long, - @field:Schema(description = "년도", example = "2024") - val year: Int, - @field:Schema(description = "학기", example = "2") - val semester: Int, - @field:Schema(description = "현재 진행중 여부", example = "false") - val inProgress: Boolean, -) diff --git a/src/main/kotlin/com/weeth/domain/cardinal/application/dto/response/CardinalResponse.kt b/src/main/kotlin/com/weeth/domain/cardinal/application/dto/response/CardinalResponse.kt index 68f1ced0..b718a467 100644 --- a/src/main/kotlin/com/weeth/domain/cardinal/application/dto/response/CardinalResponse.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/application/dto/response/CardinalResponse.kt @@ -9,10 +9,6 @@ data class CardinalResponse( val id: Long, @field:Schema(description = "기수 번호", example = "7") val cardinalNumber: Int, - @field:Schema(description = "년도", example = "2025", nullable = true) - val year: Int?, - @field:Schema(description = "학기", example = "1", nullable = true) - val semester: Int?, @field:Schema(description = "기수 상태", example = "IN_PROGRESS") val status: CardinalStatus, @field:Schema(description = "생성 시각") diff --git a/src/main/kotlin/com/weeth/domain/cardinal/application/mapper/CardinalMapper.kt b/src/main/kotlin/com/weeth/domain/cardinal/application/mapper/CardinalMapper.kt index 213b647d..8b831a56 100644 --- a/src/main/kotlin/com/weeth/domain/cardinal/application/mapper/CardinalMapper.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/application/mapper/CardinalMapper.kt @@ -15,16 +15,12 @@ class CardinalMapper { Cardinal.create( club = club, cardinalNumber = request.cardinalNumber, - year = request.year, - semester = request.semester, ) fun toResponse(cardinal: Cardinal): CardinalResponse = CardinalResponse( cardinal.id, cardinal.cardinalNumber, - cardinal.year, - cardinal.semester, cardinal.status, cardinal.createdAt, cardinal.modifiedAt, diff --git a/src/main/kotlin/com/weeth/domain/cardinal/application/usecase/command/ManageCardinalUseCase.kt b/src/main/kotlin/com/weeth/domain/cardinal/application/usecase/command/ManageCardinalUseCase.kt index 7f153188..cda59d5e 100644 --- a/src/main/kotlin/com/weeth/domain/cardinal/application/usecase/command/ManageCardinalUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/application/usecase/command/ManageCardinalUseCase.kt @@ -1,7 +1,6 @@ package com.weeth.domain.cardinal.application.usecase.command import com.weeth.domain.cardinal.application.dto.request.CardinalSaveRequest -import com.weeth.domain.cardinal.application.dto.request.CardinalUpdateRequest import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException import com.weeth.domain.cardinal.application.exception.DuplicateCardinalException import com.weeth.domain.cardinal.application.mapper.CardinalMapper @@ -41,19 +40,15 @@ class ManageCardinalUseCase( } @Transactional - fun update( + fun activate( clubId: Long, - request: CardinalUpdateRequest, + cardinalId: Long, userId: Long, ) { clubPermissionPolicy.requireAdmin(clubId, userId) val cardinal = - cardinalRepository.findByIdAndClubId(request.id, clubId) ?: throw CardinalNotFoundException() + cardinalRepository.findByIdAndClubId(cardinalId, clubId) ?: throw CardinalNotFoundException() - cardinal.update(request.year, request.semester) - - if (request.inProgress) { - cardinalStatusPolicy.activateExclusively(cardinal) - } + cardinalStatusPolicy.activateExclusively(cardinal) } } diff --git a/src/main/kotlin/com/weeth/domain/cardinal/domain/entity/Cardinal.kt b/src/main/kotlin/com/weeth/domain/cardinal/domain/entity/Cardinal.kt index f828d77a..a5e7939a 100644 --- a/src/main/kotlin/com/weeth/domain/cardinal/domain/entity/Cardinal.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/domain/entity/Cardinal.kt @@ -31,8 +31,6 @@ class Cardinal( id: Long = 0L, @Column(nullable = false) val cardinalNumber: Int, - year: Int? = null, - semester: Int? = null, status: CardinalStatus = CardinalStatus.DONE, ) : BaseEntity() { @Id @@ -46,25 +44,10 @@ class Cardinal( var club: Club = club private set - var year: Int? = year - private set - - var semester: Int? = semester - private set - @Enumerated(EnumType.STRING) var status: CardinalStatus = status private set - fun update( - year: Int, - semester: Int, - ) { - validatePeriod(year, semester) - this.year = year - this.semester = semester - } - fun inProgress() { status = CardinalStatus.IN_PROGRESS } @@ -77,28 +60,14 @@ class Cardinal( fun create( club: Club, cardinalNumber: Int, - year: Int? = null, - semester: Int? = null, status: CardinalStatus = CardinalStatus.DONE, ): Cardinal { require(cardinalNumber > 0) { "기수 번호는 0보다 커야 합니다." } - year?.let { require(it > 0) { "연도는 0보다 커야 합니다." } } - semester?.let { require(it in 1..2) { "학기는 1 또는 2여야 합니다." } } return Cardinal( club = club, cardinalNumber = cardinalNumber, - year = year, - semester = semester, status = status, ) } - - private fun validatePeriod( - year: Int, - semester: Int, - ) { - require(year > 0) { "연도는 0보다 커야 합니다." } - require(semester in 1..2) { "학기는 1 또는 2여야 합니다." } - } } } diff --git a/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalReader.kt b/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalReader.kt index 81899291..1b942252 100644 --- a/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalReader.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalReader.kt @@ -6,17 +6,6 @@ import com.weeth.domain.cardinal.domain.enums.CardinalStatus interface CardinalReader { fun getByCardinalNumber(cardinalNumber: Int): Cardinal - fun getByYearAndSemester( - year: Int, - semester: Int, - ): Cardinal - - fun getByClubIdAndYearAndSemester( - clubId: Long, - year: Int, - semester: Int, - ): Cardinal - fun findByIdOrNull(cardinalId: Long): Cardinal? fun findAllByCardinalNumberDesc(): List @@ -34,4 +23,9 @@ interface CardinalReader { fun findAllByClubIdOrderByCardinalNumberAsc(clubId: Long): List fun findInProgressByClubId(clubId: Long): Cardinal? + + fun findAllByClubIdAndIdIn( + clubId: Long, + ids: List, + ): List } diff --git a/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalRepository.kt b/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalRepository.kt index 99420b26..b41fbd41 100644 --- a/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalRepository.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalRepository.kt @@ -9,28 +9,16 @@ import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Lock import org.springframework.data.jpa.repository.Query import org.springframework.data.jpa.repository.QueryHints -import java.util.Optional interface CardinalRepository : JpaRepository, CardinalReader { - fun findByCardinalNumber(cardinal: Int): Optional - - fun findByYearAndSemester( - year: Int, - semester: Int, - ): Optional - - fun findByClubIdAndYearAndSemester( - clubId: Long, - year: Int, - semester: Int, - ): Optional + fun findByCardinalNumber(cardinal: Int): Cardinal? @Lock(LockModeType.PESSIMISTIC_WRITE) @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) - @Query("SELECT c FROM Cardinal c WHERE c.status = 'IN_PROGRESS'") - fun findAllInProgressWithLock(): List + @Query("SELECT c FROM Cardinal c WHERE c.club.id = :clubId AND c.status = 'IN_PROGRESS'") + fun findAllInProgressByClubIdWithLock(clubId: Long): List fun findAllByOrderByCardinalNumberDesc(): List @@ -60,20 +48,14 @@ interface CardinalRepository : findFirstByClubIdAndStatusOrderByCardinalNumberDesc(clubId, CardinalStatus.IN_PROGRESS) override fun getByCardinalNumber(cardinalNumber: Int): Cardinal = - findByCardinalNumber(cardinalNumber).orElseThrow { CardinalNotFoundException() } - - override fun getByYearAndSemester( - year: Int, - semester: Int, - ): Cardinal = findByYearAndSemester(year, semester).orElseThrow { CardinalNotFoundException() } - - override fun getByClubIdAndYearAndSemester( - clubId: Long, - year: Int, - semester: Int, - ): Cardinal = findByClubIdAndYearAndSemester(clubId, year, semester).orElseThrow { CardinalNotFoundException() } + findByCardinalNumber(cardinalNumber) ?: throw CardinalNotFoundException() override fun findByIdOrNull(cardinalId: Long): Cardinal? = findById(cardinalId).orElse(null) override fun findAllByCardinalNumberDesc(): List = findAllByOrderByCardinalNumberDesc() + + override fun findAllByClubIdAndIdIn( + clubId: Long, + ids: List, + ): List } diff --git a/src/main/kotlin/com/weeth/domain/cardinal/domain/service/CardinalStatusPolicy.kt b/src/main/kotlin/com/weeth/domain/cardinal/domain/service/CardinalStatusPolicy.kt index 75b06867..5c610e58 100644 --- a/src/main/kotlin/com/weeth/domain/cardinal/domain/service/CardinalStatusPolicy.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/domain/service/CardinalStatusPolicy.kt @@ -9,8 +9,7 @@ class CardinalStatusPolicy( private val cardinalRepository: CardinalRepository, ) { fun activateExclusively(cardinal: Cardinal) { - // TODO: 현재는 전역 IN_PROGRESS cardinal을 모두 종료한다. clubId 기준으로 범위를 제한해야 동아리 간 격리가 유지된다. - val inProgressCardinals = cardinalRepository.findAllInProgressWithLock() + val inProgressCardinals = cardinalRepository.findAllInProgressByClubIdWithLock(cardinal.club.id) inProgressCardinals.forEach(Cardinal::done) cardinal.inProgress() } diff --git a/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalAdminController.kt b/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalAdminController.kt index 78c1607a..3a889f6f 100644 --- a/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/cardinal/presentation/CardinalAdminController.kt @@ -1,7 +1,6 @@ package com.weeth.domain.cardinal.presentation import com.weeth.domain.cardinal.application.dto.request.CardinalSaveRequest -import com.weeth.domain.cardinal.application.dto.request.CardinalUpdateRequest import com.weeth.domain.cardinal.application.exception.CardinalErrorCode import com.weeth.domain.cardinal.application.usecase.command.ManageCardinalUseCase import com.weeth.global.auth.annotation.CurrentUser @@ -15,6 +14,7 @@ import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.Valid import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping @@ -27,15 +27,15 @@ import org.springframework.web.bind.annotation.RestController class CardinalAdminController( private val manageCardinalUseCase: ManageCardinalUseCase, ) { - @PatchMapping - @Operation(summary = "기수 정보 수정 API") - fun update( + @PatchMapping("/{cardinalId}") + @Operation(summary = "현재 진행 기수 지정 API") + fun activate( @TsidParam @TsidPathVariable clubId: Long, - @RequestBody @Valid request: CardinalUpdateRequest, + @PathVariable cardinalId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse { - manageCardinalUseCase.update(clubId, request, userId) + manageCardinalUseCase.activate(clubId, cardinalId, userId) return CommonResponse.success(CardinalResponseCode.CARDINAL_UPDATE_SUCCESS) } diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/request/UpdateMemberCardinalRequest.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/request/UpdateMemberCardinalRequest.kt new file mode 100644 index 00000000..b167fc1b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/request/UpdateMemberCardinalRequest.kt @@ -0,0 +1,16 @@ +package com.weeth.domain.club.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotEmpty + +data class UpdateMemberCardinalRequest( + @field:Schema(description = "기수 ID 목록 (최소 1개)", example = "[1, 2, 3]") + @field:NotEmpty + val cardinalIds: List, + @field:Schema( + description = "출석 기록이 있는 기수 삭제 시 강제 삭제 여부. 서버가 응답코드 21118을 반환하면 true로 재요청", + example = "false", + defaultValue = "false", + ) + val force: Boolean = false, +) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/CardinalRemovalHasAttendanceException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/CardinalRemovalHasAttendanceException.kt new file mode 100644 index 00000000..22b098f7 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/CardinalRemovalHasAttendanceException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class CardinalRemovalHasAttendanceException : BaseException(ClubErrorCode.CARDINAL_REMOVAL_HAS_ATTENDANCE) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt index 2e742254..cbb9ff76 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt @@ -59,4 +59,11 @@ enum class ClubErrorCode( @ExplainError("관리자가 자기 자신의 권한을 변경하려 할 때 발생합니다.") SELF_ROLE_CHANGE_NOT_ALLOWED(21117, HttpStatus.BAD_REQUEST, "자기 자신의 권한은 변경할 수 없습니다."), + + @ExplainError("삭제하려는 기수에 출석/결석 기록이 존재할 때 발생합니다. force=true로 재요청하면 출석 기록을 포함해 삭제됩니다.") + CARDINAL_REMOVAL_HAS_ATTENDANCE( + 21118, + HttpStatus.UNPROCESSABLE_ENTITY, + "출석 기록이 있는 기수가 포함되어 있습니다. 삭제하려면 force=true로 재요청하세요.", + ), } diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt index 3e32c3ed..5688bf09 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt @@ -1,12 +1,15 @@ package com.weeth.domain.club.application.usecase.command import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.attendance.domain.enums.AttendanceStatus import com.weeth.domain.attendance.domain.repository.AttendanceRepository import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException import com.weeth.domain.cardinal.domain.entity.Cardinal import com.weeth.domain.cardinal.domain.repository.CardinalReader import com.weeth.domain.club.application.dto.request.ClubMemberApplyObRequest import com.weeth.domain.club.application.dto.request.ClubMemberRoleUpdateRequest +import com.weeth.domain.club.application.dto.request.UpdateMemberCardinalRequest +import com.weeth.domain.club.application.exception.CardinalRemovalHasAttendanceException import com.weeth.domain.club.application.exception.ClubMemberNotFoundException import com.weeth.domain.club.application.exception.ClubMemberNotInClubException import com.weeth.domain.club.application.exception.LeadSelfTransferException @@ -22,6 +25,7 @@ import com.weeth.domain.club.domain.repository.ClubMemberReader import com.weeth.domain.club.domain.service.ClubMemberCardinalPolicy import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.penalty.domain.repository.PenaltyReader import com.weeth.domain.session.domain.repository.SessionReader import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -38,6 +42,7 @@ class AdminClubMemberUseCase( private val clubMemberReader: ClubMemberReader, private val sessionReader: SessionReader, private val attendanceRepository: AttendanceRepository, + private val penaltyReader: PenaltyReader, private val clubMemberCardinalRepository: ClubMemberCardinalRepository, ) { @Transactional @@ -130,6 +135,7 @@ class AdminClubMemberUseCase( }.associateBy { it.id } val cardinalByNumber = mutableMapOf() + val attendanceInitMap = linkedMapOf>() uniqueRequests.forEach { request -> val member = memberMap[request.clubMemberId] ?: throw ClubMemberNotFoundException() @@ -143,24 +149,103 @@ class AdminClubMemberUseCase( if (clubMemberCardinalPolicy.isLatestOrFirstCardinal(member, nextCardinal)) { member.resetAttendanceStats() member.resetPenaltyCount() - initializeAttendances(clubId, member, nextCardinal) + attendanceInitMap.getOrPut(member) { mutableListOf() }.add(nextCardinal) } clubMemberCardinalRepository.save(ClubMemberCardinal.create(member, nextCardinal)) } } + + attendanceInitMap.forEach { (member, cardinals) -> + initializeAttendances(clubId, member, cardinals) + } + } + + @Transactional + fun updateCardinals( + clubId: Long, + userId: Long, + clubMemberId: Long, + request: UpdateMemberCardinalRequest, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + + val member = + clubMemberReader.findByIdWithLock(clubMemberId) + ?: throw ClubMemberNotFoundException() + if (member.club.id != clubId) throw ClubMemberNotInClubException() + + val distinctIds = request.cardinalIds.distinct() + val newCardinals = cardinalReader.findAllByClubIdAndIdIn(clubId, distinctIds) + if (newCardinals.size != distinctIds.size) throw CardinalNotFoundException() + + val currentMemberCardinals = clubMemberCardinalRepository.findAllByClubMembers(listOf(member)) + val existingCardinalIds = currentMemberCardinals.map { it.cardinal.id }.toSet() + val newCardinalIds = newCardinals.map { it.id }.toSet() + + val toRemove = currentMemberCardinals.filter { it.cardinal.id !in newCardinalIds } + if (toRemove.isNotEmpty()) { + val removedCardinalNumbers = toRemove.map { it.cardinal.cardinalNumber } + val sessions = sessionReader.findAllByClubIdAndCardinalIn(clubId, removedCardinalNumbers) + if (sessions.isNotEmpty()) { + val attendances = attendanceRepository.findAllByClubMemberAndSessionIn(member, sessions) + + // force=true면 강제 삭제, 아니면 클라이언트에 확인 요청 + val hasRecord = + attendances.any { + it.status == AttendanceStatus.ATTEND || it.status == AttendanceStatus.ABSENT + } + if (!request.force && hasRecord) { + throw CardinalRemovalHasAttendanceException() + } + + attendanceRepository.deleteAll(attendances) + } + + val latestCardinal = newCardinals.maxByOrNull { it.cardinalNumber } + if (latestCardinal == null) { + member.recalculateAttendanceStats(0, 0) + member.recalculatePenaltyCount(0) + } else if (latestCardinal.id in existingCardinalIds) { + // 기존 기수가 최신 — 해당 기수 출석 기준으로 재계산 + val remaining = + attendanceRepository.findAllByClubMemberIdAndCardinal( + member.id, + latestCardinal.cardinalNumber, + ) + member.recalculateAttendanceStats( + remaining.count { it.status == AttendanceStatus.ATTEND }, + remaining.count { it.status == AttendanceStatus.ABSENT }, + ) + val penaltyCount = penaltyReader.countByClubMemberIdAndCardinalId(member.id, latestCardinal.id) + member.recalculatePenaltyCount(penaltyCount) + // else: 최신 기수가 toAdd 소속 → toAdd 블록에서 reset 처리 + } + + clubMemberCardinalRepository.deleteAll(toRemove) + } + + val toAdd = newCardinals.filter { it.id !in existingCardinalIds } + if (toAdd.isNotEmpty()) { + val maxAdded = toAdd.maxBy { it.cardinalNumber } + if (clubMemberCardinalPolicy.isLatestOrFirstCardinal(member, maxAdded)) { + member.resetAttendanceStats() + member.resetPenaltyCount() + } + clubMemberCardinalRepository.saveAll(toAdd.map { ClubMemberCardinal.create(member, it) }) + initializeAttendances(clubId, member, toAdd) + } } // TODO: ManageClubMemberUsecase.initializeAttendances와 중복 — MVP 후 공통 서비스로 추출 private fun initializeAttendances( clubId: Long, member: ClubMember, - cardinal: Cardinal, + cardinals: List, ) { - val sessions = sessionReader.findAllByClubIdAndCardinalIn(clubId, listOf(cardinal.cardinalNumber)) + val sessions = sessionReader.findAllByClubIdAndCardinalIn(clubId, cardinals.map { it.cardinalNumber }) if (sessions.isEmpty()) return - val attendances = sessions.map { Attendance.create(session = it, clubMember = member) } - attendanceRepository.saveAll(attendances) + attendanceRepository.saveAll(sessions.map { Attendance.create(session = it, clubMember = member) }) } } diff --git a/src/main/kotlin/com/weeth/domain/club/domain/entity/ClubMember.kt b/src/main/kotlin/com/weeth/domain/club/domain/entity/ClubMember.kt index b43ce117..10f905fd 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/entity/ClubMember.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/entity/ClubMember.kt @@ -134,6 +134,13 @@ class ClubMember( attendanceStats.reset() } + fun recalculateAttendanceStats( + attendCount: Int, + absentCount: Int, + ) { + attendanceStats.recalculate(attendCount, absentCount) + } + fun incrementPenaltyCount() { penaltyCount++ } @@ -142,6 +149,11 @@ class ClubMember( penaltyCount = 0 } + fun recalculatePenaltyCount(count: Int) { + require(count >= 0) { "패널티 수는 0 이상이어야 합니다." } + penaltyCount = count + } + fun updateProfileImageUrl(storageKey: String?) { val trimmed = storageKey?.trim()?.takeIf { it.isNotBlank() } require((trimmed?.length ?: 0) <= 500) { "프로필 이미지 storageKey는 500자 이하여야 합니다." } diff --git a/src/main/kotlin/com/weeth/domain/club/domain/vo/ClubAttendanceStats.kt b/src/main/kotlin/com/weeth/domain/club/domain/vo/ClubAttendanceStats.kt index 67424747..8cb8737b 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/vo/ClubAttendanceStats.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/vo/ClubAttendanceStats.kt @@ -27,6 +27,15 @@ class ClubAttendanceStats( attendanceRate = 0 } + fun recalculate( + attendCount: Int, + absentCount: Int, + ) { + attendanceCount = attendCount + absenceCount = absentCount + recalculateRate() + } + fun attend() { attendanceCount++ recalculateRate() diff --git a/src/main/kotlin/com/weeth/domain/club/presentation/ClubAdminController.kt b/src/main/kotlin/com/weeth/domain/club/presentation/ClubAdminController.kt index a219eb69..75997340 100644 --- a/src/main/kotlin/com/weeth/domain/club/presentation/ClubAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/club/presentation/ClubAdminController.kt @@ -3,6 +3,7 @@ package com.weeth.domain.club.presentation import com.weeth.domain.club.application.dto.request.ClubMemberApplyObRequest import com.weeth.domain.club.application.dto.request.ClubMemberRoleUpdateRequest import com.weeth.domain.club.application.dto.request.ClubUpdateRequest +import com.weeth.domain.club.application.dto.request.UpdateMemberCardinalRequest import com.weeth.domain.club.application.dto.response.ClubDetailResponse import com.weeth.domain.club.application.dto.response.ClubMemberResponse import com.weeth.domain.club.application.exception.ClubErrorCode @@ -166,6 +167,20 @@ class ClubAdminController( return CommonResponse.success(ClubResponseCode.LEAD_TRANSFERRED_SUCCESS) } + @PatchMapping("/members/{clubMemberId}/cardinals") + @Operation(summary = "멤버 기수 수정") + @ApiErrorCodeExample(ClubErrorCode::class) + fun updateMemberCardinals( + @Parameter(hidden = true) @CurrentUser userId: Long, + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable clubMemberId: Long, + @Valid @RequestBody request: UpdateMemberCardinalRequest, + ): CommonResponse { + adminClubMemberUseCase.updateCardinals(clubId, userId, clubMemberId, request) + return CommonResponse.success(ClubResponseCode.MEMBER_CARDINAL_UPDATED_SUCCESS) + } + @PatchMapping("/members/apply-ob") @Operation(summary = "멤버 OB 기수 등록") fun applyOb( diff --git a/src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt b/src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt index e93bcbb3..d5341fa0 100644 --- a/src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/club/presentation/ClubResponseCode.kt @@ -32,4 +32,5 @@ enum class ClubResponseCode( MEMBER_SUMMARY_FIND_SUCCESS(11121, HttpStatus.OK, "내 요약 정보를 성공적으로 조회했습니다."), PROFILE_STATUS_FIND_SUCCESS(11122, HttpStatus.OK, "프로필 완성 상태를 성공적으로 조회했습니다."), MEMBER_RESTORED_SUCCESS(11123, HttpStatus.OK, "멤버가 복구되었습니다."), + MEMBER_CARDINAL_UPDATED_SUCCESS(11124, HttpStatus.OK, "멤버 기수가 수정되었습니다."), } diff --git a/src/main/kotlin/com/weeth/domain/penalty/domain/repository/PenaltyReader.kt b/src/main/kotlin/com/weeth/domain/penalty/domain/repository/PenaltyReader.kt new file mode 100644 index 00000000..8f5c502d --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/penalty/domain/repository/PenaltyReader.kt @@ -0,0 +1,8 @@ +package com.weeth.domain.penalty.domain.repository + +interface PenaltyReader { + fun countByClubMemberIdAndCardinalId( + clubMemberId: Long, + cardinalId: Long, + ): Int +} diff --git a/src/main/kotlin/com/weeth/domain/penalty/domain/repository/PenaltyRepository.kt b/src/main/kotlin/com/weeth/domain/penalty/domain/repository/PenaltyRepository.kt index dbb205a9..049fffd2 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/domain/repository/PenaltyRepository.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/domain/repository/PenaltyRepository.kt @@ -9,7 +9,9 @@ import org.springframework.data.jpa.repository.Query import org.springframework.data.jpa.repository.QueryHints import org.springframework.data.repository.query.Param -interface PenaltyRepository : JpaRepository { +interface PenaltyRepository : + JpaRepository, + PenaltyReader { @Lock(LockModeType.PESSIMISTIC_WRITE) @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) @Query("SELECT p FROM Penalty p WHERE p.id = :penaltyId") @@ -17,6 +19,12 @@ interface PenaltyRepository : JpaRepository { @Param("penaltyId") penaltyId: Long, ): Penalty? + @Query("SELECT COUNT(p) FROM Penalty p WHERE p.clubMember.id = :clubMemberId AND p.cardinal.id = :cardinalId") + override fun countByClubMemberIdAndCardinalId( + @Param("clubMemberId") clubMemberId: Long, + @Param("cardinalId") cardinalId: Long, + ): Int + @Query( "SELECT p FROM Penalty p JOIN FETCH p.clubMember cm JOIN FETCH cm.user JOIN FETCH p.cardinal WHERE cm.id = :clubMemberId AND p.cardinal.id = :cardinalId ORDER BY p.id DESC", ) diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt b/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt index e98ef936..86d57a6d 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt @@ -1,6 +1,5 @@ package com.weeth.domain.schedule.application.usecase.query -import com.weeth.domain.cardinal.domain.repository.CardinalReader import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.schedule.application.dto.response.EventResponse import com.weeth.domain.schedule.application.dto.response.ScheduleResponse @@ -19,7 +18,6 @@ import java.time.LocalDateTime class GetScheduleQueryService( private val eventRepository: EventRepository, private val sessionReader: SessionReader, - private val cardinalReader: CardinalReader, private val clubMemberPolicy: ClubMemberPolicy, private val scheduleMapper: ScheduleMapper, private val eventMapper: EventMapper, @@ -58,23 +56,24 @@ class GetScheduleQueryService( return (events + sessions).sortedBy { it.start } } - fun findYearly( // TODO: 기수가 1학기라는 보장이 없음. 기수 말고 날짜 기준으로 받아오기. (MVP 후) + fun findYearly( clubId: Long, userId: Long, year: Int, - semester: Int, ): Map> { clubMemberPolicy.getActiveMember(clubId, userId) - val cardinal = cardinalReader.getByClubIdAndYearAndSemester(clubId, year, semester) + + val start = LocalDateTime.of(year, 1, 1, 0, 0) + val end = LocalDateTime.of(year, 12, 31, 23, 59, 59) val events = eventRepository - .findAllByClubIdAndCardinal(clubId, cardinal.cardinalNumber) + .findByClubIdAndStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc(clubId, end, start) .map { scheduleMapper.toResponse(it, false) } val sessions = sessionReader - .findAllByClubIdAndCardinalIn(clubId, listOf(cardinal.cardinalNumber)) + .findAllByClubIdAndStartBetween(clubId, start, end) .map { scheduleMapper.toResponse(it, true) } return (events + sessions) diff --git a/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt b/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt index 82ea37e7..150939d4 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt @@ -43,10 +43,9 @@ class ScheduleController( @TsidPathVariable clubId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, @RequestParam year: Int, - @RequestParam semester: Int, ): CommonResponse>> = CommonResponse.success( ScheduleResponseCode.SCHEDULE_YEARLY_FIND_SUCCESS, - getScheduleQueryService.findYearly(clubId, userId, year, semester), + getScheduleQueryService.findYearly(clubId, userId, year), ) } diff --git a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCaseTest.kt index 2c26a79d..c807ce48 100644 --- a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCaseTest.kt @@ -47,7 +47,7 @@ class ManageAccountUseCaseTest : val request = AccountSaveRequest("설명", 100_000, 40) every { accountRepository.existsByClubIdAndCardinal(clubId, 40) } returns false every { cardinalReader.findByClubIdAndCardinalNumber(clubId, 40) } returns - CardinalTestFixture.createCardinal(cardinalNumber = 40, year = 2026, semester = 1) + CardinalTestFixture.createCardinal(cardinalNumber = 40) every { accountRepository.save(any()) } answers { firstArg() } useCase.save(clubId, request, userId) diff --git a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCaseTest.kt index a457c657..9dd2fdb0 100644 --- a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageReceiptUseCaseTest.kt @@ -66,7 +66,7 @@ class ManageReceiptUseCaseTest : cardinalNumber: Int, ) { every { cardinalReader.findByClubIdAndCardinalNumber(clubId, cardinalNumber) } returns - CardinalTestFixture.createCardinal(cardinalNumber = cardinalNumber, year = 2026, semester = 1) + CardinalTestFixture.createCardinal(cardinalNumber = cardinalNumber) } describe("save") { diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt index ebca6714..528cb6a3 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt @@ -74,8 +74,6 @@ class GetAttendanceQueryServiceTest : id = 1L, club = member.club, cardinalNumber = 8, - year = 2026, - semester = 1, ) val session1 = SessionTestFixture.createSession( diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt index eec2946c..ebe3410d 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt @@ -173,8 +173,6 @@ class ManagePostUseCaseTest : val cardinal = CardinalTestFixture.createCardinalInProgress( cardinalNumber = 6, - year = 2026, - semester = 1, ) val clubMemberCardinal = ClubMemberCardinalTestFixture.create(cardinal = cardinal) val request = CreatePostRequest(title = "게시글", content = "내용") diff --git a/src/test/kotlin/com/weeth/domain/cardinal/application/usecase/command/CardinalUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/cardinal/application/usecase/command/CardinalUseCaseTest.kt index a5ac7c67..631b3865 100644 --- a/src/test/kotlin/com/weeth/domain/cardinal/application/usecase/command/CardinalUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/cardinal/application/usecase/command/CardinalUseCaseTest.kt @@ -1,7 +1,6 @@ package com.weeth.domain.cardinal.application.usecase.command import com.weeth.domain.cardinal.application.dto.request.CardinalSaveRequest -import com.weeth.domain.cardinal.application.dto.request.CardinalUpdateRequest import com.weeth.domain.cardinal.application.dto.response.CardinalResponse import com.weeth.domain.cardinal.application.mapper.CardinalMapper import com.weeth.domain.cardinal.application.usecase.query.GetCardinalQueryService @@ -46,7 +45,7 @@ class CardinalUseCaseTest : val clubId = 1L val userId = 99L - val club = ClubTestFixture.createClub() + val club = ClubTestFixture.createClub(id = clubId) beforeTest { clearMocks( @@ -69,9 +68,9 @@ class CardinalUseCaseTest : describe("save") { context("진행중이 아닌 기수라면") { it("검증 후 저장만 한다") { - val request = CardinalSaveRequest(7, 2025, 1, false) - val toSave = CardinalTestFixture.createCardinal(cardinalNumber = 7, year = 2025, semester = 1) - val saved = CardinalTestFixture.createCardinal(cardinalNumber = 7, year = 2025, semester = 1) + val request = CardinalSaveRequest(7, false) + val toSave = CardinalTestFixture.createCardinal(cardinalNumber = 7) + val saved = CardinalTestFixture.createCardinal(cardinalNumber = 7) every { cardinalRepository.findByClubIdAndCardinalNumber(clubId, 7) } returns null every { cardinalMapper.toEntity(club, request) } returns toSave @@ -81,28 +80,25 @@ class CardinalUseCaseTest : verify { cardinalRepository.findByClubIdAndCardinalNumber(clubId, 7) } verify { cardinalRepository.save(toSave) } - verify(exactly = 0) { cardinalRepository.findAllInProgressWithLock() } + verify(exactly = 0) { cardinalRepository.findAllInProgressByClubIdWithLock(clubId) } } } context("새 기수가 진행중이라면") { it("기존 기수는 DONE, 현재기수는 IN_PROGRESS가 된다") { - val request = CardinalSaveRequest(7, 2025, 1, true) - val oldCardinal = - CardinalTestFixture.createCardinalInProgress(cardinalNumber = 6, year = 2024, semester = 2) - val newCardinalBeforeSave = - CardinalTestFixture.createCardinal(cardinalNumber = 7, year = 2025, semester = 1) - val newCardinalAfterSave = - CardinalTestFixture.createCardinal(cardinalNumber = 7, year = 2025, semester = 1) + val request = CardinalSaveRequest(7, true) + val oldCardinal = CardinalTestFixture.createCardinalInProgress(club = club, cardinalNumber = 6) + val newCardinalBeforeSave = CardinalTestFixture.createCardinal(club = club, cardinalNumber = 7) + val newCardinalAfterSave = CardinalTestFixture.createCardinal(club = club, cardinalNumber = 7) every { cardinalRepository.findByClubIdAndCardinalNumber(clubId, 7) } returns null - every { cardinalRepository.findAllInProgressWithLock() } returns listOf(oldCardinal) + every { cardinalRepository.findAllInProgressByClubIdWithLock(clubId) } returns listOf(oldCardinal) every { cardinalMapper.toEntity(club, request) } returns newCardinalBeforeSave every { cardinalRepository.save(newCardinalBeforeSave) } returns newCardinalAfterSave manageCardinalUseCase.save(clubId, request, userId) - verify { cardinalRepository.findAllInProgressWithLock() } + verify { cardinalRepository.findAllInProgressByClubIdWithLock(clubId) } verify { cardinalRepository.save(newCardinalBeforeSave) } oldCardinal.status shouldBe CardinalStatus.DONE @@ -111,31 +107,29 @@ class CardinalUseCaseTest : } } - describe("update") { - it("연도와 학기를 변경한다") { - val cardinal = CardinalTestFixture.createCardinal(cardinalNumber = 6, year = 2024, semester = 2) + describe("activate") { + it("해당 기수를 IN_PROGRESS로 지정하고 나머지는 DONE으로 변경한다") { + val cardinal = CardinalTestFixture.createCardinal(club = club, cardinalNumber = 6) + val oldCardinal = CardinalTestFixture.createCardinalInProgress(club = club, cardinalNumber = 5) every { cardinalRepository.findByIdAndClubId(1L, clubId) } returns cardinal + every { cardinalRepository.findAllInProgressByClubIdWithLock(clubId) } returns listOf(oldCardinal) - manageCardinalUseCase.update(clubId, CardinalUpdateRequest(1L, 2025, 1, false), userId) + manageCardinalUseCase.activate(clubId, 1L, userId) - cardinal.year shouldBe 2025 - cardinal.semester shouldBe 1 + oldCardinal.status shouldBe CardinalStatus.DONE + cardinal.status shouldBe CardinalStatus.IN_PROGRESS } } describe("findAll") { it("조회된 모든 기수를 DTO로 매핑한다") { - val cardinal1 = - CardinalTestFixture.createCardinal(id = 1L, cardinalNumber = 6, year = 2024, semester = 2) - val cardinal2 = - CardinalTestFixture.createCardinalInProgress(id = 2L, cardinalNumber = 7, year = 2025, semester = 1) + val cardinal1 = CardinalTestFixture.createCardinal(id = 1L, cardinalNumber = 6) + val cardinal2 = CardinalTestFixture.createCardinalInProgress(id = 2L, cardinalNumber = 7) val cardinals = listOf(cardinal1, cardinal2) val now = LocalDateTime.now() - val response1 = - CardinalResponse(1L, 6, 2024, 2, CardinalStatus.DONE, now.minusDays(5), now.minusDays(3)) - val response2 = - CardinalResponse(2L, 7, 2025, 1, CardinalStatus.IN_PROGRESS, now.minusDays(2), now) + val response1 = CardinalResponse(1L, 6, CardinalStatus.DONE, now.minusDays(5), now.minusDays(3)) + val response2 = CardinalResponse(2L, 7, CardinalStatus.IN_PROGRESS, now.minusDays(2), now) every { cardinalReader.findAllByClubIdOrderByCardinalNumberAsc(clubId) } returns cardinals every { cardinalMapper.toResponse(cardinal1) } returns response1 diff --git a/src/test/kotlin/com/weeth/domain/cardinal/domain/entity/CardinalTest.kt b/src/test/kotlin/com/weeth/domain/cardinal/domain/entity/CardinalTest.kt index 371a454a..a0269c7d 100644 --- a/src/test/kotlin/com/weeth/domain/cardinal/domain/entity/CardinalTest.kt +++ b/src/test/kotlin/com/weeth/domain/cardinal/domain/entity/CardinalTest.kt @@ -10,7 +10,7 @@ class CardinalTest : val club = ClubTestFixture.createClub() "inProgress/done 상태 전환" { - val cardinal = Cardinal(club = club, cardinalNumber = 10, year = 2026, semester = 1) + val cardinal = Cardinal(club = club, cardinalNumber = 10) cardinal.inProgress() cardinal.status shouldBe CardinalStatus.IN_PROGRESS @@ -18,12 +18,4 @@ class CardinalTest : cardinal.done() cardinal.status shouldBe CardinalStatus.DONE } - - "update는 year/semester를 변경한다" { - val cardinal = Cardinal(club = club, cardinalNumber = 9, year = 2025, semester = 2) - cardinal.update(2026, 1) - - cardinal.year shouldBe 2026 - cardinal.semester shouldBe 1 - } }) diff --git a/src/test/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalRepositoryTest.kt b/src/test/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalRepositoryTest.kt index 3c02c706..9902b81b 100644 --- a/src/test/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalRepositoryTest.kt +++ b/src/test/kotlin/com/weeth/domain/cardinal/domain/repository/CardinalRepositoryTest.kt @@ -5,7 +5,6 @@ import com.weeth.domain.cardinal.fixture.CardinalTestFixture import com.weeth.domain.club.domain.repository.ClubRepository import com.weeth.domain.club.fixture.ClubTestFixture import io.kotest.core.spec.style.StringSpec -import io.kotest.matchers.optional.shouldBePresent import io.kotest.matchers.shouldBe import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest @@ -25,15 +24,11 @@ class CardinalRepositoryTest( CardinalTestFixture.createCardinal( club = club, cardinalNumber = 7, - year = 2025, - semester = 1, ) cardinalRepository.save(cardinal) val result = cardinalRepository.findByCardinalNumber(7) - result.shouldBePresent { - it.year shouldBe 2025 - } + result shouldBe cardinal } }) diff --git a/src/test/kotlin/com/weeth/domain/cardinal/fixture/CardinalTestFixture.kt b/src/test/kotlin/com/weeth/domain/cardinal/fixture/CardinalTestFixture.kt index 5a2f540d..1bf53947 100644 --- a/src/test/kotlin/com/weeth/domain/cardinal/fixture/CardinalTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/cardinal/fixture/CardinalTestFixture.kt @@ -10,15 +10,11 @@ object CardinalTestFixture { id: Long? = null, club: Club = ClubTestFixture.createClub(), cardinalNumber: Int, - year: Int, - semester: Int, ): Cardinal = Cardinal( club = club, id = id ?: 0L, cardinalNumber = cardinalNumber, - year = year, - semester = semester, status = CardinalStatus.DONE, ) @@ -26,15 +22,11 @@ object CardinalTestFixture { id: Long? = null, club: Club = ClubTestFixture.createClub(), cardinalNumber: Int, - year: Int, - semester: Int, ): Cardinal = Cardinal( club = club, id = id ?: 0L, cardinalNumber = cardinalNumber, - year = year, - semester = semester, status = CardinalStatus.IN_PROGRESS, ) } diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCaseTest.kt index ee4c8706..bee3ab8b 100644 --- a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCaseTest.kt @@ -1,11 +1,14 @@ package com.weeth.domain.club.application.usecase.command import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.attendance.fixture.AttendanceTestFixture import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException import com.weeth.domain.cardinal.domain.repository.CardinalReader import com.weeth.domain.cardinal.fixture.CardinalTestFixture import com.weeth.domain.club.application.dto.request.ClubMemberApplyObRequest import com.weeth.domain.club.application.dto.request.ClubMemberRoleUpdateRequest +import com.weeth.domain.club.application.dto.request.UpdateMemberCardinalRequest +import com.weeth.domain.club.application.exception.CardinalRemovalHasAttendanceException import com.weeth.domain.club.application.exception.ClubMemberNotInClubException import com.weeth.domain.club.application.exception.LeadSelfTransferException import com.weeth.domain.club.application.exception.LeadTransferOnlyException @@ -13,6 +16,7 @@ import com.weeth.domain.club.application.exception.MemberNotActiveException import com.weeth.domain.club.application.exception.NotLeadException import com.weeth.domain.club.application.exception.SelfBanNotAllowedException import com.weeth.domain.club.application.exception.SelfRoleChangeNotAllowedException +import com.weeth.domain.club.domain.entity.ClubMemberCardinal import com.weeth.domain.club.domain.enums.MemberRole import com.weeth.domain.club.domain.enums.MemberStatus import com.weeth.domain.club.domain.repository.ClubMemberCardinalRepository @@ -22,6 +26,7 @@ import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.club.fixture.ClubMemberTestFixture import com.weeth.domain.club.fixture.ClubTestFixture +import com.weeth.domain.penalty.domain.repository.PenaltyReader import com.weeth.domain.session.domain.repository.SessionReader import com.weeth.domain.session.fixture.SessionTestFixture import io.kotest.assertions.throwables.shouldThrow @@ -42,6 +47,7 @@ class AdminClubMemberUseCaseTest : val clubMemberReader = mockk(relaxed = true) val sessionReader = mockk(relaxed = true) val attendanceRepository = mockk(relaxed = true) + val penaltyReader = mockk(relaxed = true) val clubMemberCardinalRepository = mockk(relaxed = true) val useCase = AdminClubMemberUseCase( @@ -52,6 +58,7 @@ class AdminClubMemberUseCaseTest : clubMemberReader, sessionReader, attendanceRepository, + penaltyReader, clubMemberCardinalRepository, ) val club = ClubTestFixture.createClub(id = 1L) @@ -66,6 +73,7 @@ class AdminClubMemberUseCaseTest : clubMemberReader, sessionReader, attendanceRepository, + penaltyReader, clubMemberCardinalRepository, ) every { @@ -265,8 +273,6 @@ class AdminClubMemberUseCaseTest : id = 1L, club = adminMember.club, cardinalNumber = 8, - year = 2026, - semester = 1, ) val session = SessionTestFixture.createSession(club = adminMember.club, cardinal = 8) every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember @@ -291,8 +297,6 @@ class AdminClubMemberUseCaseTest : id = 1L, club = adminMember.club, cardinalNumber = 8, - year = 2026, - semester = 1, ) every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember every { clubMemberReader.findAllByIdsWithLock(listOf(20L)) } returns listOf(member) @@ -315,8 +319,6 @@ class AdminClubMemberUseCaseTest : id = 1L, club = adminMember.club, cardinalNumber = 8, - year = 2026, - semester = 1, ) every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember every { clubMemberReader.findAllByIdsWithLock(listOf(20L)) } returns listOf(member) @@ -354,15 +356,13 @@ class AdminClubMemberUseCaseTest : } } - it("현재 기수 등록 시 출석 통계를 초기화한다") { + it("최신/첫 기수 등록 시 출석 통계를 초기화한다") { val member = ClubMemberTestFixture.createActiveMember(id = 20L, club = adminMember.club) val cardinal = CardinalTestFixture.createCardinal( id = 1L, club = adminMember.club, cardinalNumber = 8, - year = 2026, - semester = 1, ) repeat(2) { member.attend() } repeat(1) { member.absent() } @@ -380,4 +380,201 @@ class AdminClubMemberUseCaseTest : member.attendanceStats.attendanceRate shouldBe 0 } } + + describe("updateCardinals") { + // 각 it에서 member를 독립 생성하여 상태 오염 방지 + fun createMember() = ClubMemberTestFixture.createActiveMember(id = 20L, club = club) + + fun stubMemberLock(member: com.weeth.domain.club.domain.entity.ClubMember) { + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubMemberReader.findByIdWithLock(20L) } returns member + } + + it("기수를 추가하면 해당 기수의 세션에 출석이 초기화된다") { + val member = createMember() + val cardinal = CardinalTestFixture.createCardinal(id = 1L, club = club, cardinalNumber = 8) + val session = SessionTestFixture.createSession(club = club, cardinal = 8) + stubMemberLock(member) + every { cardinalReader.findAllByClubIdAndIdIn(1L, listOf(1L)) } returns listOf(cardinal) + every { clubMemberCardinalRepository.findAllByClubMembers(listOf(member)) } returns emptyList() + every { clubMemberCardinalPolicy.isLatestOrFirstCardinal(member, cardinal) } returns false + every { sessionReader.findAllByClubIdAndCardinalIn(1L, listOf(8)) } returns listOf(session) + + useCase.updateCardinals(1L, 10L, 20L, UpdateMemberCardinalRequest(cardinalIds = listOf(1L))) + + verify( + exactly = 1, + ) { attendanceRepository.saveAll(any>()) } + } + + it("기수를 추가할 때 세션이 없으면 출석 초기화를 하지 않는다") { + val member = createMember() + val cardinal = CardinalTestFixture.createCardinal(id = 1L, club = club, cardinalNumber = 8) + stubMemberLock(member) + every { cardinalReader.findAllByClubIdAndIdIn(1L, listOf(1L)) } returns listOf(cardinal) + every { clubMemberCardinalRepository.findAllByClubMembers(listOf(member)) } returns emptyList() + every { clubMemberCardinalPolicy.isLatestOrFirstCardinal(member, cardinal) } returns false + every { sessionReader.findAllByClubIdAndCardinalIn(1L, listOf(8)) } returns emptyList() + + useCase.updateCardinals(1L, 10L, 20L, UpdateMemberCardinalRequest(cardinalIds = listOf(1L))) + + verify( + exactly = 0, + ) { attendanceRepository.saveAll(any>()) } + } + + it("최신 기수를 새로 추가하면 출석 통계와 패널티가 리셋된다") { + val member = + createMember().also { + repeat(3) { _ -> it.attend() } + it.incrementPenaltyCount() + } + val existingCardinal = CardinalTestFixture.createCardinal(id = 1L, club = club, cardinalNumber = 8) + val newCardinal = CardinalTestFixture.createCardinal(id = 2L, club = club, cardinalNumber = 9) + val existingLink = ClubMemberCardinal.create(member, existingCardinal) + stubMemberLock(member) + every { cardinalReader.findAllByClubIdAndIdIn(1L, listOf(1L, 2L)) } returns + listOf(existingCardinal, newCardinal) + every { clubMemberCardinalRepository.findAllByClubMembers(listOf(member)) } returns listOf(existingLink) + every { clubMemberCardinalPolicy.isLatestOrFirstCardinal(member, newCardinal) } returns true + every { sessionReader.findAllByClubIdAndCardinalIn(1L, listOf(9)) } returns emptyList() + + useCase.updateCardinals(1L, 10L, 20L, UpdateMemberCardinalRequest(cardinalIds = listOf(1L, 2L))) + + member.attendanceStats.attendanceCount shouldBe 0 + member.attendanceStats.absenceCount shouldBe 0 + member.penaltyCount shouldBe 0 + } + + it("출석 기록 없는 기수 삭제 시 force 없이도 바로 삭제된다") { + val member = createMember() + // 현재: 8기, 9기 보유 → 요청: 9기만 유지 → 8기 삭제 + val keepCardinal = CardinalTestFixture.createCardinal(id = 2L, club = club, cardinalNumber = 9) + val removeCardinal = CardinalTestFixture.createCardinal(id = 1L, club = club, cardinalNumber = 8) + val keepLink = ClubMemberCardinal.create(member, keepCardinal) + val removeLink = ClubMemberCardinal.create(member, removeCardinal) + val session = SessionTestFixture.createSession(club = club, cardinal = 8) + stubMemberLock(member) + every { cardinalReader.findAllByClubIdAndIdIn(1L, listOf(2L)) } returns listOf(keepCardinal) + every { clubMemberCardinalRepository.findAllByClubMembers(listOf(member)) } returns + listOf(keepLink, removeLink) + every { sessionReader.findAllByClubIdAndCardinalIn(1L, listOf(8)) } returns listOf(session) + every { attendanceRepository.findAllByClubMemberAndSessionIn(member, listOf(session)) } returns + emptyList() + every { attendanceRepository.findAllByClubMemberIdAndCardinal(20L, 9) } returns emptyList() + every { penaltyReader.countByClubMemberIdAndCardinalId(20L, 2L) } returns 0 + + useCase.updateCardinals(1L, 10L, 20L, UpdateMemberCardinalRequest(cardinalIds = listOf(2L))) + + member.penaltyCount shouldBe 0 + verify(exactly = 1) { clubMemberCardinalRepository.deleteAll(listOf(removeLink)) } + } + + it("출석/결석 기록이 있는 기수 삭제 시 force=false면 예외가 발생한다") { + val member = createMember() + // 현재: 8기, 9기 보유 → 요청: 9기만 유지 → 8기 삭제 + val keepCardinal = CardinalTestFixture.createCardinal(id = 2L, club = club, cardinalNumber = 9) + val removeCardinal = CardinalTestFixture.createCardinal(id = 1L, club = club, cardinalNumber = 8) + val keepLink = ClubMemberCardinal.create(member, keepCardinal) + val removeLink = ClubMemberCardinal.create(member, removeCardinal) + val session = SessionTestFixture.createSession(club = club, cardinal = 8) + val attendance = AttendanceTestFixture.createAttendance(session, member).also { it.attend() } + stubMemberLock(member) + every { cardinalReader.findAllByClubIdAndIdIn(1L, listOf(2L)) } returns listOf(keepCardinal) + every { clubMemberCardinalRepository.findAllByClubMembers(listOf(member)) } returns + listOf(keepLink, removeLink) + every { sessionReader.findAllByClubIdAndCardinalIn(1L, listOf(8)) } returns listOf(session) + every { attendanceRepository.findAllByClubMemberAndSessionIn(member, listOf(session)) } returns + listOf(attendance) + + shouldThrow { + useCase.updateCardinals( + 1L, + 10L, + 20L, + UpdateMemberCardinalRequest(cardinalIds = listOf(2L), force = false), + ) + } + + verify(exactly = 0) { + attendanceRepository.deleteAll(any>()) + } + verify(exactly = 0) { clubMemberCardinalRepository.deleteAll(any()) } + } + + it("출석/결석 기록이 있는 기수 삭제 시 force=true면 남은 출석 기록 기준으로 통계가 재계산된다") { + val member = createMember() + // 현재: 8기, 9기 보유 → 요청: 8기만 유지 → 9기 삭제 + val keepCardinal = CardinalTestFixture.createCardinal(id = 1L, club = club, cardinalNumber = 8) + val removeCardinal = CardinalTestFixture.createCardinal(id = 2L, club = club, cardinalNumber = 9) + val keepLink = ClubMemberCardinal.create(member, keepCardinal) + val removeLink = ClubMemberCardinal.create(member, removeCardinal) + val session8 = SessionTestFixture.createSession(club = club, cardinal = 8) + val session9 = SessionTestFixture.createSession(club = club, cardinal = 9) + val removeAttendance = AttendanceTestFixture.createAttendance(session9, member).also { it.attend() } + val remainingAttendance = AttendanceTestFixture.createAttendance(session8, member).also { it.attend() } + stubMemberLock(member) + every { cardinalReader.findAllByClubIdAndIdIn(1L, listOf(1L)) } returns listOf(keepCardinal) + every { clubMemberCardinalRepository.findAllByClubMembers(listOf(member)) } returns + listOf(keepLink, removeLink) + every { sessionReader.findAllByClubIdAndCardinalIn(1L, listOf(9)) } returns listOf(session9) + every { attendanceRepository.findAllByClubMemberAndSessionIn(member, listOf(session9)) } returns + listOf(removeAttendance) + every { attendanceRepository.findAllByClubMemberIdAndCardinal(20L, 8) } returns + listOf(remainingAttendance) + every { penaltyReader.countByClubMemberIdAndCardinalId(20L, 1L) } returns 2 + + useCase.updateCardinals( + 1L, + 10L, + 20L, + UpdateMemberCardinalRequest(cardinalIds = listOf(1L), force = true), + ) + + // 9기 제거 후 남은 출석(8기 1건) 기준으로 통계 재계산, 패널티도 8기 기준으로 복구 + member.attendanceStats.attendanceCount shouldBe 1 + member.penaltyCount shouldBe 2 + verify(exactly = 1) { attendanceRepository.deleteAll(listOf(removeAttendance)) } + verify(exactly = 1) { clubMemberCardinalRepository.deleteAll(listOf(removeLink)) } + } + + it("모든 기수 제거 시 출석 통계와 패널티가 0으로 초기화된다") { + val member = createMember() + val removeCardinal = CardinalTestFixture.createCardinal(id = 1L, club = club, cardinalNumber = 8) + val removeLink = ClubMemberCardinal.create(member, removeCardinal) + val session = SessionTestFixture.createSession(club = club, cardinal = 8) + stubMemberLock(member) + every { cardinalReader.findAllByClubIdAndIdIn(1L, emptyList()) } returns emptyList() + every { clubMemberCardinalRepository.findAllByClubMembers(listOf(member)) } returns listOf(removeLink) + every { sessionReader.findAllByClubIdAndCardinalIn(1L, listOf(8)) } returns listOf(session) + every { attendanceRepository.findAllByClubMemberAndSessionIn(member, listOf(session)) } returns + emptyList() + + useCase.updateCardinals(1L, 10L, 20L, UpdateMemberCardinalRequest(cardinalIds = emptyList())) + + member.attendanceStats.attendanceCount shouldBe 0 + member.attendanceStats.absenceCount shouldBe 0 + member.penaltyCount shouldBe 0 + } + + it("존재하지 않는 기수 ID가 포함되면 예외가 발생한다") { + val member = createMember() + stubMemberLock(member) + every { cardinalReader.findAllByClubIdAndIdIn(1L, listOf(999L)) } returns emptyList() + + shouldThrow { + useCase.updateCardinals(1L, 10L, 20L, UpdateMemberCardinalRequest(cardinalIds = listOf(999L))) + } + } + + it("다른 동아리 소속 멤버면 예외가 발생한다") { + val otherClubMember = ClubMemberTestFixture.createActiveMember(id = 20L) + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubMemberReader.findByIdWithLock(20L) } returns otherClubMember + + shouldThrow { + useCase.updateCardinals(1L, 10L, 20L, UpdateMemberCardinalRequest(cardinalIds = listOf(1L))) + } + } + } }) diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt index 9093ebd9..d5286c2a 100644 --- a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt @@ -221,16 +221,12 @@ class ManageClubMemberUseCaseTest : id = 1L, club = club, cardinalNumber = 30, - year = 2024, - semester = 1, ) val cardinal31 = CardinalTestFixture.createCardinal( id = 2L, club = club, cardinalNumber = 31, - year = 2024, - semester = 2, ) val session30 = SessionTestFixture.createSession(club = club, cardinal = 30) val session31 = SessionTestFixture.createSession(club = club, cardinal = 31) @@ -273,8 +269,6 @@ class ManageClubMemberUseCaseTest : id = 1L, club = club, cardinalNumber = 30, - year = 2024, - semester = 1, ) every { clubMemberPolicy.getActiveMemberWithLock(1L, 10L) } returns member @@ -311,8 +305,6 @@ class ManageClubMemberUseCaseTest : id = 1L, club = club, cardinalNumber = 30, - year = 2024, - semester = 1, ) every { clubMemberPolicy.getActiveMemberWithLock(1L, 10L) } returns member diff --git a/src/test/kotlin/com/weeth/domain/club/domain/service/ClubMemberCardinalPolicyTest.kt b/src/test/kotlin/com/weeth/domain/club/domain/service/ClubMemberCardinalPolicyTest.kt index c15a6b01..d6239f37 100644 --- a/src/test/kotlin/com/weeth/domain/club/domain/service/ClubMemberCardinalPolicyTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/domain/service/ClubMemberCardinalPolicyTest.kt @@ -32,8 +32,6 @@ class ClubMemberCardinalPolicyTest : CardinalTestFixture.createCardinal( club = club, cardinalNumber = 5, - year = 2026, - semester = 1, ) val memberCardinal = ClubMemberCardinal.create(clubMember = member, cardinal = cardinal) @@ -62,8 +60,6 @@ class ClubMemberCardinalPolicyTest : id = 10L, club = club, cardinalNumber = 3, - year = 2025, - semester = 1, ) context("멤버가 해당 기수에 속하지 않는 경우") { @@ -94,8 +90,6 @@ class ClubMemberCardinalPolicyTest : CardinalTestFixture.createCardinal( club = club, cardinalNumber = 1, - year = 2024, - semester = 1, ) every { clubMemberCardinalReader.findLatestCardinalByClubMember(member) } returns null @@ -110,15 +104,11 @@ class ClubMemberCardinalPolicyTest : CardinalTestFixture.createCardinal( club = club, cardinalNumber = 3, - year = 2025, - semester = 1, ) val newCardinal = CardinalTestFixture.createCardinal( club = club, cardinalNumber = 4, - year = 2025, - semester = 2, ) val latestMemberCardinal = ClubMemberCardinal.create(clubMember = member, cardinal = latestCardinal) @@ -137,8 +127,6 @@ class ClubMemberCardinalPolicyTest : CardinalTestFixture.createCardinal( club = club, cardinalNumber = 3, - year = 2025, - semester = 1, ) val memberCardinal = ClubMemberCardinal.create(clubMember = member, cardinal = cardinal) @@ -156,15 +144,11 @@ class ClubMemberCardinalPolicyTest : CardinalTestFixture.createCardinal( club = club, cardinalNumber = 5, - year = 2026, - semester = 1, ) val oldCardinal = CardinalTestFixture.createCardinal( club = club, cardinalNumber = 2, - year = 2024, - semester = 2, ) val latestMemberCardinal = ClubMemberCardinal.create(clubMember = member, cardinal = latestCardinal) diff --git a/src/test/kotlin/com/weeth/domain/club/fixture/ClubMemberCardinalTestFixture.kt b/src/test/kotlin/com/weeth/domain/club/fixture/ClubMemberCardinalTestFixture.kt index 19ae8ec8..da526620 100644 --- a/src/test/kotlin/com/weeth/domain/club/fixture/ClubMemberCardinalTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/club/fixture/ClubMemberCardinalTestFixture.kt @@ -8,7 +8,7 @@ import com.weeth.domain.club.domain.entity.ClubMemberCardinal object ClubMemberCardinalTestFixture { fun create( clubMember: ClubMember = ClubMemberTestFixture.createActiveMember(), - cardinal: Cardinal = CardinalTestFixture.createCardinal(cardinalNumber = 1, year = 2026, semester = 1), + cardinal: Cardinal = CardinalTestFixture.createCardinal(cardinalNumber = 1), ): ClubMemberCardinal = ClubMemberCardinal( clubMember = clubMember, diff --git a/src/test/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryServiceTest.kt index cdeed328..92a72055 100644 --- a/src/test/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryServiceTest.kt @@ -98,7 +98,7 @@ class GetSessionQueryServiceTest : } it("cardinal이 지정되면 해당 기수의 세션만 반환한다") { - val cardinal = CardinalTestFixture.createCardinal(cardinalNumber = 3, year = 2026, semester = 1) + val cardinal = CardinalTestFixture.createCardinal(cardinalNumber = 3) val sessions = listOf(SessionTestFixture.createSession(cardinal = 3)) val response = mockk() From 784ac94b50c5a048c60f5ceacd4612b9d252b798 Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:24:08 +0900 Subject: [PATCH 43/73] =?UTF-8?q?[WTH-226]=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EB=B0=98=EB=B3=B5=20=EC=83=9D=EC=84=B1=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 세션 반복 생성을 위한 entity, enums 추가 * feat: 세션 반복 생성을 위한 dto/예외 설정 * feat: 세션 그룹 저장소 및 세션 저장소 메서드 추가 * refactor: duration 기반으로 계산하도록 개선 * refactor: 세션 생성 유스케이스 분리 + 반복 생성 로직 추가 * refactor: 세션 업데이트 유스케이스 분리 + 반복 세션 수정 로직 추가 * feat: 반복 세션 삭제 유스케이스 추가 * refactor: 예외 처리시 data도 선택적으로 받을 수 있도록 수정 * refactor: 세션 관리 유스케이스 분리 * refactor: 세션그룹 삭제 메서드 추가 * refactor: 메퍼 메서드 추가 및 세션 도메인으로 이전 * feat: 세션 그룹으로 조회 메서드 추가 * refactor: 세션 그룹 별 조회하도록 로직 개선 * refactor: 세션 그룹 별 조회하도록 로직 개선 * refactor: 이번 주 세션 리스트로 반환하도록 수정 * refactor: duration 계산 메서드 분리 * test: 테스트 적용 * refactor: 이번 주 세션 리스트로 반환하도록 수정 * refactor: lint 설정 * feat: 세션 반복 관련 API 가 * refactor: UI에 맞게 API 수정 * refactor: 세션 도메인으로 이전 * refactor: 응답 DTO 개선 * refactor: PATCH 스럽도록 수정 * refactor: 세션 id로 매핑 수정 * refactor: import 문 수정 * refactor: 엣지케이스 관련 주석 보강 * refactor: 제약조건 추가 * refactor: 개별 삭제 동시성 문제 보강 * refactor: 개별 삭제 동시성 문제 보강 * refactor: totalCount를 저장하지 않고, 조회시 계산해 반환하도록 수정 * refactor: dto 설명 수정 * refactor: 세션 그룹 업데이트 및 설명 포맷 개선 * refactor: 락 조회시 정렬 추가 * refactor: 장소, 설명 선택 처리 * refactor: QueryHints 추가 * refactor: id 변경 * refactor: fixture 파라미터 변경 --- .../attendance/domain/entity/Attendance.kt | 4 +- .../domain/repository/AttendanceRepository.kt | 18 +- .../dto/request/ScheduleSaveRequest.kt | 13 +- .../dto/request/ScheduleUpdateRequest.kt | 30 +- .../application/dto/response/EventResponse.kt | 4 +- .../dto/response/ScheduleResponse.kt | 9 +- .../dto/response/SessionInfosResponse.kt | 10 - .../application/mapper/ScheduleMapper.kt | 19 +- .../application/mapper/SessionMapper.kt | 79 ---- .../usecase/command/ManageEventUseCase.kt | 14 +- .../usecase/query/GetScheduleQueryService.kt | 8 +- .../domain/schedule/domain/entity/Event.kt | 12 +- .../dto/request/SessionCreateRequest.kt | 29 ++ .../dto/request/SessionUpdateRequest.kt | 19 + .../response/ClosedSessionCountResponse.kt | 8 + .../dto/response/SessionGroupResponse.kt | 29 ++ .../dto/response/SessionInfoResponse.kt | 7 +- .../dto/response/SessionInfosResponse.kt | 10 + .../dto/response/SessionResponse.kt | 2 +- .../ClosedSessionIncludedException.kt | 12 + .../exception/EndBeforeStartException.kt | 5 + .../RecurrenceEndDateBeforeStartException.kt | 5 + .../RecurrenceEndDateExceedsMaxException.kt | 5 + .../RecurrenceEndDateRequiredException.kt | 5 + .../application/exception/SessionErrorCode.kt | 29 ++ .../SessionGroupNotFoundException.kt | 5 + .../application/mapper/SessionMapper.kt | 166 ++++++++ .../usecase/command/CreateSessionUseCase.kt | 110 ++++++ .../usecase/command/DeleteSessionUseCase.kt | 156 ++++++++ .../usecase/command/ManageSessionUseCase.kt | 98 ----- .../usecase/command/UpdateSessionUseCase.kt | 114 ++++++ .../usecase/query/GetSessionQueryService.kt | 35 +- .../domain/session/domain/entity/Session.kt | 22 +- .../session/domain/entity/SessionGroup.kt | 62 +++ .../session/domain/enums/RecurrenceType.kt | 7 + .../domain/enums/SessionGroupStatus.kt | 6 + .../session/domain/enums/UpdateScope.kt | 6 + .../repository/SessionGroupRepository.kt | 19 + .../domain/repository/SessionRepository.kt | 34 +- .../domain/service/RecurringSessionPolicy.kt | 98 +++++ .../presentation/SessionAdminController.kt | 59 ++- .../session/presentation/SessionController.kt | 2 +- .../global/common/exception/BaseException.kt | 1 + .../exception/CommonExceptionHandler.kt | 9 +- .../command/CreateSessionUseCaseTest.kt | 328 ++++++++++++++++ .../command/DeleteSessionUseCaseTest.kt | 359 ++++++++++++++++++ .../command/UpdateSessionUseCaseTest.kt | 292 ++++++++++++++ .../query/GetSessionQueryServiceTest.kt | 12 +- .../service/RecurringSessionPolicyTest.kt | 188 +++++++++ .../session/fixture/SessionTestFixture.kt | 27 ++ 50 files changed, 2320 insertions(+), 280 deletions(-) delete mode 100644 src/main/kotlin/com/weeth/domain/schedule/application/dto/response/SessionInfosResponse.kt delete mode 100644 src/main/kotlin/com/weeth/domain/schedule/application/mapper/SessionMapper.kt create mode 100644 src/main/kotlin/com/weeth/domain/session/application/dto/request/SessionCreateRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/session/application/dto/request/SessionUpdateRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/session/application/dto/response/ClosedSessionCountResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/session/application/dto/response/SessionGroupResponse.kt rename src/main/kotlin/com/weeth/domain/{schedule => session}/application/dto/response/SessionInfoResponse.kt (63%) create mode 100644 src/main/kotlin/com/weeth/domain/session/application/dto/response/SessionInfosResponse.kt rename src/main/kotlin/com/weeth/domain/{schedule => session}/application/dto/response/SessionResponse.kt (95%) create mode 100644 src/main/kotlin/com/weeth/domain/session/application/exception/ClosedSessionIncludedException.kt create mode 100644 src/main/kotlin/com/weeth/domain/session/application/exception/EndBeforeStartException.kt create mode 100644 src/main/kotlin/com/weeth/domain/session/application/exception/RecurrenceEndDateBeforeStartException.kt create mode 100644 src/main/kotlin/com/weeth/domain/session/application/exception/RecurrenceEndDateExceedsMaxException.kt create mode 100644 src/main/kotlin/com/weeth/domain/session/application/exception/RecurrenceEndDateRequiredException.kt create mode 100644 src/main/kotlin/com/weeth/domain/session/application/exception/SessionGroupNotFoundException.kt create mode 100644 src/main/kotlin/com/weeth/domain/session/application/mapper/SessionMapper.kt create mode 100644 src/main/kotlin/com/weeth/domain/session/application/usecase/command/CreateSessionUseCase.kt create mode 100644 src/main/kotlin/com/weeth/domain/session/application/usecase/command/DeleteSessionUseCase.kt delete mode 100644 src/main/kotlin/com/weeth/domain/session/application/usecase/command/ManageSessionUseCase.kt create mode 100644 src/main/kotlin/com/weeth/domain/session/application/usecase/command/UpdateSessionUseCase.kt create mode 100644 src/main/kotlin/com/weeth/domain/session/domain/entity/SessionGroup.kt create mode 100644 src/main/kotlin/com/weeth/domain/session/domain/enums/RecurrenceType.kt create mode 100644 src/main/kotlin/com/weeth/domain/session/domain/enums/SessionGroupStatus.kt create mode 100644 src/main/kotlin/com/weeth/domain/session/domain/enums/UpdateScope.kt create mode 100644 src/main/kotlin/com/weeth/domain/session/domain/repository/SessionGroupRepository.kt create mode 100644 src/main/kotlin/com/weeth/domain/session/domain/service/RecurringSessionPolicy.kt create mode 100644 src/test/kotlin/com/weeth/domain/session/application/usecase/command/CreateSessionUseCaseTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/session/application/usecase/command/DeleteSessionUseCaseTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/session/application/usecase/command/UpdateSessionUseCaseTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/session/domain/service/RecurringSessionPolicyTest.kt diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/entity/Attendance.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/entity/Attendance.kt index 3dc71ee3..6153b009 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/domain/entity/Attendance.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/entity/Attendance.kt @@ -22,12 +22,12 @@ import org.hibernate.annotations.OnDeleteAction @Entity @Table( uniqueConstraints = [ - UniqueConstraint(name = "uk_attendance_session_member", columnNames = ["meeting_id", "club_member_id"]), + UniqueConstraint(name = "uk_attendance_session_member", columnNames = ["session_id", "club_member_id"]), ], ) class Attendance( @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "meeting_id") + @JoinColumn(name = "session_id") val session: Session, @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "club_member_id") diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt index 96530daf..6456648b 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt @@ -35,7 +35,7 @@ interface AttendanceRepository : JpaRepository { @Lock(LockModeType.PESSIMISTIC_WRITE) @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) @Query( - "SELECT a FROM Attendance a JOIN FETCH a.clubMember cm JOIN FETCH cm.user WHERE a.session = :session AND cm.memberStatus = :status", + "SELECT a FROM Attendance a JOIN FETCH a.clubMember cm JOIN FETCH cm.user WHERE a.session = :session AND cm.memberStatus = :status ORDER BY a.id ASC", ) fun findAllBySessionAndClubMemberMemberStatusWithLock( @Param("session") session: Session, @@ -101,10 +101,26 @@ interface AttendanceRepository : JpaRepository { @Param("sessions") sessions: List, ): List + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query( + "SELECT a FROM Attendance a JOIN FETCH a.clubMember cm JOIN FETCH cm.user WHERE a.session IN :sessions AND cm.memberStatus = :status ORDER BY a.id ASC", + ) + fun findAllBySessionInAndClubMemberMemberStatusWithLock( + @Param("sessions") sessions: List, + @Param("status") status: MemberStatus, + ): List + @Modifying(flushAutomatically = true, clearAutomatically = true) @Query("DELETE FROM Attendance a WHERE a.session = :session") fun deleteAllBySession(session: Session) + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("DELETE FROM Attendance a WHERE a.session IN :sessions") + fun deleteAllBySessionIn( + @Param("sessions") sessions: List, + ) + @Query("SELECT a FROM Attendance a JOIN a.session s WHERE a.clubMember = :clubMember AND s.club.id = :clubId") fun findAllByClubMemberAndClubId( @Param("clubMember") clubMember: ClubMember, diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/dto/request/ScheduleSaveRequest.kt b/src/main/kotlin/com/weeth/domain/schedule/application/dto/request/ScheduleSaveRequest.kt index 942a6608..d32c6b91 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/application/dto/request/ScheduleSaveRequest.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/application/dto/request/ScheduleSaveRequest.kt @@ -12,21 +12,16 @@ data class ScheduleSaveRequest( @field:NotBlank val title: String, @field:Schema(description = "일정 내용", example = "1박 2일 MT입니다.") - @field:NotBlank @field:Size(max = 500) - val content: String, + val content: String? = null, @field:Schema(description = "장소", example = "가평") - @field:NotBlank - val location: String, + val location: String? = null, @field:Schema(description = "기수", example = "4") - @field:NotNull val cardinal: Int, - @field:Schema(description = "시작 시간", example = "2024-03-01T10:00:00") - @field:NotNull + @field:Schema(description = "시작 시간", example = "2026-03-25T10:00:00") @field:DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) val start: LocalDateTime, - @field:Schema(description = "종료 시간", example = "2024-03-01T12:00:00") - @field:NotNull + @field:Schema(description = "종료 시간", example = "2026-03-25T12:00:00") @field:DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) val end: LocalDateTime, ) diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/dto/request/ScheduleUpdateRequest.kt b/src/main/kotlin/com/weeth/domain/schedule/application/dto/request/ScheduleUpdateRequest.kt index e9694e21..93c6799b 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/application/dto/request/ScheduleUpdateRequest.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/application/dto/request/ScheduleUpdateRequest.kt @@ -1,29 +1,19 @@ package com.weeth.domain.schedule.application.dto.request import io.swagger.v3.oas.annotations.media.Schema -import jakarta.validation.constraints.NotBlank -import jakarta.validation.constraints.NotNull import jakarta.validation.constraints.Size -import org.springframework.format.annotation.DateTimeFormat import java.time.LocalDateTime data class ScheduleUpdateRequest( - @field:Schema(description = "일정 제목", example = "MT") - @field:NotBlank - val title: String, - @field:Schema(description = "일정 내용", example = "1박 2일 MT입니다.") - @field:NotBlank + @field:Schema(description = "일정 제목 (null=변경 안 함)", example = "MT") + val title: String?, + @field:Schema(description = "일정 내용 (null=변경 안 함)", example = "1박 2일 MT입니다.") @field:Size(max = 500) - val content: String, - @field:Schema(description = "장소", example = "가평") - @field:NotBlank - val location: String, - @field:Schema(description = "시작 시간", example = "2024-03-01T10:00:00") - @field:NotNull - @field:DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) - val start: LocalDateTime, - @field:Schema(description = "종료 시간", example = "2024-03-01T12:00:00") - @field:NotNull - @field:DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) - val end: LocalDateTime, + val content: String?, + @field:Schema(description = "장소 (null=변경 안 함)", example = "가평") + val location: String?, + @field:Schema(description = "시작 시간 (null=변경 안 함)", example = "2026-03-28T10:00:00") + val start: LocalDateTime?, + @field:Schema(description = "종료 시간 (null=변경 안 함)", example = "2026-03-28T10:00:00") + val end: LocalDateTime?, ) diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/EventResponse.kt b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/EventResponse.kt index 71c6ad14..0ab58b3b 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/EventResponse.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/EventResponse.kt @@ -10,9 +10,9 @@ data class EventResponse( @field:Schema(description = "일정 제목", example = "MT") val title: String, @field:Schema(description = "일정 내용") - val content: String, + val content: String?, @field:Schema(description = "장소", example = "가평") - val location: String, + val location: String?, @field:Schema(description = "작성자 이름", example = "이지훈") val name: String?, @field:Schema(description = "기수", example = "4") diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/ScheduleResponse.kt b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/ScheduleResponse.kt index 1a0d883f..aa487473 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/ScheduleResponse.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/ScheduleResponse.kt @@ -1,5 +1,6 @@ package com.weeth.domain.schedule.application.dto.response +import com.weeth.domain.schedule.domain.enums.Type import io.swagger.v3.oas.annotations.media.Schema import java.time.LocalDateTime @@ -12,6 +13,10 @@ data class ScheduleResponse( val start: LocalDateTime, @field:Schema(description = "종료 시간") val end: LocalDateTime, - @field:Schema(description = "정기모임 여부") - val isSession: Boolean, + @field:Schema(description = "일정 유형", example = "SESSION") + val type: Type, + @field:Schema(description = "장소", example = "가천대 체육관") + val location: String?, + @field:Schema(description = "기수", example = "7") + val cardinal: Int, ) diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/SessionInfosResponse.kt b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/SessionInfosResponse.kt deleted file mode 100644 index 88409aa5..00000000 --- a/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/SessionInfosResponse.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.weeth.domain.schedule.application.dto.response - -import io.swagger.v3.oas.annotations.media.Schema - -data class SessionInfosResponse( - @field:Schema(description = "이번 주 정기모임") - val thisWeek: SessionInfoResponse?, - @field:Schema(description = "정기모임 목록") - val sessions: List, -) diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/mapper/ScheduleMapper.kt b/src/main/kotlin/com/weeth/domain/schedule/application/mapper/ScheduleMapper.kt index efe5ac27..6d8ad1d4 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/application/mapper/ScheduleMapper.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/application/mapper/ScheduleMapper.kt @@ -2,32 +2,31 @@ package com.weeth.domain.schedule.application.mapper import com.weeth.domain.schedule.application.dto.response.ScheduleResponse import com.weeth.domain.schedule.domain.entity.Event +import com.weeth.domain.schedule.domain.enums.Type import com.weeth.domain.session.domain.entity.Session import org.springframework.stereotype.Component @Component class ScheduleMapper { - fun toResponse( - event: Event, - isSession: Boolean, - ): ScheduleResponse = + fun toResponse(event: Event): ScheduleResponse = ScheduleResponse( id = event.id, title = event.title, start = event.start, end = event.end, - isSession = isSession, + type = Type.EVENT, + location = event.location, + cardinal = event.cardinal, ) - fun toResponse( - session: Session, - isSession: Boolean, - ): ScheduleResponse = + fun toResponse(session: Session): ScheduleResponse = ScheduleResponse( id = session.id, title = session.title, start = session.start, end = session.end, - isSession = isSession, + type = Type.SESSION, + location = session.location, + cardinal = session.cardinal, ) } diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/mapper/SessionMapper.kt b/src/main/kotlin/com/weeth/domain/schedule/application/mapper/SessionMapper.kt deleted file mode 100644 index a65ae5e8..00000000 --- a/src/main/kotlin/com/weeth/domain/schedule/application/mapper/SessionMapper.kt +++ /dev/null @@ -1,79 +0,0 @@ -package com.weeth.domain.schedule.application.mapper - -import com.weeth.domain.club.domain.entity.Club -import com.weeth.domain.schedule.application.dto.request.ScheduleSaveRequest -import com.weeth.domain.schedule.application.dto.response.SessionInfoResponse -import com.weeth.domain.schedule.application.dto.response.SessionInfosResponse -import com.weeth.domain.schedule.application.dto.response.SessionResponse -import com.weeth.domain.schedule.domain.enums.Type -import com.weeth.domain.session.domain.entity.Session -import com.weeth.domain.user.domain.entity.User -import org.springframework.stereotype.Component - -@Component -class SessionMapper { - fun toResponse(session: Session): SessionResponse = - SessionResponse( - id = session.id, - title = session.title, - content = session.content, - location = session.location, - name = session.user?.name, - cardinal = session.cardinal, - type = Type.SESSION, - code = null, - start = session.start, - end = session.end, - createdAt = session.createdAt, - modifiedAt = session.modifiedAt, - ) - - fun toAdminResponse(session: Session): SessionResponse = - SessionResponse( - id = session.id, - title = session.title, - content = session.content, - location = session.location, - name = session.user?.name, - cardinal = session.cardinal, - type = Type.SESSION, - code = session.code, - start = session.start, - end = session.end, - createdAt = session.createdAt, - modifiedAt = session.modifiedAt, - ) - - fun toInfo(session: Session): SessionInfoResponse = - SessionInfoResponse( - id = session.id, - cardinal = session.cardinal, - title = session.title, - start = session.start, - ) - - fun toInfos( - thisWeek: Session?, - sessions: List, - ): SessionInfosResponse = - SessionInfosResponse( - thisWeek = thisWeek?.let { toInfo(it) }, - sessions = sessions.map { toInfo(it) }, - ) - - fun toEntity( - club: Club, - request: ScheduleSaveRequest, - user: User, - ): Session = - Session.create( - club = club, - title = request.title, - content = request.content, - location = request.location, - cardinal = request.cardinal, - start = request.start, - end = request.end, - user = user, - ) -} diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/usecase/command/ManageEventUseCase.kt b/src/main/kotlin/com/weeth/domain/schedule/application/usecase/command/ManageEventUseCase.kt index 4713e241..8d669057 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/application/usecase/command/ManageEventUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/application/usecase/command/ManageEventUseCase.kt @@ -1,5 +1,6 @@ package com.weeth.domain.schedule.application.usecase.command +import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException import com.weeth.domain.cardinal.domain.repository.CardinalReader import com.weeth.domain.club.domain.repository.ClubReader import com.weeth.domain.club.domain.service.ClubPermissionPolicy @@ -31,8 +32,8 @@ class ManageEventUseCase( clubPermissionPolicy.requireAdmin(clubId, userId) val club = clubReader.getClubById(clubId) val user = userReader.getById(userId) - // TODO: 전역 cardinal 조회 대신 clubId 기준 조회를 사용해야 다른 동아리 기수로 검증이 통과하지 않는다. - cardinalReader.getByCardinalNumber(request.cardinal) + cardinalReader.findByClubIdAndCardinalNumber(clubId, request.cardinal) + ?: throw CardinalNotFoundException() eventRepository.save(eventMapper.toEntity(club, request, user)) } @@ -47,7 +48,14 @@ class ManageEventUseCase( val user = userReader.getById(userId) val event = eventRepository.findByIdOrNull(eventId) ?: throw EventNotFoundException() if (event.club.id != clubId) throw EventNotFoundException() - event.update(request.title, request.content, request.location, request.start, request.end, user) + event.update( + title = request.title ?: event.title, + content = request.content ?: event.content, + location = request.location ?: event.location, + start = request.start ?: event.start, + end = request.end ?: event.end, + user = user, + ) } @Transactional diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt b/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt index 86d57a6d..2725bd43 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt @@ -46,12 +46,12 @@ class GetScheduleQueryService( val events = eventRepository .findByClubIdAndStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc(clubId, end, start) - .map { scheduleMapper.toResponse(it, false) } + .map { scheduleMapper.toResponse(it) } val sessions = sessionReader .findAllByClubIdAndStartBetween(clubId, start, end) - .map { scheduleMapper.toResponse(it, true) } + .map { scheduleMapper.toResponse(it) } return (events + sessions).sortedBy { it.start } } @@ -69,12 +69,12 @@ class GetScheduleQueryService( val events = eventRepository .findByClubIdAndStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc(clubId, end, start) - .map { scheduleMapper.toResponse(it, false) } + .map { scheduleMapper.toResponse(it) } val sessions = sessionReader .findAllByClubIdAndStartBetween(clubId, start, end) - .map { scheduleMapper.toResponse(it, true) } + .map { scheduleMapper.toResponse(it) } return (events + sessions) .sortedBy { it.start } diff --git a/src/main/kotlin/com/weeth/domain/schedule/domain/entity/Event.kt b/src/main/kotlin/com/weeth/domain/schedule/domain/entity/Event.kt index 615356b8..7d2c05f9 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/domain/entity/Event.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/domain/entity/Event.kt @@ -18,8 +18,8 @@ class Event( club: Club, var title: String, @Column(length = 500) - var content: String, - var location: String, + var content: String? = null, + var location: String? = null, var cardinal: Int, var start: LocalDateTime, var end: LocalDateTime, @@ -38,8 +38,8 @@ class Event( fun update( title: String, - content: String, - location: String, + content: String?, + location: String?, start: LocalDateTime, end: LocalDateTime, user: User?, @@ -58,8 +58,8 @@ class Event( fun create( club: Club, title: String, - content: String, - location: String, + content: String?, + location: String?, cardinal: Int, start: LocalDateTime, end: LocalDateTime, diff --git a/src/main/kotlin/com/weeth/domain/session/application/dto/request/SessionCreateRequest.kt b/src/main/kotlin/com/weeth/domain/session/application/dto/request/SessionCreateRequest.kt new file mode 100644 index 00000000..3acb49a1 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/dto/request/SessionCreateRequest.kt @@ -0,0 +1,29 @@ +package com.weeth.domain.session.application.dto.request + +import com.weeth.domain.session.domain.enums.RecurrenceType +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size +import java.time.LocalDate +import java.time.LocalDateTime + +data class SessionCreateRequest( + @field:Schema(description = "세션 제목", example = "1차 정기모임") + @field:NotBlank + val title: String, + @field:Schema(description = "세션 내용", example = "OT 및 자기소개") + @field:Size(max = 500) + val content: String?, + @field:Schema(description = "모임 장소", example = "공학관 401호") + val location: String?, + @field:Schema(description = "기수", example = "1") + val cardinal: Int, + @field:Schema(description = "시작 시간", example = "2026-03-26T10:00:00") + val start: LocalDateTime, + @field:Schema(description = "종료 시간", example = "2026-03-26T22:00:00") + val end: LocalDateTime, + @field:Schema(description = "반복 설정 (null=비반복, DAILY/WEEKLY/MONTHLY)") + val recurrenceType: RecurrenceType?, + @field:Schema(description = "반복 종료일 (반복 설정 시 필수, 시작일 기준 최대 1년)", example = "2026-06-30") + val recurrenceEndDate: LocalDate?, +) diff --git a/src/main/kotlin/com/weeth/domain/session/application/dto/request/SessionUpdateRequest.kt b/src/main/kotlin/com/weeth/domain/session/application/dto/request/SessionUpdateRequest.kt new file mode 100644 index 00000000..9c14f0ee --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/dto/request/SessionUpdateRequest.kt @@ -0,0 +1,19 @@ +package com.weeth.domain.session.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.Size +import java.time.LocalDateTime + +data class SessionUpdateRequest( + @field:Schema(description = "세션 제목 (null=변경 안 함)", example = "1차 정기모임") + val title: String?, + @field:Schema(description = "세션 내용 (null=변경 안 함)", example = "OT 및 자기소개") + @field:Size(max = 500) + val content: String?, + @field:Schema(description = "모임 장소 (null=변경 안 함)", example = "공학관 401호") + val location: String?, + @field:Schema(description = "시작 시간 (null=변경 안 함)", example = "2026-03-27T10:00:00") + val start: LocalDateTime?, + @field:Schema(description = "종료 시간 (null=변경 안 함)", example = "2026-03-27T22:00:00") + val end: LocalDateTime?, +) diff --git a/src/main/kotlin/com/weeth/domain/session/application/dto/response/ClosedSessionCountResponse.kt b/src/main/kotlin/com/weeth/domain/session/application/dto/response/ClosedSessionCountResponse.kt new file mode 100644 index 00000000..40ea6c6b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/dto/response/ClosedSessionCountResponse.kt @@ -0,0 +1,8 @@ +package com.weeth.domain.session.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class ClosedSessionCountResponse( + @field:Schema(description = "이미 진행된(CLOSED) 세션 수") + val closedSessionCount: Int, +) diff --git a/src/main/kotlin/com/weeth/domain/session/application/dto/response/SessionGroupResponse.kt b/src/main/kotlin/com/weeth/domain/session/application/dto/response/SessionGroupResponse.kt new file mode 100644 index 00000000..cd51bafa --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/dto/response/SessionGroupResponse.kt @@ -0,0 +1,29 @@ +package com.weeth.domain.session.application.dto.response + +import com.weeth.domain.session.domain.enums.RecurrenceType +import com.weeth.domain.session.domain.enums.SessionGroupStatus +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDate + +data class SessionGroupResponse( + @field:Schema(description = "반복 그룹 ID (null이면 비반복)") + val groupId: Long?, + @field:Schema(description = "세션 제목") + val title: String, + @field:Schema(description = "반복 설정 (null이면 비반복)") + val recurrenceType: RecurrenceType?, + @field:Schema(description = "반복 설명 텍스트 (예: '매주 수요일 19시')") + val recurrenceDescription: String?, + @field:Schema(description = "그룹 첫 세션 시작일") + val startDate: LocalDate?, + @field:Schema(description = "반복 종료일") + val endDate: LocalDate?, + @field:Schema(description = "완료(CLOSED) 세션 수") + val completedCount: Int, + @field:Schema(description = "전체 세션 수") + val totalCount: Int, + @field:Schema(description = "그룹 상태") + val status: SessionGroupStatus, + @field:Schema(description = "세션 목록") + val sessions: List, +) diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/SessionInfoResponse.kt b/src/main/kotlin/com/weeth/domain/session/application/dto/response/SessionInfoResponse.kt similarity index 63% rename from src/main/kotlin/com/weeth/domain/schedule/application/dto/response/SessionInfoResponse.kt rename to src/main/kotlin/com/weeth/domain/session/application/dto/response/SessionInfoResponse.kt index 2a93789e..6836a50b 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/SessionInfoResponse.kt +++ b/src/main/kotlin/com/weeth/domain/session/application/dto/response/SessionInfoResponse.kt @@ -1,5 +1,6 @@ -package com.weeth.domain.schedule.application.dto.response +package com.weeth.domain.session.application.dto.response +import com.weeth.domain.session.domain.enums.SessionStatus import io.swagger.v3.oas.annotations.media.Schema import java.time.LocalDateTime @@ -12,4 +13,8 @@ data class SessionInfoResponse( val title: String, @field:Schema(description = "시작 시간") val start: LocalDateTime, + @field:Schema(description = "종료 시간") + val end: LocalDateTime, + @field:Schema(description = "상태") + val status: SessionStatus, ) diff --git a/src/main/kotlin/com/weeth/domain/session/application/dto/response/SessionInfosResponse.kt b/src/main/kotlin/com/weeth/domain/session/application/dto/response/SessionInfosResponse.kt new file mode 100644 index 00000000..fee6c3f7 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/dto/response/SessionInfosResponse.kt @@ -0,0 +1,10 @@ +package com.weeth.domain.session.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class SessionInfosResponse( + @field:Schema(description = "이번 주 정기모임 목록") + val thisWeek: List, + @field:Schema(description = "정기모임 목록") + val sessions: List, +) diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/SessionResponse.kt b/src/main/kotlin/com/weeth/domain/session/application/dto/response/SessionResponse.kt similarity index 95% rename from src/main/kotlin/com/weeth/domain/schedule/application/dto/response/SessionResponse.kt rename to src/main/kotlin/com/weeth/domain/session/application/dto/response/SessionResponse.kt index c8960f11..6f28df2d 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/SessionResponse.kt +++ b/src/main/kotlin/com/weeth/domain/session/application/dto/response/SessionResponse.kt @@ -1,4 +1,4 @@ -package com.weeth.domain.schedule.application.dto.response +package com.weeth.domain.session.application.dto.response import com.fasterxml.jackson.annotation.JsonInclude import com.weeth.domain.schedule.domain.enums.Type diff --git a/src/main/kotlin/com/weeth/domain/session/application/exception/ClosedSessionIncludedException.kt b/src/main/kotlin/com/weeth/domain/session/application/exception/ClosedSessionIncludedException.kt new file mode 100644 index 00000000..d6392370 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/exception/ClosedSessionIncludedException.kt @@ -0,0 +1,12 @@ +package com.weeth.domain.session.application.exception + +import com.weeth.domain.session.application.dto.response.ClosedSessionCountResponse +import com.weeth.global.common.exception.BaseException + +class ClosedSessionIncludedException( + errorCode: SessionErrorCode, + closedSessionCount: Int, +) : BaseException( + errorCode = errorCode, + data = ClosedSessionCountResponse(closedSessionCount), + ) diff --git a/src/main/kotlin/com/weeth/domain/session/application/exception/EndBeforeStartException.kt b/src/main/kotlin/com/weeth/domain/session/application/exception/EndBeforeStartException.kt new file mode 100644 index 00000000..a414626c --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/exception/EndBeforeStartException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.session.application.exception + +import com.weeth.global.common.exception.BaseException + +class EndBeforeStartException : BaseException(SessionErrorCode.END_BEFORE_START) diff --git a/src/main/kotlin/com/weeth/domain/session/application/exception/RecurrenceEndDateBeforeStartException.kt b/src/main/kotlin/com/weeth/domain/session/application/exception/RecurrenceEndDateBeforeStartException.kt new file mode 100644 index 00000000..65415dcf --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/exception/RecurrenceEndDateBeforeStartException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.session.application.exception + +import com.weeth.global.common.exception.BaseException + +class RecurrenceEndDateBeforeStartException : BaseException(SessionErrorCode.RECURRENCE_END_DATE_BEFORE_START) diff --git a/src/main/kotlin/com/weeth/domain/session/application/exception/RecurrenceEndDateExceedsMaxException.kt b/src/main/kotlin/com/weeth/domain/session/application/exception/RecurrenceEndDateExceedsMaxException.kt new file mode 100644 index 00000000..e2a418e4 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/exception/RecurrenceEndDateExceedsMaxException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.session.application.exception + +import com.weeth.global.common.exception.BaseException + +class RecurrenceEndDateExceedsMaxException : BaseException(SessionErrorCode.RECURRENCE_END_DATE_EXCEEDS_MAX) diff --git a/src/main/kotlin/com/weeth/domain/session/application/exception/RecurrenceEndDateRequiredException.kt b/src/main/kotlin/com/weeth/domain/session/application/exception/RecurrenceEndDateRequiredException.kt new file mode 100644 index 00000000..f71d7ac9 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/exception/RecurrenceEndDateRequiredException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.session.application.exception + +import com.weeth.global.common.exception.BaseException + +class RecurrenceEndDateRequiredException : BaseException(SessionErrorCode.RECURRENCE_END_DATE_REQUIRED) diff --git a/src/main/kotlin/com/weeth/domain/session/application/exception/SessionErrorCode.kt b/src/main/kotlin/com/weeth/domain/session/application/exception/SessionErrorCode.kt index b7f4876c..4b04a050 100644 --- a/src/main/kotlin/com/weeth/domain/session/application/exception/SessionErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/session/application/exception/SessionErrorCode.kt @@ -14,4 +14,33 @@ enum class SessionErrorCode( @ExplainError("출석 요청 시각이 정기모임 시작 10분 전 ~ 종료 10분 후 범위를 벗어날 때 발생합니다.") SESSION_NOT_IN_PROGRESS(20301, HttpStatus.BAD_REQUEST, "출석 가능한 시간이 아닙니다."), + + @ExplainError("반복 설정 시 반복 종료일이 필수인데 제공되지 않았을 때 발생합니다.") + RECURRENCE_END_DATE_REQUIRED(20302, HttpStatus.BAD_REQUEST, "반복 종료일은 필수입니다."), + + @ExplainError("반복 종료일이 세션 시작일 이전이거나 같을 때 발생합니다.") + RECURRENCE_END_DATE_BEFORE_START(20303, HttpStatus.BAD_REQUEST, "반복 종료일은 시작일 이후여야 합니다."), + + @ExplainError("요청한 세션 그룹 ID에 해당하는 세션 그룹이 존재하지 않을 때 발생합니다.") + SESSION_GROUP_NOT_FOUND(20304, HttpStatus.NOT_FOUND, "존재하지 않는 세션 그룹입니다."), + + @ExplainError("THIS_AND_FUTURE 수정 범위에 이미 진행된(CLOSED) 세션이 포함될 때 발생합니다. force=true로 재요청하면 포함하여 수정합니다.") + CLOSED_SESSION_INCLUDED_IN_UPDATE( + 20305, + HttpStatus.CONFLICT, + "이미 진행된 세션이 수정 범위에 포함되어 있습니다. 계속하려면 force=true로 요청하세요.", + ), + + @ExplainError("THIS_AND_FUTURE 삭제 범위에 이미 진행된(CLOSED) 세션이 포함될 때 발생합니다. force=true로 재요청하면 포함하여 삭제합니다.") + CLOSED_SESSION_INCLUDED_IN_DELETE( + 20306, + HttpStatus.CONFLICT, + "이미 진행된 세션이 삭제 범위에 포함되어 있습니다. 계속하려면 force=true로 요청하세요.", + ), + + @ExplainError("반복 종료일이 시작일 기준 1년을 초과할 때 발생합니다.") + RECURRENCE_END_DATE_EXCEEDS_MAX(20307, HttpStatus.BAD_REQUEST, "반복 종료일은 시작일 기준 최대 1년까지 설정할 수 있습니다."), + + @ExplainError("종료 시간이 시작 시간보다 앞설 때 발생합니다. start만 변경할 경우 end도 함께 전달해야 합니다.") + END_BEFORE_START(20308, HttpStatus.BAD_REQUEST, "종료 시간은 시작 시간 이후여야 합니다."), } diff --git a/src/main/kotlin/com/weeth/domain/session/application/exception/SessionGroupNotFoundException.kt b/src/main/kotlin/com/weeth/domain/session/application/exception/SessionGroupNotFoundException.kt new file mode 100644 index 00000000..fe36f7f0 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/exception/SessionGroupNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.session.application.exception + +import com.weeth.global.common.exception.BaseException + +class SessionGroupNotFoundException : BaseException(SessionErrorCode.SESSION_GROUP_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/session/application/mapper/SessionMapper.kt b/src/main/kotlin/com/weeth/domain/session/application/mapper/SessionMapper.kt new file mode 100644 index 00000000..d86be05a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/mapper/SessionMapper.kt @@ -0,0 +1,166 @@ +package com.weeth.domain.session.application.mapper + +import com.weeth.domain.club.domain.entity.Club +import com.weeth.domain.schedule.domain.enums.Type +import com.weeth.domain.session.application.dto.request.SessionCreateRequest +import com.weeth.domain.session.application.dto.response.SessionGroupResponse +import com.weeth.domain.session.application.dto.response.SessionInfoResponse +import com.weeth.domain.session.application.dto.response.SessionInfosResponse +import com.weeth.domain.session.application.dto.response.SessionResponse +import com.weeth.domain.session.domain.entity.Session +import com.weeth.domain.session.domain.entity.SessionGroup +import com.weeth.domain.session.domain.enums.SessionGroupStatus +import com.weeth.domain.session.domain.enums.SessionStatus +import com.weeth.domain.session.domain.service.RecurringSessionPolicy +import com.weeth.domain.user.domain.entity.User +import org.springframework.stereotype.Component +import java.time.LocalDate +import java.time.LocalDateTime + +@Component +class SessionMapper( + private val recurringSessionPolicy: RecurringSessionPolicy, +) { + fun toResponse(session: Session): SessionResponse = + SessionResponse( + id = session.id, + title = session.title, + content = session.content, + location = session.location, + name = session.user?.name, + cardinal = session.cardinal, + type = Type.SESSION, + code = null, + start = session.start, + end = session.end, + createdAt = session.createdAt, + modifiedAt = session.modifiedAt, + ) + + fun toAdminResponse(session: Session): SessionResponse = + SessionResponse( + id = session.id, + title = session.title, + content = session.content, + location = session.location, + name = session.user?.name, + cardinal = session.cardinal, + type = Type.SESSION, + code = session.code, + start = session.start, + end = session.end, + createdAt = session.createdAt, + modifiedAt = session.modifiedAt, + ) + + fun toInfo(session: Session): SessionInfoResponse = + SessionInfoResponse( + id = session.id, + cardinal = session.cardinal, + title = session.title, + start = session.start, + end = session.end, + status = session.status, + ) + + fun toGroupResponse( + group: SessionGroup, + sessions: List, + ): SessionGroupResponse { + val completedCount = sessions.count { it.status == SessionStatus.CLOSED } + val allCompleted = completedCount == sessions.size + val firstSession = sessions.minByOrNull { it.start } + return SessionGroupResponse( + groupId = group.id, + title = group.title, + recurrenceType = group.recurrenceType, + recurrenceDescription = + recurringSessionPolicy.buildRecurrenceDescription( + group.recurrenceType, + group.startTime, + firstSession?.start?.toLocalDate() ?: group.recurrenceEndDate, + ), + startDate = firstSession?.start?.toLocalDate(), + endDate = group.recurrenceEndDate, + completedCount = completedCount, + totalCount = sessions.size, + status = if (allCompleted) SessionGroupStatus.COMPLETED else SessionGroupStatus.IN_PROGRESS, + sessions = sessions.sortedBy { it.start }.map { toInfo(it) }, + ) + } + + fun toSingleGroupResponse(session: Session): SessionGroupResponse { + val completed = session.status == SessionStatus.CLOSED + return SessionGroupResponse( + groupId = null, + title = session.title, + recurrenceType = null, + recurrenceDescription = null, + startDate = session.start.toLocalDate(), + endDate = null, + completedCount = if (completed) 1 else 0, + totalCount = 1, + status = if (completed) SessionGroupStatus.COMPLETED else SessionGroupStatus.IN_PROGRESS, + sessions = listOf(toInfo(session)), + ) + } + + fun toInfos( + thisWeekSessions: List, + groupedSessions: List, + ): SessionInfosResponse = + SessionInfosResponse( + thisWeek = thisWeekSessions.map { toInfo(it) }, + sessions = groupedSessions, + ) + + fun toEntity( + club: Club, + request: SessionCreateRequest, + user: User, + ): Session = + Session.Companion.create( + club = club, + title = request.title, + content = request.content, + location = request.location, + cardinal = request.cardinal, + start = request.start, + end = request.end, + user = user, + ) + + fun toSessionGroup( + request: SessionCreateRequest, + recurrenceEndDate: LocalDate, + ): SessionGroup = + SessionGroup( + title = request.title, + recurrenceType = checkNotNull(request.recurrenceType), + recurrenceEndDate = recurrenceEndDate, + cardinal = request.cardinal, + startTime = request.start.toLocalTime(), + endTime = request.end.toLocalTime(), + ) + + fun toEntities( + club: Club, + request: SessionCreateRequest, + user: User, + sessionGroup: SessionGroup, + schedules: List>, + ): List = + schedules.map { (start, end) -> + Session.Companion.create( + club = club, + title = request.title, + content = request.content, + location = request.location, + cardinal = request.cardinal, + start = start, + end = end, + user = user, + sessionGroup = sessionGroup, + ) + } +} diff --git a/src/main/kotlin/com/weeth/domain/session/application/usecase/command/CreateSessionUseCase.kt b/src/main/kotlin/com/weeth/domain/session/application/usecase/command/CreateSessionUseCase.kt new file mode 100644 index 00000000..ebb3e068 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/usecase/command/CreateSessionUseCase.kt @@ -0,0 +1,110 @@ +package com.weeth.domain.session.application.usecase.command + +import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException +import com.weeth.domain.cardinal.domain.repository.CardinalReader +import com.weeth.domain.club.domain.entity.Club +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.repository.ClubMemberCardinalReader +import com.weeth.domain.club.domain.repository.ClubReader +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.session.application.dto.request.SessionCreateRequest +import com.weeth.domain.session.application.exception.RecurrenceEndDateBeforeStartException +import com.weeth.domain.session.application.exception.RecurrenceEndDateExceedsMaxException +import com.weeth.domain.session.application.exception.RecurrenceEndDateRequiredException +import com.weeth.domain.session.application.mapper.SessionMapper +import com.weeth.domain.session.domain.repository.SessionGroupRepository +import com.weeth.domain.session.domain.repository.SessionRepository +import com.weeth.domain.session.domain.service.RecurringSessionPolicy +import com.weeth.domain.user.domain.entity.User +import com.weeth.domain.user.domain.repository.UserReader +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class CreateSessionUseCase( + private val sessionRepository: SessionRepository, + private val attendanceRepository: AttendanceRepository, + private val sessionGroupRepository: SessionGroupRepository, + private val userReader: UserReader, + private val cardinalReader: CardinalReader, + private val sessionMapper: SessionMapper, + private val clubReader: ClubReader, + private val clubMemberCardinalReader: ClubMemberCardinalReader, + private val clubPermissionPolicy: ClubPermissionPolicy, + private val recurringSessionPolicy: RecurringSessionPolicy, +) { + @Transactional + fun create( + clubId: Long, + request: SessionCreateRequest, + userId: Long, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + + val club = clubReader.getClubById(clubId) + val user = userReader.getById(userId) + cardinalReader.findByClubIdAndCardinalNumber(clubId, request.cardinal) ?: throw CardinalNotFoundException() + + val members = + clubMemberCardinalReader + .findAllByClubIdAndCardinalNumber(clubId, request.cardinal, MemberStatus.ACTIVE) + .map { it.clubMember } + + when (request.recurrenceType) { + null -> createSingleSession(club, request, user, members) + else -> createRecurringSessions(club, request, user, members) + } + } + + private fun createSingleSession( + club: Club, + request: SessionCreateRequest, + user: User, + members: List, + ) { + val session = sessionMapper.toEntity(club, request, user) + + sessionRepository.save(session) + attendanceRepository.saveAll(members.map { Attendance.create(session, it) }) + } + + /** + * 반복 세션 생성 메서드 + */ + private fun createRecurringSessions( + club: Club, + request: SessionCreateRequest, + user: User, + members: List, + ) { + val recurrenceType = checkNotNull(request.recurrenceType) + val startDate = request.start.toLocalDate() + val endDate = + request.recurrenceEndDate + ?: throw RecurrenceEndDateRequiredException() + + if (endDate.isBefore(startDate)) { + throw RecurrenceEndDateBeforeStartException() + } + if (endDate.isAfter(startDate.plusYears(1))) { + throw RecurrenceEndDateExceedsMaxException() + } + + val schedules = recurringSessionPolicy.calculateSchedules(request.start, request.end, recurrenceType, endDate) + if (schedules.isEmpty()) { + throw RecurrenceEndDateBeforeStartException() + } + + val group = sessionMapper.toSessionGroup(request, endDate) + sessionGroupRepository.save(group) + + val sessions = sessionMapper.toEntities(club, request, user, group, schedules) + sessionRepository.saveAll(sessions) + + val attendances = sessions.flatMap { session -> members.map { Attendance.create(session, it) } } + attendanceRepository.saveAll(attendances) + } +} diff --git a/src/main/kotlin/com/weeth/domain/session/application/usecase/command/DeleteSessionUseCase.kt b/src/main/kotlin/com/weeth/domain/session/application/usecase/command/DeleteSessionUseCase.kt new file mode 100644 index 00000000..8a73084d --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/usecase/command/DeleteSessionUseCase.kt @@ -0,0 +1,156 @@ +package com.weeth.domain.session.application.usecase.command + +import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.attendance.domain.enums.AttendanceStatus +import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.session.application.exception.ClosedSessionIncludedException +import com.weeth.domain.session.application.exception.SessionErrorCode +import com.weeth.domain.session.application.exception.SessionGroupNotFoundException +import com.weeth.domain.session.application.exception.SessionNotFoundException +import com.weeth.domain.session.domain.entity.Session +import com.weeth.domain.session.domain.entity.SessionGroup +import com.weeth.domain.session.domain.enums.SessionStatus +import com.weeth.domain.session.domain.enums.UpdateScope +import com.weeth.domain.session.domain.repository.SessionGroupRepository +import com.weeth.domain.session.domain.repository.SessionRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class DeleteSessionUseCase( + private val sessionRepository: SessionRepository, + private val attendanceRepository: AttendanceRepository, + private val sessionGroupRepository: SessionGroupRepository, + private val clubPermissionPolicy: ClubPermissionPolicy, +) { + @Transactional + fun delete( + clubId: Long, + sessionId: Long, + userId: Long, + scope: UpdateScope = UpdateScope.THIS_ONLY, + force: Boolean = false, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + + val session = sessionRepository.findByIdWithLock(sessionId) ?: throw SessionNotFoundException() + if (session.club.id != clubId) throw SessionNotFoundException() + + // 단일 세션인 경우 + if (!session.isRecurring) { + deleteSingleSession(session) + return + } + + val group = checkNotNull(session.sessionGroup) { "반복 세션인데 그룹이 없습니다" } + + // 반복 세션인 경우 + when (scope) { + UpdateScope.THIS_ONLY -> { + deleteSingleSession(session) + val lockedGroup = sessionGroupRepository.findByIdWithLock(group.id) ?: return + deleteGroupIfEmpty(lockedGroup) + } + + UpdateScope.THIS_AND_FUTURE -> { + val futureSessions = + sessionRepository.findAllBySessionGroupAndStartGreaterThanEqualWithLock( + group, + session.start, + ) + + validateNoClosedSessions(futureSessions, force) + + val attendances = + attendanceRepository.findAllBySessionInAndClubMemberMemberStatusWithLock( + futureSessions, + MemberStatus.ACTIVE, + ) + + rollbackAttendances(attendances) + attendanceRepository.deleteAllBySessionIn(futureSessions) + sessionRepository.deleteAll(futureSessions) + + val lockedGroup = sessionGroupRepository.findByIdWithLock(group.id) ?: return + deleteGroupIfEmpty(lockedGroup) + } + } + } + + private fun validateNoClosedSessions( + futureSessions: List, + force: Boolean, + ) { + if (!force) { + val closedCount = futureSessions.count { it.status == SessionStatus.CLOSED } + if (closedCount > 0) { + throw ClosedSessionIncludedException( + SessionErrorCode.CLOSED_SESSION_INCLUDED_IN_DELETE, + closedCount, + ) + } + } + } + + @Transactional + fun deleteGroup( + clubId: Long, + groupId: Long, + userId: Long, + force: Boolean = false, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + + val group = + sessionGroupRepository.findById(groupId).orElseThrow { SessionGroupNotFoundException() } + val sessions = sessionRepository.findAllBySessionGroupWithLock(group) + + if (sessions.isEmpty()) { + sessionGroupRepository.delete(group) + return + } + + if (sessions.first().club.id != clubId) throw SessionGroupNotFoundException() + + validateNoClosedSessions(sessions, force) + + val attendances = + attendanceRepository.findAllBySessionInAndClubMemberMemberStatusWithLock( + sessions, + MemberStatus.ACTIVE, + ) + + rollbackAttendances(attendances) + attendanceRepository.deleteAllBySessionIn(sessions) + sessionRepository.deleteAll(sessions) + sessionGroupRepository.delete(group) + } + + private fun deleteSingleSession(session: Session) { + val attendances = + attendanceRepository.findAllBySessionAndClubMemberMemberStatusWithLock(session, MemberStatus.ACTIVE) + rollbackAttendances(attendances) + + attendanceRepository.deleteAllBySession(session) + sessionRepository.delete(session) + } + + private fun rollbackAttendances(attendances: List) { + attendances.forEach { a -> + when (a.status) { + AttendanceStatus.ATTEND -> a.clubMember.removeAttend() + AttendanceStatus.ABSENT -> a.clubMember.removeAbsent() + else -> Unit + } + } + } + + private fun deleteGroupIfEmpty(group: SessionGroup) { + val remainingCount = sessionRepository.countBySessionGroup(group) + if (remainingCount == 0L) { + sessionGroupRepository.delete(group) + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/session/application/usecase/command/ManageSessionUseCase.kt b/src/main/kotlin/com/weeth/domain/session/application/usecase/command/ManageSessionUseCase.kt deleted file mode 100644 index e883cb47..00000000 --- a/src/main/kotlin/com/weeth/domain/session/application/usecase/command/ManageSessionUseCase.kt +++ /dev/null @@ -1,98 +0,0 @@ -package com.weeth.domain.session.application.usecase.command - -import com.weeth.domain.attendance.domain.entity.Attendance -import com.weeth.domain.attendance.domain.enums.AttendanceStatus -import com.weeth.domain.attendance.domain.repository.AttendanceRepository -import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException -import com.weeth.domain.cardinal.domain.repository.CardinalReader -import com.weeth.domain.club.domain.enums.MemberStatus -import com.weeth.domain.club.domain.repository.ClubMemberCardinalReader -import com.weeth.domain.club.domain.repository.ClubReader -import com.weeth.domain.club.domain.service.ClubPermissionPolicy -import com.weeth.domain.schedule.application.dto.request.ScheduleSaveRequest -import com.weeth.domain.schedule.application.dto.request.ScheduleUpdateRequest -import com.weeth.domain.schedule.application.mapper.SessionMapper -import com.weeth.domain.session.application.exception.SessionNotFoundException -import com.weeth.domain.session.domain.repository.SessionRepository -import com.weeth.domain.user.domain.repository.UserReader -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional - -@Service -class ManageSessionUseCase( - private val sessionRepository: SessionRepository, - private val attendanceRepository: AttendanceRepository, - private val userReader: UserReader, - private val cardinalReader: CardinalReader, - private val sessionMapper: SessionMapper, - private val clubReader: ClubReader, - private val clubMemberCardinalReader: ClubMemberCardinalReader, - private val clubPermissionPolicy: ClubPermissionPolicy, -) { - @Transactional - fun create( - clubId: Long, - request: ScheduleSaveRequest, - userId: Long, - ) { - clubPermissionPolicy.requireAdmin(clubId, userId) - val club = clubReader.getClubById(clubId) - val user = userReader.getById(userId) - cardinalReader.findByClubIdAndCardinalNumber(clubId, request.cardinal) ?: throw CardinalNotFoundException() - val membersWithCardinal = - clubMemberCardinalReader - .findAllByClubIdAndCardinalNumber(clubId, request.cardinal, MemberStatus.ACTIVE) - .map { it.clubMember } - - val session = sessionMapper.toEntity(club, request, user) - sessionRepository.save(session) - - attendanceRepository.saveAll(membersWithCardinal.map { Attendance.create(session, it) }) - } - - @Transactional - fun update( - clubId: Long, - sessionId: Long, - request: ScheduleUpdateRequest, - userId: Long, - ) { - clubPermissionPolicy.requireAdmin(clubId, userId) - val session = sessionRepository.findByIdWithLock(sessionId) ?: throw SessionNotFoundException() - if (session.club.id != clubId) throw SessionNotFoundException() - val user = userReader.getById(userId) - - session.updateInfo(request.title, request.content, request.location, request.start, request.end, user) - } - - @Transactional - fun delete( - clubId: Long, - sessionId: Long, - userId: Long, - ) { - clubPermissionPolicy.requireAdmin(clubId, userId) - val session = sessionRepository.findByIdWithLock(sessionId) ?: throw SessionNotFoundException() - if (session.club.id != clubId) throw SessionNotFoundException() - val attendances = - attendanceRepository.findAllBySessionAndClubMemberMemberStatusWithLock( - session, - MemberStatus.ACTIVE, - ) - - attendances.forEach { a -> - when (a.status) { - AttendanceStatus.ATTEND -> a.clubMember.removeAttend() - - // 출석률 재계산은 내부에 - AttendanceStatus.ABSENT -> a.clubMember.removeAbsent() - - // 출석률 재계산은 내부에 - else -> Unit - } - } - - attendanceRepository.deleteAllBySession(session) - sessionRepository.delete(session) - } -} diff --git a/src/main/kotlin/com/weeth/domain/session/application/usecase/command/UpdateSessionUseCase.kt b/src/main/kotlin/com/weeth/domain/session/application/usecase/command/UpdateSessionUseCase.kt new file mode 100644 index 00000000..00698c5a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/application/usecase/command/UpdateSessionUseCase.kt @@ -0,0 +1,114 @@ +package com.weeth.domain.session.application.usecase.command + +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.session.application.dto.request.SessionUpdateRequest +import com.weeth.domain.session.application.exception.ClosedSessionIncludedException +import com.weeth.domain.session.application.exception.EndBeforeStartException +import com.weeth.domain.session.application.exception.SessionErrorCode +import com.weeth.domain.session.application.exception.SessionNotFoundException +import com.weeth.domain.session.domain.entity.Session +import com.weeth.domain.session.domain.enums.SessionStatus +import com.weeth.domain.session.domain.enums.UpdateScope +import com.weeth.domain.session.domain.repository.SessionRepository +import com.weeth.domain.session.domain.service.RecurringSessionPolicy +import com.weeth.domain.user.domain.entity.User +import com.weeth.domain.user.domain.repository.UserReader +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +class UpdateSessionUseCase( + private val sessionRepository: SessionRepository, + private val userReader: UserReader, + private val clubPermissionPolicy: ClubPermissionPolicy, + private val recurringSessionPolicy: RecurringSessionPolicy, +) { + @Transactional + fun update( + clubId: Long, + sessionId: Long, + request: SessionUpdateRequest, + userId: Long, + scope: UpdateScope = UpdateScope.THIS_ONLY, + force: Boolean = false, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + + val session = sessionRepository.findByIdWithLock(sessionId) ?: throw SessionNotFoundException() + if (session.club.id != clubId) throw SessionNotFoundException() + val user = userReader.getById(userId) + + val effectiveStart = request.start ?: session.start + val effectiveEnd = request.end ?: session.end + if (effectiveEnd.isBefore(effectiveStart)) throw EndBeforeStartException() + + if (!session.isRecurring || scope == UpdateScope.THIS_ONLY) { + updateSingleSession(session, request, effectiveStart, effectiveEnd, user) + } else { + updateRecurringSessions(session, request, effectiveStart, effectiveEnd, user, force) + } + } + + private fun updateSingleSession( + session: Session, + request: SessionUpdateRequest, + effectiveStart: LocalDateTime, + effectiveEnd: LocalDateTime, + user: User, + ) { + session.updateInfo( + title = request.title ?: session.title, + content = request.content ?: session.content, + location = request.location ?: session.location, + start = effectiveStart, + end = effectiveEnd, + user = user, + ) + } + + /** + * 반복 세션을 수정한다. + * 반복 수정을 하는 경우 세션/출석의 상태를 유지하기 위해 별도로 세션을 삭제/재생성 하지 않고, in-place로 갱신한다. + * 이 경우 반복 세션 중 특정 세션 이후의 시간을 미루는 경우 이전 날짜의 세션이 남아있을 수 있으나, 이는 사용자가 삭제할 수 있도록 유지한다. + */ + private fun updateRecurringSessions( + session: Session, + request: SessionUpdateRequest, + effectiveStart: LocalDateTime, + effectiveEnd: LocalDateTime, + user: User, + force: Boolean, + ) { + val group = checkNotNull(session.sessionGroup) { "반복 세션인데 그룹이 없습니다" } + val futureSessions = + sessionRepository.findAllBySessionGroupAndStartGreaterThanEqualWithLock(group, session.start) + + if (!force) { + val closedCount = futureSessions.count { it.status == SessionStatus.CLOSED } + if (closedCount > 0) { + throw ClosedSessionIncludedException(SessionErrorCode.CLOSED_SESSION_INCLUDED_IN_UPDATE, closedCount) + } + } + + val effectiveTitle = request.title ?: session.title + + futureSessions.forEach { s -> + val (start, end) = recurringSessionPolicy.adjustTime(s.start, effectiveStart, effectiveEnd) + s.updateInfo( + title = effectiveTitle, + content = request.content ?: s.content, + location = request.location ?: s.location, + start = start, + end = end, + user = user, + ) + } + + group.updateMetadata( + title = effectiveTitle, + startTime = effectiveStart.toLocalTime(), + endTime = effectiveEnd.toLocalTime(), + ) + } +} diff --git a/src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt b/src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt index b72ffde8..b021463b 100644 --- a/src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryService.kt @@ -4,10 +4,11 @@ import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException import com.weeth.domain.cardinal.domain.repository.CardinalReader import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.club.domain.service.ClubPermissionPolicy -import com.weeth.domain.schedule.application.dto.response.SessionInfosResponse -import com.weeth.domain.schedule.application.dto.response.SessionResponse -import com.weeth.domain.schedule.application.mapper.SessionMapper +import com.weeth.domain.session.application.dto.response.SessionGroupResponse +import com.weeth.domain.session.application.dto.response.SessionInfosResponse +import com.weeth.domain.session.application.dto.response.SessionResponse import com.weeth.domain.session.application.exception.SessionNotFoundException +import com.weeth.domain.session.application.mapper.SessionMapper import com.weeth.domain.session.domain.entity.Session import com.weeth.domain.session.domain.repository.SessionRepository import org.springframework.stereotype.Service @@ -58,15 +59,37 @@ class GetSessionQueryService( } val thisWeek = findThisWeek(sessions) - return sessionMapper.toInfos(thisWeek, sessions) + val groupedResponses = buildGroupResponses(sessions) + + return sessionMapper.toInfos(thisWeek, groupedResponses) + } + + private fun buildGroupResponses(sessions: List): List { + // 반복 세션은 그룹별로 묶고, 비반복 세션은 개별로 처리 + val groupResponses = + sessions + .filter { it.isRecurring } + .groupBy { checkNotNull(it.sessionGroup).id } + .map { (_, groupSessions) -> + val group = checkNotNull(groupSessions.first().sessionGroup) + sessionMapper.toGroupResponse(group, groupSessions) + } + + val singleResponses = + sessions + .filter { !it.isRecurring } + .map { sessionMapper.toSingleGroupResponse(it) } + + // 시작일 기준 내림차순 정렬 + return (groupResponses + singleResponses).sortedByDescending { it.startDate } } - private fun findThisWeek(sessions: List): Session? { + private fun findThisWeek(sessions: List): List { val today = LocalDate.now() val startOfWeek = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) val endOfWeek = today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)) - return sessions.firstOrNull { s -> + return sessions.filter { s -> val d = s.start.toLocalDate() !d.isBefore(startOfWeek) && !d.isAfter(endOfWeek) } diff --git a/src/main/kotlin/com/weeth/domain/session/domain/entity/Session.kt b/src/main/kotlin/com/weeth/domain/session/domain/entity/Session.kt index 64569335..4df5bfd0 100644 --- a/src/main/kotlin/com/weeth/domain/session/domain/entity/Session.kt +++ b/src/main/kotlin/com/weeth/domain/session/domain/entity/Session.kt @@ -15,12 +15,21 @@ import jakarta.persistence.Id import jakarta.persistence.JoinColumn import jakarta.persistence.ManyToOne import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint import java.security.SecureRandom import java.time.LocalDateTime import kotlin.random.asKotlinRandom @Entity -@Table(name = "session") +@Table( + name = "session", + uniqueConstraints = [ + UniqueConstraint( + name = "uk_session_group_start", + columnNames = ["session_group_id", "start"], + ), + ], +) class Session( club: Club, title: String, @@ -32,6 +41,7 @@ class Session( code: Int, status: SessionStatus = SessionStatus.OPEN, user: User? = null, + sessionGroup: SessionGroup? = null, ) : BaseEntity() { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "club_id", nullable = false) @@ -74,6 +84,14 @@ class Session( var user: User? = user private set + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "session_group_id") + var sessionGroup: SessionGroup? = sessionGroup + private set + + val isRecurring: Boolean + get() = sessionGroup != null + fun close() { check(status == SessionStatus.OPEN) { "이미 종료된 세션입니다" } status = SessionStatus.CLOSED @@ -119,6 +137,7 @@ class Session( start: LocalDateTime, end: LocalDateTime, user: User?, + sessionGroup: SessionGroup? = null, ): Session { require(title.isNotBlank()) { "제목은 필수입니다" } require(!end.isBefore(start)) { "종료 시간은 시작 시간 이후여야 합니다" } @@ -132,6 +151,7 @@ class Session( end = end, code = generateCode(), user = user, + sessionGroup = sessionGroup, ) } diff --git a/src/main/kotlin/com/weeth/domain/session/domain/entity/SessionGroup.kt b/src/main/kotlin/com/weeth/domain/session/domain/entity/SessionGroup.kt new file mode 100644 index 00000000..33c63dd0 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/domain/entity/SessionGroup.kt @@ -0,0 +1,62 @@ +package com.weeth.domain.session.domain.entity + +import com.weeth.domain.session.domain.enums.RecurrenceType +import com.weeth.global.common.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.time.LocalDate +import java.time.LocalTime + +@Entity +@Table(name = "session_group") +class SessionGroup( + title: String, + recurrenceType: RecurrenceType, + recurrenceEndDate: LocalDate, + cardinal: Int, + startTime: LocalTime, + endTime: LocalTime, +) : BaseEntity() { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long = 0L + private set + + var title: String = title + private set + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + var recurrenceType: RecurrenceType = recurrenceType + private set + + var recurrenceEndDate: LocalDate = recurrenceEndDate + private set + + var cardinal: Int = cardinal + private set + + // 반복 기준 시작 시각 + var startTime: LocalTime = startTime + private set + + // 반복 기준 종료 시각 + var endTime: LocalTime = endTime + private set + + fun updateMetadata( + title: String, + startTime: LocalTime, + endTime: LocalTime, + ) { + this.title = title + this.startTime = startTime + this.endTime = endTime + } +} diff --git a/src/main/kotlin/com/weeth/domain/session/domain/enums/RecurrenceType.kt b/src/main/kotlin/com/weeth/domain/session/domain/enums/RecurrenceType.kt new file mode 100644 index 00000000..211ce908 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/domain/enums/RecurrenceType.kt @@ -0,0 +1,7 @@ +package com.weeth.domain.session.domain.enums + +enum class RecurrenceType { + DAILY, + WEEKLY, + MONTHLY, +} diff --git a/src/main/kotlin/com/weeth/domain/session/domain/enums/SessionGroupStatus.kt b/src/main/kotlin/com/weeth/domain/session/domain/enums/SessionGroupStatus.kt new file mode 100644 index 00000000..6a195079 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/domain/enums/SessionGroupStatus.kt @@ -0,0 +1,6 @@ +package com.weeth.domain.session.domain.enums + +enum class SessionGroupStatus { + COMPLETED, // 전체 종료 + IN_PROGRESS, // 진행 중 (미완료 세션 존재) +} diff --git a/src/main/kotlin/com/weeth/domain/session/domain/enums/UpdateScope.kt b/src/main/kotlin/com/weeth/domain/session/domain/enums/UpdateScope.kt new file mode 100644 index 00000000..5d99ee17 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/domain/enums/UpdateScope.kt @@ -0,0 +1,6 @@ +package com.weeth.domain.session.domain.enums + +enum class UpdateScope { + THIS_ONLY, // 해당 세션만 + THIS_AND_FUTURE, // 해당 세션을 포함한 이후 세션 전체 +} diff --git a/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionGroupRepository.kt b/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionGroupRepository.kt new file mode 100644 index 00000000..30bc6dc7 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionGroupRepository.kt @@ -0,0 +1,19 @@ +package com.weeth.domain.session.domain.repository + +import com.weeth.domain.session.domain.entity.SessionGroup +import jakarta.persistence.LockModeType +import jakarta.persistence.QueryHint +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Lock +import org.springframework.data.jpa.repository.Query +import org.springframework.data.jpa.repository.QueryHints +import org.springframework.data.repository.query.Param + +interface SessionGroupRepository : JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query("SELECT sg FROM SessionGroup sg WHERE sg.id = :id") + fun findByIdWithLock( + @Param("id") id: Long, + ): SessionGroup? +} diff --git a/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionRepository.kt b/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionRepository.kt index 91b0664c..3f2c21a1 100644 --- a/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionRepository.kt +++ b/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionRepository.kt @@ -2,6 +2,7 @@ package com.weeth.domain.session.domain.repository import com.weeth.domain.session.application.exception.SessionNotFoundException import com.weeth.domain.session.domain.entity.Session +import com.weeth.domain.session.domain.entity.SessionGroup import com.weeth.domain.session.domain.enums.SessionStatus import jakarta.persistence.LockModeType import jakarta.persistence.QueryHint @@ -25,11 +26,17 @@ interface SessionRepository : clubId: Long, ): Session? - fun findAllByClubIdOrderByStartDesc(clubId: Long): List + @Query("SELECT s FROM Session s LEFT JOIN FETCH s.sessionGroup WHERE s.club.id = :clubId ORDER BY s.start DESC") + fun findAllByClubIdOrderByStartDesc( + @Param("clubId") clubId: Long, + ): List + @Query( + "SELECT s FROM Session s LEFT JOIN FETCH s.sessionGroup WHERE s.club.id = :clubId AND s.cardinal = :cardinal ORDER BY s.start DESC", + ) fun findAllByClubIdAndCardinalOrderByStartDesc( - clubId: Long, - cardinal: Int, + @Param("clubId") clubId: Long, + @Param("cardinal") cardinal: Int, ): List override fun findAllByCardinalOrderByStartAsc(cardinal: Int): List @@ -64,4 +71,25 @@ interface SessionRepository : clubId: Long, cardinals: List, ): List + + // 기준 시작시각 이후 세션 조회 + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query( + "SELECT s FROM Session s WHERE s.sessionGroup = :group AND s.start >= :start ORDER BY s.start ASC, s.id ASC", + ) + fun findAllBySessionGroupAndStartGreaterThanEqualWithLock( + @Param("group") group: SessionGroup, + @Param("start") start: LocalDateTime, + ): List + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query("SELECT s FROM Session s WHERE s.sessionGroup = :group ORDER BY s.start ASC, s.id ASC") + fun findAllBySessionGroupWithLock( + @Param("group") group: SessionGroup, + ): List + + // 세션 그룹의 남은 세션 수 조회 (삭제 후 빈 그룹 정리용) + fun countBySessionGroup(group: SessionGroup): Long } diff --git a/src/main/kotlin/com/weeth/domain/session/domain/service/RecurringSessionPolicy.kt b/src/main/kotlin/com/weeth/domain/session/domain/service/RecurringSessionPolicy.kt new file mode 100644 index 00000000..c55747c3 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/session/domain/service/RecurringSessionPolicy.kt @@ -0,0 +1,98 @@ +package com.weeth.domain.session.domain.service + +import com.weeth.domain.session.domain.enums.RecurrenceType +import org.springframework.stereotype.Service +import java.time.Duration +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.format.DateTimeFormatter + +@Service +class RecurringSessionPolicy { + fun adjustTime( + originalStart: LocalDateTime, + newStart: LocalDateTime, + newEnd: LocalDateTime, + ): Pair { + val startTime = newStart.toLocalTime() + val duration = Duration.between(newStart, newEnd) + val start = LocalDateTime.of(originalStart.toLocalDate(), startTime) + + return start to start.plus(duration) + } + + /** + * 반복 세션의 시작/종료 시각 쌍을 계산한다. + */ + fun calculateSchedules( + startDateTime: LocalDateTime, + endDateTime: LocalDateTime, + recurrenceType: RecurrenceType, + recurrenceEndDate: LocalDate, + ): List> { + val startDate = startDateTime.toLocalDate() + val schedules = mutableListOf>() + var index = 0 + + while (true) { + val currentDate = + when (recurrenceType) { + // startDate.plusMonths(n) 방식으로 1/31 → 2/28 → 3/31 대응 + RecurrenceType.MONTHLY -> startDate.plusMonths(index.toLong()) + + RecurrenceType.WEEKLY -> startDate.plusWeeks(index.toLong()) + + RecurrenceType.DAILY -> startDate.plusDays(index.toLong()) + } + if (currentDate.isAfter(recurrenceEndDate)) break + + val base = LocalDateTime.of(currentDate, startDateTime.toLocalTime()) + val (start, end) = adjustTime(base, startDateTime, endDateTime) + schedules.add(start to end) + index++ + } + + return schedules + } + + /** + * 반복 유형과 기준 날짜로 사람이 읽을 수 있는 설명 문자열을 생성한다. + * ex) "매일 14시", "매주 수요일 14시", "매월 15일 14시" + */ + fun buildRecurrenceDescription( + recurrenceType: RecurrenceType, + startTime: LocalTime, + baseDate: LocalDate, + ): String { + val timeStr = + if (startTime.minute == 0) { + startTime.format(DateTimeFormatter.ofPattern("H시")) + } else { + startTime.format(DateTimeFormatter.ofPattern("H시 m분")) + } + return when (recurrenceType) { + RecurrenceType.DAILY -> { + "매일 $timeStr" + } + + RecurrenceType.WEEKLY -> { + val dayOfWeek = + when (baseDate.dayOfWeek.value) { + 1 -> "월요일" + 2 -> "화요일" + 3 -> "수요일" + 4 -> "목요일" + 5 -> "금요일" + 6 -> "토요일" + else -> "일요일" + } + "매주 $dayOfWeek $timeStr" + } + + RecurrenceType.MONTHLY -> { + "매월 ${baseDate.dayOfMonth}일 $timeStr" + } + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/session/presentation/SessionAdminController.kt b/src/main/kotlin/com/weeth/domain/session/presentation/SessionAdminController.kt index 60f378c3..12875256 100644 --- a/src/main/kotlin/com/weeth/domain/session/presentation/SessionAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/session/presentation/SessionAdminController.kt @@ -1,11 +1,14 @@ package com.weeth.domain.session.presentation -import com.weeth.domain.schedule.application.dto.request.ScheduleSaveRequest -import com.weeth.domain.schedule.application.dto.request.ScheduleUpdateRequest -import com.weeth.domain.schedule.application.dto.response.SessionInfosResponse +import com.weeth.domain.session.application.dto.request.SessionCreateRequest +import com.weeth.domain.session.application.dto.request.SessionUpdateRequest +import com.weeth.domain.session.application.dto.response.SessionInfosResponse import com.weeth.domain.session.application.exception.SessionErrorCode -import com.weeth.domain.session.application.usecase.command.ManageSessionUseCase +import com.weeth.domain.session.application.usecase.command.CreateSessionUseCase +import com.weeth.domain.session.application.usecase.command.DeleteSessionUseCase +import com.weeth.domain.session.application.usecase.command.UpdateSessionUseCase import com.weeth.domain.session.application.usecase.query.GetSessionQueryService +import com.weeth.domain.session.domain.enums.UpdateScope import com.weeth.global.auth.annotation.CurrentUser import com.weeth.global.common.exception.ApiErrorCodeExample import com.weeth.global.common.response.CommonResponse @@ -30,48 +33,76 @@ import org.springframework.web.bind.annotation.RestController @RequestMapping("/api/v4/admin/clubs/{clubId}/sessions") @ApiErrorCodeExample(SessionErrorCode::class) class SessionAdminController( - private val manageSessionUseCase: ManageSessionUseCase, + private val createSessionUseCase: CreateSessionUseCase, + private val updateSessionUseCase: UpdateSessionUseCase, + private val deleteSessionUseCase: DeleteSessionUseCase, private val getSessionQueryService: GetSessionQueryService, ) { @PostMapping - @Operation(summary = "정기모임 생성") + @Operation(summary = "정기모임 생성 (반복 지원)") fun create( @TsidParam @TsidPathVariable clubId: Long, - @Valid @RequestBody dto: ScheduleSaveRequest, + @Valid @RequestBody dto: SessionCreateRequest, @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse { - manageSessionUseCase.create(clubId, dto, userId) + createSessionUseCase.create(clubId, dto, userId) return CommonResponse.success(SessionResponseCode.SESSION_SAVE_SUCCESS) } @PatchMapping("/{sessionId}") - @Operation(summary = "정기모임 수정") + @Operation( + summary = "정기모임 수정", + description = "scope=THIS_AND_FUTURE 시 이후 전체 세션 수정. CLOSED 세션 포함 시 force=true로 재요청 필요", + ) fun update( @TsidParam @TsidPathVariable clubId: Long, @PathVariable sessionId: Long, - @Valid @RequestBody dto: ScheduleUpdateRequest, + @Valid @RequestBody dto: SessionUpdateRequest, + @RequestParam(defaultValue = "THIS_ONLY") scope: UpdateScope, + @RequestParam(defaultValue = "false") force: Boolean, @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse { - manageSessionUseCase.update(clubId, sessionId, dto, userId) + updateSessionUseCase.update(clubId, sessionId, dto, userId, scope, force) return CommonResponse.success(SessionResponseCode.SESSION_UPDATE_SUCCESS) } @DeleteMapping("/{sessionId}") - @Operation(summary = "정기모임 삭제") + @Operation( + summary = "정기모임 삭제", + description = "scope=THIS_AND_FUTURE 시 이후 전체 세션 삭제. CLOSED 세션 포함 시 force=true로 재요청 필요", + ) fun delete( @TsidParam @TsidPathVariable clubId: Long, @PathVariable sessionId: Long, + @RequestParam(defaultValue = "THIS_ONLY") scope: UpdateScope, + @RequestParam(defaultValue = "false") force: Boolean, @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse { - manageSessionUseCase.delete(clubId, sessionId, userId) + deleteSessionUseCase.delete(clubId, sessionId, userId, scope, force) + return CommonResponse.success(SessionResponseCode.SESSION_DELETE_SUCCESS) + } + + @DeleteMapping("/groups/{groupId}") + @Operation( + summary = "세션 그룹 전체 삭제", + description = "반복 세션 그룹과 소속 세션을 모두 삭제. CLOSED 세션 포함 시 force=true로 재요청 필요", + ) + fun deleteGroup( + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable groupId: Long, + @RequestParam(defaultValue = "false") force: Boolean, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + deleteSessionUseCase.deleteGroup(clubId, groupId, userId, force) return CommonResponse.success(SessionResponseCode.SESSION_DELETE_SUCCESS) } @GetMapping - @Operation(summary = "정기모임 목록 조회") + @Operation(summary = "정기모임 목록 조회 (반복 그룹 단위)") fun getSessionInfos( @TsidParam @TsidPathVariable clubId: Long, diff --git a/src/main/kotlin/com/weeth/domain/session/presentation/SessionController.kt b/src/main/kotlin/com/weeth/domain/session/presentation/SessionController.kt index 1b912e6f..42d8cbcf 100644 --- a/src/main/kotlin/com/weeth/domain/session/presentation/SessionController.kt +++ b/src/main/kotlin/com/weeth/domain/session/presentation/SessionController.kt @@ -1,6 +1,6 @@ package com.weeth.domain.session.presentation -import com.weeth.domain.schedule.application.dto.response.SessionResponse +import com.weeth.domain.session.application.dto.response.SessionResponse import com.weeth.domain.session.application.exception.SessionErrorCode import com.weeth.domain.session.application.usecase.query.GetSessionQueryService import com.weeth.global.auth.annotation.CurrentUser diff --git a/src/main/kotlin/com/weeth/global/common/exception/BaseException.kt b/src/main/kotlin/com/weeth/global/common/exception/BaseException.kt index 300d8b64..db519072 100644 --- a/src/main/kotlin/com/weeth/global/common/exception/BaseException.kt +++ b/src/main/kotlin/com/weeth/global/common/exception/BaseException.kt @@ -3,6 +3,7 @@ package com.weeth.global.common.exception abstract class BaseException( val errorCode: ErrorCodeInterface, message: String? = null, + val data: Any? = null, ) : RuntimeException(message ?: errorCode.message) { val statusCode: Int get() = errorCode.status.value() } diff --git a/src/main/kotlin/com/weeth/global/common/exception/CommonExceptionHandler.kt b/src/main/kotlin/com/weeth/global/common/exception/CommonExceptionHandler.kt index 0e658236..ed89b2df 100644 --- a/src/main/kotlin/com/weeth/global/common/exception/CommonExceptionHandler.kt +++ b/src/main/kotlin/com/weeth/global/common/exception/CommonExceptionHandler.kt @@ -14,11 +14,16 @@ class CommonExceptionHandler { private val log = LoggerFactory.getLogger(javaClass) @ExceptionHandler(BaseException::class) - fun handle(ex: BaseException): ResponseEntity> { + fun handle(ex: BaseException): ResponseEntity> { log.warn("예외 처리(BaseException)", ex) log.warn(LOG_FORMAT, ex::class.simpleName, ex.statusCode, ex.message) - val response = CommonResponse.error(ex.errorCode) + val response = + if (ex.data != null) { + CommonResponse.error(ex.errorCode, ex.data) + } else { + CommonResponse.error(ex.errorCode) + } return ResponseEntity .status(ex.statusCode) diff --git a/src/test/kotlin/com/weeth/domain/session/application/usecase/command/CreateSessionUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/session/application/usecase/command/CreateSessionUseCaseTest.kt new file mode 100644 index 00000000..5f32e485 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/session/application/usecase/command/CreateSessionUseCaseTest.kt @@ -0,0 +1,328 @@ +package com.weeth.domain.session.application.usecase.command + +import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException +import com.weeth.domain.cardinal.domain.repository.CardinalReader +import com.weeth.domain.cardinal.fixture.CardinalTestFixture +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.repository.ClubMemberCardinalReader +import com.weeth.domain.club.domain.repository.ClubReader +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.club.fixture.ClubMemberTestFixture +import com.weeth.domain.club.fixture.ClubTestFixture +import com.weeth.domain.session.application.dto.request.SessionCreateRequest +import com.weeth.domain.session.application.exception.RecurrenceEndDateBeforeStartException +import com.weeth.domain.session.application.exception.RecurrenceEndDateExceedsMaxException +import com.weeth.domain.session.application.exception.RecurrenceEndDateRequiredException +import com.weeth.domain.session.application.mapper.SessionMapper +import com.weeth.domain.session.domain.entity.Session +import com.weeth.domain.session.domain.entity.SessionGroup +import com.weeth.domain.session.domain.enums.RecurrenceType +import com.weeth.domain.session.domain.repository.SessionGroupRepository +import com.weeth.domain.session.domain.repository.SessionRepository +import com.weeth.domain.session.domain.service.RecurringSessionPolicy +import com.weeth.domain.session.fixture.SessionTestFixture +import com.weeth.domain.user.domain.repository.UserReader +import com.weeth.domain.user.fixture.UserTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.Runs +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import java.time.LocalDate +import java.time.LocalDateTime + +class CreateSessionUseCaseTest : + DescribeSpec({ + val sessionRepository = mockk() + val attendanceRepository = mockk() + val sessionGroupRepository = mockk() + val userReader = mockk() + val cardinalReader = mockk() + val clubReader = mockk() + val clubMemberCardinalReader = mockk() + val clubPermissionPolicy = mockk(relaxed = true) + val recurringSessionPolicy = RecurringSessionPolicy() + val sessionMapper = SessionMapper(recurringSessionPolicy) + + val useCase = + CreateSessionUseCase( + sessionRepository = sessionRepository, + attendanceRepository = attendanceRepository, + sessionGroupRepository = sessionGroupRepository, + userReader = userReader, + cardinalReader = cardinalReader, + sessionMapper = sessionMapper, + clubReader = clubReader, + clubMemberCardinalReader = clubMemberCardinalReader, + clubPermissionPolicy = clubPermissionPolicy, + recurringSessionPolicy = recurringSessionPolicy, + ) + + val clubId = 1L + val userId = 10L + val club = ClubTestFixture.createClub(id = clubId) + val user = UserTestFixture.createActiveUser1() + val cardinal = CardinalTestFixture.createCardinal(cardinalNumber = 1, club = club) + + beforeTest { + clearMocks( + sessionRepository, + attendanceRepository, + sessionGroupRepository, + userReader, + cardinalReader, + clubReader, + clubMemberCardinalReader, + clubPermissionPolicy, + ) + every { clubReader.getClubById(clubId) } returns club + every { userReader.getById(userId) } returns user + every { cardinalReader.findByClubIdAndCardinalNumber(clubId, 1) } returns cardinal + every { clubMemberCardinalReader.findAllByClubIdAndCardinalNumber(clubId, 1, MemberStatus.ACTIVE) } returns + emptyList() + every { sessionRepository.save(any()) } answers { firstArg() } + every { sessionRepository.saveAll(any>()) } answers { firstArg() } + every { sessionGroupRepository.save(any()) } answers { firstArg() } + every { attendanceRepository.saveAll(any>()) } answers { firstArg() } + } + + describe("create") { + context("단일 세션 생성 (recurrenceType = null)") { + it("세션 1개와 출석 레코드를 생성한다") { + val request = + SessionCreateRequest( + title = "1차 정기모임", + content = "OT", + location = "공학관 401호", + cardinal = 1, + start = LocalDateTime.of(2026, 4, 1, 10, 0), + end = LocalDateTime.of(2026, 4, 1, 12, 0), + recurrenceType = null, + recurrenceEndDate = null, + ) + + useCase.create(clubId, request, userId) + + verify(exactly = 1) { sessionRepository.save(any()) } + verify(exactly = 0) { sessionGroupRepository.save(any()) } + verify(exactly = 0) { sessionRepository.saveAll(any>()) } + } + } + + context("반복 세션 생성 (WEEKLY)") { + it("주간 반복 세션들이 올바르게 생성된다") { + val request = + SessionCreateRequest( + title = "주간 스터디", + content = null, + location = null, + cardinal = 1, + start = LocalDateTime.of(2026, 4, 1, 14, 0), // 수요일 + end = LocalDateTime.of(2026, 4, 1, 16, 0), + recurrenceType = RecurrenceType.WEEKLY, + recurrenceEndDate = LocalDate.of(2026, 4, 22), // 4주차 수요일 + ) + val sessionsSlot = slot>() + + every { sessionRepository.saveAll(capture(sessionsSlot)) } answers { firstArg() } + + useCase.create(clubId, request, userId) + + verify(exactly = 1) { sessionGroupRepository.save(any()) } + sessionsSlot.captured.size shouldBe 4 + } + + it("멤버가 있으면 세션 수 × 멤버 수 만큼 출석 레코드가 생성된다") { + val member = ClubMemberTestFixture.createActiveMember(club = club) + val memberCardinal = mockk(relaxed = true) + every { memberCardinal.clubMember } returns member + every { + clubMemberCardinalReader.findAllByClubIdAndCardinalNumber(clubId, 1, MemberStatus.ACTIVE) + } returns listOf(memberCardinal) + + val request = + SessionCreateRequest( + title = "주간 스터디", + content = null, + location = null, + cardinal = 1, + start = LocalDateTime.of(2026, 4, 1, 14, 0), + end = LocalDateTime.of(2026, 4, 1, 16, 0), + recurrenceType = RecurrenceType.WEEKLY, + recurrenceEndDate = LocalDate.of(2026, 4, 15), // 3주 + ) + val attendancesSlot = slot>() + + every { attendanceRepository.saveAll(capture(attendancesSlot)) } answers { firstArg() } + + useCase.create(clubId, request, userId) + + // 3주 × 1명 = 3개 출석 레코드 + attendancesSlot.captured.size shouldBe 3 + } + } + + context("반복 세션 생성 (MONTHLY)") { + it("월간 반복 세션들이 올바르게 생성된다") { + val request = + SessionCreateRequest( + title = "월례 회의", + content = null, + location = null, + cardinal = 1, + start = LocalDateTime.of(2026, 1, 31, 10, 0), + end = LocalDateTime.of(2026, 1, 31, 12, 0), + recurrenceType = RecurrenceType.MONTHLY, + recurrenceEndDate = LocalDate.of(2026, 4, 30), + ) + val sessionsSlot = slot>() + + every { sessionRepository.saveAll(capture(sessionsSlot)) } answers { firstArg() } + + useCase.create(clubId, request, userId) + + val sessions = sessionsSlot.captured + sessions.size shouldBe 4 + + sessions[0].start.toLocalDate() shouldBe LocalDate.of(2026, 1, 31) + sessions[1].start.toLocalDate() shouldBe LocalDate.of(2026, 2, 28) + sessions[2].start.toLocalDate() shouldBe LocalDate.of(2026, 3, 31) + sessions[3].start.toLocalDate() shouldBe LocalDate.of(2026, 4, 30) + } + } + + context("자정을 넘는 반복 세션 (22:00~02:00)") { + it("end 날짜가 start 다음날로 설정된다") { + val request = + SessionCreateRequest( + title = "야간 스터디", + content = null, + location = null, + cardinal = 1, + start = LocalDateTime.of(2026, 4, 1, 22, 0), + end = LocalDateTime.of(2026, 4, 2, 2, 0), // 다음날 새벽 2시 + recurrenceType = RecurrenceType.WEEKLY, + recurrenceEndDate = LocalDate.of(2026, 4, 15), + ) + val sessionsSlot = slot>() + + every { sessionRepository.saveAll(capture(sessionsSlot)) } answers { firstArg() } + + useCase.create(clubId, request, userId) + + val sessions = sessionsSlot.captured + sessions.size shouldBe 3 + + // 각 세션의 start는 해당 날짜 22시, end는 다음날 02시 + sessions[0].start shouldBe LocalDateTime.of(2026, 4, 1, 22, 0) + sessions[0].end shouldBe LocalDateTime.of(2026, 4, 2, 2, 0) + sessions[1].start shouldBe LocalDateTime.of(2026, 4, 8, 22, 0) + sessions[1].end shouldBe LocalDateTime.of(2026, 4, 9, 2, 0) + sessions[2].start shouldBe LocalDateTime.of(2026, 4, 15, 22, 0) + sessions[2].end shouldBe LocalDateTime.of(2026, 4, 16, 2, 0) + } + } + + context("검증 실패") { + it("존재하지 않는 기수이면 예외를 던진다") { + every { cardinalReader.findByClubIdAndCardinalNumber(clubId, 99) } returns null + + val request = + SessionCreateRequest( + title = "세션", + content = null, + location = null, + cardinal = 99, + start = LocalDateTime.of(2026, 4, 1, 10, 0), + end = LocalDateTime.of(2026, 4, 1, 12, 0), + recurrenceType = null, + recurrenceEndDate = null, + ) + + shouldThrow { + useCase.create(clubId, request, userId) + } + } + + it("반복 타입이 있는데 종료일이 없으면 예외를 던진다") { + val request = + SessionCreateRequest( + title = "반복 세션", + content = null, + location = null, + cardinal = 1, + start = LocalDateTime.of(2026, 4, 1, 10, 0), + end = LocalDateTime.of(2026, 4, 1, 12, 0), + recurrenceType = RecurrenceType.WEEKLY, + recurrenceEndDate = null, + ) + + shouldThrow { + useCase.create(clubId, request, userId) + } + } + + it("반복 종료일이 시작일보다 이전이면 예외를 던진다") { + val request = + SessionCreateRequest( + title = "반복 세션", + content = null, + location = null, + cardinal = 1, + start = LocalDateTime.of(2026, 4, 10, 10, 0), + end = LocalDateTime.of(2026, 4, 10, 12, 0), + recurrenceType = RecurrenceType.WEEKLY, + recurrenceEndDate = LocalDate.of(2026, 4, 1), + ) + + shouldThrow { + useCase.create(clubId, request, userId) + } + } + + it("반복 종료일이 시작일 기준 1년을 초과하면 예외를 던진다") { + val request = + SessionCreateRequest( + title = "반복 세션", + content = null, + location = null, + cardinal = 1, + start = LocalDateTime.of(2026, 4, 1, 10, 0), + end = LocalDateTime.of(2026, 4, 1, 12, 0), + recurrenceType = RecurrenceType.WEEKLY, + recurrenceEndDate = LocalDate.of(2027, 4, 2), // 1년 + 1일 + ) + + shouldThrow { + useCase.create(clubId, request, userId) + } + } + + it("반복 종료일이 시작일 기준 정확히 1년이면 성공한다") { + val request = + SessionCreateRequest( + title = "반복 세션", + content = null, + location = null, + cardinal = 1, + start = LocalDateTime.of(2026, 4, 1, 10, 0), + end = LocalDateTime.of(2026, 4, 1, 12, 0), + recurrenceType = RecurrenceType.WEEKLY, + recurrenceEndDate = LocalDate.of(2027, 4, 1), // 정확히 1년 + ) + + useCase.create(clubId, request, userId) + + verify(exactly = 1) { sessionGroupRepository.save(any()) } + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/session/application/usecase/command/DeleteSessionUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/session/application/usecase/command/DeleteSessionUseCaseTest.kt new file mode 100644 index 00000000..5c1cbc55 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/session/application/usecase/command/DeleteSessionUseCaseTest.kt @@ -0,0 +1,359 @@ +package com.weeth.domain.session.application.usecase.command + +import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.attendance.domain.enums.AttendanceStatus +import com.weeth.domain.attendance.domain.repository.AttendanceRepository +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.club.fixture.ClubTestFixture +import com.weeth.domain.session.application.exception.ClosedSessionIncludedException +import com.weeth.domain.session.application.exception.SessionGroupNotFoundException +import com.weeth.domain.session.application.exception.SessionNotFoundException +import com.weeth.domain.session.domain.entity.Session +import com.weeth.domain.session.domain.enums.SessionStatus +import com.weeth.domain.session.domain.enums.UpdateScope +import com.weeth.domain.session.domain.repository.SessionGroupRepository +import com.weeth.domain.session.domain.repository.SessionRepository +import com.weeth.domain.session.fixture.SessionTestFixture +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.Runs +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import java.time.LocalDateTime +import java.util.Optional + +class DeleteSessionUseCaseTest : + DescribeSpec({ + val sessionRepository = mockk() + val attendanceRepository = mockk() + val sessionGroupRepository = mockk() + val clubPermissionPolicy = mockk(relaxed = true) + + val useCase = + DeleteSessionUseCase(sessionRepository, attendanceRepository, sessionGroupRepository, clubPermissionPolicy) + + val clubId = 1L + val userId = 10L + val club = ClubTestFixture.createClub(id = clubId) + + beforeTest { + clearMocks(sessionRepository, attendanceRepository, sessionGroupRepository, clubPermissionPolicy) + every { attendanceRepository.findAllBySessionAndClubMemberMemberStatusWithLock(any(), any()) } returns + emptyList() + every { attendanceRepository.findAllBySessionInAndClubMemberMemberStatusWithLock(any(), any()) } returns + emptyList() + every { attendanceRepository.deleteAllBySession(any()) } just Runs + every { attendanceRepository.deleteAllBySessionIn(any()) } just Runs + every { sessionRepository.delete(any()) } just Runs + every { sessionRepository.deleteAll(any>()) } just Runs + every { sessionGroupRepository.delete(any()) } just Runs + } + + describe("delete") { + context("존재하지 않는 세션") { + it("예외를 던진다") { + every { sessionRepository.findByIdWithLock(99L) } returns null + + shouldThrow { + useCase.delete(clubId, 99L, userId) + } + } + } + + context("다른 클럽의 세션") { + it("예외를 던진다") { + val otherClub = ClubTestFixture.createClub(id = 999L) + val session = SessionTestFixture.createSession(id = 1L, club = otherClub) + every { sessionRepository.findByIdWithLock(1L) } returns session + + shouldThrow { + useCase.delete(clubId, 1L, userId) + } + } + } + + context("단일 세션 삭제") { + it("세션과 출석 레코드를 삭제한다") { + val session = SessionTestFixture.createSession(id = 1L, club = club) + every { sessionRepository.findByIdWithLock(1L) } returns session + + useCase.delete(clubId, 1L, userId) + + verify(exactly = 1) { attendanceRepository.deleteAllBySession(session) } + verify(exactly = 1) { sessionRepository.delete(session) } + } + } + + context("반복 세션 THIS_ONLY 삭제") { + it("해당 세션만 삭제하고 그룹은 유지한다") { + val group = SessionTestFixture.createSessionGroup(id = 1L) + val session = SessionTestFixture.createSession(id = 1L, club = club, sessionGroup = group) + every { sessionRepository.findByIdWithLock(1L) } returns session + every { sessionGroupRepository.findByIdWithLock(1L) } returns group + every { sessionRepository.countBySessionGroup(group) } returns 3L + + useCase.delete(clubId, 1L, userId, scope = UpdateScope.THIS_ONLY) + + verify(exactly = 1) { sessionRepository.delete(session) } + verify(exactly = 0) { sessionGroupRepository.delete(any()) } + } + + it("마지막 세션 삭제 시 그룹도 함께 삭제한다") { + val group = SessionTestFixture.createSessionGroup(id = 1L) + val session = SessionTestFixture.createSession(id = 1L, club = club, sessionGroup = group) + every { sessionRepository.findByIdWithLock(1L) } returns session + every { sessionGroupRepository.findByIdWithLock(1L) } returns group + every { sessionRepository.countBySessionGroup(group) } returns 0L + + useCase.delete(clubId, 1L, userId, scope = UpdateScope.THIS_ONLY) + + verify(exactly = 1) { sessionGroupRepository.delete(group) } + } + } + + context("반복 세션 THIS_AND_FUTURE 삭제") { + it("해당 세션부터 이후 모든 세션을 삭제한다") { + val group = SessionTestFixture.createSessionGroup(id = 1L) + val session1 = + SessionTestFixture.createSession( + id = 1L, + club = club, + sessionGroup = group, + start = LocalDateTime.of(2026, 4, 1, 10, 0), + end = LocalDateTime.of(2026, 4, 1, 12, 0), + ) + val session2 = + SessionTestFixture.createSession( + id = 2L, + club = club, + sessionGroup = group, + start = LocalDateTime.of(2026, 4, 8, 10, 0), + end = LocalDateTime.of(2026, 4, 8, 12, 0), + ) + val futureSessions = listOf(session1, session2) + + every { sessionRepository.findByIdWithLock(1L) } returns session1 + every { + sessionRepository.findAllBySessionGroupAndStartGreaterThanEqualWithLock(group, session1.start) + } returns futureSessions + every { sessionGroupRepository.findByIdWithLock(1L) } returns group + every { sessionRepository.countBySessionGroup(group) } returns 2L + + useCase.delete(clubId, 1L, userId, scope = UpdateScope.THIS_AND_FUTURE) + + verify(exactly = 1) { attendanceRepository.deleteAllBySessionIn(futureSessions) } + verify(exactly = 1) { sessionRepository.deleteAll(futureSessions) } + } + + it("CLOSED 세션 포함 시 force=false이면 예외를 던진다") { + val group = SessionTestFixture.createSessionGroup(id = 1L) + val openSession = + SessionTestFixture.createSession( + id = 1L, + club = club, + sessionGroup = group, + start = LocalDateTime.of(2026, 4, 1, 10, 0), + end = LocalDateTime.of(2026, 4, 1, 12, 0), + ) + val closedSession = + SessionTestFixture.createSession( + id = 2L, + club = club, + sessionGroup = group, + status = SessionStatus.CLOSED, + start = LocalDateTime.of(2026, 4, 8, 10, 0), + end = LocalDateTime.of(2026, 4, 8, 12, 0), + ) + + every { sessionRepository.findByIdWithLock(1L) } returns openSession + every { + sessionRepository.findAllBySessionGroupAndStartGreaterThanEqualWithLock( + group, + openSession.start, + ) + } returns listOf(openSession, closedSession) + + shouldThrow { + useCase.delete(clubId, 1L, userId, scope = UpdateScope.THIS_AND_FUTURE, force = false) + } + } + + it("CLOSED 세션 포함 시 force=true이면 정상 삭제된다") { + val group = SessionTestFixture.createSessionGroup(id = 1L) + val openSession = + SessionTestFixture.createSession( + id = 1L, + club = club, + sessionGroup = group, + start = LocalDateTime.of(2026, 4, 1, 10, 0), + end = LocalDateTime.of(2026, 4, 1, 12, 0), + ) + val closedSession = + SessionTestFixture.createSession( + id = 2L, + club = club, + sessionGroup = group, + status = SessionStatus.CLOSED, + start = LocalDateTime.of(2026, 4, 8, 10, 0), + end = LocalDateTime.of(2026, 4, 8, 12, 0), + ) + + every { sessionRepository.findByIdWithLock(1L) } returns openSession + every { + sessionRepository.findAllBySessionGroupAndStartGreaterThanEqualWithLock( + group, + openSession.start, + ) + } returns listOf(openSession, closedSession) + every { sessionGroupRepository.findByIdWithLock(1L) } returns group + every { sessionRepository.countBySessionGroup(group) } returns 2L + + shouldNotThrowAny { + useCase.delete(clubId, 1L, userId, scope = UpdateScope.THIS_AND_FUTURE, force = true) + } + + verify(exactly = 1) { sessionRepository.deleteAll(listOf(openSession, closedSession)) } + } + + it("모든 세션을 삭제하면 그룹도 함께 삭제된다") { + val group = SessionTestFixture.createSessionGroup(id = 1L) + val session1 = + SessionTestFixture.createSession( + id = 1L, + club = club, + sessionGroup = group, + start = LocalDateTime.of(2026, 4, 1, 10, 0), + end = LocalDateTime.of(2026, 4, 1, 12, 0), + ) + val session2 = + SessionTestFixture.createSession( + id = 2L, + club = club, + sessionGroup = group, + start = LocalDateTime.of(2026, 4, 8, 10, 0), + end = LocalDateTime.of(2026, 4, 8, 12, 0), + ) + + every { sessionRepository.findByIdWithLock(1L) } returns session1 + every { + sessionRepository.findAllBySessionGroupAndStartGreaterThanEqualWithLock(group, session1.start) + } returns listOf(session1, session2) + every { sessionGroupRepository.findByIdWithLock(1L) } returns group + every { sessionRepository.countBySessionGroup(group) } returns 0L + + useCase.delete(clubId, 1L, userId, scope = UpdateScope.THIS_AND_FUTURE) + + verify(exactly = 1) { sessionGroupRepository.delete(group) } + } + } + } + + describe("deleteGroup") { + context("존재하지 않는 그룹") { + it("예외를 던진다") { + every { sessionGroupRepository.findById(99L) } returns Optional.empty() + + shouldThrow { + useCase.deleteGroup(clubId, 99L, userId) + } + } + } + + context("다른 클럽의 세션 그룹") { + it("예외를 던진다") { + val otherClub = ClubTestFixture.createClub(id = 999L) + val group = SessionTestFixture.createSessionGroup(id = 1L) + val session = SessionTestFixture.createSession(id = 1L, club = otherClub, sessionGroup = group) + + every { sessionGroupRepository.findById(1L) } returns Optional.of(group) + every { sessionRepository.findAllBySessionGroupWithLock(group) } returns listOf(session) + + shouldThrow { + useCase.deleteGroup(clubId, 1L, userId) + } + } + } + + context("그룹 전체 삭제") { + it("모든 세션과 출석을 삭제하고 그룹을 삭제한다") { + val group = SessionTestFixture.createSessionGroup(id = 1L) + val session1 = + SessionTestFixture.createSession( + id = 1L, + club = club, + sessionGroup = group, + start = LocalDateTime.of(2026, 4, 1, 10, 0), + end = LocalDateTime.of(2026, 4, 1, 12, 0), + ) + val session2 = + SessionTestFixture.createSession( + id = 2L, + club = club, + sessionGroup = group, + start = LocalDateTime.of(2026, 4, 8, 10, 0), + end = LocalDateTime.of(2026, 4, 8, 12, 0), + ) + val sessions = listOf(session1, session2) + + every { sessionGroupRepository.findById(1L) } returns Optional.of(group) + every { sessionRepository.findAllBySessionGroupWithLock(group) } returns sessions + + useCase.deleteGroup(clubId, 1L, userId) + + verify(exactly = 1) { attendanceRepository.deleteAllBySessionIn(sessions) } + verify(exactly = 1) { sessionRepository.deleteAll(sessions) } + verify(exactly = 1) { sessionGroupRepository.delete(group) } + } + } + + context("CLOSED 세션 포함") { + it("force=false이면 예외를 던진다") { + val group = SessionTestFixture.createSessionGroup(id = 1L) + val closedSession = + SessionTestFixture.createSession( + id = 1L, + club = club, + sessionGroup = group, + status = SessionStatus.CLOSED, + start = LocalDateTime.of(2026, 4, 1, 10, 0), + end = LocalDateTime.of(2026, 4, 1, 12, 0), + ) + + every { sessionGroupRepository.findById(1L) } returns Optional.of(group) + every { sessionRepository.findAllBySessionGroupWithLock(group) } returns listOf(closedSession) + + shouldThrow { + useCase.deleteGroup(clubId, 1L, userId, force = false) + } + } + + it("force=true이면 정상 삭제된다") { + val group = SessionTestFixture.createSessionGroup(id = 1L) + val closedSession = + SessionTestFixture.createSession( + id = 1L, + club = club, + sessionGroup = group, + status = SessionStatus.CLOSED, + start = LocalDateTime.of(2026, 4, 1, 10, 0), + end = LocalDateTime.of(2026, 4, 1, 12, 0), + ) + + every { sessionGroupRepository.findById(1L) } returns Optional.of(group) + every { sessionRepository.findAllBySessionGroupWithLock(group) } returns listOf(closedSession) + + shouldNotThrowAny { + useCase.deleteGroup(clubId, 1L, userId, force = true) + } + + verify(exactly = 1) { sessionGroupRepository.delete(group) } + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/session/application/usecase/command/UpdateSessionUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/session/application/usecase/command/UpdateSessionUseCaseTest.kt new file mode 100644 index 00000000..c6488900 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/session/application/usecase/command/UpdateSessionUseCaseTest.kt @@ -0,0 +1,292 @@ +package com.weeth.domain.session.application.usecase.command + +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.club.fixture.ClubTestFixture +import com.weeth.domain.session.application.dto.request.SessionUpdateRequest +import com.weeth.domain.session.application.exception.ClosedSessionIncludedException +import com.weeth.domain.session.application.exception.SessionNotFoundException +import com.weeth.domain.session.domain.enums.SessionStatus +import com.weeth.domain.session.domain.enums.UpdateScope +import com.weeth.domain.session.domain.repository.SessionRepository +import com.weeth.domain.session.domain.service.RecurringSessionPolicy +import com.weeth.domain.session.fixture.SessionTestFixture +import com.weeth.domain.user.domain.repository.UserReader +import com.weeth.domain.user.fixture.UserTestFixture +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import java.time.LocalDateTime +import java.time.LocalTime + +class UpdateSessionUseCaseTest : + DescribeSpec({ + val sessionRepository = mockk() + val userReader = mockk() + val clubPermissionPolicy = mockk(relaxed = true) + + val recurringSessionPolicy = RecurringSessionPolicy() + val useCase = UpdateSessionUseCase(sessionRepository, userReader, clubPermissionPolicy, recurringSessionPolicy) + + val clubId = 1L + val userId = 10L + val club = ClubTestFixture.createClub(id = clubId) + val user = UserTestFixture.createActiveUser1() + + beforeTest { + clearMocks(sessionRepository, userReader, clubPermissionPolicy) + every { userReader.getById(userId) } returns user + } + + describe("update") { + context("존재하지 않는 세션") { + it("예외를 던진다") { + every { sessionRepository.findByIdWithLock(99L) } returns null + val request = SessionUpdateRequest("변경", null, null, null, null) + + shouldThrow { + useCase.update(clubId, 99L, request, userId) + } + } + } + + context("다른 클럽의 세션") { + it("예외를 던진다") { + val otherClub = ClubTestFixture.createClub(id = 999L) + val session = SessionTestFixture.createSession(id = 1L, club = otherClub) + every { sessionRepository.findByIdWithLock(1L) } returns session + + val request = SessionUpdateRequest("변경", null, null, null, null) + + shouldThrow { + useCase.update(clubId, 1L, request, userId) + } + } + } + + context("THIS_ONLY 수정") { + it("단일 세션의 제목과 내용을 수정한다") { + val session = SessionTestFixture.createSession(id = 1L, club = club) + every { sessionRepository.findByIdWithLock(1L) } returns session + + val request = SessionUpdateRequest("변경된 제목", "변경된 내용", null, null, null) + + useCase.update(clubId, 1L, request, userId) + + session.title shouldBe "변경된 제목" + session.content shouldBe "변경된 내용" + } + + it("반복 세션이어도 THIS_ONLY이면 해당 세션만 수정한다") { + val group = SessionTestFixture.createSessionGroup(id = 1L) + val session = SessionTestFixture.createSession(id = 1L, club = club, sessionGroup = group) + every { sessionRepository.findByIdWithLock(1L) } returns session + + val request = SessionUpdateRequest("개별 변경", null, null, null, null) + + useCase.update(clubId, 1L, request, userId, scope = UpdateScope.THIS_ONLY) + + session.title shouldBe "개별 변경" + } + } + + context("THIS_AND_FUTURE 수정") { + it("이후 모든 세션의 시간 부분만 변경된다 (날짜는 유지)") { + val group = SessionTestFixture.createSessionGroup(id = 1L) + val session1 = + SessionTestFixture.createSession( + id = 1L, + club = club, + sessionGroup = group, + start = LocalDateTime.of(2026, 4, 1, 10, 0), + end = LocalDateTime.of(2026, 4, 1, 12, 0), + ) + val session2 = + SessionTestFixture.createSession( + id = 2L, + club = club, + sessionGroup = group, + start = LocalDateTime.of(2026, 4, 8, 10, 0), + end = LocalDateTime.of(2026, 4, 8, 12, 0), + ) + + every { sessionRepository.findByIdWithLock(1L) } returns session1 + every { + sessionRepository.findAllBySessionGroupAndStartGreaterThanEqualWithLock(group, session1.start) + } returns listOf(session1, session2) + + val request = + SessionUpdateRequest( + title = "통합 수정", + content = null, + location = null, + start = LocalDateTime.of(2026, 4, 1, 14, 0), // 시간만 14시로 변경 + end = LocalDateTime.of(2026, 4, 1, 16, 0), + ) + + useCase.update(clubId, 1L, request, userId, scope = UpdateScope.THIS_AND_FUTURE) + + // 날짜는 각각 유지, 시간만 변경 + session1.start shouldBe LocalDateTime.of(2026, 4, 1, 14, 0) + session1.end shouldBe LocalDateTime.of(2026, 4, 1, 16, 0) + session2.start shouldBe LocalDateTime.of(2026, 4, 8, 14, 0) + session2.end shouldBe LocalDateTime.of(2026, 4, 8, 16, 0) + + // 제목도 일괄 변경 + session1.title shouldBe "통합 수정" + session2.title shouldBe "통합 수정" + } + + it("CLOSED 세션 포함 시 force=false이면 예외를 던진다") { + val group = SessionTestFixture.createSessionGroup(id = 1L) + val openSession = + SessionTestFixture.createSession( + id = 1L, + club = club, + sessionGroup = group, + start = LocalDateTime.of(2026, 4, 1, 10, 0), + end = LocalDateTime.of(2026, 4, 1, 12, 0), + ) + val closedSession = + SessionTestFixture.createSession( + id = 2L, + club = club, + sessionGroup = group, + status = SessionStatus.CLOSED, + start = LocalDateTime.of(2026, 4, 8, 10, 0), + end = LocalDateTime.of(2026, 4, 8, 12, 0), + ) + + every { sessionRepository.findByIdWithLock(1L) } returns openSession + every { + sessionRepository.findAllBySessionGroupAndStartGreaterThanEqualWithLock( + group, + openSession.start, + ) + } returns listOf(openSession, closedSession) + + val request = SessionUpdateRequest("수정", null, null, null, null) + + shouldThrow { + useCase.update(clubId, 1L, request, userId, scope = UpdateScope.THIS_AND_FUTURE, force = false) + } + } + + it("CLOSED 세션 포함 시 force=true이면 정상 수정된다") { + val group = SessionTestFixture.createSessionGroup(id = 1L) + val openSession = + SessionTestFixture.createSession( + id = 1L, + club = club, + sessionGroup = group, + start = LocalDateTime.of(2026, 4, 1, 10, 0), + end = LocalDateTime.of(2026, 4, 1, 12, 0), + ) + val closedSession = + SessionTestFixture.createSession( + id = 2L, + club = club, + sessionGroup = group, + status = SessionStatus.CLOSED, + start = LocalDateTime.of(2026, 4, 8, 10, 0), + end = LocalDateTime.of(2026, 4, 8, 12, 0), + ) + + every { sessionRepository.findByIdWithLock(1L) } returns openSession + every { + sessionRepository.findAllBySessionGroupAndStartGreaterThanEqualWithLock( + group, + openSession.start, + ) + } returns listOf(openSession, closedSession) + + val request = SessionUpdateRequest("강제 수정", null, null, null, null) + + shouldNotThrowAny { + useCase.update(clubId, 1L, request, userId, scope = UpdateScope.THIS_AND_FUTURE, force = true) + } + + openSession.title shouldBe "강제 수정" + closedSession.title shouldBe "강제 수정" + } + + it("시간 변경이 null이면 각 세션의 기존 시간을 유지한다") { + val group = SessionTestFixture.createSessionGroup(id = 1L) + val session1 = + SessionTestFixture.createSession( + id = 1L, + club = club, + sessionGroup = group, + start = LocalDateTime.of(2026, 4, 1, 10, 0), + end = LocalDateTime.of(2026, 4, 1, 12, 0), + ) + val session2 = + SessionTestFixture.createSession( + id = 2L, + club = club, + sessionGroup = group, + start = LocalDateTime.of(2026, 4, 8, 10, 0), + end = LocalDateTime.of(2026, 4, 8, 12, 0), + ) + + every { sessionRepository.findByIdWithLock(1L) } returns session1 + every { + sessionRepository.findAllBySessionGroupAndStartGreaterThanEqualWithLock(group, session1.start) + } returns listOf(session1, session2) + + // 시간은 null, 제목만 변경 + val request = SessionUpdateRequest("제목만 변경", null, null, null, null) + + useCase.update(clubId, 1L, request, userId, scope = UpdateScope.THIS_AND_FUTURE) + + session1.start.toLocalTime() shouldBe LocalTime.of(10, 0) + session2.start.toLocalTime() shouldBe LocalTime.of(10, 0) + } + + it("자정을 넘는 시간으로 변경하면 end 날짜가 다음날로 설정된다") { + val group = SessionTestFixture.createSessionGroup(id = 1L) + val session1 = + SessionTestFixture.createSession( + id = 1L, + club = club, + sessionGroup = group, + start = LocalDateTime.of(2026, 4, 1, 10, 0), + end = LocalDateTime.of(2026, 4, 1, 12, 0), + ) + val session2 = + SessionTestFixture.createSession( + id = 2L, + club = club, + sessionGroup = group, + start = LocalDateTime.of(2026, 4, 8, 10, 0), + end = LocalDateTime.of(2026, 4, 8, 12, 0), + ) + + every { sessionRepository.findByIdWithLock(1L) } returns session1 + every { + sessionRepository.findAllBySessionGroupAndStartGreaterThanEqualWithLock(group, session1.start) + } returns listOf(session1, session2) + + // 22:00~02:00(다음날)로 변경 + val request = + SessionUpdateRequest( + title = null, + content = null, + location = null, + start = LocalDateTime.of(2026, 4, 1, 22, 0), + end = LocalDateTime.of(2026, 4, 2, 2, 0), + ) + + useCase.update(clubId, 1L, request, userId, scope = UpdateScope.THIS_AND_FUTURE) + + session1.start shouldBe LocalDateTime.of(2026, 4, 1, 22, 0) + session1.end shouldBe LocalDateTime.of(2026, 4, 2, 2, 0) + session2.start shouldBe LocalDateTime.of(2026, 4, 8, 22, 0) + session2.end shouldBe LocalDateTime.of(2026, 4, 9, 2, 0) + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryServiceTest.kt index 92a72055..c162e17a 100644 --- a/src/test/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/session/application/usecase/query/GetSessionQueryServiceTest.kt @@ -6,10 +6,10 @@ import com.weeth.domain.cardinal.fixture.CardinalTestFixture import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.club.fixture.ClubMemberTestFixture -import com.weeth.domain.schedule.application.dto.response.SessionInfosResponse -import com.weeth.domain.schedule.application.dto.response.SessionResponse -import com.weeth.domain.schedule.application.mapper.SessionMapper +import com.weeth.domain.session.application.dto.response.SessionInfosResponse +import com.weeth.domain.session.application.dto.response.SessionResponse import com.weeth.domain.session.application.exception.SessionNotFoundException +import com.weeth.domain.session.application.mapper.SessionMapper import com.weeth.domain.session.domain.repository.SessionRepository import com.weeth.domain.session.fixture.SessionTestFixture import io.kotest.assertions.throwables.shouldThrow @@ -89,7 +89,8 @@ class GetSessionQueryServiceTest : val response = mockk() every { sessionRepository.findAllByClubIdOrderByStartDesc(clubId) } returns sessions - every { sessionMapper.toInfos(any(), sessions) } returns response + every { sessionMapper.toSingleGroupResponse(any()) } returns mockk(relaxed = true) + every { sessionMapper.toInfos(any(), any()) } returns response val result = queryService.findSessionInfos(clubId, userId, null) @@ -104,7 +105,8 @@ class GetSessionQueryServiceTest : every { cardinalReader.findByClubIdAndCardinalNumber(clubId, 3) } returns cardinal every { sessionRepository.findAllByClubIdAndCardinalOrderByStartDesc(clubId, 3) } returns sessions - every { sessionMapper.toInfos(any(), sessions) } returns response + every { sessionMapper.toSingleGroupResponse(any()) } returns mockk(relaxed = true) + every { sessionMapper.toInfos(any(), any()) } returns response val result = queryService.findSessionInfos(clubId, userId, 3) diff --git a/src/test/kotlin/com/weeth/domain/session/domain/service/RecurringSessionPolicyTest.kt b/src/test/kotlin/com/weeth/domain/session/domain/service/RecurringSessionPolicyTest.kt new file mode 100644 index 00000000..d0afff60 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/session/domain/service/RecurringSessionPolicyTest.kt @@ -0,0 +1,188 @@ +package com.weeth.domain.session.domain.service + +import com.weeth.domain.session.domain.enums.RecurrenceType +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime + +class RecurringSessionPolicyTest : + StringSpec({ + val policy = RecurringSessionPolicy() + val defaultStartTime = LocalTime.of(14, 0) + val defaultEndTime = LocalTime.of(16, 0) + + fun startOf(date: LocalDate): LocalDateTime = LocalDateTime.of(date, defaultStartTime) + + fun endOf(date: LocalDate): LocalDateTime = LocalDateTime.of(date, defaultEndTime) + + // === DAILY === + + "DAILY: 시작일부터 종료일까지 매일 스케줄을 생성한다" { + val start = LocalDate.of(2026, 3, 1) + val end = LocalDate.of(2026, 3, 5) + + val schedules = policy.calculateSchedules(startOf(start), endOf(start), RecurrenceType.DAILY, end) + + schedules shouldHaveSize 5 + schedules[0].first shouldBe startOf(LocalDate.of(2026, 3, 1)) + schedules[0].second shouldBe endOf(LocalDate.of(2026, 3, 1)) + schedules[4].first shouldBe startOf(LocalDate.of(2026, 3, 5)) + schedules[4].second shouldBe endOf(LocalDate.of(2026, 3, 5)) + } + + "DAILY: 시작일과 종료일이 같으면 1개만 생성한다" { + val date = LocalDate.of(2026, 3, 1) + + val schedules = policy.calculateSchedules(startOf(date), endOf(date), RecurrenceType.DAILY, date) + + schedules shouldHaveSize 1 + schedules[0].first shouldBe startOf(date) + } + + // === WEEKLY === + + "WEEKLY: 매주 같은 요일에 스케줄을 생성한다" { + val start = LocalDate.of(2026, 3, 4) // 수요일 + val end = LocalDate.of(2026, 3, 25) // 수요일 + + val schedules = policy.calculateSchedules(startOf(start), endOf(start), RecurrenceType.WEEKLY, end) + + schedules shouldHaveSize 4 + schedules[0].first shouldBe startOf(LocalDate.of(2026, 3, 4)) + schedules[1].first shouldBe startOf(LocalDate.of(2026, 3, 11)) + schedules[2].first shouldBe startOf(LocalDate.of(2026, 3, 18)) + schedules[3].first shouldBe startOf(LocalDate.of(2026, 3, 25)) + } + + "WEEKLY: 종료일이 정확히 다음 주 전이면 1개만 생성한다" { + val start = LocalDate.of(2026, 3, 4) // 수요일 + val end = LocalDate.of(2026, 3, 10) // 화요일 (다음 수요일 전) + + val schedules = policy.calculateSchedules(startOf(start), endOf(start), RecurrenceType.WEEKLY, end) + + schedules shouldHaveSize 1 + schedules[0].first shouldBe startOf(start) + } + + // === MONTHLY === + + "MONTHLY: 매월 같은 일자에 스케줄을 생성한다" { + val start = LocalDate.of(2026, 1, 15) + val end = LocalDate.of(2026, 4, 15) + + val schedules = policy.calculateSchedules(startOf(start), endOf(start), RecurrenceType.MONTHLY, end) + + schedules shouldHaveSize 4 + schedules[0].first shouldBe startOf(LocalDate.of(2026, 1, 15)) + schedules[1].first shouldBe startOf(LocalDate.of(2026, 2, 15)) + schedules[2].first shouldBe startOf(LocalDate.of(2026, 3, 15)) + schedules[3].first shouldBe startOf(LocalDate.of(2026, 4, 15)) + } + + "MONTHLY: 31일 시작이면 짧은 달은 말일로 조정된다" { + val start = LocalDate.of(2026, 1, 31) + val end = LocalDate.of(2026, 4, 30) + + val schedules = policy.calculateSchedules(startOf(start), endOf(start), RecurrenceType.MONTHLY, end) + + // 1/31 → 2/28 → 3/31 → 4/30 (원본 기준 plusMonths) + schedules shouldHaveSize 4 + schedules[0].first.toLocalDate() shouldBe LocalDate.of(2026, 1, 31) + schedules[1].first.toLocalDate() shouldBe LocalDate.of(2026, 2, 28) + schedules[2].first.toLocalDate() shouldBe LocalDate.of(2026, 3, 31) + schedules[3].first.toLocalDate() shouldBe LocalDate.of(2026, 4, 30) + } + + "MONTHLY: 체이닝 방식과 다르게 원본 기준으로 계산된다" { + // 체이닝: 1/31 → 2/28 → 3/28 (X) + // 원본 기준: 1/31 → 2/28 → 3/31 (O) + val start = LocalDate.of(2026, 1, 31) + val end = LocalDate.of(2026, 3, 31) + + val schedules = policy.calculateSchedules(startOf(start), endOf(start), RecurrenceType.MONTHLY, end) + + schedules shouldHaveSize 3 + schedules[2].first.toLocalDate() shouldBe LocalDate.of(2026, 3, 31) + } + + // === duration (자정 넘김) === + + "자정을 넘기는 세션도 duration 기반으로 정상 처리된다" { + val date = LocalDate.of(2026, 3, 1) + val startDateTime = LocalDateTime.of(date, LocalTime.of(22, 0)) + val endDateTime = LocalDateTime.of(date.plusDays(1), LocalTime.of(2, 0)) // 4시간 + + val schedules = + policy.calculateSchedules( + startDateTime, + endDateTime, + RecurrenceType.DAILY, + date.plusDays(2), + ) + + schedules shouldHaveSize 3 + // 첫째 날: 3/1 22:00 ~ 3/2 02:00 + schedules[0].first shouldBe LocalDateTime.of(2026, 3, 1, 22, 0) + schedules[0].second shouldBe LocalDateTime.of(2026, 3, 2, 2, 0) + // 둘째 날: 3/2 22:00 ~ 3/3 02:00 + schedules[1].first shouldBe LocalDateTime.of(2026, 3, 2, 22, 0) + schedules[1].second shouldBe LocalDateTime.of(2026, 3, 3, 2, 0) + } + + // === 경계 조건 === + + "종료일이 시작일보다 이전이면 빈 리스트를 반환한다" { + val start = LocalDate.of(2026, 3, 10) + val end = LocalDate.of(2026, 3, 1) + + val schedules = policy.calculateSchedules(startOf(start), endOf(start), RecurrenceType.WEEKLY, end) + + schedules.shouldBeEmpty() + } + + "MONTHLY: 종료일이 다음 달 전이면 시작일만 포함된다" { + val start = LocalDate.of(2026, 3, 15) + val end = LocalDate.of(2026, 4, 14) + + val schedules = policy.calculateSchedules(startOf(start), endOf(start), RecurrenceType.MONTHLY, end) + + schedules shouldHaveSize 1 + schedules[0].first shouldBe startOf(start) + } + + // === buildRecurrenceDescription === + + "DAILY: '매일 N시' 형식으로 반환한다" { + val result = + policy.buildRecurrenceDescription( + RecurrenceType.DAILY, + LocalTime.of(14, 0), + LocalDate.of(2026, 3, 4), + ) + result shouldBe "매일 14시" + } + + "WEEKLY: '매주 X요일 N시' 형식으로 반환한다" { + val result = + policy.buildRecurrenceDescription( + RecurrenceType.WEEKLY, + LocalTime.of(10, 0), + LocalDate.of(2026, 3, 4), // 수요일 + ) + result shouldBe "매주 수요일 10시" + } + + "MONTHLY: '매월 N일 N시' 형식으로 반환한다" { + val result = + policy.buildRecurrenceDescription( + RecurrenceType.MONTHLY, + LocalTime.of(19, 0), + LocalDate.of(2026, 3, 15), + ) + result shouldBe "매월 15일 19시" + } + }) diff --git a/src/test/kotlin/com/weeth/domain/session/fixture/SessionTestFixture.kt b/src/test/kotlin/com/weeth/domain/session/fixture/SessionTestFixture.kt index 11f73243..04ccbe4a 100644 --- a/src/test/kotlin/com/weeth/domain/session/fixture/SessionTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/session/fixture/SessionTestFixture.kt @@ -3,10 +3,13 @@ package com.weeth.domain.session.fixture import com.weeth.domain.club.domain.entity.Club import com.weeth.domain.club.fixture.ClubTestFixture import com.weeth.domain.session.domain.entity.Session +import com.weeth.domain.session.domain.entity.SessionGroup +import com.weeth.domain.session.domain.enums.RecurrenceType import com.weeth.domain.session.domain.enums.SessionStatus import org.springframework.test.util.ReflectionTestUtils import java.time.LocalDate import java.time.LocalDateTime +import java.time.LocalTime object SessionTestFixture { fun createSession( @@ -20,6 +23,7 @@ object SessionTestFixture { status: SessionStatus = SessionStatus.OPEN, start: LocalDateTime = LocalDateTime.of(2026, 3, 1, 10, 0), end: LocalDateTime = LocalDateTime.of(2026, 3, 1, 12, 0), + sessionGroup: SessionGroup? = null, ): Session { val session = Session( @@ -32,11 +36,34 @@ object SessionTestFixture { status = status, start = start, end = end, + sessionGroup = sessionGroup, ) if (id != 0L) ReflectionTestUtils.setField(session, "id", id) return session } + fun createSessionGroup( + id: Long = 0L, + title: String = "반복 세션", + recurrenceType: RecurrenceType = RecurrenceType.WEEKLY, + recurrenceEndDate: LocalDate = LocalDate.of(2026, 6, 30), + cardinal: Int = 1, + startTime: LocalTime = LocalTime.of(10, 0), + endTime: LocalTime = LocalTime.of(12, 0), + ): SessionGroup { + val group = + SessionGroup( + title = title, + recurrenceType = recurrenceType, + recurrenceEndDate = recurrenceEndDate, + cardinal = cardinal, + startTime = startTime, + endTime = endTime, + ) + if (id != 0L) ReflectionTestUtils.setField(group, "id", id) + return group + } + fun createOneDaySession( date: LocalDate, cardinal: Int, From 646e267474214ed7253d4df8f29252470397966f Mon Sep 17 00:00:00 2001 From: Jeon Soo Hyeon <128474444+soo0711@users.noreply.github.com> Date: Tue, 31 Mar 2026 23:08:17 +0900 Subject: [PATCH 44/73] =?UTF-8?q?[WTH-236]=20=EB=9E=9C=EB=94=A9=20?= =?UTF-8?q?=EB=AC=B8=EC=9D=98=ED=95=98=EA=B8=B0=20api=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20(#45)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: Notion, Slack 설정 추가 * feat: 문의 port 인터페이스 추가 * feet: 문의하기 로직 구현 * feet: Notion, Slack Adapter 구현 * feet: 문의하기 엔트포인트 추가 * refactor: 응답코드 수정 * refactor: 문의하기 비동기로 변경 * chore: AsyncConfig 추가 --- src/main/kotlin/com/weeth/WeethApplication.kt | 2 + .../dto/request/CreateInquiryRequest.kt | 18 ++++++ .../usecase/command/CreateInquiryUseCase.kt | 17 ++++++ .../user/domain/port/InquiryNotifyPort.kt | 8 +++ .../user/domain/port/InquirySavePort.kt | 8 +++ .../NotionInquirySaveAdapter.kt | 61 +++++++++++++++++++ .../SlackInquiryNotifyAdapter.kt | 35 +++++++++++ .../user/presentation/UserController.kt | 13 ++++ .../user/presentation/UserResponseCode.kt | 1 + .../com/weeth/global/config/AsyncConfig.kt | 19 ++++++ .../com/weeth/global/config/SecurityConfig.kt | 1 + .../config/properties/NotionProperties.kt | 13 ++++ .../config/properties/SlackProperties.kt | 11 ++++ src/main/resources/application.yml | 8 +++ 14 files changed, 215 insertions(+) create mode 100644 src/main/kotlin/com/weeth/domain/user/application/dto/request/CreateInquiryRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/application/usecase/command/CreateInquiryUseCase.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/domain/port/InquiryNotifyPort.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/domain/port/InquirySavePort.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/infrastructure/NotionInquirySaveAdapter.kt create mode 100644 src/main/kotlin/com/weeth/domain/user/infrastructure/SlackInquiryNotifyAdapter.kt create mode 100644 src/main/kotlin/com/weeth/global/config/AsyncConfig.kt create mode 100644 src/main/kotlin/com/weeth/global/config/properties/NotionProperties.kt create mode 100644 src/main/kotlin/com/weeth/global/config/properties/SlackProperties.kt diff --git a/src/main/kotlin/com/weeth/WeethApplication.kt b/src/main/kotlin/com/weeth/WeethApplication.kt index 8664ed91..55d667ba 100644 --- a/src/main/kotlin/com/weeth/WeethApplication.kt +++ b/src/main/kotlin/com/weeth/WeethApplication.kt @@ -4,9 +4,11 @@ import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication import org.springframework.data.jpa.repository.config.EnableJpaAuditing +import org.springframework.scheduling.annotation.EnableAsync import org.springframework.scheduling.annotation.EnableScheduling import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +@EnableAsync @EnableScheduling @EnableJpaAuditing @EnableWebSecurity diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/request/CreateInquiryRequest.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/request/CreateInquiryRequest.kt new file mode 100644 index 00000000..0a260414 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/request/CreateInquiryRequest.kt @@ -0,0 +1,18 @@ +package com.weeth.domain.user.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size + +data class CreateInquiryRequest( + @field:Schema(description = "이메일", example = "user@example.com") + @field:NotBlank + @field:Email + @field:Size(max = 255) + val email: String, + @field:Schema(description = "문의 내용", example = "서비스에 대해 문의드립니다.") + @field:NotBlank + @field:Size(max = 1000) + val message: String, +) diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/CreateInquiryUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/CreateInquiryUseCase.kt new file mode 100644 index 00000000..bd1c3c41 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/CreateInquiryUseCase.kt @@ -0,0 +1,17 @@ +package com.weeth.domain.user.application.usecase.command + +import com.weeth.domain.user.application.dto.request.CreateInquiryRequest +import com.weeth.domain.user.domain.port.InquiryNotifyPort +import com.weeth.domain.user.domain.port.InquirySavePort +import org.springframework.stereotype.Service + +@Service +class CreateInquiryUseCase( + private val inquirySavePort: InquirySavePort, + private val inquiryNotifyPort: InquiryNotifyPort, +) { + fun execute(request: CreateInquiryRequest) { + inquirySavePort.save(request.email, request.message) + inquiryNotifyPort.notify(request.email, request.message) + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/port/InquiryNotifyPort.kt b/src/main/kotlin/com/weeth/domain/user/domain/port/InquiryNotifyPort.kt new file mode 100644 index 00000000..0acd48c4 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/port/InquiryNotifyPort.kt @@ -0,0 +1,8 @@ +package com.weeth.domain.user.domain.port + +interface InquiryNotifyPort { + fun notify( + email: String, + message: String, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/port/InquirySavePort.kt b/src/main/kotlin/com/weeth/domain/user/domain/port/InquirySavePort.kt new file mode 100644 index 00000000..5d794af6 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/domain/port/InquirySavePort.kt @@ -0,0 +1,8 @@ +package com.weeth.domain.user.domain.port + +interface InquirySavePort { + fun save( + email: String, + message: String, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/user/infrastructure/NotionInquirySaveAdapter.kt b/src/main/kotlin/com/weeth/domain/user/infrastructure/NotionInquirySaveAdapter.kt new file mode 100644 index 00000000..c2ca6cb0 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/infrastructure/NotionInquirySaveAdapter.kt @@ -0,0 +1,61 @@ +package com.weeth.domain.user.infrastructure + +import com.weeth.domain.user.domain.port.InquirySavePort +import com.weeth.global.config.properties.NotionProperties +import org.slf4j.LoggerFactory +import org.springframework.http.HttpHeaders +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Component +import org.springframework.web.client.RestClient +import java.time.LocalDate + +@Component +class NotionInquirySaveAdapter( + private val notionProperties: NotionProperties, + restClientBuilder: RestClient.Builder, +) : InquirySavePort { + private val restClient = restClientBuilder.baseUrl("https://api.notion.com").build() + private val log = LoggerFactory.getLogger(javaClass) + + @Async + override fun save( + email: String, + message: String, + ) { + val body = + mapOf( + "parent" to + mapOf( + "type" to "database_id", + "database_id" to notionProperties.inquiryDatabaseId, + ), + "properties" to + mapOf( + "문의내용" to + mapOf( + "title" to listOf(mapOf("text" to mapOf("content" to message))), + ), + "이메일" to + mapOf( + "email" to email, + ), + "날짜" to + mapOf( + "date" to mapOf("start" to LocalDate.now().toString()), + ), + ), + ) + + runCatching { + restClient + .post() + .uri("/v1/pages") + .header(HttpHeaders.AUTHORIZATION, "Bearer ${notionProperties.token}") + .header("Notion-Version", notionProperties.version) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .body(body) + .retrieve() + .toBodilessEntity() + }.onFailure { e -> log.warn("Notion 저장 실패: {}", e.message) } + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/infrastructure/SlackInquiryNotifyAdapter.kt b/src/main/kotlin/com/weeth/domain/user/infrastructure/SlackInquiryNotifyAdapter.kt new file mode 100644 index 00000000..25488bc1 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/infrastructure/SlackInquiryNotifyAdapter.kt @@ -0,0 +1,35 @@ +package com.weeth.domain.user.infrastructure + +import com.weeth.domain.user.domain.port.InquiryNotifyPort +import com.weeth.global.config.properties.SlackProperties +import org.slf4j.LoggerFactory +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Component +import org.springframework.web.client.RestClient + +@Component +class SlackInquiryNotifyAdapter( + private val slackProperties: SlackProperties, + restClientBuilder: RestClient.Builder, +) : InquiryNotifyPort { + private val restClient = restClientBuilder.build() + private val log = LoggerFactory.getLogger(javaClass) + + @Async + override fun notify( + email: String, + message: String, + ) { + val text = "*[랜딩 문의하기]*\n*이메일:* $email\n*문의 내용:*\n```$message```" + + runCatching { + restClient + .post() + .uri(slackProperties.webhookUrl) + .header("Content-Type", "application/json") + .body(mapOf("text" to text)) + .retrieve() + .toBodilessEntity() + }.onFailure { e -> log.warn("Slack 알림 전송 실패: {}", e.message) } + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt b/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt index f46018e3..94f56fe6 100644 --- a/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt +++ b/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt @@ -1,12 +1,14 @@ package com.weeth.domain.user.presentation import com.weeth.domain.user.application.dto.request.AgreeTermsRequest +import com.weeth.domain.user.application.dto.request.CreateInquiryRequest import com.weeth.domain.user.application.dto.request.SocialLoginRequest import com.weeth.domain.user.application.dto.request.UpdateUserProfileRequest import com.weeth.domain.user.application.dto.response.SocialLoginResponse import com.weeth.domain.user.application.exception.UserErrorCode import com.weeth.domain.user.application.usecase.command.AgreeTermsUseCase import com.weeth.domain.user.application.usecase.command.AuthUserUseCase +import com.weeth.domain.user.application.usecase.command.CreateInquiryUseCase import com.weeth.domain.user.application.usecase.command.SocialLoginUseCase import com.weeth.domain.user.application.usecase.command.UpdateUserProfileUseCase import com.weeth.global.auth.annotation.CurrentUser @@ -38,6 +40,7 @@ class UserController( private val socialLoginUseCase: SocialLoginUseCase, private val updateUserProfileUseCase: UpdateUserProfileUseCase, private val agreeTermsUseCase: AgreeTermsUseCase, + private val createInquiryUseCase: CreateInquiryUseCase, private val tokenCookieProvider: TokenCookieProvider, ) { @PostMapping("/social/kakao") @@ -104,6 +107,16 @@ class UserController( return CommonResponse.success(UserResponseCode.USER_UPDATE_SUCCESS) } + @PostMapping("/inquiries") + @Operation(summary = "문의하기") + @SecurityRequirements + fun createInquiry( + @RequestBody @Valid request: CreateInquiryRequest, + ): CommonResponse { + createInquiryUseCase.execute(request) + return CommonResponse.success(UserResponseCode.INQUIRY_SEND_SUCCESS) + } + private fun buildTokenResponse( body: CommonResponse, accessToken: String, diff --git a/src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt b/src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt index c0a186ba..c1776a65 100644 --- a/src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt @@ -12,4 +12,5 @@ enum class UserResponseCode( JWT_REFRESH_SUCCESS(10902, HttpStatus.OK, "토큰 재발급에 성공했습니다."), SOCIAL_LOGIN_SUCCESS(10903, HttpStatus.OK, "소셜 로그인이 성공적으로 처리되었습니다."), USER_TERMS_AGREE_SUCCESS(10904, HttpStatus.OK, "약관 동의가 성공적으로 처리되었습니다."), + INQUIRY_SEND_SUCCESS(10905, HttpStatus.OK, "문의가 성공적으로 접수되었습니다."), } diff --git a/src/main/kotlin/com/weeth/global/config/AsyncConfig.kt b/src/main/kotlin/com/weeth/global/config/AsyncConfig.kt new file mode 100644 index 00000000..c9fffaa9 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/config/AsyncConfig.kt @@ -0,0 +1,19 @@ +package com.weeth.global.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor +import java.util.concurrent.Executor + +@Configuration +class AsyncConfig { + @Bean(name = ["taskExecutor"]) + fun taskExecutor(): Executor = + ThreadPoolTaskExecutor().apply { + corePoolSize = 5 + maxPoolSize = 10 + queueCapacity = 50 + setThreadNamePrefix("async-") + initialize() + } +} diff --git a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt index d1c02a2b..33a27fef 100644 --- a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt +++ b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt @@ -45,6 +45,7 @@ class SecurityConfig( "/api/v4/users/social/kakao", "/api/v4/users/social/apple", "/api/v4/users/social/refresh", + "/api/v4/users/inquiries", ).permitAll() .requestMatchers("/health-check") .permitAll() diff --git a/src/main/kotlin/com/weeth/global/config/properties/NotionProperties.kt b/src/main/kotlin/com/weeth/global/config/properties/NotionProperties.kt new file mode 100644 index 00000000..e0c10158 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/config/properties/NotionProperties.kt @@ -0,0 +1,13 @@ +package com.weeth.global.config.properties + +import jakarta.validation.constraints.NotBlank +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.validation.annotation.Validated + +@Validated +@ConfigurationProperties(prefix = "notion") +data class NotionProperties( + @field:NotBlank val token: String, + @field:NotBlank val version: String, + @field:NotBlank val inquiryDatabaseId: String, +) diff --git a/src/main/kotlin/com/weeth/global/config/properties/SlackProperties.kt b/src/main/kotlin/com/weeth/global/config/properties/SlackProperties.kt new file mode 100644 index 00000000..a90636ac --- /dev/null +++ b/src/main/kotlin/com/weeth/global/config/properties/SlackProperties.kt @@ -0,0 +1,11 @@ +package com.weeth.global.config.properties + +import jakarta.validation.constraints.NotBlank +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.validation.annotation.Validated + +@Validated +@ConfigurationProperties(prefix = "slack") +data class SlackProperties( + @field:NotBlank val webhookUrl: String, +) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 535ad46d..d955eb5e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -49,3 +49,11 @@ app: career-net: key: ${CAREER_NET_API_KEY} base-url: https://www.career.go.kr/cnet/openapi/getOpenApi + +slack: + webhook-url: ${SLACK_WEBHOOK_URL} + +notion: + token: ${NOTION_TOKEN} + version: ${NOTION_VERSION} + inquiry-database-id: ${NOTION_INQUIRY_DATABASE_ID} From 60f941c1099f9376a2c412a4a3e091f8822cecec Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Wed, 1 Apr 2026 15:11:32 +0900 Subject: [PATCH 45/73] =?UTF-8?q?[WTH-237]=20=EC=95=A0=ED=94=8C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=8B=9C=20=EC=9C=A0=EC=A0=80=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EB=B0=98=ED=99=98=20(#44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 애플 로그인 수정 * refactor: 로그인 시 유저 이름 반환 * refactor: 주석 및 파일명 변경 * refactor: 파싱 로직 이전 * refactor: 컨트롤러 정리 * test: 의존성 추가 --- .../presentation/AccountAdminController.kt | 2 +- .../account/presentation/AccountController.kt | 2 +- .../presentation/ReceiptAdminController.kt | 6 +- .../presentation/AttendanceController.kt | 4 +- .../club/presentation/ClubAdminController.kt | 2 +- .../presentation/PenaltyAdminController.kt | 8 +- .../presentation/PenaltyUserController.kt | 2 +- .../dto/response/SocialLoginResponse.kt | 2 + .../user/application/mapper/UserMapper.kt | 2 + .../usecase/command/SocialLoginUseCase.kt | 52 +++++++++++- .../domain/user/domain/port/SocialAuthPort.kt | 3 + .../infrastructure/AppleSocialAuthAdapter.kt | 18 ++-- .../presentation/SocialCallbackController.kt | 84 +++++++++++++++++++ .../controller/ExceptionDocController.kt | 1 - .../com/weeth/global/config/SecurityConfig.kt | 2 + .../config/properties/OAuthProperties.kt | 2 + src/main/resources/application.yml | 1 + .../usecase/command/SocialLoginUseCaseTest.kt | 4 +- 18 files changed, 173 insertions(+), 24 deletions(-) create mode 100644 src/main/kotlin/com/weeth/domain/user/presentation/SocialCallbackController.kt diff --git a/src/main/kotlin/com/weeth/domain/account/presentation/AccountAdminController.kt b/src/main/kotlin/com/weeth/domain/account/presentation/AccountAdminController.kt index ffbbc65b..d44b4a7b 100644 --- a/src/main/kotlin/com/weeth/domain/account/presentation/AccountAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/account/presentation/AccountAdminController.kt @@ -26,7 +26,7 @@ class AccountAdminController( private val manageAccountUseCase: ManageAccountUseCase, ) { @PostMapping - @Operation(summary = "회비 총 금액 기입") + @Operation(summary = "회비 총 금액 기입", hidden = true) fun save( @TsidParam @TsidPathVariable clubId: Long, diff --git a/src/main/kotlin/com/weeth/domain/account/presentation/AccountController.kt b/src/main/kotlin/com/weeth/domain/account/presentation/AccountController.kt index 6bc7e7a3..36035ab5 100644 --- a/src/main/kotlin/com/weeth/domain/account/presentation/AccountController.kt +++ b/src/main/kotlin/com/weeth/domain/account/presentation/AccountController.kt @@ -25,7 +25,7 @@ class AccountController( private val getAccountQueryService: GetAccountQueryService, ) { @GetMapping("/{cardinal}") - @Operation(summary = "회비 내역 조회") + @Operation(summary = "회비 내역 조회", hidden = true) fun find( @TsidParam @TsidPathVariable clubId: Long, diff --git a/src/main/kotlin/com/weeth/domain/account/presentation/ReceiptAdminController.kt b/src/main/kotlin/com/weeth/domain/account/presentation/ReceiptAdminController.kt index 9de17985..86cb2267 100644 --- a/src/main/kotlin/com/weeth/domain/account/presentation/ReceiptAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/account/presentation/ReceiptAdminController.kt @@ -32,7 +32,7 @@ class ReceiptAdminController( private val manageReceiptUseCase: ManageReceiptUseCase, ) { @PostMapping - @Operation(summary = "회비 사용 내역 기입") + @Operation(summary = "회비 사용 내역 기입", hidden = true) fun save( @TsidParam @TsidPathVariable clubId: Long, @@ -44,7 +44,7 @@ class ReceiptAdminController( } @DeleteMapping("/{receiptId}") - @Operation(summary = "회비 사용 내역 취소") + @Operation(summary = "회비 사용 내역 취소", hidden = true) fun delete( @TsidParam @TsidPathVariable clubId: Long, @@ -56,7 +56,7 @@ class ReceiptAdminController( } @PatchMapping("/{receiptId}") - @Operation(summary = "회비 사용 내역 수정") + @Operation(summary = "회비 사용 내역 수정", hidden = true) fun update( @TsidParam @TsidPathVariable clubId: Long, diff --git a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt index 912e045c..8212ff76 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt @@ -41,7 +41,7 @@ class AttendanceController( } @GetMapping - @Operation(summary = "출석 메인페이지") + @Operation(summary = "내 출석 요약 조회") fun find( @TsidParam @TsidPathVariable clubId: Long, @@ -53,7 +53,7 @@ class AttendanceController( ) @GetMapping("/detail") - @Operation(summary = "출석 내역 상세조회") + @Operation(summary = "내 출석 상세 내역 조회") fun findAll( @TsidParam @TsidPathVariable clubId: Long, diff --git a/src/main/kotlin/com/weeth/domain/club/presentation/ClubAdminController.kt b/src/main/kotlin/com/weeth/domain/club/presentation/ClubAdminController.kt index 75997340..753d6b7f 100644 --- a/src/main/kotlin/com/weeth/domain/club/presentation/ClubAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/club/presentation/ClubAdminController.kt @@ -182,7 +182,7 @@ class ClubAdminController( } @PatchMapping("/members/apply-ob") - @Operation(summary = "멤버 OB 기수 등록") + @Operation(summary = "멤버 OB 기수 등록", deprecated = true) fun applyOb( @Parameter(hidden = true) @CurrentUser userId: Long, @TsidParam diff --git a/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyAdminController.kt b/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyAdminController.kt index a119f5d7..fc6d75d6 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyAdminController.kt @@ -37,7 +37,7 @@ class PenaltyAdminController( private val getPenaltyQueryService: GetPenaltyQueryService, ) { @PostMapping - @Operation(summary = "패널티 부여") + @Operation(summary = "패널티 부여", hidden = true) fun assignPenalty( @TsidParam @TsidPathVariable clubId: Long, @@ -49,7 +49,7 @@ class PenaltyAdminController( } @PatchMapping - @Operation(summary = "패널티 수정") + @Operation(summary = "패널티 수정", hidden = true) fun update( @TsidParam @TsidPathVariable clubId: Long, @@ -61,7 +61,7 @@ class PenaltyAdminController( } @GetMapping - @Operation(summary = "전체 패널티 조회") + @Operation(summary = "전체 패널티 조회", hidden = true) fun findAll( @TsidParam @TsidPathVariable clubId: Long, @@ -74,7 +74,7 @@ class PenaltyAdminController( ) @DeleteMapping - @Operation(summary = "패널티 삭제") + @Operation(summary = "패널티 삭제", hidden = true) fun delete( @TsidParam @TsidPathVariable clubId: Long, diff --git a/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyUserController.kt b/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyUserController.kt index 7f2baa53..55898566 100644 --- a/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyUserController.kt +++ b/src/main/kotlin/com/weeth/domain/penalty/presentation/PenaltyUserController.kt @@ -23,7 +23,7 @@ class PenaltyUserController( private val getPenaltyQueryService: GetPenaltyQueryService, ) { @GetMapping - @Operation(summary = "본인 패널티 조회") + @Operation(summary = "본인 패널티 조회", hidden = true) fun findAllPenalties( @TsidParam @TsidPathVariable clubId: Long, diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/response/SocialLoginResponse.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/response/SocialLoginResponse.kt index 43fcbc3b..91c29170 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/dto/response/SocialLoginResponse.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/response/SocialLoginResponse.kt @@ -3,6 +3,8 @@ package com.weeth.domain.user.application.dto.response import io.swagger.v3.oas.annotations.media.Schema data class SocialLoginResponse( + @field:Schema(description = "사용자 이름") + val name: String, @field:Schema(description = "액세스 토큰") val accessToken: String, @field:Schema(description = "리프레시 토큰") diff --git a/src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt b/src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt index deee9cc8..4a317510 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/mapper/UserMapper.kt @@ -10,10 +10,12 @@ class UserMapper( private val fileAccessUrlPort: FileAccessUrlPort, ) { fun toSocialLoginResponse( + userName: String, token: JwtDto, registered: Boolean, ): SocialLoginResponse = SocialLoginResponse( + name = userName, accessToken = token.accessToken, refreshToken = token.refreshToken, registered = registered, diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCase.kt index c16ce401..deddd8e3 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCase.kt @@ -1,5 +1,6 @@ package com.weeth.domain.user.application.usecase.command +import com.fasterxml.jackson.databind.ObjectMapper import com.weeth.domain.user.application.dto.request.SocialLoginRequest import com.weeth.domain.user.application.dto.response.SocialLoginResponse import com.weeth.domain.user.application.exception.EmailNotFoundException @@ -15,6 +16,7 @@ import com.weeth.domain.user.domain.vo.SocialAuthResult import com.weeth.domain.user.infrastructure.SocialAuthPortRegistry import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase import com.weeth.global.auth.jwt.domain.enums.TokenType +import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -25,7 +27,10 @@ class SocialLoginUseCase( private val socialAuthPortRegistry: SocialAuthPortRegistry, private val jwtManageUseCase: JwtManageUseCase, private val userMapper: UserMapper, + private val objectMapper: ObjectMapper, ) { + private val log = LoggerFactory.getLogger(javaClass) + @Transactional fun socialLoginByKakao(request: SocialLoginRequest): SocialLoginResponse = socialLogin(SocialProvider.KAKAO, request) @@ -34,18 +39,45 @@ class SocialLoginUseCase( fun socialLoginByApple(request: SocialLoginRequest): SocialLoginResponse = socialLogin(SocialProvider.APPLE, request) + /** + * Apple form_post 콜백 전용 로그인. + * id_token을 직접 검증하여 code 교환 과정을 생략하고, + * Apple이 최초 인가 시에만 전달하는 user JSON의 이름을 반영한다. + * + * TODO: 탈퇴 기능 구현 시 Apple 계정 연결 해제(revoke)를 위해 + * 콜백의 code를 Apple 토큰 엔드포인트에 교환하여 refresh token을 받고 DB에 저장해야 한다. + * (Apple Revoke Tokens API: POST https://appleid.apple.com/auth/revoke) + */ + @Transactional + fun socialLoginByAppleCallback( + idToken: String, + userJson: String?, + ): SocialLoginResponse { + val authResult = socialAuthPortRegistry.get(SocialProvider.APPLE).authenticateWithIdToken(idToken) + val userName = parseAppleUserName(userJson) + val effectiveResult = + if (!userName.isNullOrBlank() && authResult.name.isNullOrBlank()) { + authResult.copy(name = userName) + } else { + authResult + } + return processLogin(effectiveResult) + } + private fun socialLogin( provider: SocialProvider, request: SocialLoginRequest, - ): SocialLoginResponse { - val user = findOrCreateUser(authResult = socialAuthPortRegistry.get(provider).authenticate(request.authCode)) + ): SocialLoginResponse = processLogin(socialAuthPortRegistry.get(provider).authenticate(request.authCode)) + + private fun processLogin(authResult: SocialAuthResult): SocialLoginResponse { + val user = findOrCreateUser(authResult) if (user.isBannedOrLeft()) throw UserInActiveException() val tokenType = if (user.isRegistered()) TokenType.ACCESS else TokenType.TEMPORARY val token = jwtManageUseCase.create(user.id, user.emailValue, tokenType) - return userMapper.toSocialLoginResponse(token, user.isRegistered()) + return userMapper.toSocialLoginResponse(user.name, token, user.isRegistered()) } // TODO: 실제 서비스 출시 시 이메일 기반 기존 사용자 연동 및 유저 알림 기능 필요 @@ -79,4 +111,18 @@ class SocialLoginUseCase( return user } + + private fun parseAppleUserName(userJson: String?): String? { + if (userJson.isNullOrBlank()) return null + return try { + val node = objectMapper.readTree(userJson) + val nameNode = node["name"] ?: return null + val firstName = nameNode["firstName"]?.asText()?.trim() ?: "" + val lastName = nameNode["lastName"]?.asText()?.trim() ?: "" + "$lastName$firstName".trim().takeIf { it.isNotBlank() } + } catch (e: Exception) { + log.warn("Apple user JSON 파싱 실패: {}", e.message) + null + } + } } diff --git a/src/main/kotlin/com/weeth/domain/user/domain/port/SocialAuthPort.kt b/src/main/kotlin/com/weeth/domain/user/domain/port/SocialAuthPort.kt index 40cc5b54..9e0cf052 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/port/SocialAuthPort.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/port/SocialAuthPort.kt @@ -7,4 +7,7 @@ interface SocialAuthPort { fun provider(): SocialProvider fun authenticate(authCode: String): SocialAuthResult + + fun authenticateWithIdToken(idToken: String): SocialAuthResult = + throw UnsupportedOperationException("${provider()}은(는) ID token 직접 인증을 지원하지 않습니다") } diff --git a/src/main/kotlin/com/weeth/domain/user/infrastructure/AppleSocialAuthAdapter.kt b/src/main/kotlin/com/weeth/domain/user/infrastructure/AppleSocialAuthAdapter.kt index c2f416b5..318bbfde 100644 --- a/src/main/kotlin/com/weeth/domain/user/infrastructure/AppleSocialAuthAdapter.kt +++ b/src/main/kotlin/com/weeth/domain/user/infrastructure/AppleSocialAuthAdapter.kt @@ -4,6 +4,7 @@ import com.weeth.domain.user.domain.enums.SocialProvider import com.weeth.domain.user.domain.port.SocialAuthPort import com.weeth.domain.user.domain.vo.SocialAuthResult import com.weeth.global.auth.apple.AppleAuthService +import com.weeth.global.auth.apple.dto.AppleUserInfo import org.springframework.stereotype.Component @Component @@ -15,15 +16,20 @@ class AppleSocialAuthAdapter( override fun authenticate(authCode: String): SocialAuthResult { val appleToken = appleAuthService.getAppleToken(authCode) val userInfo = appleAuthService.verifyAndDecodeIdToken(appleToken.idToken) - val email = userInfo.email?.trim()?.lowercase() ?: "" - val providerName = userInfo.name?.trim()?.takeIf { it.isNotBlank() } + return toSocialAuthResult(userInfo) + } + + override fun authenticateWithIdToken(idToken: String): SocialAuthResult { + val userInfo = appleAuthService.verifyAndDecodeIdToken(idToken) + return toSocialAuthResult(userInfo) + } - return SocialAuthResult( + private fun toSocialAuthResult(userInfo: AppleUserInfo): SocialAuthResult = + SocialAuthResult( provider = SocialProvider.APPLE, providerUserId = userInfo.appleId, - email = email, + email = userInfo.email?.trim()?.lowercase() ?: "", emailVerified = userInfo.emailVerified, - name = providerName, + name = userInfo.name?.trim()?.takeIf { it.isNotBlank() }, ) - } } diff --git a/src/main/kotlin/com/weeth/domain/user/presentation/SocialCallbackController.kt b/src/main/kotlin/com/weeth/domain/user/presentation/SocialCallbackController.kt new file mode 100644 index 00000000..4f80505c --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/presentation/SocialCallbackController.kt @@ -0,0 +1,84 @@ +package com.weeth.domain.user.presentation + +import com.weeth.domain.user.application.usecase.command.SocialLoginUseCase +import com.weeth.global.auth.jwt.application.service.TokenCookieProvider +import com.weeth.global.config.properties.OAuthProperties +import io.swagger.v3.oas.annotations.Hidden +import org.slf4j.LoggerFactory +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.util.UriComponentsBuilder + +/** + * Apple Sign in with Apple의 form_post 콜백을 처리하는 컨트롤러. + */ +@Hidden +@RestController +class SocialCallbackController( + private val socialLoginUseCase: SocialLoginUseCase, + private val tokenCookieProvider: TokenCookieProvider, + oAuthProperties: OAuthProperties, +) { + private val log = LoggerFactory.getLogger(javaClass) + private val frontendRedirectUri = oAuthProperties.apple.frontendRedirectUri + + @PostMapping( + "/api/v4/users/social/apple/callback", + consumes = [MediaType.APPLICATION_FORM_URLENCODED_VALUE], + ) + fun handleCallback( + @RequestParam("id_token", required = false) idToken: String?, + @RequestParam("user", required = false) userJson: String?, + @RequestParam("error", required = false) error: String?, + ): ResponseEntity { + if (error != null || idToken.isNullOrBlank()) { + return redirect( + UriComponentsBuilder + .fromUriString(frontendRedirectUri) + .queryParam("error", error ?: "unknown") + .toUriString(), + ) + } + + return try { + val response = socialLoginUseCase.socialLoginByAppleCallback(idToken, userJson) + + val redirectUri = + UriComponentsBuilder + .fromUriString(frontendRedirectUri) + .queryParam("registered", response.registered) + .queryParam("name", response.name) + .toUriString() + + ResponseEntity + .status(HttpStatus.FOUND) + .header(HttpHeaders.LOCATION, redirectUri) + .header( + HttpHeaders.SET_COOKIE, + tokenCookieProvider.createAccessTokenCookie(response.accessToken).toString(), + ).header( + HttpHeaders.SET_COOKIE, + tokenCookieProvider.createRefreshTokenCookie(response.refreshToken).toString(), + ).build() + } catch (e: Exception) { + log.error("Apple 콜백 처리 중 오류 발생", e) + redirect( + UriComponentsBuilder + .fromUriString(frontendRedirectUri) + .queryParam("error", "login_failed") + .toUriString(), + ) + } + } + + private fun redirect(uri: String): ResponseEntity = + ResponseEntity + .status(HttpStatus.FOUND) + .header(HttpHeaders.LOCATION, uri) + .build() +} diff --git a/src/main/kotlin/com/weeth/global/common/controller/ExceptionDocController.kt b/src/main/kotlin/com/weeth/global/common/controller/ExceptionDocController.kt index 1bc82ac0..60e2f038 100644 --- a/src/main/kotlin/com/weeth/global/common/controller/ExceptionDocController.kt +++ b/src/main/kotlin/com/weeth/global/common/controller/ExceptionDocController.kt @@ -56,7 +56,6 @@ class ExceptionDocController { fun userErrorCodes() { } - // todo: SAS 관련 예외도 추가 @GetMapping("/auth") @Operation(summary = "인증/인가 에러 코드 목록") @ApiErrorCodeExample(JwtErrorCode::class) diff --git a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt index 33a27fef..cec24e64 100644 --- a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt +++ b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt @@ -44,6 +44,7 @@ class SecurityConfig( .requestMatchers( "/api/v4/users/social/kakao", "/api/v4/users/social/apple", + "/api/v4/users/social/apple/callback", "/api/v4/users/social/refresh", "/api/v4/users/inquiries", ).permitAll() @@ -88,6 +89,7 @@ class SecurityConfig( "http://127.0.0.1:*", "https://13.124.170.169.nip.io", "https://develop.d2o3vlabneheuu.amplifyapp.com", + "https://appleid.apple.com", ) allowedMethods = listOf("GET", "POST", "PATCH", "DELETE", "OPTIONS") allowedHeaders = listOf("*") diff --git a/src/main/kotlin/com/weeth/global/config/properties/OAuthProperties.kt b/src/main/kotlin/com/weeth/global/config/properties/OAuthProperties.kt index 9d845342..d96ea1c4 100644 --- a/src/main/kotlin/com/weeth/global/config/properties/OAuthProperties.kt +++ b/src/main/kotlin/com/weeth/global/config/properties/OAuthProperties.kt @@ -40,5 +40,7 @@ data class OAuthProperties( val keysUri: String, @field:NotBlank val privateKeyPath: String, + @field:NotBlank + val frontendRedirectUri: String, ) } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d955eb5e..9bd2cbb5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -28,6 +28,7 @@ auth: token_uri: https://appleid.apple.com/auth/token keys_uri: https://appleid.apple.com/auth/keys private_key_path: ${APPLE_PRIVATE_KEY_PATH} + frontend_redirect_uri: ${APPLE_FRONTEND_REDIRECT_URI} management: endpoints: diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCaseTest.kt index bbc853ca..14a4bac5 100644 --- a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/SocialLoginUseCaseTest.kt @@ -1,5 +1,6 @@ package com.weeth.domain.user.application.usecase.command +import com.fasterxml.jackson.databind.ObjectMapper import com.weeth.domain.file.domain.port.FileAccessUrlPort import com.weeth.domain.user.application.dto.request.SocialLoginRequest import com.weeth.domain.user.application.exception.EmailNotFoundException @@ -7,7 +8,6 @@ import com.weeth.domain.user.application.mapper.UserMapper import com.weeth.domain.user.domain.entity.User import com.weeth.domain.user.domain.entity.UserSocialAccount import com.weeth.domain.user.domain.enums.SocialProvider -import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.port.SocialAuthPort import com.weeth.domain.user.domain.repository.UserRepository import com.weeth.domain.user.domain.repository.UserSocialAccountRepository @@ -35,6 +35,7 @@ class SocialLoginUseCaseTest : val jwtManageUseCase = mockk() val fileAccessUrlPort = mockk() val userMapper = UserMapper(fileAccessUrlPort) + val objectMapper = mockk() val useCase = SocialLoginUseCase( @@ -43,6 +44,7 @@ class SocialLoginUseCaseTest : socialAuthPortRegistry = socialAuthPortRegistry, jwtManageUseCase = jwtManageUseCase, userMapper = userMapper, + objectMapper, ) beforeTest { From 6c025b2418a480978d8ac64f8d23f6bb81cd1b30 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Wed, 1 Apr 2026 15:33:52 +0900 Subject: [PATCH 46/73] =?UTF-8?q?HOTFIX:=20CORS=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/com/weeth/global/config/SecurityConfig.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt index cec24e64..dc9dc607 100644 --- a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt +++ b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt @@ -87,8 +87,7 @@ class SecurityConfig( listOf( "http://localhost:*", "http://127.0.0.1:*", - "https://13.124.170.169.nip.io", - "https://develop.d2o3vlabneheuu.amplifyapp.com", + "https://*.v4.weeth.kr", "https://appleid.apple.com", ) allowedMethods = listOf("GET", "POST", "PATCH", "DELETE", "OPTIONS") From 8030bdc08fa6d61dedf94d749791d2f1adf0b441 Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Wed, 1 Apr 2026 22:43:34 +0900 Subject: [PATCH 47/73] =?UTF-8?q?fix:=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=EC=9D=B4=20=EB=B3=80=EA=B2=BD=EB=90=9C=20?= =?UTF-8?q?=EA=B2=BD=EC=9A=B0=20=EB=B0=94=EB=A1=9C=20=EB=B0=98=EC=98=81=20?= =?UTF-8?q?=EB=90=98=EB=8F=84=EB=A1=9D=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#46)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/dev/scripts/deploy.sh | 9 +++++++-- infra/prod/scripts/deploy.sh | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/infra/dev/scripts/deploy.sh b/infra/dev/scripts/deploy.sh index bef723d8..9ed3c0c9 100755 --- a/infra/dev/scripts/deploy.sh +++ b/infra/dev/scripts/deploy.sh @@ -48,8 +48,13 @@ done echo "reverse_proxy weeth-dev-app-${NEW_COLOR}:8080" > ./caddy/upstream.conf -# Caddy가 실행 중이면 reload, 아니면 시작 -if docker compose ps caddy --format '{{.State}}' 2>/dev/null | grep -q running; then +# 현재 Caddy 컨테이너의 DOMAIN과 비교하여 변경 시에만 재생성 +CURRENT_DOMAIN=$(docker inspect weeth-dev-caddy --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null | grep '^DOMAIN=' | cut -d= -f2-) + +if [ "$CURRENT_DOMAIN" != "$DOMAIN" ]; then + echo "[deploy] domain changed, recreating caddy" + docker compose up -d --force-recreate caddy +elif docker compose ps caddy --format '{{.State}}' 2>/dev/null | grep -q running; then docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile else docker compose up -d caddy diff --git a/infra/prod/scripts/deploy.sh b/infra/prod/scripts/deploy.sh index 2033b362..5e9b4bc9 100755 --- a/infra/prod/scripts/deploy.sh +++ b/infra/prod/scripts/deploy.sh @@ -48,8 +48,13 @@ done echo "reverse_proxy weeth-prod-app-${NEW_COLOR}:8080" > ./caddy/upstream.conf -# Caddy가 실행 중이면 reload, 아니면 시작 -if docker compose ps caddy --format '{{.State}}' 2>/dev/null | grep -q running; then +# 현재 Caddy 컨테이너의 DOMAIN과 비교하여 변경 시에만 재생성 +CURRENT_DOMAIN=$(docker inspect weeth-prod-caddy --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null | grep '^DOMAIN=' | cut -d= -f2-) + +if [ "$CURRENT_DOMAIN" != "$DOMAIN" ]; then + echo "[deploy] domain changed, recreating caddy" + docker compose up -d --force-recreate caddy +elif docker compose ps caddy --format '{{.State}}' 2>/dev/null | grep -q running; then docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile else docker compose up -d caddy From d94f5c75fa2c987b94b230e9369f4160d7e7e7dd Mon Sep 17 00:00:00 2001 From: Jeon Soo Hyeon <128474444+soo0711@users.noreply.github.com> Date: Tue, 7 Apr 2026 21:04:54 +0900 Subject: [PATCH 48/73] =?UTF-8?q?[WTH-251]=20post=20comment=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=EC=9E=90=20=ED=83=80=EC=9E=85=EC=9D=84=20club=20membe?= =?UTF-8?q?r=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20(#48)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: Post/Comment user 필드 clubMember로 변경 * refactor: clubMember로 fetch join * refactor: userReader 제거 * refactor: Mapper authorMember 제거 * refactor: Service buildMemberMap 제거 * refactor: Post/Comment fixture user를 clubMember로 변경 * test: 관련 test 수정 * test: 관련 test 수정 --- .../board/application/mapper/PostMapper.kt | 6 +- .../usecase/command/ManagePostUseCase.kt | 7 +- .../usecase/query/GetPostQueryService.kt | 28 +------ .../weeth/domain/board/domain/entity/Post.kt | 14 ++-- .../board/domain/repository/PostRepository.kt | 16 ++-- .../application/mapper/CommentMapper.kt | 8 +- .../usecase/command/ManageCommentUseCase.kt | 9 +- .../usecase/query/GetCommentQueryService.kt | 14 +--- .../domain/comment/domain/entity/Comment.kt | 12 +-- .../domain/repository/CommentRepository.kt | 10 +++ .../application/mapper/DashboardMapper.kt | 3 +- .../usecase/query/GetDashboardQueryService.kt | 12 --- .../application/mapper/PostMapperTest.kt | 7 +- .../usecase/command/ManagePostUseCaseTest.kt | 83 +++++-------------- .../usecase/query/GetPostQueryServiceTest.kt | 45 +++------- .../domain/board/fixture/PostTestFixture.kt | 8 +- .../usecase/command/CommentConcurrencyTest.kt | 18 +++- .../command/ManageCommentUseCaseTest.kt | 57 ++++++++----- .../query/CommentQueryPerformanceTest.kt | 49 +++++------ .../query/GetCommentQueryServiceTest.kt | 29 ++++--- .../domain/entity/CommentEntityTest.kt | 12 +-- .../comment/fixture/CommentTestFixture.kt | 7 +- .../query/GetDashboardQueryServiceTest.kt | 9 +- 23 files changed, 189 insertions(+), 274 deletions(-) diff --git a/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt b/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt index 62a63ffe..8a6e1f75 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt @@ -26,7 +26,6 @@ class PostMapper( fun toDetailResponse( post: Post, - authorMember: ClubMember, comments: List, files: List, isLiked: Boolean, @@ -34,7 +33,7 @@ class PostMapper( id = post.id, boardId = post.board.id, boardName = post.board.name, - author = UserInfo.of(post.user, authorMember.memberRole, resolveProfileImage(authorMember)), + author = UserInfo.of(post.clubMember.user, post.clubMember.memberRole, resolveProfileImage(post.clubMember)), title = post.title, content = post.content, time = post.modifiedAt, @@ -46,13 +45,12 @@ class PostMapper( fun toListResponse( post: Post, - authorMember: ClubMember, hasFile: Boolean, now: LocalDateTime, isLiked: Boolean, ) = PostListResponse( id = post.id, - author = UserInfo.of(post.user, authorMember.memberRole, resolveProfileImage(authorMember)), + author = UserInfo.of(post.clubMember.user, post.clubMember.memberRole, resolveProfileImage(post.clubMember)), boardId = post.board.id, boardName = post.board.name, title = post.title, diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt index 7868d60d..6dce1ddc 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt @@ -20,7 +20,6 @@ import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.repository.FileReader import com.weeth.domain.file.domain.repository.FileRepository -import com.weeth.domain.user.domain.repository.UserReader import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -28,7 +27,6 @@ import org.springframework.transaction.annotation.Transactional class ManagePostUseCase( private val postRepository: PostRepository, private val boardRepository: BoardRepository, - private val userReader: UserReader, private val clubMemberPolicy: ClubMemberPolicy, private val clubMemberCardinalReader: ClubMemberCardinalReader, private val fileRepository: FileRepository, @@ -44,7 +42,6 @@ class ManagePostUseCase( userId: Long, ): PostSaveResponse { val member = clubMemberPolicy.getActiveMember(clubId, userId) - val user = userReader.getById(userId) val board = findBoardInClub(boardId, clubId) validateWritePermission(board, member) @@ -57,7 +54,7 @@ class ManagePostUseCase( Post.create( title = request.title, content = request.content, - user = user, + clubMember = member, board = board, cardinalNumber = currentCardinalNumber, ) @@ -75,7 +72,6 @@ class ManagePostUseCase( userId: Long, ): PostSaveResponse { val member = clubMemberPolicy.getActiveMember(clubId, userId) - val user = userReader.getById(userId) val post = findPost(postId) if (post.board.club.id != clubId) throw PostNotFoundException() validateOwner(post, userId) @@ -97,7 +93,6 @@ class ManagePostUseCase( userId: Long, ) { val member = clubMemberPolicy.getActiveMember(clubId, userId) - val user = userReader.getById(userId) val post = findPost(postId) if (post.board.club.id != clubId) throw PostNotFoundException() validateOwner(post, userId) diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt index 2f868ca9..e5b26f58 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt @@ -10,9 +10,7 @@ import com.weeth.domain.board.application.mapper.PostMapper import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.board.domain.repository.PostLikeRepository import com.weeth.domain.board.domain.repository.PostRepository -import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.club.domain.enums.MemberRole -import com.weeth.domain.club.domain.repository.ClubMemberReader import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.comment.application.usecase.query.GetCommentQueryService import com.weeth.domain.comment.domain.repository.CommentReader @@ -34,7 +32,6 @@ class GetPostQueryService( private val boardRepository: BoardRepository, private val postLikeRepository: PostLikeRepository, private val clubMemberPolicy: ClubMemberPolicy, - private val clubMemberReader: ClubMemberReader, private val commentReader: CommentReader, private val getCommentQueryService: GetCommentQueryService, private val fileReader: FileReader, @@ -60,14 +57,10 @@ class GetPostQueryService( val files = fileReader.findAll(FileOwnerType.POST, post.id).map(fileMapper::toFileResponse) val comments = commentReader.findAllByPostId(post.id) - val commentAuthorIds = comments.map { it.user.id }.distinct() - val allAuthorIds = (commentAuthorIds + post.user.id).distinct() - val memberMap = buildMemberMap(clubId, allAuthorIds) - - val commentTree = getCommentQueryService.toCommentTreeResponses(comments, memberMap) + val commentTree = getCommentQueryService.toCommentTreeResponses(comments) val isLiked = postLikeRepository.existsByPostAndUserIdAndIsActiveTrue(post, userId) - return postMapper.toDetailResponse(post, memberMap.getValue(post.user.id), commentTree, files, isLiked) + return postMapper.toDetailResponse(post, commentTree, files, isLiked) } fun findAllPosts( @@ -93,7 +86,6 @@ class GetPostQueryService( val posts = postRepository.findAllActiveByBoardIds(accessibleBoardIds, pageable) val postIds = posts.content.map { it.id } - val memberMap = buildMemberMap(clubId, posts.content.map { it.user.id }.distinct()) val fileExistsByPostId = buildFileExistsMap(postIds) val likedPostIds = postLikeRepository.findLikedPostIds(postIds, userId) val now = LocalDateTime.now() @@ -101,7 +93,6 @@ class GetPostQueryService( return posts.map { post -> postMapper.toListResponse( post, - memberMap.getValue(post.user.id), fileExistsByPostId[post.id] == true, now, post.id in likedPostIds, @@ -125,14 +116,12 @@ class GetPostQueryService( val postIds = posts.content.map { it.id } val fileExistsByPostId = buildFileExistsMap(postIds) - val memberMap = buildMemberMap(clubId, posts.content.map { it.user.id }.distinct()) val likedPostIds = postLikeRepository.findLikedPostIds(postIds, userId) val now = LocalDateTime.now() return posts.map { post -> postMapper.toListResponse( post, - memberMap.getValue(post.user.id), fileExistsByPostId[post.id] == true, now, post.id in likedPostIds, @@ -160,14 +149,12 @@ class GetPostQueryService( val postIds = posts.content.map { it.id } val fileExistsByPostId = buildFileExistsMap(postIds) - val memberMap = buildMemberMap(clubId, posts.content.map { it.user.id }.distinct()) val likedPostIds = postLikeRepository.findLikedPostIds(postIds, userId) val now = LocalDateTime.now() return posts.map { post -> postMapper.toListResponse( post, - memberMap.getValue(post.user.id), fileExistsByPostId[post.id] == true, now, post.id in likedPostIds, @@ -175,17 +162,6 @@ class GetPostQueryService( } } - /** - * Post, Comment 조회 시 작성자 정보를 매핑하기 위한 헬퍼 메서드 - */ - private fun buildMemberMap( - clubId: Long, - userIds: List, - ): Map { - if (userIds.isEmpty()) return emptyMap() - return clubMemberReader.findAllByClubIdAndUserIds(clubId, userIds).associateBy { it.user.id } - } - private fun validatePage( pageNumber: Int, pageSize: Int, diff --git a/src/main/kotlin/com/weeth/domain/board/domain/entity/Post.kt b/src/main/kotlin/com/weeth/domain/board/domain/entity/Post.kt index 32d886f8..7e949f69 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/entity/Post.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/entity/Post.kt @@ -1,6 +1,6 @@ package com.weeth.domain.board.domain.entity -import com.weeth.domain.user.domain.entity.User +import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.global.common.entity.BaseEntity import jakarta.persistence.Column import jakarta.persistence.Entity @@ -17,7 +17,7 @@ import jakarta.persistence.Table class Post( title: String, content: String, - user: User, + clubMember: ClubMember, board: Board, cardinalNumber: Int? = null, ) : BaseEntity() { @@ -35,8 +35,8 @@ class Post( private set @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "user_id", nullable = false) - var user: User = user + @JoinColumn(name = "club_member_id", nullable = false) + var clubMember: ClubMember = clubMember private set @ManyToOne(fetch = FetchType.LAZY, optional = false) @@ -78,7 +78,7 @@ class Post( likeCount-- } - fun isOwnedBy(userId: Long): Boolean = user.id == userId + fun isOwnedBy(userId: Long): Boolean = clubMember.user.id == userId fun belongsToClub(clubId: Long): Boolean = board.club.id == clubId && !board.isDeleted @@ -108,7 +108,7 @@ class Post( fun create( title: String, content: String, - user: User, + clubMember: ClubMember, board: Board, cardinalNumber: Int? = null, ): Post { @@ -117,7 +117,7 @@ class Post( return Post( title = title, content = content, - user = user, + clubMember = clubMember, board = board, cardinalNumber = cardinalNumber, ) diff --git a/src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt index b2aef8ff..d15a994f 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt @@ -19,7 +19,7 @@ import java.time.LocalDateTime interface PostRepository : JpaRepository, PostReader { - @EntityGraph(attributePaths = ["user", "board"]) + @EntityGraph(attributePaths = ["clubMember", "clubMember.user", "board"]) @Query( """ SELECT p @@ -35,7 +35,7 @@ interface PostRepository : pageable: Pageable, ): Slice - @EntityGraph(attributePaths = ["user", "board"]) + @EntityGraph(attributePaths = ["clubMember", "clubMember.user", "board"]) @Query( """ SELECT p @@ -52,6 +52,7 @@ interface PostRepository : fun findByIdAndIsDeletedFalse(id: Long): Post? + @EntityGraph(attributePaths = ["clubMember", "clubMember.user", "board"]) @Query( """ SELECT p @@ -65,6 +66,7 @@ interface PostRepository : @Param("id") id: Long, ): Post? + @EntityGraph(attributePaths = ["board", "board.club"]) @Lock(LockModeType.PESSIMISTIC_WRITE) @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) @Query( @@ -80,7 +82,7 @@ interface PostRepository : @Param("id") id: Long, ): Post? - @EntityGraph(attributePaths = ["user", "board"]) + @EntityGraph(attributePaths = ["clubMember", "clubMember.user", "board"]) @Query( """ SELECT p @@ -106,7 +108,7 @@ interface PostRepository : pageable: Pageable, ): Slice = findAllActiveByBoardIds(boardIds, pageable) - @EntityGraph(attributePaths = ["user"]) + @EntityGraph(attributePaths = ["clubMember", "clubMember.user"]) @Query( """ SELECT p @@ -122,7 +124,7 @@ interface PostRepository : pageable: Pageable, ): Slice - @EntityGraph(attributePaths = ["user"]) + @EntityGraph(attributePaths = ["clubMember", "clubMember.user"]) @Query( """ SELECT p @@ -138,7 +140,7 @@ interface PostRepository : pageable: Pageable, ): Slice - @EntityGraph(attributePaths = ["user"]) + @EntityGraph(attributePaths = ["clubMember", "clubMember.user"]) @Query( """ SELECT p @@ -156,7 +158,7 @@ interface PostRepository : pageable: Pageable, ): Slice - @EntityGraph(attributePaths = ["user"]) + @EntityGraph(attributePaths = ["clubMember", "clubMember.user"]) @Query( """ SELECT p diff --git a/src/main/kotlin/com/weeth/domain/comment/application/mapper/CommentMapper.kt b/src/main/kotlin/com/weeth/domain/comment/application/mapper/CommentMapper.kt index 006b5b0d..0a167ecf 100644 --- a/src/main/kotlin/com/weeth/domain/comment/application/mapper/CommentMapper.kt +++ b/src/main/kotlin/com/weeth/domain/comment/application/mapper/CommentMapper.kt @@ -1,6 +1,5 @@ package com.weeth.domain.comment.application.mapper -import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.comment.application.dto.response.CommentResponse import com.weeth.domain.comment.domain.entity.Comment import com.weeth.domain.file.application.dto.response.FileResponse @@ -14,7 +13,6 @@ class CommentMapper( ) { fun toCommentDto( comment: Comment, - authorMember: ClubMember, children: List, fileUrls: List, ): CommentResponse = @@ -22,9 +20,9 @@ class CommentMapper( id = comment.id, author = UserInfo.of( - comment.user, - authorMember.memberRole, - authorMember.profileImageStorageKey?.let { fileAccessUrlPort.resolve(it) }, + comment.clubMember.user, + comment.clubMember.memberRole, + comment.clubMember.profileImageStorageKey?.let { fileAccessUrlPort.resolve(it) }, ), content = comment.content, time = comment.modifiedAt, diff --git a/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt b/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt index cb6e2444..cbc2501d 100644 --- a/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt @@ -3,6 +3,7 @@ package com.weeth.domain.comment.application.usecase.command import com.weeth.domain.board.application.exception.PostNotFoundException import com.weeth.domain.board.domain.entity.Post import com.weeth.domain.board.domain.repository.PostRepository +import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.comment.application.dto.request.CommentSaveRequest import com.weeth.domain.comment.application.dto.request.CommentUpdateRequest import com.weeth.domain.comment.application.exception.CommentAlreadyDeletedException @@ -15,7 +16,6 @@ import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.repository.FileReader import com.weeth.domain.file.domain.repository.FileRepository -import com.weeth.domain.user.domain.repository.UserReader import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -23,7 +23,7 @@ import org.springframework.transaction.annotation.Transactional class ManageCommentUseCase( private val commentRepository: CommentRepository, private val postRepository: PostRepository, // 타 도메인 이므로 Reader 사용 검토 - private val userReader: UserReader, + private val clubMemberPolicy: ClubMemberPolicy, private val fileReader: FileReader, private val fileRepository: FileRepository, private val fileMapper: FileMapper, @@ -34,8 +34,9 @@ class ManageCommentUseCase( postId: Long, userId: Long, ) { - val user = userReader.getById(userId) val post = findPostWithLock(postId) + val clubId = post.board.club.id + val clubMember = clubMemberPolicy.getActiveMember(clubId, userId) val parent = dto.parentCommentId?.let { parentId -> commentRepository.findByIdAndPostId(parentId, postId) ?: throw CommentNotFoundException() @@ -45,7 +46,7 @@ class ManageCommentUseCase( Comment.createForPost( content = dto.content, post = post, - user = user, + clubMember = clubMember, parent = parent, ) val savedComment = commentRepository.save(comment) diff --git a/src/main/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryService.kt b/src/main/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryService.kt index 54e9592a..1c698b91 100644 --- a/src/main/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryService.kt @@ -1,6 +1,5 @@ package com.weeth.domain.comment.application.usecase.query -import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.comment.application.dto.response.CommentResponse import com.weeth.domain.comment.application.mapper.CommentMapper import com.weeth.domain.comment.domain.entity.Comment @@ -21,10 +20,7 @@ class GetCommentQueryService( /** * Comment 리스트를 받아 자식, 부모 관계 트리를 형성하는 메서드 */ - fun toCommentTreeResponses( - comments: List, - memberMap: Map, - ): List { + fun toCommentTreeResponses(comments: List): List { if (comments.isEmpty()) { return emptyList() } @@ -42,18 +38,17 @@ class GetCommentQueryService( return comments .filter { it.parent == null } - .map { mapToCommentResponse(it, childrenByParentId, filesByCommentId, memberMap) } + .map { mapToCommentResponse(it, childrenByParentId, filesByCommentId) } } private fun mapToCommentResponse( comment: Comment, childrenByParentId: Map>, filesByCommentId: Map>, - memberMap: Map, ): CommentResponse { val children = childrenByParentId[comment.id] - ?.map { mapToCommentResponse(it, childrenByParentId, filesByCommentId, memberMap) } + ?.map { mapToCommentResponse(it, childrenByParentId, filesByCommentId) } ?: emptyList() val files = @@ -61,7 +56,6 @@ class GetCommentQueryService( ?.map(fileMapper::toFileResponse) ?: emptyList() - val authorMember = memberMap.getValue(comment.user.id) - return commentMapper.toCommentDto(comment, authorMember, children, files) + return commentMapper.toCommentDto(comment, children, files) } } diff --git a/src/main/kotlin/com/weeth/domain/comment/domain/entity/Comment.kt b/src/main/kotlin/com/weeth/domain/comment/domain/entity/Comment.kt index f070b906..2dbcef75 100644 --- a/src/main/kotlin/com/weeth/domain/comment/domain/entity/Comment.kt +++ b/src/main/kotlin/com/weeth/domain/comment/domain/entity/Comment.kt @@ -1,8 +1,8 @@ package com.weeth.domain.comment.domain.entity import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.comment.domain.vo.CommentContent -import com.weeth.domain.user.domain.entity.User import com.weeth.global.common.entity.BaseEntity import jakarta.persistence.CascadeType import jakarta.persistence.Column @@ -31,8 +31,8 @@ class Comment( @JoinColumn(name = "post_id") val post: Post, @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id") - val user: User, + @JoinColumn(name = "club_member_id") + val clubMember: ClubMember, @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "parent_id") val parent: Comment? = null, @@ -50,7 +50,7 @@ class Comment( content = CommentContent.from(newContent).value } - fun isOwnedBy(userId: Long): Boolean = user.id == userId + fun isOwnedBy(userId: Long): Boolean = clubMember.user.id == userId companion object { private const val DELETED_CONTENT = "삭제된 댓글입니다." @@ -58,7 +58,7 @@ class Comment( fun createForPost( content: String, post: Post, - user: User, + clubMember: ClubMember, parent: Comment?, ): Comment { require(parent == null || parent.post.id == post.id) { @@ -67,7 +67,7 @@ class Comment( return Comment( content = CommentContent.from(content).value, post = post, - user = user, + clubMember = clubMember, parent = parent, ) } diff --git a/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentRepository.kt b/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentRepository.kt index 3728d37d..b511b037 100644 --- a/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentRepository.kt +++ b/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentRepository.kt @@ -1,13 +1,23 @@ package com.weeth.domain.comment.domain.repository import com.weeth.domain.comment.domain.entity.Comment +import org.springframework.data.jpa.repository.EntityGraph import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param interface CommentRepository : JpaRepository, CommentReader { + @EntityGraph(attributePaths = ["clubMember", "clubMember.user"]) fun findByIdAndPostId( id: Long, postId: Long, ): Comment? + + @EntityGraph(attributePaths = ["clubMember", "clubMember.user"]) + @Query("SELECT c FROM Comment c WHERE c.post.id = :postId") + override fun findAllByPostId( + @Param("postId") postId: Long, + ): List } diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/mapper/DashboardMapper.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/mapper/DashboardMapper.kt index 97731d9a..73a35942 100644 --- a/src/main/kotlin/com/weeth/domain/dashboard/application/mapper/DashboardMapper.kt +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/mapper/DashboardMapper.kt @@ -99,12 +99,11 @@ class DashboardMapper( fun toPostResponse( post: Post, - authorMember: ClubMember, files: List, now: LocalDateTime, ) = DashboardPostResponse( id = post.id, - author = UserInfo.of(post.user, authorMember.memberRole, resolveProfileImage(authorMember)), + author = UserInfo.of(post.clubMember.user, post.clubMember.memberRole, resolveProfileImage(post.clubMember)), title = post.title, content = post.content, time = post.createdAt, diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryService.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryService.kt index c6e56108..7cac5e13 100644 --- a/src/main/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryService.kt @@ -3,7 +3,6 @@ package com.weeth.domain.dashboard.application.usecase.query import com.weeth.domain.board.domain.enums.BoardType import com.weeth.domain.board.domain.repository.BoardReader import com.weeth.domain.board.domain.repository.PostReader -import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.club.domain.repository.ClubMemberReader import com.weeth.domain.club.domain.repository.ClubReader import com.weeth.domain.club.domain.service.ClubMemberPolicy @@ -90,13 +89,10 @@ class GetDashboardQueryService( val now = LocalDateTime.now() val postIds = posts.content.map { it.id } val filesByPostId = fileReader.findAll(FileOwnerType.POST, postIds).groupBy { it.ownerId } - val authorIds = posts.content.map { it.user.id }.distinct() - val memberMap = buildMemberMap(clubId, authorIds) return posts.map { post -> dashboardMapper.toPostResponse( post = post, - authorMember = memberMap.getValue(post.user.id), files = filesByPostId[post.id] ?: emptyList(), now = now, ) @@ -131,14 +127,6 @@ class GetDashboardQueryService( return dashboardMapper.toScheduleResponses(events, sessions) } - private fun buildMemberMap( - clubId: Long, - userIds: List, - ): Map { - if (userIds.isEmpty()) return emptyMap() - return clubMemberReader.findAllByClubIdAndUserIds(clubId, userIds).associateBy { it.user.id } - } - fun getUnreadNotice( clubId: Long, userId: Long, diff --git a/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt b/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt index 2ea39cfc..52f28963 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt @@ -34,11 +34,12 @@ class PostMapperTest : every { authorMember.memberRole } returns MemberRole.USER every { authorMember.profileImageStorageKey } returns null + every { authorMember.user } returns user every { post.id } returns 1L every { post.title } returns "제목" every { post.content } returns "내용" - every { post.user } returns user + every { post.clubMember } returns authorMember every { post.board } returns board every { post.commentCount } returns 2 every { post.likeCount } returns 0 @@ -47,7 +48,7 @@ class PostMapperTest : describe("toListResponse") { it("24시간 이내 생성된 게시글은 isNew=true") { - val response = mapper.toListResponse(post, authorMember, hasFile = true, now = now, isLiked = false) + val response = mapper.toListResponse(post, hasFile = true, now = now, isLiked = false) response.id shouldBe 1L response.hasFile shouldBe true @@ -81,7 +82,7 @@ class PostMapperTest : ), ) - val response = mapper.toDetailResponse(post, authorMember, comments, files, isLiked = false) + val response = mapper.toDetailResponse(post, comments, files, isLiked = false) response.id shouldBe 1L response.commentCount shouldBe 2 diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt index ebe3410d..6fa3167e 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt @@ -20,16 +20,13 @@ import com.weeth.domain.club.domain.enums.MemberRole import com.weeth.domain.club.domain.repository.ClubMemberCardinalReader import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.club.fixture.ClubMemberCardinalTestFixture +import com.weeth.domain.club.fixture.ClubMemberTestFixture import com.weeth.domain.file.application.dto.request.FileSaveRequest import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.entity.File import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.repository.FileReader import com.weeth.domain.file.domain.repository.FileRepository -import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.enums.Status -import com.weeth.domain.user.domain.repository.UserReader -import com.weeth.domain.user.domain.vo.Email import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec @@ -40,13 +37,11 @@ import io.mockk.just import io.mockk.mockk import io.mockk.runs import io.mockk.verify -import org.springframework.test.util.ReflectionTestUtils class ManagePostUseCaseTest : DescribeSpec({ val postRepository = mockk() val boardRepository = mockk() - val userReader = mockk() val clubMemberPolicy = mockk(relaxed = true) val clubMemberCardinalReader = mockk() val fileRepository = mockk() @@ -58,7 +53,6 @@ class ManagePostUseCaseTest : ManagePostUseCase( postRepository, boardRepository, - userReader, clubMemberPolicy, clubMemberCardinalReader, fileRepository, @@ -80,20 +74,10 @@ class ManagePostUseCaseTest : ownerId = ownerId, ) - fun createUser(id: Long = 1L): User = - User( - name = "적순", - email = Email.from("test1@test.com"), - status = Status.ACTIVE, - ).apply { - ReflectionTestUtils.setField(this, "id", id) - } - beforeTest { clearMocks( postRepository, boardRepository, - userReader, clubMemberPolicy, clubMemberCardinalReader, fileRepository, @@ -108,17 +92,13 @@ class ManagePostUseCaseTest : every { postMapper.toSaveResponse(any()) } returns PostSaveResponse(1L) every { fileRepository.delete(any()) } just runs every { clubMemberCardinalReader.findLatestCardinalByClubMember(any()) } returns null - // update/delete 공통 기본값: 작성자 - every { userReader.getById(any()) } returns UserTestFixture.createActiveUser1(1L) } describe("save") { it("일반 게시판에서 게시글을 저장한다") { - val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val request = CreatePostRequest(title = "제목", content = "내용") - every { userReader.getById(1L) } returns user every { boardRepository.findByIdAndClubIdAndIsDeletedFalse(10L, 1L) } returns board val result = useCase.save(1L, 10L, request, 1L) @@ -128,7 +108,6 @@ class ManagePostUseCaseTest : } it("ADMIN 전용 게시판에 일반 사용자가 작성하면 예외를 던진다") { - val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create( name = "공지", @@ -137,7 +116,6 @@ class ManagePostUseCaseTest : ) val request = CreatePostRequest(title = "제목", content = "내용") - every { userReader.getById(1L) } returns user every { boardRepository.findByIdAndClubIdAndIsDeletedFalse(20L, 1L) } returns board shouldThrow { @@ -148,7 +126,6 @@ class ManagePostUseCaseTest : } it("비공개 게시판에 일반 사용자가 작성하면 예외를 던진다") { - val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create( name = "비공개", @@ -157,7 +134,6 @@ class ManagePostUseCaseTest : ) val request = CreatePostRequest(title = "제목", content = "내용") - every { userReader.getById(1L) } returns user every { boardRepository.findByIdAndClubIdAndIsDeletedFalse(21L, 1L) } returns board shouldThrow { @@ -168,16 +144,11 @@ class ManagePostUseCaseTest : } it("사용자의 최신 기수가 존재하면 게시글에 자동 반영된다") { - val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) - val cardinal = - CardinalTestFixture.createCardinalInProgress( - cardinalNumber = 6, - ) + val cardinal = CardinalTestFixture.createCardinalInProgress(cardinalNumber = 6) val clubMemberCardinal = ClubMemberCardinalTestFixture.create(cardinal = cardinal) val request = CreatePostRequest(title = "게시글", content = "내용") - every { userReader.getById(1L) } returns user every { boardRepository.findByIdAndClubIdAndIsDeletedFalse(11L, 1L) } returns board every { clubMemberCardinalReader.findLatestCardinalByClubMember(any()) } returns clubMemberCardinal @@ -189,11 +160,9 @@ class ManagePostUseCaseTest : } it("사용자의 기수 정보가 없으면 cardinalNumber가 null로 저장된다") { - val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val request = CreatePostRequest(title = "게시글", content = "내용") - every { userReader.getById(1L) } returns user every { boardRepository.findByIdAndClubIdAndIsDeletedFalse(11L, 1L) } returns board useCase.save(1L, 11L, request, 1L) @@ -204,10 +173,8 @@ class ManagePostUseCaseTest : } it("존재하지 않는 boardId면 예외를 던진다") { - val user = UserTestFixture.createActiveUser1(1L) val request = CreatePostRequest(title = "제목", content = "내용") - every { userReader.getById(1L) } returns user every { boardRepository.findByIdAndClubIdAndIsDeletedFalse(999L, 1L) } returns null shouldThrow { @@ -218,13 +185,12 @@ class ManagePostUseCaseTest : describe("update") { it("files가 null이면 기존 파일을 유지한다") { - val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val clubId = board.club.id - val post = Post.create("제목", "내용", user, board) + val ownerMember = ClubMemberTestFixture.createActiveMember(user = UserTestFixture.createActiveUser1(1L)) + val post = Post.create("제목", "내용", ownerMember, board) val request = UpdatePostRequest(title = "수정", content = "수정") - every { userReader.getById(1L) } returns user every { postRepository.findActivePostById(1L) } returns post useCase.update(clubId, 1L, request, 1L) @@ -234,10 +200,10 @@ class ManagePostUseCaseTest : } it("files가 있으면 기존 파일을 soft delete 후 교체한다") { - val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val clubId = board.club.id - val post = PostTestFixture.create(title = "제목", content = "내용", user = user, board = board) + val ownerMember = ClubMemberTestFixture.createActiveMember(user = UserTestFixture.createActiveUser1(1L)) + val post = PostTestFixture.create(title = "제목", content = "내용", clubMember = ownerMember, board = board) val oldFile = createUploadedPostFile("old.png") val newFiles = listOf(createUploadedPostFile("new.png")) val request = @@ -255,7 +221,6 @@ class ManagePostUseCaseTest : ), ) - every { userReader.getById(1L) } returns user every { postRepository.findActivePostById(1L) } returns post every { fileReader.findAll(FileOwnerType.POST, any(), any()) } returns listOf(oldFile) every { fileMapper.toFileList(request.files, FileOwnerType.POST, any()) } returns newFiles @@ -270,13 +235,12 @@ class ManagePostUseCaseTest : } it("title이 null이면 기존 제목을 유지한다") { - val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val clubId = board.club.id - val post = Post.create("원래 제목", "원래 내용", user, board) + val ownerMember = ClubMemberTestFixture.createActiveMember(user = UserTestFixture.createActiveUser1(1L)) + val post = Post.create("원래 제목", "원래 내용", ownerMember, board) val request = UpdatePostRequest(content = "수정된 내용") - every { userReader.getById(1L) } returns user every { postRepository.findActivePostById(1L) } returns post useCase.update(clubId, 1L, request, 1L) @@ -286,13 +250,12 @@ class ManagePostUseCaseTest : } it("content가 null이면 기존 내용을 유지한다") { - val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val clubId = board.club.id - val post = Post.create("원래 제목", "원래 내용", user, board) + val ownerMember = ClubMemberTestFixture.createActiveMember(user = UserTestFixture.createActiveUser1(1L)) + val post = Post.create("원래 제목", "원래 내용", ownerMember, board) val request = UpdatePostRequest(title = "수정된 제목") - every { userReader.getById(1L) } returns user every { postRepository.findActivePostById(1L) } returns post useCase.update(clubId, 1L, request, 1L) @@ -310,7 +273,6 @@ class ManagePostUseCaseTest : } it("게시판이 ADMIN 전용으로 바뀐 후 일반 사용자가 수정하면 예외를 던진다") { - val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create( name = "공지", @@ -318,9 +280,9 @@ class ManagePostUseCaseTest : config = BoardConfig(writePermission = MemberRole.ADMIN), ) val clubId = board.club.id - val post = PostTestFixture.create(title = "제목", content = "내용", user = user, board = board) + val ownerMember = ClubMemberTestFixture.createActiveMember(user = UserTestFixture.createActiveUser1(1L)) + val post = PostTestFixture.create(title = "제목", content = "내용", clubMember = ownerMember, board = board) - every { userReader.getById(1L) } returns user every { postRepository.findActivePostById(1L) } returns post shouldThrow { @@ -329,7 +291,6 @@ class ManagePostUseCaseTest : } it("게시판이 비공개로 바뀐 후 일반 사용자가 수정하면 예외를 던진다") { - val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create( name = "비공개", @@ -337,9 +298,9 @@ class ManagePostUseCaseTest : config = BoardConfig(isPrivate = true), ) val clubId = board.club.id - val post = PostTestFixture.create(title = "제목", content = "내용", user = user, board = board) + val ownerMember = ClubMemberTestFixture.createActiveMember(user = UserTestFixture.createActiveUser1(1L)) + val post = PostTestFixture.create(title = "제목", content = "내용", clubMember = ownerMember, board = board) - every { userReader.getById(1L) } returns user every { postRepository.findActivePostById(1L) } returns post shouldThrow { @@ -350,13 +311,12 @@ class ManagePostUseCaseTest : describe("delete") { it("삭제 시 첨부 파일과 게시글을 soft delete한다") { - val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val clubId = board.club.id - val post = PostTestFixture.create(title = "제목", content = "내용", user = user, board = board) + val ownerMember = ClubMemberTestFixture.createActiveMember(user = UserTestFixture.createActiveUser1(1L)) + val post = PostTestFixture.create(title = "제목", content = "내용", clubMember = ownerMember, board = board) val oldFile = createUploadedPostFile("old.png") - every { userReader.getById(1L) } returns user every { postRepository.findActivePostById(1L) } returns post every { fileReader.findAll(FileOwnerType.POST, any(), any()) } returns listOf(oldFile) @@ -376,7 +336,6 @@ class ManagePostUseCaseTest : } it("게시판이 ADMIN 전용으로 바뀐 후 일반 사용자가 삭제하면 예외를 던진다") { - val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create( name = "공지", @@ -384,9 +343,9 @@ class ManagePostUseCaseTest : config = BoardConfig(writePermission = MemberRole.ADMIN), ) val clubId = board.club.id - val post = PostTestFixture.create(title = "제목", content = "내용", user = user, board = board) + val ownerMember = ClubMemberTestFixture.createActiveMember(user = UserTestFixture.createActiveUser1(1L)) + val post = PostTestFixture.create(title = "제목", content = "내용", clubMember = ownerMember, board = board) - every { userReader.getById(1L) } returns user every { postRepository.findActivePostById(1L) } returns post shouldThrow { @@ -397,14 +356,12 @@ class ManagePostUseCaseTest : describe("owner validation") { it("작성자가 아니면 수정 시 예외를 던진다") { - val owner = UserTestFixture.createActiveUser1(1L) - val otherUser = UserTestFixture.createActiveUser1(2L) + val ownerMember = ClubMemberTestFixture.createActiveMember(user = UserTestFixture.createActiveUser1(1L)) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val clubId = board.club.id - val post = PostTestFixture.create(title = "제목", content = "내용", user = owner, board = board) + val post = PostTestFixture.create(title = "제목", content = "내용", clubMember = ownerMember, board = board) val request = UpdatePostRequest(title = "수정", content = "수정") - every { userReader.getById(2L) } returns otherUser every { postRepository.findActivePostById(1L) } returns post shouldThrow { diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt index efcb5479..e8de86c2 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt @@ -13,7 +13,6 @@ import com.weeth.domain.board.domain.repository.PostRepository import com.weeth.domain.board.fixture.BoardTestFixture import com.weeth.domain.board.fixture.PostTestFixture import com.weeth.domain.club.domain.enums.MemberRole -import com.weeth.domain.club.domain.repository.ClubMemberReader import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.club.fixture.ClubMemberTestFixture import com.weeth.domain.comment.application.dto.response.CommentResponse @@ -44,7 +43,6 @@ class GetPostQueryServiceTest : val boardRepository = mockk() val postLikeRepository = mockk() val clubMemberPolicy = mockk(relaxed = true) - val clubMemberReader = mockk() val commentReader = mockk() val getCommentQueryService = mockk() val fileReader = mockk() @@ -57,7 +55,6 @@ class GetPostQueryServiceTest : boardRepository, postLikeRepository, clubMemberPolicy, - clubMemberReader, commentReader, getCommentQueryService, fileReader, @@ -74,7 +71,6 @@ class GetPostQueryServiceTest : boardRepository, postLikeRepository, clubMemberPolicy, - clubMemberReader, commentReader, getCommentQueryService, fileReader, @@ -99,13 +95,7 @@ class GetPostQueryServiceTest : val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val actualClubId = board.club.id val member = ClubMemberTestFixture.createActiveMember(club = board.club, user = user) - val post = - PostTestFixture.create( - title = "제목", - content = "내용", - user = user, - board = board, - ) + val post = PostTestFixture.create(title = "제목", content = "내용", clubMember = member, board = board) val comments = listOf(mockk()) val fileResponses = listOf( @@ -148,11 +138,10 @@ class GetPostQueryServiceTest : every { clubMemberPolicy.getActiveMember(actualClubId, userId) } returns member every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post every { commentReader.findAllByPostId(any()) } returns emptyList() - every { clubMemberReader.findAllByClubIdAndUserIds(actualClubId, any()) } returns listOf(member) - every { getCommentQueryService.toCommentTreeResponses(any(), any()) } returns comments + every { getCommentQueryService.toCommentTreeResponses(any()) } returns comments every { fileReader.findAll(FileOwnerType.POST, any(), any()) } returns files every { postLikeRepository.existsByPostAndUserIdAndIsActiveTrue(post, userId) } returns false - every { postMapper.toDetailResponse(post, member, comments, fileResponses, false) } returns detail + every { postMapper.toDetailResponse(post, comments, fileResponses, false) } returns detail every { fileMapper.toFileResponse(files.first()) } returns fileResponses.first() val result = queryService.findPost(actualClubId, userId, 1L) @@ -172,7 +161,7 @@ class GetPostQueryServiceTest : PostTestFixture.create( title = "제목", content = "내용", - user = user, + clubMember = member, board = privateBoard, ) @@ -188,17 +177,15 @@ class GetPostQueryServiceTest : val user = UserTestFixture.createActiveUser1(1L) val deletedBoard = BoardTestFixture - .create( - name = "삭제", - type = BoardType.GENERAL, - ).also { it.markDeleted() } + .create(name = "삭제", type = BoardType.GENERAL) + .also { it.markDeleted() } val actualClubId = deletedBoard.club.id val member = ClubMemberTestFixture.createActiveMember(club = deletedBoard.club, user = user) val post = PostTestFixture.create( title = "제목", content = "내용", - user = user, + clubMember = member, board = deletedBoard, ) @@ -270,7 +257,7 @@ class GetPostQueryServiceTest : val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val member = ClubMemberTestFixture.createActiveMember(club = board.club, user = user) - val post = PostTestFixture.create(title = "제목", content = "내용", user = user, board = board) + val post = PostTestFixture.create(title = "제목", content = "내용", clubMember = member, board = board) val pageable = PageRequest.of(0, 10) val postSlice = SliceImpl(listOf(post), pageable, false) val response = @@ -293,9 +280,8 @@ class GetPostQueryServiceTest : listOf(board) every { postRepository.findAllActiveByBoardIds(any(), any()) } returns postSlice every { fileReader.findAll(FileOwnerType.POST, any>(), any()) } returns emptyList() - every { clubMemberReader.findAllByClubIdAndUserIds(clubId, any()) } returns listOf(member) every { postLikeRepository.findLikedPostIds(any(), any()) } returns emptySet() - every { postMapper.toListResponse(any(), any(), any(), any(), any()) } returns response + every { postMapper.toListResponse(any(), any(), any(), any()) } returns response val result = queryService.findAllPosts(clubId, userId, 0, 10) @@ -321,14 +307,8 @@ class GetPostQueryServiceTest : it("목록 조회 시 mapper를 통해 응답으로 변환한다") { val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) - val member = ClubMemberTestFixture.createActiveMember(user = user) - val post = - PostTestFixture.create( - title = "제목", - content = "내용", - user = user, - board = board, - ) + val member = ClubMemberTestFixture.createActiveMember(club = board.club, user = user) + val post = PostTestFixture.create(title = "제목", content = "내용", clubMember = member, board = board) val pageable = PageRequest.of(0, 10) val postSlice = SliceImpl(listOf(post), pageable, false) val response = @@ -350,9 +330,8 @@ class GetPostQueryServiceTest : every { boardRepository.findByIdAndClubIdAndIsDeletedFalse(1L, clubId) } returns board every { postRepository.findAllActiveByBoardId(1L, any()) } returns postSlice every { fileReader.findAll(FileOwnerType.POST, any>(), any()) } returns emptyList() - every { clubMemberReader.findAllByClubIdAndUserIds(clubId, any()) } returns listOf(member) every { postLikeRepository.findLikedPostIds(any(), any()) } returns emptySet() - every { postMapper.toListResponse(any(), any(), any(), any(), any()) } returns response + every { postMapper.toListResponse(any(), any(), any(), any()) } returns response val result = queryService.findPosts(clubId, userId, 1L, 0, 10) diff --git a/src/test/kotlin/com/weeth/domain/board/fixture/PostTestFixture.kt b/src/test/kotlin/com/weeth/domain/board/fixture/PostTestFixture.kt index bb64a142..0b8cd245 100644 --- a/src/test/kotlin/com/weeth/domain/board/fixture/PostTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/board/fixture/PostTestFixture.kt @@ -2,14 +2,14 @@ package com.weeth.domain.board.fixture import com.weeth.domain.board.domain.entity.Board import com.weeth.domain.board.domain.entity.Post -import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.fixture.UserTestFixture +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.fixture.ClubMemberTestFixture object PostTestFixture { fun create( title: String = "게시글", content: String = "내용", - user: User = UserTestFixture.createActiveUser1(1L), + clubMember: ClubMember = ClubMemberTestFixture.createActiveMember(), board: Board = BoardTestFixture.create(), cardinalNumber: Int? = null, initialLikeCount: Int = 0, @@ -17,7 +17,7 @@ object PostTestFixture { Post( title = title, content = content, - user = user, + clubMember = clubMember, board = board, cardinalNumber = cardinalNumber, ).also { post -> diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt index 1ff692bb..c6605053 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt @@ -7,6 +7,8 @@ import com.weeth.domain.board.domain.entity.Post import com.weeth.domain.board.domain.enums.BoardType import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.board.domain.repository.PostRepository +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.repository.ClubMemberRepository import com.weeth.domain.club.domain.repository.ClubRepository import com.weeth.domain.club.fixture.ClubTestFixture import com.weeth.domain.comment.application.dto.request.CommentSaveRequest @@ -46,6 +48,7 @@ class CommentConcurrencyTest( private val userRepository: UserRepository, private val commentRepository: CommentRepository, private val clubRepository: ClubRepository, + private val clubMemberRepository: ClubMemberRepository, private val entityManager: EntityManager, private val atomicCommentCountCommand: AtomicCommentCountCommand, ) : DescribeSpec({ @@ -102,11 +105,13 @@ class CommentConcurrencyTest( type = BoardType.GENERAL, ), ) + val clubMember = ClubMember.create(club = club, user = user).also { it.accept() } + clubMemberRepository.save(clubMember) return postRepository.save( Post( title = title, content = "내용", - user = user, + clubMember = clubMember, board = board, ), ) @@ -119,6 +124,11 @@ class CommentConcurrencyTest( val runId = UUID.randomUUID().toString().take(8) val users = createUsers(threadCount, runId) val post = createPost("동시성 테스트 게시글-$runId", users.first(), runId) + // 나머지 사용자들도 같은 클럽의 ClubMember로 등록 (ACTIVE 상태로 저장) + users.drop(1).forEach { commenter -> + val member = ClubMember.create(club = post.board.club, user = commenter).also { it.accept() } + clubMemberRepository.save(member) + } val executor = Executors.newFixedThreadPool(threadCount) val latch = CountDownLatch(threadCount) val successCount = AtomicInteger(0) @@ -206,6 +216,7 @@ class CommentConcurrencyTest( commentRepository.deleteAllInBatch() postRepository.deleteAllInBatch() boardRepository.deleteAllInBatch() + clubMemberRepository.deleteAllInBatch() clubRepository.deleteAllInBatch() userRepository.deleteAllInBatch() } @@ -298,8 +309,9 @@ class AtomicCommentCountCommand( repeat(maxRetries) { attempt -> try { transactionTemplate.executeWithoutResult { - val user = entityManager.getReference(User::class.java, userId) val post = entityManager.getReference(Post::class.java, postId) + // 벤치마크 전용: commentCount 동시성 측정이 목적이므로 userId 대신 post 작성자의 ClubMember를 재사용 + val clubMember = post.clubMember val parent = dto.parentCommentId?.let { parentId -> commentRepository.findByIdAndPostId(parentId, postId) @@ -310,7 +322,7 @@ class AtomicCommentCountCommand( Comment.createForPost( content = dto.content, post = post, - user = user, + clubMember = clubMember, parent = parent, ), ) diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt index 38446825..d3cb81b5 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt @@ -2,6 +2,8 @@ package com.weeth.domain.comment.application.usecase.command import com.weeth.domain.board.domain.repository.PostRepository import com.weeth.domain.board.fixture.PostTestFixture +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.fixture.ClubMemberTestFixture import com.weeth.domain.comment.application.dto.request.CommentSaveRequest import com.weeth.domain.comment.application.dto.request.CommentUpdateRequest import com.weeth.domain.comment.application.exception.CommentAlreadyDeletedException @@ -9,6 +11,7 @@ import com.weeth.domain.comment.application.exception.CommentNotFoundException import com.weeth.domain.comment.application.exception.CommentNotOwnedException import com.weeth.domain.comment.domain.entity.Comment import com.weeth.domain.comment.domain.repository.CommentRepository +import com.weeth.domain.comment.fixture.CommentTestFixture import com.weeth.domain.file.application.dto.request.FileSaveRequest import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.entity.File @@ -16,7 +19,6 @@ import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.enums.FileStatus import com.weeth.domain.file.domain.repository.FileReader import com.weeth.domain.file.domain.repository.FileRepository -import com.weeth.domain.user.domain.repository.UserReader import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec @@ -32,7 +34,7 @@ class ManageCommentUseCaseTest : DescribeSpec({ val commentRepository = mockk(relaxUnitFun = true) val postRepository = mockk() - val userReader = mockk() + val clubMemberPolicy = mockk(relaxed = true) val fileReader = mockk() val fileRepository = mockk(relaxed = true) val fileMapper = mockk() @@ -41,14 +43,14 @@ class ManageCommentUseCaseTest : ManageCommentUseCase( commentRepository, postRepository, - userReader, + clubMemberPolicy, fileReader, fileRepository, fileMapper, ) beforeTest { - clearMocks(commentRepository, postRepository, userReader, fileReader, fileRepository, fileMapper) + clearMocks(commentRepository, postRepository, clubMemberPolicy, fileReader, fileRepository, fileMapper) every { fileMapper.toFileList(any(), FileOwnerType.COMMENT, any()) } returns emptyList() every { commentRepository.save(any()) } answers { firstArg() } every { fileReader.findAll(FileOwnerType.COMMENT, any(), any()) } returns emptyList() @@ -57,11 +59,9 @@ class ManageCommentUseCaseTest : describe("savePostComment") { it("최상위 댓글 저장 시 댓글 수가 증가한다") { - val user = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.create(user = user) + val post = PostTestFixture.create() val dto = CommentSaveRequest(parentCommentId = null, content = "최상위 댓글", files = null) - every { userReader.getById(1L) } returns user every { postRepository.findByIdWithLock(10L) } returns post useCase.savePostComment(dto, postId = 10L, userId = 1L) @@ -72,11 +72,9 @@ class ManageCommentUseCaseTest : } it("부모 댓글이 존재하지 않으면 예외를 던진다") { - val user = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.create(user = user) + val post = PostTestFixture.create() val dto = CommentSaveRequest(parentCommentId = 999L, content = "대댓글", files = null) - every { userReader.getById(1L) } returns user every { postRepository.findByIdWithLock(10L) } returns post every { commentRepository.findByIdAndPostId(999L, 10L) } returns null @@ -89,8 +87,9 @@ class ManageCommentUseCaseTest : describe("updatePostComment") { it("작성자가 아니면 예외를 던진다") { val owner = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.create(user = owner) - val comment = Comment(id = 200L, content = "old", post = post, user = owner) + val ownerMember = ClubMemberTestFixture.createActiveMember(user = owner) + val post = PostTestFixture.create() + val comment = CommentTestFixture.createPostComment(id = 200L, post = post, clubMember = ownerMember) val dto = CommentUpdateRequest(content = "new", files = null) every { commentRepository.findByIdAndPostId(200L, 10L) } returns comment @@ -102,8 +101,9 @@ class ManageCommentUseCaseTest : it("files가 있으면 기존 파일은 삭제되고 새 파일이 저장된다") { val owner = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.create(user = owner) - val comment = Comment(id = 202L, content = "old", post = post, user = owner) + val ownerMember = ClubMemberTestFixture.createActiveMember(user = owner) + val post = PostTestFixture.create() + val comment = CommentTestFixture.createPostComment(id = 202L, post = post, clubMember = ownerMember) val dto = CommentUpdateRequest( content = "new content", @@ -151,11 +151,12 @@ class ManageCommentUseCaseTest : describe("deletePostComment") { it("리프 댓글 삭제 시 hard delete 되고 댓글 수가 감소한다") { val owner = UserTestFixture.createActiveUser1(1L) + val ownerMember = ClubMemberTestFixture.createActiveMember(user = owner) val post = - PostTestFixture.create(user = owner, title = "title").also { + PostTestFixture.create(title = "title").also { it.increaseCommentCount() } - val comment = Comment(id = 310L, content = "leaf", post = post, user = owner) + val comment = CommentTestFixture.createPostComment(id = 310L, post = post, clubMember = ownerMember) every { postRepository.findByIdWithLock(10L) } returns post every { commentRepository.findByIdAndPostId(310L, 10L) } returns comment @@ -168,14 +169,21 @@ class ManageCommentUseCaseTest : it("자식이 있는 댓글 삭제 시 soft delete 된다") { val owner = UserTestFixture.createActiveUser1(1L) + val ownerMember = ClubMemberTestFixture.createActiveMember(user = owner) val post = - PostTestFixture.create(user = owner).also { + PostTestFixture.create().also { it.increaseCommentCount() it.increaseCommentCount() } - val comment = Comment(id = 300L, content = "target", post = post, user = owner) - val child = Comment(id = 301L, content = "child", post = post, user = owner, parent = comment) + val comment = CommentTestFixture.createPostComment(id = 300L, post = post, clubMember = ownerMember) + val child = + CommentTestFixture.createPostComment( + id = 301L, + post = post, + clubMember = ownerMember, + parent = comment, + ) comment.children.add(child) every { postRepository.findByIdWithLock(10L) } returns post @@ -191,8 +199,15 @@ class ManageCommentUseCaseTest : it("이미 삭제된 댓글은 삭제할 수 없다") { val owner = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.create(user = owner) - val comment = Comment(id = 320L, content = "삭제된 댓글입니다.", post = post, user = owner, isDeleted = true) + val ownerMember = ClubMemberTestFixture.createActiveMember(user = owner) + val post = PostTestFixture.create() + val comment = + CommentTestFixture.createPostComment( + id = 320L, + post = post, + clubMember = ownerMember, + isDeleted = true, + ) every { postRepository.findByIdWithLock(10L) } returns post every { commentRepository.findByIdAndPostId(320L, 10L) } returns comment diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt index f2f4caa1..45b75ace 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt @@ -72,14 +72,14 @@ class CommentQueryPerformanceTest( } fun createPost( - user: User, + clubMember: ClubMember, board: Board, ): Post = postRepository.save( Post( title = "query-performance", content = "measure comment query performance", - user = user, + clubMember = clubMember, board = board, cardinalNumber = 4, ), @@ -87,7 +87,6 @@ class CommentQueryPerformanceTest( data class SetupResult( val commentIds: List, - val memberMap: Map, ) fun setupData( @@ -97,13 +96,9 @@ class CommentQueryPerformanceTest( ): SetupResult { val user = createUser() val board = createBoard() - val post = createPost(user, board) - val clubMember = - clubMemberRepository - .save( - ClubMember.create(club = board.club, user = user), - ).apply { accept() } - val memberMap = mapOf(user.id to clubMember) + val clubMember = ClubMember.create(club = board.club, user = user).also { it.accept() } + clubMemberRepository.save(clubMember) + val post = createPost(clubMember, board) val commentIds = mutableListOf() repeat(rootCount) { rootIdx -> @@ -112,7 +107,7 @@ class CommentQueryPerformanceTest( Comment.createForPost( content = "root-$rootIdx", post = post, - user = user, + clubMember = clubMember, parent = null, ), ) @@ -123,7 +118,7 @@ class CommentQueryPerformanceTest( Comment.createForPost( content = "child-$rootIdx-$childIdx", post = post, - user = user, + clubMember = clubMember, parent = root, ), ) @@ -146,7 +141,7 @@ class CommentQueryPerformanceTest( } } - return SetupResult(commentIds, memberMap) + return SetupResult(commentIds) } describe("comment file query performance") { @@ -156,12 +151,11 @@ class CommentQueryPerformanceTest( childrenPerRoot: Int, filesPerComment: Int, ) { - val (_, memberMap) = - setupData( - rootCount = rootCount, - childrenPerRoot = childrenPerRoot, - filesPerComment = filesPerComment, - ) + setupData( + rootCount = rootCount, + childrenPerRoot = childrenPerRoot, + filesPerComment = filesPerComment, + ) val fileAccessUrlPort = object : FileAccessUrlPort { @@ -178,7 +172,7 @@ class CommentQueryPerformanceTest( val legacy = QueryCountUtil.count(entityManager) { val comments = commentRepository.findAll().sortedBy { it.id } - val tree = legacyService.toCommentTreeResponses(comments, memberMap) + val tree = legacyService.toCommentTreeResponses(comments) tree.size shouldBe rootCount } @@ -187,7 +181,7 @@ class CommentQueryPerformanceTest( val improved = QueryCountUtil.count(entityManager) { val comments = commentRepository.findAll().sortedBy { it.id } - val tree = improvedService.toCommentTreeResponses(comments, memberMap) + val tree = improvedService.toCommentTreeResponses(comments) tree.size shouldBe rootCount } @@ -211,10 +205,7 @@ private class LegacyCommentQueryService( private val fileMapper: FileMapper, private val commentMapper: CommentMapper, ) { - fun toCommentTreeResponses( - comments: List, - memberMap: Map, - ): List { + fun toCommentTreeResponses(comments: List): List { if (comments.isEmpty()) { return emptyList() } @@ -226,17 +217,16 @@ private class LegacyCommentQueryService( return comments .filter { it.parent == null } - .map { mapToCommentResponse(it, childrenByParentId, memberMap) } + .map { mapToCommentResponse(it, childrenByParentId) } } private fun mapToCommentResponse( comment: Comment, childrenByParentId: Map>, - memberMap: Map, ): CommentResponse { val children = childrenByParentId[comment.id] - ?.map { mapToCommentResponse(it, childrenByParentId, memberMap) } + ?.map { mapToCommentResponse(it, childrenByParentId) } ?: emptyList() val files = @@ -244,7 +234,6 @@ private class LegacyCommentQueryService( .findAll(FileOwnerType.COMMENT, comment.id) .map(fileMapper::toFileResponse) - val authorMember = memberMap.getValue(comment.user.id) - return commentMapper.toCommentDto(comment, authorMember, children, files) + return commentMapper.toCommentDto(comment, children, files) } } diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt index 85dd0fc6..c2abfcc7 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt @@ -27,9 +27,8 @@ class GetCommentQueryServiceTest : val service = GetCommentQueryService(fileReader, fileMapper, commentMapper) val user = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.create(user = user) val member = ClubMemberTestFixture.createActiveMember(user = user) - val memberMap = mapOf(user.id to member) + val post = PostTestFixture.create(clubMember = member) beforeTest { clearMocks(fileReader, fileMapper, commentMapper) @@ -49,7 +48,7 @@ class GetCommentQueryServiceTest : describe("toCommentTreeResponses") { it("빈 리스트면 빈 리스트를 반환하고 파일 조회를 하지 않는다") { - val result = service.toCommentTreeResponses(emptyList(), memberMap) + val result = service.toCommentTreeResponses(emptyList()) result shouldBe emptyList() verify(exactly = 0) { fileReader.findAll(any(), any(), any()) } @@ -57,13 +56,13 @@ class GetCommentQueryServiceTest : } it("최상위 댓글만 있을 때 파일 조회를 1회 수행한다") { - val comment = CommentTestFixture.createPostComment(id = 1L, post = post, user = user) + val comment = CommentTestFixture.createPostComment(id = 1L, post = post, clubMember = member) val response = stubResponse(1L) every { fileReader.findAll(FileOwnerType.COMMENT, listOf(1L), any()) } returns emptyList() - every { commentMapper.toCommentDto(comment, member, emptyList(), emptyList()) } returns response + every { commentMapper.toCommentDto(comment, emptyList(), emptyList()) } returns response - val result = service.toCommentTreeResponses(listOf(comment), memberMap) + val result = service.toCommentTreeResponses(listOf(comment)) result.size shouldBe 1 result[0].id shouldBe 1L @@ -71,18 +70,22 @@ class GetCommentQueryServiceTest : } it("부모-자식 구조를 트리로 조립한다") { - val parent = CommentTestFixture.createPostComment(id = 10L, post = post, user = user) - val child = CommentTestFixture.createPostComment(id = 11L, post = post, user = user, parent = parent) + val parent = CommentTestFixture.createPostComment(id = 10L, post = post, clubMember = member) + val child = + CommentTestFixture.createPostComment( + id = 11L, + post = post, + clubMember = member, + parent = parent, + ) val childResponse = stubResponse(11L) val parentResponse = stubResponse(10L, children = listOf(childResponse)) every { fileReader.findAll(FileOwnerType.COMMENT, listOf(10L, 11L), any()) } returns emptyList() - every { commentMapper.toCommentDto(child, member, emptyList(), emptyList()) } returns childResponse - every { - commentMapper.toCommentDto(parent, member, listOf(childResponse), emptyList()) - } returns parentResponse + every { commentMapper.toCommentDto(child, emptyList(), emptyList()) } returns childResponse + every { commentMapper.toCommentDto(parent, listOf(childResponse), emptyList()) } returns parentResponse - val result = service.toCommentTreeResponses(listOf(parent, child), memberMap) + val result = service.toCommentTreeResponses(listOf(parent, child)) result.size shouldBe 1 result[0].id shouldBe 10L diff --git a/src/test/kotlin/com/weeth/domain/comment/domain/entity/CommentEntityTest.kt b/src/test/kotlin/com/weeth/domain/comment/domain/entity/CommentEntityTest.kt index d7fecad9..1223fa8c 100644 --- a/src/test/kotlin/com/weeth/domain/comment/domain/entity/CommentEntityTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/domain/entity/CommentEntityTest.kt @@ -1,30 +1,30 @@ package com.weeth.domain.comment.domain.entity import com.weeth.domain.board.fixture.PostTestFixture +import com.weeth.domain.club.fixture.ClubMemberTestFixture import com.weeth.domain.comment.fixture.CommentTestFixture -import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe class CommentEntityTest : DescribeSpec({ - val user = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.create(title = "title") + val member = ClubMemberTestFixture.createActiveMember() + val post = PostTestFixture.create(title = "title", clubMember = member) describe("createForPost") { it("부모 없이 최상위 댓글을 생성한다") { - val comment = Comment.createForPost(content = "내용", post = post, user = user, parent = null) + val comment = Comment.createForPost(content = "내용", post = post, clubMember = member, parent = null) comment.content shouldBe "내용" comment.post shouldBe post - comment.user shouldBe user + comment.clubMember shouldBe member comment.parent shouldBe null } } describe("markAsDeleted") { it("isDeleted를 true로 바꾸고 내용을 대체 문구로 변경한다") { - val comment = CommentTestFixture.createPostComment(post = post, user = user) + val comment = CommentTestFixture.createPostComment(post = post, clubMember = member) comment.markAsDeleted() diff --git a/src/test/kotlin/com/weeth/domain/comment/fixture/CommentTestFixture.kt b/src/test/kotlin/com/weeth/domain/comment/fixture/CommentTestFixture.kt index fc6481fb..0b7b1d81 100644 --- a/src/test/kotlin/com/weeth/domain/comment/fixture/CommentTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/comment/fixture/CommentTestFixture.kt @@ -1,22 +1,23 @@ package com.weeth.domain.comment.fixture import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.fixture.ClubMemberTestFixture import com.weeth.domain.comment.domain.entity.Comment -import com.weeth.domain.user.domain.entity.User object CommentTestFixture { fun createPostComment( id: Long = 1L, content: String = "테스트 댓글", post: Post, - user: User, + clubMember: ClubMember = ClubMemberTestFixture.createActiveMember(), parent: Comment? = null, isDeleted: Boolean = false, ) = Comment( id = id, content = content, post = post, - user = user, + clubMember = clubMember, parent = parent, isDeleted = isDeleted, ) diff --git a/src/test/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryServiceTest.kt index 9b530e28..5d457a7c 100644 --- a/src/test/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryServiceTest.kt @@ -174,7 +174,7 @@ class GetDashboardQueryServiceTest : context("멤버인 경우") { it("공지 제외한 접근 가능한 게시판의 최신 게시글을 반환한다") { val board = BoardTestFixture.create(id = 10L, type = BoardType.GENERAL) - val post = PostTestFixture.create(board = board, user = user) + val post = PostTestFixture.create(board = board, clubMember = memberWithUser) val pageable = PageRequest.of(0, 10) val slice = SliceImpl(listOf(post), pageable, false) @@ -182,7 +182,6 @@ class GetDashboardQueryServiceTest : every { boardReader.findAllActiveByClubId(clubId) } returns listOf(board) every { postReader.findRecentByBoardIds(listOf(board.id), any()) } returns slice every { fileReader.findAll(FileOwnerType.POST, any>()) } returns emptyList() - every { clubMemberReader.findAllByClubIdAndUserIds(clubId, any()) } returns listOf(memberWithUser) val result = queryService.getRecentPosts(clubId, userId, 0, 10) @@ -201,7 +200,7 @@ class GetDashboardQueryServiceTest : it("일반 멤버에게는 비공개 게시판 글이 포함되지 않는다") { val publicBoard = BoardTestFixture.create(id = 10L, type = BoardType.GENERAL) - val post = PostTestFixture.create(board = publicBoard, user = user) + val post = PostTestFixture.create(board = publicBoard, clubMember = memberWithUser) val pageable = PageRequest.of(0, 10) val slice = SliceImpl(listOf(post), pageable, false) @@ -209,7 +208,6 @@ class GetDashboardQueryServiceTest : every { boardReader.findAllActiveByClubId(clubId) } returns listOf(publicBoard, privateBoard) every { postReader.findRecentByBoardIds(listOf(publicBoard.id), any()) } returns slice every { fileReader.findAll(FileOwnerType.POST, any>()) } returns emptyList() - every { clubMemberReader.findAllByClubIdAndUserIds(clubId, any()) } returns listOf(memberWithUser) val result = queryService.getRecentPosts(clubId, userId, 0, 10) @@ -223,7 +221,7 @@ class GetDashboardQueryServiceTest : user = user, memberRole = MemberRole.ADMIN, ) - val post = PostTestFixture.create(board = privateBoard, user = user) + val post = PostTestFixture.create(board = privateBoard, clubMember = adminMember) val pageable = PageRequest.of(0, 10) val slice = SliceImpl(listOf(post), pageable, false) @@ -231,7 +229,6 @@ class GetDashboardQueryServiceTest : every { boardReader.findAllActiveByClubId(clubId) } returns listOf(privateBoard) every { postReader.findRecentByBoardIds(listOf(privateBoard.id), any()) } returns slice every { fileReader.findAll(FileOwnerType.POST, any>()) } returns emptyList() - every { clubMemberReader.findAllByClubIdAndUserIds(clubId, any()) } returns listOf(adminMember) val result = queryService.getRecentPosts(clubId, userId, 0, 10) From bfcd07d9b4508841510a014d36024e5852230a5e Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:36:38 +0900 Subject: [PATCH 49/73] =?UTF-8?q?[WTH-247]=20=EC=9C=A0=EC=A0=80=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20nullable=20=EC=B2=98=EB=A6=AC=20(#47)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 선택 필드 nullable 및 PATCH 전략에 맞게 수정 * refactor: 초기 유저 정보 설정시 프로필 완성 여부를 검증하도록 수정 * refactor: 파일 업데이트시 하드 딜리트 시켜 replace가 정상적으로 되도록 수정 * refactor: isNew 필드 추가 * refactor: 파일 목록 반환 * test: 테스트 반영 * refactor: 업데이트시 검증 보강 * refactor: 업데이트시 검증 보강 * refactor: 충돌해결 --- .../dto/response/PostDetailResponse.kt | 2 + .../dto/response/PostListResponse.kt | 5 +- .../board/application/mapper/PostMapper.kt | 6 +- .../usecase/command/ManagePostUseCase.kt | 12 ++- .../usecase/query/GetPostQueryService.kt | 53 ++++-------- .../dto/response/ClubMemberProfileResponse.kt | 6 +- .../dto/response/ClubMemberResponse.kt | 6 +- .../command/ManageClubMemberUsecase.kt | 18 ++-- .../usecase/command/ManageClubUseCase.kt | 18 ++-- .../usecase/command/ManageCommentUseCase.kt | 14 ++-- .../weeth/domain/file/domain/entity/File.kt | 5 +- .../dto/request/UpdateUserProfileRequest.kt | 24 +++--- .../ProfileRequiredFieldsMissingException.kt | 5 ++ .../application/exception/UserErrorCode.kt | 3 + .../command/UpdateUserProfileUseCase.kt | 35 ++++++-- .../weeth/domain/user/domain/entity/User.kt | 83 +++++++++++-------- .../com/weeth/domain/user/domain/vo/Email.kt | 4 +- .../application/mapper/PostMapperTest.kt | 13 ++- .../usecase/command/ManagePostUseCaseTest.kt | 12 ++- .../usecase/query/GetPostQueryServiceTest.kt | 7 +- .../command/ManageClubMemberUseCaseTest.kt | 12 ++- .../usecase/command/ManageClubUseCaseTest.kt | 15 ++-- .../command/ManageCommentUseCaseTest.kt | 2 +- .../domain/file/domain/entity/FileTest.kt | 18 ---- .../domain/repository/FileRepositoryTest.kt | 3 +- 25 files changed, 211 insertions(+), 170 deletions(-) create mode 100644 src/main/kotlin/com/weeth/domain/user/application/exception/ProfileRequiredFieldsMissingException.kt diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt index a553f00f..c3827381 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt @@ -29,4 +29,6 @@ data class PostDetailResponse( val comments: List, @field:Schema(description = "첨부 파일 목록") val fileUrls: List, + @field:Schema(description = "신규 게시글 여부 (24시간 이내)") + val isNew: Boolean, ) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt index 37777e47..df9f57ea 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt @@ -1,5 +1,6 @@ package com.weeth.domain.board.application.dto.response +import com.weeth.domain.file.application.dto.response.FileResponse import com.weeth.domain.user.application.dto.response.UserInfo import io.swagger.v3.oas.annotations.media.Schema import java.time.LocalDateTime @@ -23,8 +24,8 @@ data class PostListResponse( val commentCount: Int, @field:Schema(description = "좋아요 정보") val like: PostLikeResponse, - @field:Schema(description = "파일 첨부 여부") - val hasFile: Boolean, + @field:Schema(description = "첨부 파일 목록") + val fileUrls: List, @field:Schema(description = "신규 게시글 여부 (24시간 이내)") val isNew: Boolean, ) diff --git a/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt b/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt index 8a6e1f75..3a31eb4c 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt @@ -29,6 +29,7 @@ class PostMapper( comments: List, files: List, isLiked: Boolean, + now: LocalDateTime, ) = PostDetailResponse( id = post.id, boardId = post.board.id, @@ -41,11 +42,12 @@ class PostMapper( like = toLikeResponse(post, isLiked), comments = comments, fileUrls = files, + isNew = post.createdAt.isAfter(now.minusHours(24)), ) fun toListResponse( post: Post, - hasFile: Boolean, + files: List, now: LocalDateTime, isLiked: Boolean, ) = PostListResponse( @@ -58,7 +60,7 @@ class PostMapper( time = post.modifiedAt, commentCount = post.commentCount, like = toLikeResponse(post, isLiked), - hasFile = hasFile, + fileUrls = files, isNew = post.createdAt.isAfter(now.minusHours(24)), ) diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt index 6dce1ddc..2c93a2b9 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt @@ -98,7 +98,7 @@ class ManagePostUseCase( validateOwner(post, userId) validateWritePermission(post.board, member) - markPostFilesDeleted(post.id) + deletePostFiles(post.id) post.markDeleted() } @@ -135,7 +135,7 @@ class ManagePostUseCase( if (files == null) { return } - markPostFilesDeleted(post.id) + deletePostFiles(post.id) savePostFiles(post, files) } @@ -149,7 +149,11 @@ class ManagePostUseCase( } } - private fun markPostFilesDeleted(postId: Long) { - fileReader.findAll(FileOwnerType.POST, postId).forEach { it.markDeleted() } + private fun deletePostFiles(postId: Long) { + val files = fileReader.findAll(FileOwnerType.POST, postId) + + if (files.isNotEmpty()) { + fileRepository.deleteAll(files) + } } } diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt index e5b26f58..3addef61 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt @@ -7,6 +7,7 @@ import com.weeth.domain.board.application.exception.NoSearchResultException import com.weeth.domain.board.application.exception.PageNotFoundException import com.weeth.domain.board.application.exception.PostNotFoundException import com.weeth.domain.board.application.mapper.PostMapper +import com.weeth.domain.board.domain.entity.Post import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.board.domain.repository.PostLikeRepository import com.weeth.domain.board.domain.repository.PostRepository @@ -15,6 +16,7 @@ import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.comment.application.usecase.query.GetCommentQueryService import com.weeth.domain.comment.domain.repository.CommentReader import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.entity.File import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.repository.FileReader import org.springframework.data.domain.PageRequest @@ -56,11 +58,11 @@ class GetPostQueryService( val files = fileReader.findAll(FileOwnerType.POST, post.id).map(fileMapper::toFileResponse) val comments = commentReader.findAllByPostId(post.id) - val commentTree = getCommentQueryService.toCommentTreeResponses(comments) val isLiked = postLikeRepository.existsByPostAndUserIdAndIsActiveTrue(post, userId) + val now = LocalDateTime.now() - return postMapper.toDetailResponse(post, commentTree, files, isLiked) + return postMapper.toDetailResponse(post, commentTree, files, isLiked, now) } fun findAllPosts( @@ -85,19 +87,8 @@ class GetPostQueryService( } val posts = postRepository.findAllActiveByBoardIds(accessibleBoardIds, pageable) - val postIds = posts.content.map { it.id } - val fileExistsByPostId = buildFileExistsMap(postIds) - val likedPostIds = postLikeRepository.findLikedPostIds(postIds, userId) - val now = LocalDateTime.now() - return posts.map { post -> - postMapper.toListResponse( - post, - fileExistsByPostId[post.id] == true, - now, - post.id in likedPostIds, - ) - } + return toPostListResponses(posts, userId) } fun findPosts( @@ -114,19 +105,7 @@ class GetPostQueryService( val pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")) val posts = postRepository.findAllActiveByBoardId(boardId, pageable) - val postIds = posts.content.map { it.id } - val fileExistsByPostId = buildFileExistsMap(postIds) - val likedPostIds = postLikeRepository.findLikedPostIds(postIds, userId) - val now = LocalDateTime.now() - - return posts.map { post -> - postMapper.toListResponse( - post, - fileExistsByPostId[post.id] == true, - now, - post.id in likedPostIds, - ) - } + return toPostListResponses(posts, userId) } fun searchPosts( @@ -147,15 +126,22 @@ class GetPostQueryService( throw NoSearchResultException() } + return toPostListResponses(posts, userId) + } + + private fun toPostListResponses( + posts: Slice, + userId: Long, + ): Slice { val postIds = posts.content.map { it.id } - val fileExistsByPostId = buildFileExistsMap(postIds) + val filesByPostId = buildFileMap(postIds) val likedPostIds = postLikeRepository.findLikedPostIds(postIds, userId) val now = LocalDateTime.now() return posts.map { post -> postMapper.toListResponse( post, - fileExistsByPostId[post.id] == true, + filesByPostId[post.id]?.map(fileMapper::toFileResponse) ?: emptyList(), now, post.id in likedPostIds, ) @@ -171,12 +157,9 @@ class GetPostQueryService( } } - private fun buildFileExistsMap(postIds: List): Map { - if (postIds.isEmpty()) { - return emptyMap() - } - val filesGrouped = fileReader.findAll(FileOwnerType.POST, postIds).groupBy { it.ownerId } - return postIds.associateWith { filesGrouped.containsKey(it) } + private fun buildFileMap(postIds: List): Map> { + if (postIds.isEmpty()) return emptyMap() + return fileReader.findAll(FileOwnerType.POST, postIds).groupBy { it.ownerId } } private fun validateBoardVisibility( // todo: 볼 권한이 없는 경우 권한 관련 예외를 던져주는게 나을지 UX 상의 후 결정 diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberProfileResponse.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberProfileResponse.kt index 2e729dbe..a1dbba12 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberProfileResponse.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberProfileResponse.kt @@ -17,13 +17,13 @@ data class ClubMemberProfileResponse( @field:Schema(description = "이메일", example = "hong@example.com") val email: String, @field:Schema(description = "전화번호", example = "01012345678") - val tel: String, + val tel: String?, @field:Schema(description = "학교", example = "가천대학교") val school: String?, @field:Schema(description = "학과", example = "컴퓨터공학과") - val department: String, + val department: String?, @field:Schema(description = "학번", example = "20201234") - val studentId: String, + val studentId: String?, @field:Schema(description = "소속 기수 목록", example = "[6, 7]") val cardinals: List, @field:Schema(description = "멤버 권한", example = "USER") diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberResponse.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberResponse.kt index 72d136f8..a6ebca8b 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberResponse.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMemberResponse.kt @@ -17,13 +17,13 @@ data class ClubMemberResponse( @field:Schema(description = "이메일", example = "hong@example.com") val email: String, @field:Schema(description = "전화번호", example = "01012345678") - val tel: String, + val tel: String?, @field:Schema(description = "학교", example = "가천대학교") val school: String?, @field:Schema(description = "학과", example = "컴퓨터공학과") - val department: String, + val department: String?, @field:Schema(description = "학번", example = "20201234") - val studentId: String, + val studentId: String?, @field:Schema(description = "소속 기수 목록", example = "[6, 7]") val cardinals: List, @field:Schema(description = "멤버 상태", example = "ACTIVE") diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt index 1c3adfb1..9b9ce1e2 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt @@ -93,12 +93,15 @@ class ManageClubMemberUsecase( if (members.isEmpty()) throw ClubMemberNotFoundException() request.profileImage?.let { profileImage -> - fileRepository - .findAllByOwnerTypeAndOwnerIdAndStatus( + val existingFiles = + fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus( FileOwnerType.CLUB_MEMBER_PROFILE, userId, FileStatus.UPLOADED, - ).forEach { it.markDeleted() } + ) + if (existingFiles.isNotEmpty()) { + fileRepository.deleteAll(existingFiles) + } val file = File.createUploaded( @@ -122,12 +125,15 @@ class ManageClubMemberUsecase( val members = clubMemberRepository.findActiveByUserId(userId) if (members.isEmpty()) throw ClubMemberNotFoundException() - fileRepository - .findAllByOwnerTypeAndOwnerIdAndStatus( + val existingFiles = + fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus( FileOwnerType.CLUB_MEMBER_PROFILE, userId, FileStatus.UPLOADED, - ).forEach { it.markDeleted() } + ) + if (existingFiles.isNotEmpty()) { + fileRepository.deleteAll(existingFiles) + } members.forEach { it.removeProfileImage() } } diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt index 374b5b9e..6431a226 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt @@ -143,12 +143,12 @@ class ManageClubUseCase( } request.profileImage?.let { image -> - markExistingFilesDeleted(FileOwnerType.CLUB_PROFILE, clubId) + deleteExistingFiles(FileOwnerType.CLUB_PROFILE, clubId) saveFile(image, FileOwnerType.CLUB_PROFILE, clubId) } request.backgroundImage?.let { image -> - markExistingFilesDeleted(FileOwnerType.CLUB_BACKGROUND, clubId) + deleteExistingFiles(FileOwnerType.CLUB_BACKGROUND, clubId) saveFile(image, FileOwnerType.CLUB_BACKGROUND, clubId) } @@ -184,7 +184,7 @@ class ManageClubUseCase( clubPermissionPolicy.requireAdmin(clubId, userId) val club = clubRepository.getClubById(clubId) - markExistingFilesDeleted(FileOwnerType.CLUB_PROFILE, clubId) + deleteExistingFiles(FileOwnerType.CLUB_PROFILE, clubId) club.removeProfileImage() } @@ -196,7 +196,7 @@ class ManageClubUseCase( clubPermissionPolicy.requireAdmin(clubId, userId) val club = clubRepository.getClubById(clubId) - markExistingFilesDeleted(FileOwnerType.CLUB_BACKGROUND, clubId) + deleteExistingFiles(FileOwnerType.CLUB_BACKGROUND, clubId) club.removeBackgroundImage() } @@ -225,13 +225,15 @@ class ManageClubUseCase( fileRepository.save(file) } - private fun markExistingFilesDeleted( + private fun deleteExistingFiles( ownerType: FileOwnerType, ownerId: Long, ) { - fileRepository - .findAllByOwnerTypeAndOwnerIdAndStatus(ownerType, ownerId, FileStatus.UPLOADED) - .forEach { it.markDeleted() } + val files = fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus(ownerType, ownerId, FileStatus.UPLOADED) + + if (files.isNotEmpty()) { + fileRepository.deleteAll(files) + } } private fun validatePrimaryContactEmail( diff --git a/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt b/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt index cbc2501d..e682cb7e 100644 --- a/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt @@ -101,7 +101,7 @@ class ManageCommentUseCase( return } - markCommentFilesDeleted(comment.id) + deleteCommentFiles(comment.id) saveCommentFiles(comment, files) } @@ -130,13 +130,15 @@ class ManageCommentUseCase( } private fun deleteCommentFiles(comment: Comment) { - markCommentFilesDeleted(comment.id) + deleteCommentFiles(comment.id) } - private fun markCommentFilesDeleted(commentId: Long) { - fileReader - .findAll(FileOwnerType.COMMENT, commentId) - .forEach { it.markDeleted() } + private fun deleteCommentFiles(commentId: Long) { + val files = fileReader.findAll(FileOwnerType.COMMENT, commentId) + + if (files.isNotEmpty()) { + fileRepository.deleteAll(files) + } } private fun ensureOwner( diff --git a/src/main/kotlin/com/weeth/domain/file/domain/entity/File.kt b/src/main/kotlin/com/weeth/domain/file/domain/entity/File.kt index a2f7471f..d1fd0a67 100644 --- a/src/main/kotlin/com/weeth/domain/file/domain/entity/File.kt +++ b/src/main/kotlin/com/weeth/domain/file/domain/entity/File.kt @@ -40,14 +40,11 @@ class File( val ownerId: Long, @Column(nullable = false, length = 100) val contentType: FileContentType, + // TODO: 하드 딜리트로 전환 완료되어 더 이상 사용되지 않음. DB 마이그레이션 후 status 컬럼 및 FileStatus enum 제거 예정 @Enumerated(EnumType.STRING) @Column(nullable = false, length = 20) var status: FileStatus = FileStatus.UPLOADED, ) : BaseEntity() { - fun markDeleted() { - status = FileStatus.DELETED - } - companion object { fun createUploaded( fileName: String, diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/request/UpdateUserProfileRequest.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/request/UpdateUserProfileRequest.kt index 38d65816..c11edb13 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/dto/request/UpdateUserProfileRequest.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/request/UpdateUserProfileRequest.kt @@ -7,22 +7,22 @@ import jakarta.validation.constraints.Size data class UpdateUserProfileRequest( @field:Schema(description = "이름", example = "홍길동") - @field:NotBlank - val name: String, + @field:Size(min = 1, max = 20) + val name: String? = null, @field:Schema(description = "이메일", example = "hong@example.com") + @field:Size(min = 1) @field:Email - @field:NotBlank - val email: String, + val email: String? = null, @field:Schema(description = "학번", example = "20201234") - @field:NotBlank - val studentId: String, + @field:Size(min = 1) + val studentId: String? = null, @field:Schema(description = "전화번호", example = "01012345678") - @field:NotBlank - val tel: String, + @field:Size(min = 1) + val tel: String? = null, @field:Schema(description = "학교", example = "가천대학교") - @field:NotBlank - val school: String, + @field:Size(min = 1) + val school: String? = null, @field:Schema(description = "학과", example = "컴퓨터공학과") - @field:NotBlank - val department: String, + @field:Size(min = 1) + val department: String? = null, ) diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/ProfileRequiredFieldsMissingException.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/ProfileRequiredFieldsMissingException.kt new file mode 100644 index 00000000..44b6e030 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/ProfileRequiredFieldsMissingException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.user.application.exception + +import com.weeth.global.common.exception.BaseException + +class ProfileRequiredFieldsMissingException : BaseException(UserErrorCode.PROFILE_REQUIRED_FIELDS_MISSING) diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/UserErrorCode.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/UserErrorCode.kt index 074ce475..80291859 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/exception/UserErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/UserErrorCode.kt @@ -44,4 +44,7 @@ enum class UserErrorCode( @ExplainError("사용자 순서 지정 시 잘못된 값이 입력되었을 때 발생합니다.") INVALID_USER_ORDER(20911, HttpStatus.BAD_REQUEST, "잘못된 사용자 순서입니다."), + + @ExplainError("프로필 초기 설정 시 필수 필드가 누락되었을 때 발생합니다.") + PROFILE_REQUIRED_FIELDS_MISSING(20912, HttpStatus.BAD_REQUEST, "프로필 초기 설정 시 모든 필수 항목을 입력해야 합니다."), } diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/UpdateUserProfileUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/UpdateUserProfileUseCase.kt index 8c598fba..8069d93d 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/UpdateUserProfileUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/UpdateUserProfileUseCase.kt @@ -1,8 +1,10 @@ package com.weeth.domain.user.application.usecase.command import com.weeth.domain.user.application.dto.request.UpdateUserProfileRequest +import com.weeth.domain.user.application.exception.ProfileRequiredFieldsMissingException import com.weeth.domain.user.application.exception.StudentIdExistsException import com.weeth.domain.user.application.exception.TelExistsException +import com.weeth.domain.user.domain.entity.User import com.weeth.domain.user.domain.repository.UserRepository import com.weeth.domain.user.domain.vo.Email import com.weeth.global.common.vo.PhoneNumber @@ -18,26 +20,47 @@ class UpdateUserProfileUseCase( request: UpdateUserProfileRequest, userId: Long, ) { - validate(request, userId) val user = userRepository.getById(userId) + if (!user.isProfileCompleted()) { + validateRequiredFields(request) + } + validateDuplicate(request, userId, user) user.update( name = request.name, - email = Email.from(request.email), + email = request.email?.let { Email.from(it) }, studentId = request.studentId, - tel = PhoneNumber.from(request.tel), + tel = request.tel?.let { PhoneNumber.from(it) }, school = request.school, department = request.department, ) } - private fun validate( + private fun validateRequiredFields(request: UpdateUserProfileRequest) { + if (request.name == null || + request.email == null || + request.studentId == null || + request.tel == null || + request.school == null || + request.department == null + ) { + throw ProfileRequiredFieldsMissingException() + } + } + + private fun validateDuplicate( request: UpdateUserProfileRequest, userId: Long, + user: User, ) { - if (userRepository.existsBySchoolAndStudentIdAndIdIsNot(request.school, request.studentId, userId)) { + val school = request.school ?: user.school + val studentId = request.studentId ?: user.studentId + if (school != null && studentId != null && + userRepository.existsBySchoolAndStudentIdAndIdIsNot(school, studentId, userId) + ) { throw StudentIdExistsException() } - if (userRepository.existsByTelAndIdIsNotValue(request.tel, userId)) { + val tel = request.tel ?: user.telValue + if (tel != null && userRepository.existsByTelAndIdIsNotValue(tel, userId)) { throw TelExistsException() } } diff --git a/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt b/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt index 99fd8835..8bec40f2 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt @@ -21,10 +21,10 @@ import jakarta.persistence.Table class User( name: String, email: Email, - studentId: String = "", - tel: PhoneNumber = PhoneNumber.from(""), - school: String = "", - department: String = "", + studentId: String? = null, + tel: PhoneNumber? = null, + school: String? = null, + department: String? = null, status: Status = Status.WAITING, ) : BaseEntity() { @Id @@ -42,21 +42,21 @@ class User( var email: Email = email private set - @Column(nullable = false, length = 20) - var studentId: String = studentId + @Column(nullable = true, length = 20) + var studentId: String? = studentId private set @Convert(converter = PhoneNumberConverter::class) - @Column(name = "tel", nullable = false, length = 20) - var tel: PhoneNumber = tel + @Column(name = "tel", nullable = true, length = 20) + var tel: PhoneNumber? = tel private set - @Column(nullable = false, length = 50) - var school: String = school + @Column(nullable = true, length = 50) + var school: String? = school private set - @Column(nullable = false, length = 100) - var department: String = department + @Column(nullable = true, length = 100) + var department: String? = department private set @Enumerated(EnumType.STRING) @@ -75,8 +75,8 @@ class User( val emailValue: String get() = email.value - val telValue: String - get() = tel.value + val telValue: String? + get() = tel?.value fun leave() { status = Status.LEFT @@ -94,27 +94,38 @@ class User( fun missingProfileFields(): List = buildList { - if (studentId.isBlank()) add("studentId") - if (telValue.isBlank()) add("tel") - if (school.isBlank()) add("school") - if (department.isBlank()) add("department") + if (studentId.isNullOrBlank()) add("studentId") + if (telValue.isNullOrBlank()) add("tel") + if (school.isNullOrBlank()) add("school") + if (department.isNullOrBlank()) add("department") } fun update( - name: String, - email: Email, - studentId: String, - tel: PhoneNumber, - school: String, - department: String, + name: String? = null, + email: Email? = null, + studentId: String? = null, + tel: PhoneNumber? = null, + school: String? = null, + department: String? = null, ) { - require(name.isNotBlank()) { "이름은 공백일 수 없습니다." } - this.name = name.trim() - this.email = email - this.studentId = studentId - this.tel = tel - this.school = school - this.department = department + name?.let { + require(it.isNotBlank()) { "이름은 공백일 수 없습니다." } + this.name = it.trim() + } + email?.let { this.email = it } + studentId?.let { + require(it.isNotBlank()) { "학번은 공백일 수 없습니다." } + this.studentId = it + } + tel?.let { this.tel = it } + school?.let { + require(it.isNotBlank()) { "학교는 공백일 수 없습니다." } + this.school = it + } + department?.let { + require(it.isNotBlank()) { "학과는 공백일 수 없습니다." } + this.department = it + } } fun agreeTerms( @@ -138,17 +149,17 @@ class User( fun create( name: String, email: String, - studentId: String = "", - tel: String = "", - school: String = "", - department: String = "", + studentId: String? = null, + tel: String? = null, + school: String? = null, + department: String? = null, status: Status = Status.WAITING, ): User = User( name = name, email = Email.from(email), studentId = studentId, - tel = PhoneNumber.from(tel), + tel = tel?.let { PhoneNumber.from(it) }, school = school, department = department, status = status, diff --git a/src/main/kotlin/com/weeth/domain/user/domain/vo/Email.kt b/src/main/kotlin/com/weeth/domain/user/domain/vo/Email.kt index 2b840356..80f5752a 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/vo/Email.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/vo/Email.kt @@ -6,9 +6,7 @@ data class Email private constructor( companion object { fun from(raw: String): Email { val normalized = raw.trim().lowercase() - if (normalized.isBlank()) { - return Email("") - } + require(normalized.isNotBlank()) { "이메일은 공백일 수 없습니다." } require(EMAIL_REGEX.matches(normalized)) { "Invalid email format." } return Email(normalized) } diff --git a/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt b/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt index 52f28963..0d8c8b13 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt @@ -15,6 +15,7 @@ import io.kotest.matchers.shouldBe import io.mockk.every import io.mockk.mockk import java.time.LocalDateTime +import kotlin.collections.emptyList class PostMapperTest : DescribeSpec({ @@ -48,10 +49,16 @@ class PostMapperTest : describe("toListResponse") { it("24시간 이내 생성된 게시글은 isNew=true") { - val response = mapper.toListResponse(post, hasFile = true, now = now, isLiked = false) + val response = + mapper.toListResponse( + post, + files = emptyList(), + now = now, + isLiked = false, + ) response.id shouldBe 1L - response.hasFile shouldBe true + response.fileUrls shouldBe emptyList() response.isNew shouldBe true } } @@ -82,7 +89,7 @@ class PostMapperTest : ), ) - val response = mapper.toDetailResponse(post, comments, files, isLiked = false) + val response = mapper.toDetailResponse(post, comments, files, isLiked = false, now = now) response.id shouldBe 1L response.commentCount shouldBe 2 diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt index 6fa3167e..6e530d25 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt @@ -199,7 +199,8 @@ class ManagePostUseCaseTest : verify(exactly = 0) { fileRepository.saveAll(any>()) } } - it("files가 있으면 기존 파일을 soft delete 후 교체한다") { + it("files가 있으면 기존 파일을 삭제 후 교체한다") { + val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val clubId = board.club.id val ownerMember = ClubMemberTestFixture.createActiveMember(user = UserTestFixture.createActiveUser1(1L)) @@ -223,14 +224,15 @@ class ManagePostUseCaseTest : every { postRepository.findActivePostById(1L) } returns post every { fileReader.findAll(FileOwnerType.POST, any(), any()) } returns listOf(oldFile) + every { fileRepository.deleteAll(any>()) } just runs every { fileMapper.toFileList(request.files, FileOwnerType.POST, any()) } returns newFiles every { fileRepository.saveAll(newFiles) } returns newFiles useCase.update(clubId, 1L, request, 1L) - oldFile.status.name shouldBe "DELETED" post.title shouldBe "수정" post.content shouldBe "수정" + verify(exactly = 1) { fileRepository.deleteAll(listOf(oldFile)) } verify(exactly = 1) { fileRepository.saveAll(newFiles) } } @@ -310,7 +312,8 @@ class ManagePostUseCaseTest : } describe("delete") { - it("삭제 시 첨부 파일과 게시글을 soft delete한다") { + it("삭제 시 첨부 파일을 삭제하고 게시글을 soft delete한다") { + val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val clubId = board.club.id val ownerMember = ClubMemberTestFixture.createActiveMember(user = UserTestFixture.createActiveUser1(1L)) @@ -319,11 +322,12 @@ class ManagePostUseCaseTest : every { postRepository.findActivePostById(1L) } returns post every { fileReader.findAll(FileOwnerType.POST, any(), any()) } returns listOf(oldFile) + every { fileRepository.deleteAll(any>()) } just runs useCase.delete(clubId, 1L, 1L) - oldFile.status.name shouldBe "DELETED" post.isDeleted shouldBe true + verify(exactly = 1) { fileRepository.deleteAll(listOf(oldFile)) } verify(exactly = 0) { postRepository.delete(any()) } } diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt index e8de86c2..2046bcc5 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt @@ -133,6 +133,7 @@ class GetPostQueryServiceTest : like = PostLikeResponse(isLiked = false, likeCount = 0), comments = comments, fileUrls = fileResponses, + isNew = false, ) every { clubMemberPolicy.getActiveMember(actualClubId, userId) } returns member @@ -141,7 +142,7 @@ class GetPostQueryServiceTest : every { getCommentQueryService.toCommentTreeResponses(any()) } returns comments every { fileReader.findAll(FileOwnerType.POST, any(), any()) } returns files every { postLikeRepository.existsByPostAndUserIdAndIsActiveTrue(post, userId) } returns false - every { postMapper.toDetailResponse(post, comments, fileResponses, false) } returns detail + every { postMapper.toDetailResponse(post, comments, fileResponses, false, any()) } returns detail every { fileMapper.toFileResponse(files.first()) } returns fileResponses.first() val result = queryService.findPost(actualClubId, userId, 1L) @@ -271,7 +272,7 @@ class GetPostQueryServiceTest : time = LocalDateTime.now(), commentCount = 0, like = PostLikeResponse(isLiked = false, likeCount = 0), - hasFile = false, + fileUrls = emptyList(), isNew = true, ) @@ -322,7 +323,7 @@ class GetPostQueryServiceTest : time = LocalDateTime.now(), commentCount = 0, like = PostLikeResponse(isLiked = false, likeCount = 0), - hasFile = false, + fileUrls = emptyList(), isNew = false, ) diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt index d5286c2a..ed68f392 100644 --- a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt @@ -97,7 +97,7 @@ class ManageClubMemberUseCaseTest : ) context("프로필 사진만 변경할 때") { - it("모든 활성 ClubMember의 기존 파일을 soft delete하고 새 파일로 URL을 업데이트한다") { + it("모든 활성 ClubMember의 기존 파일을 삭제하고 새 파일로 URL을 업데이트한다") { val member1 = ClubMemberTestFixture.createActiveMember(id = 1L) val member2 = ClubMemberTestFixture.createActiveMember(id = 2L) val existingFile = @@ -115,11 +115,13 @@ class ManageClubMemberUseCaseTest : FileStatus.UPLOADED, ) } returns listOf(existingFile) + every { fileRepository.deleteAll(any>()) } returns + Unit useCase.updateProfile(userId, UpdateMemberProfileRequest(profileImage = profileImageRequest)) - existingFile.status shouldBe FileStatus.DELETED member1.profileImageStorageKey shouldBe profileImageRequest.storageKey member2.profileImageStorageKey shouldBe profileImageRequest.storageKey + verify(exactly = 1) { fileRepository.deleteAll(listOf(existingFile)) } verify(exactly = 1) { fileRepository.save(any()) } } } @@ -169,7 +171,7 @@ class ManageClubMemberUseCaseTest : val userId = 10L context("활성 멤버가 프로필 사진을 삭제할 때") { - it("모든 활성 ClubMember의 파일을 soft delete하고 URL을 null로 만든다") { + it("모든 활성 ClubMember의 파일을 삭제하고 URL을 null로 만든다") { val member1 = ClubMemberTestFixture.createActiveMember(id = 1L) val member2 = ClubMemberTestFixture.createActiveMember(id = 2L) member1.updateProfileImageUrl("CLUB_MEMBER_PROFILE/2026-02/uuid_profile.png") @@ -190,10 +192,12 @@ class ManageClubMemberUseCaseTest : FileStatus.UPLOADED, ) } returns listOf(existingFile) + every { fileRepository.deleteAll(any>()) } returns + Unit useCase.deleteProfileImage(userId) - existingFile.status shouldBe FileStatus.DELETED + verify(exactly = 1) { fileRepository.deleteAll(listOf(existingFile)) } member1.profileImageStorageKey shouldBe null member2.profileImageStorageKey shouldBe null } diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt index 4163e24e..bdd1927e 100644 --- a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt @@ -308,7 +308,7 @@ class ManageClubUseCaseTest : club.backgroundImageStorageKey shouldBe "CLUB_BACKGROUND/2026-02/uuid_background.png" } - it("프로필 이미지를 변경하면 기존 File이 DELETED 처리되고 새 File이 생성된다") { + it("프로필 이미지를 변경하면 기존 File이 삭제되고 새 File이 생성된다") { val existingFile = mockk(relaxed = true) val club = ClubTestFixture.createClub( @@ -329,6 +329,7 @@ class ManageClubUseCaseTest : FileStatus.UPLOADED, ) } returns listOf(existingFile) + every { fileRepository.deleteAll(any>()) } just Runs useCase.update( 1L, @@ -344,7 +345,7 @@ class ManageClubUseCaseTest : ), ) - verify(exactly = 1) { existingFile.markDeleted() } + verify(exactly = 1) { fileRepository.deleteAll(listOf(existingFile)) } verify(exactly = 1) { fileRepository.save(any()) } club.profileImageStorageKey shouldBe "CLUB_PROFILE/2026-03/550e8400-e29b-41d4-a716-446655440002_new.png" } @@ -425,7 +426,7 @@ class ManageClubUseCaseTest : club.backgroundImageStorageKey shouldBe "CLUB_BACKGROUND/2026-02/uuid_background.png" } - it("기존 File 레코드가 DELETED 처리된다") { + it("기존 File 레코드가 삭제된다") { val existingFile = mockk(relaxed = true) val club = ClubTestFixture.createClub( @@ -446,10 +447,11 @@ class ManageClubUseCaseTest : FileStatus.UPLOADED, ) } returns listOf(existingFile) + every { fileRepository.deleteAll(any>()) } just Runs useCase.deleteProfileImage(1L, 10L) - verify(exactly = 1) { existingFile.markDeleted() } + verify(exactly = 1) { fileRepository.deleteAll(listOf(existingFile)) } } } @@ -484,7 +486,7 @@ class ManageClubUseCaseTest : club.backgroundImageStorageKey shouldBe null } - it("기존 File 레코드가 DELETED 처리된다") { + it("기존 File 레코드가 삭제된다") { val existingFile = mockk(relaxed = true) val club = ClubTestFixture.createClub( @@ -505,10 +507,11 @@ class ManageClubUseCaseTest : FileStatus.UPLOADED, ) } returns listOf(existingFile) + every { fileRepository.deleteAll(any>()) } just Runs useCase.deleteBackgroundImage(1L, 10L) - verify(exactly = 1) { existingFile.markDeleted() } + verify(exactly = 1) { fileRepository.deleteAll(listOf(existingFile)) } } } }) diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt index d3cb81b5..8acc5719 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt @@ -143,7 +143,7 @@ class ManageCommentUseCaseTest : useCase.updatePostComment(dto, postId = 10L, commentId = 202L, userId = 1L) comment.content shouldBe "new content" - oldFile.status.name shouldBe "DELETED" + verify(exactly = 1) { fileRepository.deleteAll(listOf(oldFile)) } verify { fileRepository.saveAll(listOf(newFile)) } } } diff --git a/src/test/kotlin/com/weeth/domain/file/domain/entity/FileTest.kt b/src/test/kotlin/com/weeth/domain/file/domain/entity/FileTest.kt index 6acfe3f7..cdcb071c 100644 --- a/src/test/kotlin/com/weeth/domain/file/domain/entity/FileTest.kt +++ b/src/test/kotlin/com/weeth/domain/file/domain/entity/FileTest.kt @@ -117,22 +117,4 @@ class FileTest : } } } - - describe("markDeleted") { - it("파일 상태를 DELETED로 변경한다") { - val file = - File.createUploaded( - fileName = "doc.pdf", - storageKey = "POST/2026-02/550e8400-e29b-41d4-a716-446655440000_doc.pdf", - fileSize = 100, - contentType = "application/pdf", - ownerType = FileOwnerType.POST, - ownerId = 2L, - ) - - file.markDeleted() - - file.status shouldBe FileStatus.DELETED - } - } }) diff --git a/src/test/kotlin/com/weeth/domain/file/domain/repository/FileRepositoryTest.kt b/src/test/kotlin/com/weeth/domain/file/domain/repository/FileRepositoryTest.kt index 38891b51..d4096a6e 100644 --- a/src/test/kotlin/com/weeth/domain/file/domain/repository/FileRepositoryTest.kt +++ b/src/test/kotlin/com/weeth/domain/file/domain/repository/FileRepositoryTest.kt @@ -14,6 +14,7 @@ import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabas import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import org.springframework.context.annotation.Import import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.test.util.ReflectionTestUtils import java.util.UUID @DataJpaTest @@ -102,7 +103,7 @@ private fun createTestFile( ownerId = ownerId, ).also { if (status == FileStatus.DELETED) { - it.markDeleted() + ReflectionTestUtils.setField(it, "status", FileStatus.DELETED) } } From 95393252a96c03c3944a29157aeeacce2fd18fa7 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 9 Apr 2026 16:13:28 +0900 Subject: [PATCH 50/73] =?UTF-8?q?HOTIFX:=20=EB=9E=9C=EB=94=A9=20CORS=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/com/weeth/global/config/SecurityConfig.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt index dc9dc607..4bf82bc3 100644 --- a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt +++ b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt @@ -88,6 +88,8 @@ class SecurityConfig( "http://localhost:*", "http://127.0.0.1:*", "https://*.v4.weeth.kr", + "https://landing.weeth.kr", + "https://www.landing.weeth.kr", "https://appleid.apple.com", ) allowedMethods = listOf("GET", "POST", "PATCH", "DELETE", "OPTIONS") From 6851b008700210155a995b23b86f4433d1274b95 Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Thu, 9 Apr 2026 20:56:56 +0900 Subject: [PATCH 51/73] =?UTF-8?q?[WTH-252]=20=EC=B6=9C=EC=84=9D=20?= =?UTF-8?q?=EC=9A=94=EC=95=BD=20=EC=A1=B0=ED=9A=8C=20api=EC=97=90=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20id=EA=B0=80=20=EC=97=86=EC=96=B4=20?= =?UTF-8?q?=EC=B6=9C=EC=84=9D=EC=9D=B4=20=EB=B6=88=EA=B0=80=EB=8A=A5?= =?UTF-8?q?=ED=95=9C=20=EB=AC=B8=EC=A0=9C=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 세션 id 반환 * refactor: 경로 매개변수로 변경 * test: test 반영 --- .../application/dto/request/CheckInRequest.kt | 2 -- .../dto/response/AttendanceSummaryResponse.kt | 4 ++-- .../application/mapper/AttendanceMapper.kt | 3 +-- .../usecase/query/GetAttendanceQueryService.kt | 2 +- .../presentation/AttendanceController.kt | 14 +++++++++++--- .../application/mapper/AttendanceMapperTest.kt | 4 +--- 6 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/dto/request/CheckInRequest.kt b/src/main/kotlin/com/weeth/domain/attendance/application/dto/request/CheckInRequest.kt index 16457161..d3c1c022 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/dto/request/CheckInRequest.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/dto/request/CheckInRequest.kt @@ -3,8 +3,6 @@ package com.weeth.domain.attendance.application.dto.request import io.swagger.v3.oas.annotations.media.Schema data class CheckInRequest( - @field:Schema(description = "세션 ID", example = "1") - val sessionId: Long, @field:Schema(description = "출석 코드", example = "123456") val code: Int, ) diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceSummaryResponse.kt b/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceSummaryResponse.kt index 1c51b954..92e2d5d8 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceSummaryResponse.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/dto/response/AttendanceSummaryResponse.kt @@ -11,8 +11,8 @@ data class AttendanceSummaryResponse( val title: String?, @field:Schema(description = "출석 상태", example = "ATTEND") val status: AttendanceStatus?, - @field:Schema(description = "어드민인 경우 출석 코드 노출", example = "123456") - val code: Int?, + @field:Schema(description = "정기모임 id", example = "1") + val sessionId: Long?, @field:Schema(description = "정기모임 시작 시간") val start: LocalDateTime?, @field:Schema(description = "정기모임 종료 시간") diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapper.kt b/src/main/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapper.kt index b3447878..4550ed03 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapper.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapper.kt @@ -16,13 +16,12 @@ class AttendanceMapper { fun toSummaryResponse( clubMember: ClubMember, attendance: Attendance?, - isAdmin: Boolean = false, ): AttendanceSummaryResponse = AttendanceSummaryResponse( attendanceRate = clubMember.attendanceStats.attendanceRate, title = attendance?.session?.title, status = attendance?.status, - code = if (isAdmin) attendance?.session?.code else null, + sessionId = attendance?.session?.id, start = attendance?.session?.start, end = attendance?.session?.end, location = attendance?.session?.location, diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt index 0bcbb726..b9c88ac5 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt @@ -39,7 +39,7 @@ class GetAttendanceQueryService( today.plusDays(1).atStartOfDay(), ) - return attendanceMapper.toSummaryResponse(clubMember, todayAttendance, isAdmin = clubMember.isAdminOrLead()) + return attendanceMapper.toSummaryResponse(clubMember, todayAttendance) } fun findAllDetailsByCurrentCardinal( diff --git a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt index 8212ff76..864f0e49 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt @@ -15,6 +15,7 @@ import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping @@ -28,20 +29,27 @@ class AttendanceController( private val manageAttendanceUseCase: ManageAttendanceUseCase, private val getAttendanceQueryService: GetAttendanceQueryService, ) { - @PostMapping("/check-in") + @PostMapping("/sessions/{sessionId}/check-in") @Operation(summary = "출석체크") fun checkIn( @TsidParam @TsidPathVariable clubId: Long, + @PathVariable sessionId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, @RequestBody checkIn: CheckInRequest, ): CommonResponse { - manageAttendanceUseCase.checkIn(clubId, userId, checkIn.sessionId, checkIn.code) + manageAttendanceUseCase.checkIn(clubId, userId, sessionId, checkIn.code) return CommonResponse.success(AttendanceResponseCode.ATTENDANCE_CHECKIN_SUCCESS) } @GetMapping - @Operation(summary = "내 출석 요약 조회") + @Operation( + summary = "내 출석 요약 조회", + description = """ + 출석을 진행하기 전 오늘의 출석 유무를 확인하기 위해서 사용됩니다.(대시보드, 출석 페이지). + 출석률은 상시 표시되며, 오늘의 출석이 없는 경우 status를 포함한 필드는 null로 반환됩니다. + """, + ) fun find( @TsidParam @TsidPathVariable clubId: Long, diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapperTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapperTest.kt index 6e6d98fe..85cd1015 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapperTest.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/application/mapper/AttendanceMapperTest.kt @@ -63,7 +63,6 @@ class AttendanceMapperTest : val main = mapper.toSummaryResponse(member, attendance) main.shouldNotBeNull() - main.code.shouldBeNull() main.title shouldBe session.title main.status shouldBe attendance.status } @@ -76,10 +75,9 @@ class AttendanceMapperTest : val member = ClubMemberTestFixture.createAdminMember(club = session.club, user = adminUser) val attendance = createAttendance(session, member) - val main = mapper.toSummaryResponse(member, attendance, isAdmin = true) + val main = mapper.toSummaryResponse(member, attendance) main.shouldNotBeNull() - main.code shouldBe expectedCode main.title shouldBe session.title main.start shouldBe session.start main.end shouldBe session.end From 471e73411b6ccac572cf14705fc6405d62c13799 Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Wed, 15 Apr 2026 12:50:06 +0900 Subject: [PATCH 52/73] =?UTF-8?q?[WTH-260]=20=EB=8F=99=EC=95=84=EB=A6=AC?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=EC=8B=9C=20club=20id=20=EB=8F=99=EC=95=84?= =?UTF-8?q?=EB=A6=AC=20=EC=9D=B4=EB=A6=84=20=EC=A6=89=EC=8B=9C=20=EB=B0=98?= =?UTF-8?q?=ED=99=98=20(#50)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 동아리중복 검증 추가 * feat: 동아리중복 검증 추가 * refactor: 동아리 생성 응답 반환 * refactor: 테스트 반영 --- .../dto/response/ClubCreateResponse.kt | 10 ++++++ .../application/exception/ClubErrorCode.kt | 3 ++ .../exception/DuplicateClubException.kt | 5 +++ .../club/application/mapper/ClubMapper.kt | 7 +++++ .../usecase/command/ManageClubUseCase.kt | 22 +++++++++++-- .../club/domain/repository/ClubRepository.kt | 5 +++ .../club/presentation/ClubController.kt | 7 +++-- .../usecase/command/ManageClubUseCaseTest.kt | 31 +++++++++++++++++++ 8 files changed, 84 insertions(+), 6 deletions(-) create mode 100644 src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubCreateResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/club/application/exception/DuplicateClubException.kt diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubCreateResponse.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubCreateResponse.kt new file mode 100644 index 00000000..de1a2c3b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubCreateResponse.kt @@ -0,0 +1,10 @@ +package com.weeth.domain.club.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class ClubCreateResponse( + @field:Schema(description = "동아리 ID (Base62 인코딩)", example = "YUNJcjFKMO") + val clubId: String, + @field:Schema(description = "동아리 이름", example = "Leets") + val clubName: String, +) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt index cbb9ff76..45b8091b 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt @@ -66,4 +66,7 @@ enum class ClubErrorCode( HttpStatus.UNPROCESSABLE_ENTITY, "출석 기록이 있는 기수가 포함되어 있습니다. 삭제하려면 force=true로 재요청하세요.", ), + + @ExplainError("생성하려는 동아리가 이미 있는 경우 발생합니다. 동아리 중복은 동일한 학교 안에 동일한 이름의 동아리가 있는지 검증합니다.") + DUPLICATE_CLUB(21119, HttpStatus.CONFLICT, "이미 존재하는 동아리입니다."), } diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/DuplicateClubException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/DuplicateClubException.kt new file mode 100644 index 00000000..53b64053 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/DuplicateClubException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class DuplicateClubException : BaseException(ClubErrorCode.DUPLICATE_CLUB) diff --git a/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt b/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt index 20c499de..e3478f15 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt @@ -1,5 +1,6 @@ package com.weeth.domain.club.application.mapper +import com.weeth.domain.club.application.dto.response.ClubCreateResponse import com.weeth.domain.club.application.dto.response.ClubDetailResponse import com.weeth.domain.club.application.dto.response.ClubInfoResponse import com.weeth.domain.club.application.dto.response.ClubMemberProfileResponse @@ -151,6 +152,12 @@ class ClubMapper( missingFields = user.missingProfileFields(), ) + fun toCreateResponse(club: Club) = + ClubCreateResponse( + clubId = TsidBase62Encoder.encode(club.id), + clubName = club.name, + ) + private fun resolveClubImage(storageKey: String?): String? = storageKey?.let { fileAccessUrlPort.resolve(it) } private fun toCardinalNumbers(cardinals: List): List { diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt index 6431a226..29d8b341 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt @@ -9,7 +9,10 @@ import com.weeth.domain.cardinal.domain.enums.CardinalStatus import com.weeth.domain.cardinal.domain.repository.CardinalRepository import com.weeth.domain.club.application.dto.request.ClubCreateRequest import com.weeth.domain.club.application.dto.request.ClubUpdateRequest +import com.weeth.domain.club.application.dto.response.ClubCreateResponse +import com.weeth.domain.club.application.exception.DuplicateClubException import com.weeth.domain.club.application.exception.EmailRequiredForPrimaryContactException +import com.weeth.domain.club.application.mapper.ClubMapper import com.weeth.domain.club.domain.entity.Club import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.club.domain.entity.ClubMemberCardinal @@ -46,6 +49,7 @@ class ManageClubUseCase( private val clubJoinPolicy: ClubJoinPolicy, private val clubPermissionPolicy: ClubPermissionPolicy, private val fileRepository: FileRepository, + private val clubMapper: ClubMapper, ) { /** * 새로운 동아리를 생성 @@ -56,12 +60,13 @@ class ManageClubUseCase( fun create( userId: Long, request: ClubCreateRequest, - ) { + ): ClubCreateResponse { + validatePrimaryContactEmail(request.primaryContact, request.contactEmail) + checkDuplicateClubName(request.schoolName, request.name) + val user = userReader.getByIdWithLock(userId) - clubJoinPolicy.validateCreateLimit(userId) - validatePrimaryContactEmail(request.primaryContact, request.contactEmail) val code = ClubCodePolicy.generateCode() val clubContact = @@ -123,6 +128,8 @@ class ManageClubUseCase( // LEAD 멤버를 최신 기수에 배정 clubMemberCardinalRepository.save(ClubMemberCardinal.create(leadMember, cardinals.last())) + + return clubMapper.toCreateResponse(club) } @Transactional @@ -244,4 +251,13 @@ class ManageClubUseCase( throw EmailRequiredForPrimaryContactException() } } + + private fun checkDuplicateClubName( + schoolName: String, + clubName: String, + ) { + if (clubRepository.existsBySchoolNameAndName(schoolName, clubName)) { + throw DuplicateClubException() + } + } } diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubRepository.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubRepository.kt index 5cc777bb..2ce690a8 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubRepository.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubRepository.kt @@ -22,6 +22,11 @@ interface ClubRepository : @Query("SELECT c FROM Club c WHERE c.id = :clubId") fun findByIdWithLock(clubId: Long): Club? + fun existsBySchoolNameAndName( + schoolName: String, + name: String, + ): Boolean + override fun getClubById(clubId: Long): Club = findById(clubId).orElseThrow { ClubNotFoundException() } override fun getClubByIdForUpdate(clubId: Long): Club = findByIdWithLock(clubId) ?: throw ClubNotFoundException() diff --git a/src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt b/src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt index 1bb760e6..ccc20cbc 100644 --- a/src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt +++ b/src/main/kotlin/com/weeth/domain/club/presentation/ClubController.kt @@ -1,6 +1,7 @@ package com.weeth.domain.club.presentation import com.weeth.domain.club.application.dto.request.ClubCreateRequest +import com.weeth.domain.club.application.dto.response.ClubCreateResponse import com.weeth.domain.club.application.dto.response.ClubInfoResponse import com.weeth.domain.club.application.dto.response.ClubMembershipStatusResponse import com.weeth.domain.club.application.dto.response.ClubPublicResponse @@ -39,10 +40,10 @@ class ClubController( fun create( @Parameter(hidden = true) @CurrentUser userId: Long, @Valid @RequestBody request: ClubCreateRequest, - ): CommonResponse { - manageClubUseCase.create(userId, request) + ): CommonResponse { + val response = manageClubUseCase.create(userId, request) - return CommonResponse.success(ClubResponseCode.CLUB_CREATED_SUCCESS) + return CommonResponse.success(ClubResponseCode.CLUB_CREATED_SUCCESS, response) } @GetMapping diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt index bdd1927e..3fb76e1b 100644 --- a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt @@ -7,7 +7,10 @@ import com.weeth.domain.cardinal.domain.enums.CardinalStatus import com.weeth.domain.cardinal.domain.repository.CardinalRepository import com.weeth.domain.club.application.dto.request.ClubCreateRequest import com.weeth.domain.club.application.dto.request.ClubUpdateRequest +import com.weeth.domain.club.application.dto.response.ClubCreateResponse import com.weeth.domain.club.application.exception.ClubCreateLimitExceededException +import com.weeth.domain.club.application.exception.DuplicateClubException +import com.weeth.domain.club.application.mapper.ClubMapper import com.weeth.domain.club.domain.entity.ClubMemberCardinal import com.weeth.domain.club.domain.enums.PrimaryContact import com.weeth.domain.club.domain.repository.ClubMemberCardinalRepository @@ -46,6 +49,7 @@ class ManageClubUseCaseTest : val clubJoinPolicy = mockk() val clubPermissionPolicy = mockk() val fileRepository = mockk() + val clubMapper = mockk() val useCase = ManageClubUseCase( clubRepository, @@ -57,6 +61,7 @@ class ManageClubUseCaseTest : clubJoinPolicy, clubPermissionPolicy, fileRepository, + clubMapper, ) val adminMember = com.weeth.domain.club.fixture.ClubMemberTestFixture @@ -73,6 +78,7 @@ class ManageClubUseCaseTest : clubJoinPolicy, clubPermissionPolicy, fileRepository, + clubMapper, ) every { clubRepository.save(any()) } answers { firstArg() } every { clubMemberRepository.save(any()) } answers { firstArg() } @@ -80,6 +86,8 @@ class ManageClubUseCaseTest : every { clubMemberCardinalRepository.save(any()) } answers { firstArg() } every { boardRepository.save(any()) } answers { firstArg() } every { clubJoinPolicy.validateCreateLimit(any()) } just Runs + every { clubRepository.existsBySchoolNameAndName(any(), any()) } returns false + every { clubMapper.toCreateResponse(any()) } returns ClubCreateResponse(clubId = "testId", clubName = "테스트") every { fileRepository.save(any()) } answers { firstArg() } every { fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus(any(), any(), any()) @@ -234,6 +242,29 @@ class ManageClubUseCaseTest : } } + context("동일 학교에 같은 이름의 동아리가 이미 존재하는 경우") { + it("DuplicateClubException이 발생하고, 이후 로직이 실행되지 않는다") { + every { userReader.getByIdWithLock(10L) } returns user + every { clubRepository.existsBySchoolNameAndName("가천대", "테스트") } returns true + + shouldThrow { + useCase.create( + 10L, + ClubCreateRequest( + name = "테스트", + schoolName = "가천대", + currentCardinal = 1, + contactPhoneNumber = "01000000000", + primaryContact = PrimaryContact.PHONE, + ), + ) + } + + verify(exactly = 0) { clubRepository.save(any()) } + verify(exactly = 0) { clubMemberRepository.save(any()) } + } + } + context("이미 LEAD로 1개 동아리를 생성한 사용자가 생성 시도하는 경우") { it("ClubCreateLimitExceededException이 발생하고, 이후 로직이 실행되지 않는다") { every { userReader.getByIdWithLock(13L) } returns user From ffe9ce0062b1b1ebc93b35783c83e20d539714a0 Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Sun, 19 Apr 2026 11:00:48 +0900 Subject: [PATCH 53/73] =?UTF-8?q?[WTH-278]=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0=20?= =?UTF-8?q?(#52)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 삭제 즉시 적용 * test: test mockk 추가 --- .../board/application/usecase/command/ManagePostUseCase.kt | 1 + .../board/application/usecase/command/ManagePostUseCaseTest.kt | 1 + 2 files changed, 2 insertions(+) diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt index 2c93a2b9..cae12c93 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt @@ -154,6 +154,7 @@ class ManagePostUseCase( if (files.isNotEmpty()) { fileRepository.deleteAll(files) + fileRepository.flush() } } } diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt index 6e530d25..ce6025cc 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt @@ -91,6 +91,7 @@ class ManagePostUseCaseTest : every { fileReader.findAll(any(), any(), any()) } returns emptyList() every { postMapper.toSaveResponse(any()) } returns PostSaveResponse(1L) every { fileRepository.delete(any()) } just runs + every { fileRepository.flush() } just runs every { clubMemberCardinalReader.findLatestCardinalByClubMember(any()) } returns null } From af48530f03c4d2113f20e907a2400e5090ef7e4d Mon Sep 17 00:00:00 2001 From: Jeon Soo Hyeon <128474444+soo0711@users.noreply.github.com> Date: Sun, 19 Apr 2026 16:52:23 +0900 Subject: [PATCH 54/73] =?UTF-8?q?[WTH-309]=20=EB=AC=B8=EC=9D=98=20?= =?UTF-8?q?=EB=82=B4=EC=9A=A9=20NotBlank=20=EA=B2=80=EC=A6=9D=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20(#53)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 문의 내용 선택 입력으로 변경 --- .../user/application/dto/request/CreateInquiryRequest.kt | 3 +-- .../com/weeth/domain/user/domain/port/InquiryNotifyPort.kt | 2 +- .../com/weeth/domain/user/domain/port/InquirySavePort.kt | 2 +- .../domain/user/infrastructure/NotionInquirySaveAdapter.kt | 4 ++-- .../domain/user/infrastructure/SlackInquiryNotifyAdapter.kt | 4 ++-- 5 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/com/weeth/domain/user/application/dto/request/CreateInquiryRequest.kt b/src/main/kotlin/com/weeth/domain/user/application/dto/request/CreateInquiryRequest.kt index 0a260414..3fda4406 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/dto/request/CreateInquiryRequest.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/dto/request/CreateInquiryRequest.kt @@ -12,7 +12,6 @@ data class CreateInquiryRequest( @field:Size(max = 255) val email: String, @field:Schema(description = "문의 내용", example = "서비스에 대해 문의드립니다.") - @field:NotBlank @field:Size(max = 1000) - val message: String, + val message: String?, ) diff --git a/src/main/kotlin/com/weeth/domain/user/domain/port/InquiryNotifyPort.kt b/src/main/kotlin/com/weeth/domain/user/domain/port/InquiryNotifyPort.kt index 0acd48c4..392d64ee 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/port/InquiryNotifyPort.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/port/InquiryNotifyPort.kt @@ -3,6 +3,6 @@ package com.weeth.domain.user.domain.port interface InquiryNotifyPort { fun notify( email: String, - message: String, + message: String?, ) } diff --git a/src/main/kotlin/com/weeth/domain/user/domain/port/InquirySavePort.kt b/src/main/kotlin/com/weeth/domain/user/domain/port/InquirySavePort.kt index 5d794af6..f1512b2a 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/port/InquirySavePort.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/port/InquirySavePort.kt @@ -3,6 +3,6 @@ package com.weeth.domain.user.domain.port interface InquirySavePort { fun save( email: String, - message: String, + message: String?, ) } diff --git a/src/main/kotlin/com/weeth/domain/user/infrastructure/NotionInquirySaveAdapter.kt b/src/main/kotlin/com/weeth/domain/user/infrastructure/NotionInquirySaveAdapter.kt index c2ca6cb0..8d2fc5c7 100644 --- a/src/main/kotlin/com/weeth/domain/user/infrastructure/NotionInquirySaveAdapter.kt +++ b/src/main/kotlin/com/weeth/domain/user/infrastructure/NotionInquirySaveAdapter.kt @@ -20,7 +20,7 @@ class NotionInquirySaveAdapter( @Async override fun save( email: String, - message: String, + message: String?, ) { val body = mapOf( @@ -33,7 +33,7 @@ class NotionInquirySaveAdapter( mapOf( "문의내용" to mapOf( - "title" to listOf(mapOf("text" to mapOf("content" to message))), + "title" to listOf(mapOf("text" to mapOf("content" to (message ?: "")))), ), "이메일" to mapOf( diff --git a/src/main/kotlin/com/weeth/domain/user/infrastructure/SlackInquiryNotifyAdapter.kt b/src/main/kotlin/com/weeth/domain/user/infrastructure/SlackInquiryNotifyAdapter.kt index 25488bc1..60e276d7 100644 --- a/src/main/kotlin/com/weeth/domain/user/infrastructure/SlackInquiryNotifyAdapter.kt +++ b/src/main/kotlin/com/weeth/domain/user/infrastructure/SlackInquiryNotifyAdapter.kt @@ -18,9 +18,9 @@ class SlackInquiryNotifyAdapter( @Async override fun notify( email: String, - message: String, + message: String?, ) { - val text = "*[랜딩 문의하기]*\n*이메일:* $email\n*문의 내용:*\n```$message```" + val text = "*[랜딩 문의하기]*\n*이메일:* $email\n*문의 내용:*\n```${message ?: ""}```" runCatching { restClient From ecd5685b21bacffb46011b26abfd8ee460ef4f51 Mon Sep 17 00:00:00 2001 From: Jeon Soo Hyeon <128474444+soo0711@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:15:45 +0900 Subject: [PATCH 55/73] =?UTF-8?q?[WTH-282]=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EC=A2=8B=EC=95=84=EC=9A=94=20=EB=B0=B1=EC=97=94=EB=93=9C=20qa?= =?UTF-8?q?=20(#54)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 댓글 반환 시간 작성 시간으로 변경 * refactor: 좋아요 토글을 like/unlike로 분리 * refactor: 공통 검증 로직 메서드로 추출 * refactor: 응답 예시 추가 --- .../dto/response/PostLikeResponse.kt | 4 +- ...ikeUseCase.kt => ManagePostLikeUseCase.kt} | 56 +++++-- .../domain/board/domain/entity/PostLike.kt | 8 +- .../board/presentation/BoardResponseCode.kt | 3 +- .../board/presentation/PostController.kt | 25 +++- .../application/mapper/CommentMapper.kt | 2 +- ...seTest.kt => ManagePostLikeUseCaseTest.kt} | 139 +++++++++++++----- .../board/domain/entity/PostLikeEntityTest.kt | 15 +- .../board/fixture/PostLikeTestFixture.kt | 2 +- 9 files changed, 186 insertions(+), 68 deletions(-) rename src/main/kotlin/com/weeth/domain/board/application/usecase/command/{TogglePostLikeUseCase.kt => ManagePostLikeUseCase.kt} (58%) rename src/test/kotlin/com/weeth/domain/board/application/usecase/command/{TogglePostLikeUseCaseTest.kt => ManagePostLikeUseCaseTest.kt} (50%) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostLikeResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostLikeResponse.kt index 4a280ebf..8b91cd3c 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostLikeResponse.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostLikeResponse.kt @@ -3,8 +3,8 @@ package com.weeth.domain.board.application.dto.response import io.swagger.v3.oas.annotations.media.Schema data class PostLikeResponse( - @field:Schema(description = "좋아요 여부") + @field:Schema(description = "좋아요 여부", example = "true") val isLiked: Boolean, - @field:Schema(description = "좋아요 수") + @field:Schema(description = "좋아요 수", example = "5") val likeCount: Int, ) diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/TogglePostLikeUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostLikeUseCase.kt similarity index 58% rename from src/main/kotlin/com/weeth/domain/board/application/usecase/command/TogglePostLikeUseCase.kt rename to src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostLikeUseCase.kt index c3df21ca..85486e19 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/TogglePostLikeUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostLikeUseCase.kt @@ -5,6 +5,7 @@ import com.weeth.domain.board.application.exception.CategoryAccessDeniedExceptio import com.weeth.domain.board.application.exception.PostLikeLockTimeoutException import com.weeth.domain.board.application.exception.PostNotFoundException import com.weeth.domain.board.application.mapper.PostMapper +import com.weeth.domain.board.domain.entity.Post import com.weeth.domain.board.domain.entity.PostLike import com.weeth.domain.board.domain.repository.PostLikeRepository import com.weeth.domain.board.domain.repository.PostRepository @@ -14,20 +15,57 @@ import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @Service -class TogglePostLikeUseCase( +class ManagePostLikeUseCase( private val postRepository: PostRepository, private val postLikeRepository: PostLikeRepository, private val clubMemberPolicy: ClubMemberPolicy, private val postMapper: PostMapper, ) { @Transactional - fun execute( + fun like( clubId: Long, postId: Long, userId: Long, ): PostLikeResponse { - val member = clubMemberPolicy.getActiveMember(clubId, userId) + val (post, existingLike) = getValidatedPostWithLike(clubId, postId, userId) + + when { + existingLike == null -> { + postLikeRepository.save(PostLike(post = post, userId = userId)) + post.increaseLikeCount() + } + + !existingLike.isActive -> { + existingLike.activate() + post.increaseLikeCount() + } + } + + return postMapper.toLikeResponse(post, isLiked = true) + } + + @Transactional + fun unlike( + clubId: Long, + postId: Long, + userId: Long, + ): PostLikeResponse { + val (post, existingLike) = getValidatedPostWithLike(clubId, postId, userId) + + if (existingLike?.isActive == true) { + existingLike.deactivate() + post.decreaseLikeCount() + } + + return postMapper.toLikeResponse(post, isLiked = false) + } + private fun getValidatedPostWithLike( + clubId: Long, + postId: Long, + userId: Long, + ): Pair { + val member = clubMemberPolicy.getActiveMember(clubId, userId) val post = try { postRepository.findByIdWithLock(postId) ?: throw PostNotFoundException() @@ -38,16 +76,6 @@ class TogglePostLikeUseCase( if (!post.belongsToClub(clubId)) throw PostNotFoundException() if (!post.board.isAccessibleBy(member.memberRole)) throw CategoryAccessDeniedException() - val existingLike = postLikeRepository.findByPostAndUserId(post, userId) - - return if (existingLike != null) { - existingLike.toggle() - if (existingLike.isActive) post.increaseLikeCount() else post.decreaseLikeCount() - postMapper.toLikeResponse(post, existingLike.isActive) - } else { - postLikeRepository.save(PostLike(post = post, userId = userId)) - post.increaseLikeCount() - postMapper.toLikeResponse(post, isLiked = true) - } + return post to postLikeRepository.findByPostAndUserId(post, userId) } } diff --git a/src/main/kotlin/com/weeth/domain/board/domain/entity/PostLike.kt b/src/main/kotlin/com/weeth/domain/board/domain/entity/PostLike.kt index 89282ef1..ca1e60cf 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/entity/PostLike.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/entity/PostLike.kt @@ -39,7 +39,11 @@ class PostLike( var isActive: Boolean = true private set - fun toggle() { - isActive = !isActive + fun activate() { + isActive = true + } + + fun deactivate() { + isActive = false } } diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt b/src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt index d8454f83..ec3b1a16 100644 --- a/src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt @@ -22,5 +22,6 @@ enum class BoardResponseCode( BOARD_NOTICE_READ_SUCCESS(10411, HttpStatus.OK, "공지를 읽음 처리했습니다."), POST_FIND_ALL_BY_CLUB_SUCCESS(10412, HttpStatus.OK, "전체 게시글 목록이 성공적으로 조회되었습니다."), BOARD_REORDERED_SUCCESS(10413, HttpStatus.OK, "게시판 순서가 성공적으로 변경되었습니다."), - POST_LIKE_TOGGLE_SUCCESS(10414, HttpStatus.OK, "게시글 좋아요가 처리되었습니다."), + POST_LIKE_SUCCESS(10414, HttpStatus.OK, "게시글에 좋아요를 눌렀습니다."), + POST_UNLIKE_SUCCESS(10415, HttpStatus.OK, "게시글 좋아요를 취소했습니다."), } diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt b/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt index 1135a9a8..f58b1846 100644 --- a/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt +++ b/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt @@ -7,9 +7,9 @@ import com.weeth.domain.board.application.dto.response.PostLikeResponse import com.weeth.domain.board.application.dto.response.PostListResponse import com.weeth.domain.board.application.dto.response.PostSaveResponse import com.weeth.domain.board.application.exception.BoardErrorCode +import com.weeth.domain.board.application.usecase.command.ManagePostLikeUseCase import com.weeth.domain.board.application.usecase.command.ManagePostUseCase import com.weeth.domain.board.application.usecase.command.MarkNoticeReadUseCase -import com.weeth.domain.board.application.usecase.command.TogglePostLikeUseCase import com.weeth.domain.board.application.usecase.query.GetPostQueryService import com.weeth.global.auth.annotation.CurrentUser import com.weeth.global.auth.jwt.application.exception.JwtErrorCode @@ -40,7 +40,7 @@ class PostController( private val managePostUseCase: ManagePostUseCase, private val getPostQueryService: GetPostQueryService, private val markNoticeReadUseCase: MarkNoticeReadUseCase, - private val togglePostLikeUseCase: TogglePostLikeUseCase, + private val managePostLikeUseCase: ManagePostLikeUseCase, ) { @PostMapping("/{boardId}/posts") @Operation(summary = "게시글 작성") @@ -153,15 +153,28 @@ class PostController( } @PostMapping("/posts/{postId}/like") - @Operation(summary = "게시글 좋아요 토글", description = "좋아요가 없으면 추가, 있으면 취소합니다.") - fun toggleLike( + @Operation(summary = "게시글 좋아요") + fun like( @TsidParam @TsidPathVariable clubId: Long, @PathVariable postId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse = CommonResponse.success( - BoardResponseCode.POST_LIKE_TOGGLE_SUCCESS, - togglePostLikeUseCase.execute(clubId, postId, userId), + BoardResponseCode.POST_LIKE_SUCCESS, + managePostLikeUseCase.like(clubId, postId, userId), + ) + + @DeleteMapping("/posts/{postId}/like") + @Operation(summary = "게시글 좋아요 취소") + fun unlike( + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable postId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success( + BoardResponseCode.POST_UNLIKE_SUCCESS, + managePostLikeUseCase.unlike(clubId, postId, userId), ) } diff --git a/src/main/kotlin/com/weeth/domain/comment/application/mapper/CommentMapper.kt b/src/main/kotlin/com/weeth/domain/comment/application/mapper/CommentMapper.kt index 0a167ecf..69712555 100644 --- a/src/main/kotlin/com/weeth/domain/comment/application/mapper/CommentMapper.kt +++ b/src/main/kotlin/com/weeth/domain/comment/application/mapper/CommentMapper.kt @@ -25,7 +25,7 @@ class CommentMapper( comment.clubMember.profileImageStorageKey?.let { fileAccessUrlPort.resolve(it) }, ), content = comment.content, - time = comment.modifiedAt, + time = comment.createdAt, fileUrls = fileUrls, children = children, ) diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/TogglePostLikeUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostLikeUseCaseTest.kt similarity index 50% rename from src/test/kotlin/com/weeth/domain/board/application/usecase/command/TogglePostLikeUseCaseTest.kt rename to src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostLikeUseCaseTest.kt index 3dba592a..90deab05 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/TogglePostLikeUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostLikeUseCaseTest.kt @@ -25,13 +25,13 @@ import io.mockk.mockk import io.mockk.verify import org.springframework.dao.PessimisticLockingFailureException -class TogglePostLikeUseCaseTest : +class ManagePostLikeUseCaseTest : DescribeSpec({ val postRepository = mockk() val postLikeRepository = mockk() val clubMemberPolicy = mockk() val postMapper = mockk(relaxed = true) - val useCase = TogglePostLikeUseCase(postRepository, postLikeRepository, clubMemberPolicy, postMapper) + val useCase = ManagePostLikeUseCase(postRepository, postLikeRepository, clubMemberPolicy, postMapper) val clubId = 1L val userId = 10L @@ -40,6 +40,15 @@ class TogglePostLikeUseCaseTest : val club = ClubTestFixture.createClub(id = clubId) val board = BoardTestFixture.create(club = club) val member = ClubMemberTestFixture.createActiveMember(club = club) + val otherPost = + PostTestFixture.create( + board = BoardTestFixture.create(club = ClubTestFixture.createClub(id = 99L)), + ) + val privatePost = + PostTestFixture.create( + board = BoardTestFixture.create(club = club, config = BoardConfig(isPrivate = true)), + ) + val userMember = ClubMemberTestFixture.createActiveMember(club = club, memberRole = MemberRole.USER) beforeTest { clearMocks(postRepository, postLikeRepository, clubMemberPolicy, postMapper) @@ -50,14 +59,12 @@ class TogglePostLikeUseCaseTest : } } - describe("execute") { + describe("like") { context("게시글이 존재하지 않을 때") { it("PostNotFoundException을 던진다") { every { postRepository.findByIdWithLock(postId) } returns null - shouldThrow { - useCase.execute(clubId, postId, userId) - } + shouldThrow { useCase.like(clubId, postId, userId) } } } @@ -66,45 +73,34 @@ class TogglePostLikeUseCaseTest : every { postRepository.findByIdWithLock(postId) } throws PessimisticLockingFailureException("lock timeout") - shouldThrow { - useCase.execute(clubId, postId, userId) - } + shouldThrow { useCase.like(clubId, postId, userId) } } } context("다른 클럽의 게시글일 때") { it("PostNotFoundException을 던진다") { - val otherClub = ClubTestFixture.createClub(id = 99L) - val otherPost = PostTestFixture.create(board = BoardTestFixture.create(club = otherClub)) every { postRepository.findByIdWithLock(postId) } returns otherPost - shouldThrow { - useCase.execute(clubId, postId, userId) - } + shouldThrow { useCase.like(clubId, postId, userId) } } } context("접근 권한이 없는 비공개 게시판일 때") { it("CategoryAccessDeniedException을 던진다") { - val privateBoard = BoardTestFixture.create(club = club, config = BoardConfig(isPrivate = true)) - val privatePost = PostTestFixture.create(board = privateBoard) - val userMember = ClubMemberTestFixture.createActiveMember(club = club, memberRole = MemberRole.USER) every { clubMemberPolicy.getActiveMember(clubId, userId) } returns userMember every { postRepository.findByIdWithLock(postId) } returns privatePost - shouldThrow { - useCase.execute(clubId, postId, userId) - } + shouldThrow { useCase.like(clubId, postId, userId) } } } - context("기존 좋아요가 없을 때") { - it("새 PostLike를 생성하고 isLiked=true, likeCount=1을 반환한다") { + context("PostLike가 없을 때") { + it("새 PostLike를 생성하고 likeCount를 증가시킨다") { val post = PostTestFixture.create(board = board) every { postRepository.findByIdWithLock(postId) } returns post every { postLikeRepository.findByPostAndUserId(post, userId) } returns null - val result = useCase.execute(clubId, postId, userId) + val result = useCase.like(clubId, postId, userId) result.isLiked shouldBe true result.likeCount shouldBe 1 @@ -112,33 +108,110 @@ class TogglePostLikeUseCaseTest : } } - context("기존 좋아요(isActive=true)가 있을 때") { - it("toggle하여 isLiked=false, likeCount를 감소시킨다") { + context("PostLike(isActive=false)가 있을 때") { + it("activate하고 likeCount를 증가시킨다") { + val post = PostTestFixture.create(board = board) + val existingLike = PostLikeTestFixture.createInactive(post = post, userId = userId) + every { postRepository.findByIdWithLock(postId) } returns post + every { postLikeRepository.findByPostAndUserId(post, userId) } returns existingLike + + val result = useCase.like(clubId, postId, userId) + + result.isLiked shouldBe true + result.likeCount shouldBe 1 + verify(exactly = 0) { postLikeRepository.save(any()) } + } + } + + context("PostLike(isActive=true)가 이미 있을 때") { + it("no-op으로 isLiked=true를 그대로 반환한다") { val post = PostTestFixture.create(board = board, initialLikeCount = 1) val existingLike = PostLikeTestFixture.createActive(post = post, userId = userId) every { postRepository.findByIdWithLock(postId) } returns post every { postLikeRepository.findByPostAndUserId(post, userId) } returns existingLike - val result = useCase.execute(clubId, postId, userId) + val result = useCase.like(clubId, postId, userId) + + result.isLiked shouldBe true + result.likeCount shouldBe 1 + verify(exactly = 0) { postLikeRepository.save(any()) } + } + } + } + + describe("unlike") { + context("게시글이 존재하지 않을 때") { + it("PostNotFoundException을 던진다") { + every { postRepository.findByIdWithLock(postId) } returns null + + shouldThrow { useCase.unlike(clubId, postId, userId) } + } + } + + context("락 획득에 실패했을 때") { + it("PostLikeLockTimeoutException을 던진다") { + every { postRepository.findByIdWithLock(postId) } throws + PessimisticLockingFailureException("lock timeout") + + shouldThrow { useCase.unlike(clubId, postId, userId) } + } + } + + context("다른 클럽의 게시글일 때") { + it("PostNotFoundException을 던진다") { + every { postRepository.findByIdWithLock(postId) } returns otherPost + + shouldThrow { useCase.unlike(clubId, postId, userId) } + } + } + + context("접근 권한이 없는 비공개 게시판일 때") { + it("CategoryAccessDeniedException을 던진다") { + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns userMember + every { postRepository.findByIdWithLock(postId) } returns privatePost + + shouldThrow { useCase.unlike(clubId, postId, userId) } + } + } + + context("PostLike(isActive=true)가 있을 때") { + it("deactivate하고 likeCount를 감소시킨다") { + val post = PostTestFixture.create(board = board, initialLikeCount = 1) + val existingLike = PostLikeTestFixture.createActive(post = post, userId = userId) + every { postRepository.findByIdWithLock(postId) } returns post + every { postLikeRepository.findByPostAndUserId(post, userId) } returns existingLike + + val result = useCase.unlike(clubId, postId, userId) result.isLiked shouldBe false result.likeCount shouldBe 0 - verify(exactly = 0) { postLikeRepository.save(any()) } } } - context("기존 좋아요(isActive=false)가 있을 때") { - it("toggle하여 isLiked=true, likeCount를 증가시킨다") { + context("PostLike(isActive=false)가 있을 때") { + it("no-op으로 isLiked=false를 그대로 반환한다") { val post = PostTestFixture.create(board = board) val existingLike = PostLikeTestFixture.createInactive(post = post, userId = userId) every { postRepository.findByIdWithLock(postId) } returns post every { postLikeRepository.findByPostAndUserId(post, userId) } returns existingLike - val result = useCase.execute(clubId, postId, userId) + val result = useCase.unlike(clubId, postId, userId) - result.isLiked shouldBe true - result.likeCount shouldBe 1 - verify(exactly = 0) { postLikeRepository.save(any()) } + result.isLiked shouldBe false + result.likeCount shouldBe 0 + } + } + + context("PostLike가 없을 때") { + it("no-op으로 isLiked=false를 반환한다") { + val post = PostTestFixture.create(board = board) + every { postRepository.findByIdWithLock(postId) } returns post + every { postLikeRepository.findByPostAndUserId(post, userId) } returns null + + val result = useCase.unlike(clubId, postId, userId) + + result.isLiked shouldBe false + result.likeCount shouldBe 0 } } } diff --git a/src/test/kotlin/com/weeth/domain/board/domain/entity/PostLikeEntityTest.kt b/src/test/kotlin/com/weeth/domain/board/domain/entity/PostLikeEntityTest.kt index c0b0e352..400d9345 100644 --- a/src/test/kotlin/com/weeth/domain/board/domain/entity/PostLikeEntityTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/domain/entity/PostLikeEntityTest.kt @@ -12,20 +12,19 @@ class PostLikeEntityTest : like.isActive shouldBe true } - "toggle은 isActive를 true에서 false로 반전시킨다" { - val like = PostLikeTestFixture.createActive() + "activate는 isActive를 true로 설정한다" { + val like = PostLikeTestFixture.createInactive() - like.toggle() + like.activate() - like.isActive shouldBe false + like.isActive shouldBe true } - "toggle을 두 번 호출하면 isActive가 다시 true가 된다" { + "deactivate는 isActive를 false로 설정한다" { val like = PostLikeTestFixture.createActive() - like.toggle() - like.toggle() + like.deactivate() - like.isActive shouldBe true + like.isActive shouldBe false } }) diff --git a/src/test/kotlin/com/weeth/domain/board/fixture/PostLikeTestFixture.kt b/src/test/kotlin/com/weeth/domain/board/fixture/PostLikeTestFixture.kt index f9aaa6e2..d217c2fa 100644 --- a/src/test/kotlin/com/weeth/domain/board/fixture/PostLikeTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/board/fixture/PostLikeTestFixture.kt @@ -12,5 +12,5 @@ object PostLikeTestFixture { fun createInactive( post: Post = PostTestFixture.create(), userId: Long = 1L, - ): PostLike = PostLike(post = post, userId = userId).also { it.toggle() } + ): PostLike = PostLike(post = post, userId = userId).also { it.deactivate() } } From 9f4b21316d25f1c1a08b3e7b0d993445c76dcc6a Mon Sep 17 00:00:00 2001 From: Jeon Soo Hyeon <128474444+soo0711@users.noreply.github.com> Date: Mon, 20 Apr 2026 20:14:48 +0900 Subject: [PATCH 56/73] =?UTF-8?q?[WTH-294]=20qr=20=EB=A7=8C=EB=A3=8C?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EC=A0=84?= =?UTF-8?q?=EB=8B=AC=20(#51)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: SSE 연결 관리를 위한 SseEmitterStore 추가 * feat: SSE port 구현 * feat: SSE adapter 구현 * feat: QR 생성 시 SSE로 출석 가능한 시간 전송 * feat: 출석 SSE 구독 엔드포인트 추가 * test: 관련 테스트 수정 * test: SseEmitterStore 단위 테스트 추가 * refactor: SsePort를 Broadcast, Subscribe로 분리 * feat: SSE Subscribe UseCase 추가 * refactor: SseEmitterStore 동시성 처리 * test: 관련 테스트 추가 및 수정 * test: 동시성 테스트 무한 대기 방지 * test: 동시성 테스트 수정 * refactor: @synchronized 병목 해소 * refactor: event로 패키지 변경 * style: 린트 적용 --- .../application/event/AttendanceOpenEvent.kt | 7 + .../usecase/command/GenerateQrTokenUseCase.kt | 12 +- .../command/SubscribeAttendanceSseUseCase.kt | 22 +++ .../domain/port/SseBroadcastPort.kt | 9 + .../domain/port/SseSubscribePort.kt | 10 ++ .../infrastructure/SseAttendanceAdapter.kt | 53 ++++++ .../infrastructure/SseEmitterStore.kt | 40 +++++ .../presentation/AttendanceController.kt | 12 ++ .../command/GenerateQrTokenUseCaseTest.kt | 12 +- .../SubscribeAttendanceSseUseCaseTest.kt | 52 ++++++ .../infrastructure/SseEmitterStoreTest.kt | 155 ++++++++++++++++++ 11 files changed, 380 insertions(+), 4 deletions(-) create mode 100644 src/main/kotlin/com/weeth/domain/attendance/application/event/AttendanceOpenEvent.kt create mode 100644 src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/SubscribeAttendanceSseUseCase.kt create mode 100644 src/main/kotlin/com/weeth/domain/attendance/domain/port/SseBroadcastPort.kt create mode 100644 src/main/kotlin/com/weeth/domain/attendance/domain/port/SseSubscribePort.kt create mode 100644 src/main/kotlin/com/weeth/domain/attendance/infrastructure/SseAttendanceAdapter.kt create mode 100644 src/main/kotlin/com/weeth/domain/attendance/infrastructure/SseEmitterStore.kt create mode 100644 src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/SubscribeAttendanceSseUseCaseTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/attendance/infrastructure/SseEmitterStoreTest.kt diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/event/AttendanceOpenEvent.kt b/src/main/kotlin/com/weeth/domain/attendance/application/event/AttendanceOpenEvent.kt new file mode 100644 index 00000000..4736596c --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/event/AttendanceOpenEvent.kt @@ -0,0 +1,7 @@ +package com.weeth.domain.attendance.application.event + +import java.time.LocalDateTime + +data class AttendanceOpenEvent( + val expiredAt: LocalDateTime, +) diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCase.kt b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCase.kt index 2e21db7e..a0cd0b3c 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCase.kt @@ -1,12 +1,13 @@ package com.weeth.domain.attendance.application.usecase.command import com.weeth.domain.attendance.application.dto.response.QrTokenResponse +import com.weeth.domain.attendance.application.event.AttendanceOpenEvent import com.weeth.domain.attendance.application.mapper.AttendanceMapper import com.weeth.domain.attendance.domain.port.QrAttendancePort +import com.weeth.domain.attendance.domain.port.SseBroadcastPort import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.session.domain.repository.SessionReader import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional import java.time.LocalDateTime @Service @@ -15,6 +16,7 @@ class GenerateQrTokenUseCase( private val qrAttendancePort: QrAttendancePort, private val attendanceMapper: AttendanceMapper, private val clubPermissionPolicy: ClubPermissionPolicy, + private val ssePort: SseBroadcastPort, ) { fun execute( sessionId: Long, @@ -28,6 +30,12 @@ class GenerateQrTokenUseCase( val expiredAt = LocalDateTime.now().plusSeconds(QrAttendancePort.TTL_SECONDS) qrAttendancePort.store(sessionId, session.code) - return attendanceMapper.toQrTokenResponse(session, expiredAt) + val response = attendanceMapper.toQrTokenResponse(session, expiredAt) + ssePort.broadcast(clubId, EVENT_QR_OPEN, AttendanceOpenEvent(expiredAt)) + return response + } + + companion object { + internal const val EVENT_QR_OPEN = "qr-open" } } diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/SubscribeAttendanceSseUseCase.kt b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/SubscribeAttendanceSseUseCase.kt new file mode 100644 index 00000000..ac850f29 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/SubscribeAttendanceSseUseCase.kt @@ -0,0 +1,22 @@ +package com.weeth.domain.attendance.application.usecase.command + +import com.weeth.domain.attendance.domain.port.SseSubscribePort +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter + +@Service +class SubscribeAttendanceSseUseCase( + private val sseSubscribePort: SseSubscribePort, + private val clubMemberPolicy: ClubMemberPolicy, +) { + @Transactional + fun execute( + clubId: Long, + userId: Long, + ): SseEmitter { + clubMemberPolicy.getActiveMember(clubId, userId) + return sseSubscribePort.subscribe(clubId, userId) + } +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/port/SseBroadcastPort.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/port/SseBroadcastPort.kt new file mode 100644 index 00000000..cee06b51 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/port/SseBroadcastPort.kt @@ -0,0 +1,9 @@ +package com.weeth.domain.attendance.domain.port + +interface SseBroadcastPort { + fun broadcast( + clubId: Long, + eventName: String, + data: Any, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/port/SseSubscribePort.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/port/SseSubscribePort.kt new file mode 100644 index 00000000..92b83d5a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/port/SseSubscribePort.kt @@ -0,0 +1,10 @@ +package com.weeth.domain.attendance.domain.port + +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter + +interface SseSubscribePort { + fun subscribe( + clubId: Long, + userId: Long, + ): SseEmitter +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/infrastructure/SseAttendanceAdapter.kt b/src/main/kotlin/com/weeth/domain/attendance/infrastructure/SseAttendanceAdapter.kt new file mode 100644 index 00000000..cdc60687 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/infrastructure/SseAttendanceAdapter.kt @@ -0,0 +1,53 @@ +package com.weeth.domain.attendance.infrastructure + +import com.fasterxml.jackson.databind.ObjectMapper +import com.weeth.domain.attendance.domain.port.SseBroadcastPort +import com.weeth.domain.attendance.domain.port.SseSubscribePort +import org.springframework.stereotype.Component +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter + +@Component +class SseAttendanceAdapter( + private val store: SseEmitterStore, + private val objectMapper: ObjectMapper, +) : SseBroadcastPort, + SseSubscribePort { + companion object { + private const val TIMEOUT = 30 * 60 * 1000L + private const val EVENT_CONNECT = "connect" + } + + override fun subscribe( + clubId: Long, + userId: Long, + ): SseEmitter { + val emitter = SseEmitter(TIMEOUT) + val cleanup = { store.remove(clubId, userId, emitter) } + + store.add(clubId, userId, emitter) + emitter.onCompletion(cleanup) + emitter.onTimeout(cleanup) + emitter.onError { cleanup() } + + runCatching { + emitter.send(SseEmitter.event().name(EVENT_CONNECT).data("connected")) + }.onFailure { cleanup() } + + return emitter + } + + override fun broadcast( + clubId: Long, + eventName: String, + data: Any, + ) { + val payload = runCatching { objectMapper.writeValueAsString(data) }.getOrElse { return } + + store.getAllByClub(clubId).forEach { (userId, emitter) -> + val cleanup = { store.remove(clubId, userId, emitter) } + runCatching { + emitter.send(SseEmitter.event().name(eventName).data(payload)) + }.onFailure { cleanup() } + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/infrastructure/SseEmitterStore.kt b/src/main/kotlin/com/weeth/domain/attendance/infrastructure/SseEmitterStore.kt new file mode 100644 index 00000000..94257d60 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/infrastructure/SseEmitterStore.kt @@ -0,0 +1,40 @@ +package com.weeth.domain.attendance.infrastructure + +import org.springframework.stereotype.Component +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList + +@Component +class SseEmitterStore { + private val store = ConcurrentHashMap>>() + + fun add( + clubId: Long, + userId: Long, + emitter: SseEmitter, + ) { + store + .computeIfAbsent(clubId) { ConcurrentHashMap() } + .computeIfAbsent(userId) { CopyOnWriteArrayList() } + .add(emitter) + } + + fun remove( + clubId: Long, + userId: Long, + emitter: SseEmitter, + ) { + store.computeIfPresent(clubId) { _, userMap -> + userMap.compute(userId) { _, emitters -> + emitters?.apply { remove(emitter) }?.ifEmpty { null } + } + userMap.takeUnless { it.isEmpty() } + } + } + + fun getAllByClub(clubId: Long): List> = + store[clubId] + ?.flatMap { (userId, emitters) -> emitters.map { emitter -> userId to emitter } } + ?: emptyList() +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt index 864f0e49..cf1f7986 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/presentation/AttendanceController.kt @@ -5,6 +5,7 @@ import com.weeth.domain.attendance.application.dto.response.AttendanceDetailResp import com.weeth.domain.attendance.application.dto.response.AttendanceSummaryResponse import com.weeth.domain.attendance.application.exception.AttendanceErrorCode import com.weeth.domain.attendance.application.usecase.command.ManageAttendanceUseCase +import com.weeth.domain.attendance.application.usecase.command.SubscribeAttendanceSseUseCase import com.weeth.domain.attendance.application.usecase.query.GetAttendanceQueryService import com.weeth.global.auth.annotation.CurrentUser import com.weeth.global.common.exception.ApiErrorCodeExample @@ -14,12 +15,14 @@ import com.weeth.global.common.web.TsidPathVariable import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.MediaType import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter @Tag(name = "ATTENDANCE", description = "출석 API") @RestController @@ -28,6 +31,7 @@ import org.springframework.web.bind.annotation.RestController class AttendanceController( private val manageAttendanceUseCase: ManageAttendanceUseCase, private val getAttendanceQueryService: GetAttendanceQueryService, + private val subscribeAttendanceSseUseCase: SubscribeAttendanceSseUseCase, ) { @PostMapping("/sessions/{sessionId}/check-in") @Operation(summary = "출석체크") @@ -71,4 +75,12 @@ class AttendanceController( AttendanceResponseCode.ATTENDANCE_FIND_ALL_SUCCESS, getAttendanceQueryService.findAllDetailsByCurrentCardinal(clubId, userId), ) + + @GetMapping("/stream", produces = [MediaType.TEXT_EVENT_STREAM_VALUE]) + @Operation(summary = "출석 SSE 구독") + fun subscribe( + @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): SseEmitter = subscribeAttendanceSseUseCase.execute(clubId, userId) } diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCaseTest.kt index 3b2e8c09..d59acfb0 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCaseTest.kt @@ -1,8 +1,10 @@ package com.weeth.domain.attendance.application.usecase.command import com.weeth.domain.attendance.application.dto.response.QrTokenResponse +import com.weeth.domain.attendance.application.event.AttendanceOpenEvent import com.weeth.domain.attendance.application.mapper.AttendanceMapper import com.weeth.domain.attendance.domain.port.QrAttendancePort +import com.weeth.domain.attendance.domain.port.SseBroadcastPort import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.session.application.exception.SessionNotFoundException import com.weeth.domain.session.domain.repository.SessionReader @@ -24,10 +26,12 @@ class GenerateQrTokenUseCaseTest : val qrAttendancePort = mockk() val attendanceMapper = mockk() val clubPermissionPolicy = mockk(relaxed = true) + val ssePort = mockk(relaxed = true) - val useCase = GenerateQrTokenUseCase(sessionReader, qrAttendancePort, attendanceMapper, clubPermissionPolicy) + val useCase = + GenerateQrTokenUseCase(sessionReader, qrAttendancePort, attendanceMapper, clubPermissionPolicy, ssePort) - beforeTest { clearMocks(sessionReader, qrAttendancePort, attendanceMapper, clubPermissionPolicy) } + beforeTest { clearMocks(sessionReader, qrAttendancePort, attendanceMapper, clubPermissionPolicy, ssePort) } describe("execute") { val sessionId = 1L @@ -52,6 +56,9 @@ class GenerateQrTokenUseCaseTest : result shouldBe expectedResponse verify(exactly = 1) { clubPermissionPolicy.requireAdmin(10L, 20L) } verify(exactly = 1) { qrAttendancePort.store(sessionId, code) } + verify(exactly = 1) { + ssePort.broadcast(10L, GenerateQrTokenUseCase.EVENT_QR_OPEN, any()) + } } } @@ -62,6 +69,7 @@ class GenerateQrTokenUseCaseTest : shouldThrow { useCase.execute(sessionId, 10L, 20L) } verify(exactly = 0) { qrAttendancePort.store(any(), any()) } + verify(exactly = 0) { ssePort.broadcast(any(), any(), any()) } } } } diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/SubscribeAttendanceSseUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/SubscribeAttendanceSseUseCaseTest.kt new file mode 100644 index 00000000..150fa3aa --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/SubscribeAttendanceSseUseCaseTest.kt @@ -0,0 +1,52 @@ +package com.weeth.domain.attendance.application.usecase.command + +import com.weeth.domain.attendance.domain.port.SseSubscribePort +import com.weeth.domain.club.application.exception.MemberNotActiveException +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter + +class SubscribeAttendanceSseUseCaseTest : + DescribeSpec({ + val sseSubscribePort = mockk() + val clubMemberPolicy = mockk() + val useCase = SubscribeAttendanceSseUseCase(sseSubscribePort, clubMemberPolicy) + + beforeTest { clearMocks(sseSubscribePort, clubMemberPolicy) } + + describe("execute") { + val clubId = 1L + val userId = 100L + + context("활성 멤버인 경우") { + it("SseSubscribePort를 호출하고 SseEmitter를 반환한다") { + val emitter = mockk(relaxed = true) + + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns mockk() + every { sseSubscribePort.subscribe(clubId, userId) } returns emitter + + val result = useCase.execute(clubId, userId) + + result shouldBe emitter + verify(exactly = 1) { clubMemberPolicy.getActiveMember(clubId, userId) } + verify(exactly = 1) { sseSubscribePort.subscribe(clubId, userId) } + } + } + + context("비활성 멤버이거나 클럽에 속하지 않은 경우") { + it("예외를 던지고 SSE 구독을 하지 않는다") { + every { clubMemberPolicy.getActiveMember(clubId, userId) } throws MemberNotActiveException() + + shouldThrow { useCase.execute(clubId, userId) } + + verify(exactly = 0) { sseSubscribePort.subscribe(any(), any()) } + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/attendance/infrastructure/SseEmitterStoreTest.kt b/src/test/kotlin/com/weeth/domain/attendance/infrastructure/SseEmitterStoreTest.kt new file mode 100644 index 00000000..a4644d57 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/attendance/infrastructure/SseEmitterStoreTest.kt @@ -0,0 +1,155 @@ +package com.weeth.domain.attendance.infrastructure + +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.mockk.mockk +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +class SseEmitterStoreTest : + StringSpec({ + val clubId = 1L + val userId = 100L + + "emitter를 추가하면 getAllByClub에서 조회된다" { + val store = SseEmitterStore() + val emitter = mockk(relaxed = true) + + store.add(clubId, userId, emitter) + + store.getAllByClub(clubId) shouldHaveSize 1 + } + + "같은 userId로 여러 emitter를 추가하면 멀티탭이 지원된다" { + val store = SseEmitterStore() + val emitter1 = mockk(relaxed = true) + val emitter2 = mockk(relaxed = true) + + store.add(clubId, userId, emitter1) + store.add(clubId, userId, emitter2) + + store.getAllByClub(clubId) shouldHaveSize 2 + } + + "emitter를 제거하면 조회되지 않는다" { + val store = SseEmitterStore() + val emitter = mockk(relaxed = true) + store.add(clubId, userId, emitter) + + store.remove(clubId, userId, emitter) + + store.getAllByClub(clubId).shouldBeEmpty() + } + + "마지막 emitter 제거 시 내부 map 엔트리가 정리된다" { + val store = SseEmitterStore() + val emitter = mockk(relaxed = true) + store.add(clubId, userId, emitter) + + store.remove(clubId, userId, emitter) + + store.getAllByClub(clubId).shouldBeEmpty() + } + + "여러 emitter 중 하나만 제거하면 나머지는 유지된다" { + val store = SseEmitterStore() + val emitter1 = mockk(relaxed = true) + val emitter2 = mockk(relaxed = true) + store.add(clubId, userId, emitter1) + store.add(clubId, userId, emitter2) + + store.remove(clubId, userId, emitter1) + + store.getAllByClub(clubId) shouldHaveSize 1 + } + + "getAllByClub은 userId와 emitter 쌍을 반환한다" { + val store = SseEmitterStore() + val emitter = mockk(relaxed = true) + store.add(clubId, userId, emitter) + + val result = store.getAllByClub(clubId) + + result.first().first shouldBe userId + result.first().second shouldBe emitter + } + + "존재하지 않는 clubId로 조회하면 빈 리스트를 반환한다" { + val store = SseEmitterStore() + + store.getAllByClub(999L).shouldBeEmpty() + } + + "동시에 여러 스레드에서 add를 호출해도 emitter가 유실되지 않는다" { + val store = SseEmitterStore() + val threadCount = 100 + val latch = CountDownLatch(threadCount) + val executor = Executors.newFixedThreadPool(threadCount) + + repeat(threadCount) { i -> + executor.submit { + try { + store.add(clubId, userId + i, mockk(relaxed = true)) + } finally { + latch.countDown() + } + } + } + + try { + latch.await(10, TimeUnit.SECONDS) shouldBe true + } finally { + executor.shutdown() + executor.awaitTermination(5, TimeUnit.SECONDS) + } + + store.getAllByClub(clubId) shouldHaveSize threadCount + } + + "동시에 add와 remove를 호출해도 store 상태가 일관성을 유지한다" { + val store = SseEmitterStore() + val threadCount = 50 + val executor = Executors.newFixedThreadPool(threadCount * 2) + + // 제거할 emitter를 사전에 store에 등록해 remove가 항상 유효한 대상을 갖도록 보장 + val toRemove = List(threadCount) { mockk(relaxed = true) } + toRemove.forEach { store.add(clubId, userId, it) } + + val toAdd = List(threadCount) { mockk(relaxed = true) } + val latch = CountDownLatch(threadCount * 2) + + repeat(threadCount) { i -> + executor.submit { + try { + store.add(clubId, userId, toAdd[i]) + } finally { + latch.countDown() + } + } + executor.submit { + try { + store.remove(clubId, userId, toRemove[i]) + } finally { + latch.countDown() + } + } + } + + try { + latch.await(10, TimeUnit.SECONDS) shouldBe true + } finally { + executor.shutdown() + executor.awaitTermination(5, TimeUnit.SECONDS) + } + + // O(1) 조회를 위해 Set으로 변환 + val inStore = store.getAllByClub(clubId).map { it.second }.toSet() + + toAdd.all { it in inStore } shouldBe true + toRemove.none { it in inStore } shouldBe true + } + }) From e25a0549a37c4a3c329b73311f8eafcc34e418e8 Mon Sep 17 00:00:00 2001 From: Jeon Soo Hyeon <128474444+soo0711@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:42:46 +0900 Subject: [PATCH 57/73] =?UTF-8?q?[WTH-310]=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=EC=B5=9C=EA=B7=BC=20=EA=B2=8C=EC=8B=9C=EA=B8=80?= =?UTF-8?q?=EC=97=90=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=97=AC=EB=B6=80=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80=20(#55)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 대시보드 최근 게시글 응답에 isLiked 필드 추가 * test: isLiked 테스트 추가 --- .../domain/board/domain/repository/PostLikeReader.kt | 8 ++++++++ .../domain/board/domain/repository/PostLikeRepository.kt | 6 ++++-- .../application/dto/response/DashboardPostResponse.kt | 5 +++-- .../dashboard/application/mapper/DashboardMapper.kt | 4 +++- .../application/usecase/query/GetDashboardQueryService.kt | 7 +++++++ .../usecase/query/GetDashboardQueryServiceTest.kt | 8 ++++++++ 6 files changed, 33 insertions(+), 5 deletions(-) create mode 100644 src/main/kotlin/com/weeth/domain/board/domain/repository/PostLikeReader.kt diff --git a/src/main/kotlin/com/weeth/domain/board/domain/repository/PostLikeReader.kt b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostLikeReader.kt new file mode 100644 index 00000000..0804f775 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostLikeReader.kt @@ -0,0 +1,8 @@ +package com.weeth.domain.board.domain.repository + +interface PostLikeReader { + fun findLikedPostIds( + postIds: List, + userId: Long, + ): Set +} diff --git a/src/main/kotlin/com/weeth/domain/board/domain/repository/PostLikeRepository.kt b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostLikeRepository.kt index 978e9973..1b988add 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/repository/PostLikeRepository.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostLikeRepository.kt @@ -5,7 +5,9 @@ import com.weeth.domain.board.domain.entity.PostLike import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query -interface PostLikeRepository : JpaRepository { +interface PostLikeRepository : + JpaRepository, + PostLikeReader { fun existsByPostAndUserIdAndIsActiveTrue( post: Post, userId: Long, @@ -25,7 +27,7 @@ interface PostLikeRepository : JpaRepository { AND pl.isActive = true """, ) - fun findLikedPostIds( + override fun findLikedPostIds( postIds: List, userId: Long, ): Set diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardPostResponse.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardPostResponse.kt index efbc110c..db715b72 100644 --- a/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardPostResponse.kt +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardPostResponse.kt @@ -1,5 +1,6 @@ package com.weeth.domain.dashboard.application.dto.response +import com.weeth.domain.board.application.dto.response.PostLikeResponse import com.weeth.domain.file.application.dto.response.FileResponse import com.weeth.domain.user.application.dto.response.UserInfo import io.swagger.v3.oas.annotations.media.Schema @@ -18,8 +19,8 @@ data class DashboardPostResponse( val time: LocalDateTime, @field:Schema(description = "댓글 수", example = "5") val commentCount: Int, - @field:Schema(description = "좋아요 수", example = "3") - val likeCount: Int, + @field:Schema(description = "좋아요 정보") + val like: PostLikeResponse, @field:Schema(description = "첨부 파일 목록") val fileUrls: List, @field:Schema(description = "24시간 내 새 게시글 여부", example = "true") diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/mapper/DashboardMapper.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/mapper/DashboardMapper.kt index 73a35942..d1f8952d 100644 --- a/src/main/kotlin/com/weeth/domain/dashboard/application/mapper/DashboardMapper.kt +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/mapper/DashboardMapper.kt @@ -1,5 +1,6 @@ package com.weeth.domain.dashboard.application.mapper +import com.weeth.domain.board.application.dto.response.PostLikeResponse import com.weeth.domain.board.domain.entity.Post import com.weeth.domain.club.domain.entity.Club import com.weeth.domain.club.domain.entity.ClubMember @@ -101,6 +102,7 @@ class DashboardMapper( post: Post, files: List, now: LocalDateTime, + isLiked: Boolean, ) = DashboardPostResponse( id = post.id, author = UserInfo.of(post.clubMember.user, post.clubMember.memberRole, resolveProfileImage(post.clubMember)), @@ -108,7 +110,7 @@ class DashboardMapper( content = post.content, time = post.createdAt, commentCount = post.commentCount, - likeCount = post.likeCount, + like = PostLikeResponse(isLiked = isLiked, likeCount = post.likeCount), fileUrls = files.map(fileMapper::toFileResponse), isNew = post.createdAt.isAfter(now.minusHours(24)), ) diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryService.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryService.kt index 7cac5e13..b6b97099 100644 --- a/src/main/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryService.kt @@ -2,6 +2,7 @@ package com.weeth.domain.dashboard.application.usecase.query import com.weeth.domain.board.domain.enums.BoardType import com.weeth.domain.board.domain.repository.BoardReader +import com.weeth.domain.board.domain.repository.PostLikeReader import com.weeth.domain.board.domain.repository.PostReader import com.weeth.domain.club.domain.repository.ClubMemberReader import com.weeth.domain.club.domain.repository.ClubReader @@ -29,6 +30,7 @@ import java.time.LocalDateTime @Transactional(readOnly = true) class GetDashboardQueryService( private val boardReader: BoardReader, + private val postLikeReader: PostLikeReader, private val clubReader: ClubReader, private val clubMemberReader: ClubMemberReader, private val clubMemberPolicy: ClubMemberPolicy, @@ -88,13 +90,18 @@ class GetDashboardQueryService( val posts = postReader.findRecentByBoardIds(accessibleBoardIds, pageable) val now = LocalDateTime.now() val postIds = posts.content.map { it.id } + + if (postIds.isEmpty()) return SliceImpl(emptyList(), pageable, false) + val filesByPostId = fileReader.findAll(FileOwnerType.POST, postIds).groupBy { it.ownerId } + val likedPostIds = postLikeReader.findLikedPostIds(postIds, userId) return posts.map { post -> dashboardMapper.toPostResponse( post = post, files = filesByPostId[post.id] ?: emptyList(), now = now, + isLiked = post.id in likedPostIds, ) } } diff --git a/src/test/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryServiceTest.kt index 5d457a7c..9d5f4067 100644 --- a/src/test/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryServiceTest.kt @@ -2,6 +2,7 @@ package com.weeth.domain.dashboard.application.usecase.query import com.weeth.domain.board.domain.enums.BoardType import com.weeth.domain.board.domain.repository.BoardReader +import com.weeth.domain.board.domain.repository.PostLikeReader import com.weeth.domain.board.domain.repository.PostReader import com.weeth.domain.board.domain.vo.BoardConfig import com.weeth.domain.board.fixture.BoardTestFixture @@ -40,6 +41,7 @@ import java.time.LocalDateTime class GetDashboardQueryServiceTest : DescribeSpec({ val boardReader = mockk() + val postLikeReader = mockk() val clubReader = mockk() val clubMemberReader = mockk() val clubMemberPolicy = ClubMemberPolicy(clubMemberReader) @@ -55,6 +57,7 @@ class GetDashboardQueryServiceTest : val queryService = GetDashboardQueryService( boardReader = boardReader, + postLikeReader = postLikeReader, clubReader = clubReader, clubMemberReader = clubMemberReader, clubMemberPolicy = clubMemberPolicy, @@ -75,6 +78,7 @@ class GetDashboardQueryServiceTest : beforeTest { clearMocks( boardReader, + postLikeReader, clubReader, clubMemberReader, eventReader, @@ -182,11 +186,13 @@ class GetDashboardQueryServiceTest : every { boardReader.findAllActiveByClubId(clubId) } returns listOf(board) every { postReader.findRecentByBoardIds(listOf(board.id), any()) } returns slice every { fileReader.findAll(FileOwnerType.POST, any>()) } returns emptyList() + every { postLikeReader.findLikedPostIds(listOf(post.id), userId) } returns emptySet() val result = queryService.getRecentPosts(clubId, userId, 0, 10) result.content.size shouldBe 1 result.content[0].fileUrls.isEmpty() shouldBe true + result.content[0].like.isLiked shouldBe false } } @@ -208,6 +214,7 @@ class GetDashboardQueryServiceTest : every { boardReader.findAllActiveByClubId(clubId) } returns listOf(publicBoard, privateBoard) every { postReader.findRecentByBoardIds(listOf(publicBoard.id), any()) } returns slice every { fileReader.findAll(FileOwnerType.POST, any>()) } returns emptyList() + every { postLikeReader.findLikedPostIds(listOf(post.id), userId) } returns emptySet() val result = queryService.getRecentPosts(clubId, userId, 0, 10) @@ -229,6 +236,7 @@ class GetDashboardQueryServiceTest : every { boardReader.findAllActiveByClubId(clubId) } returns listOf(privateBoard) every { postReader.findRecentByBoardIds(listOf(privateBoard.id), any()) } returns slice every { fileReader.findAll(FileOwnerType.POST, any>()) } returns emptyList() + every { postLikeReader.findLikedPostIds(listOf(post.id), userId) } returns emptySet() val result = queryService.getRecentPosts(clubId, userId, 0, 10) From b9c38890186d0a61c741123c02641c5bc52e43ed Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Fri, 24 Apr 2026 19:05:20 +0900 Subject: [PATCH 58/73] =?UTF-8?q?[WTH-105]=20Weeth=20Server=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=88=ED=84=B0=EB=A7=81=20=EC=84=A4=EC=A0=95=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 프로메테우스 API Overview 추가 * feat: 프로메테우스 외부/내부 인프라 대시보드 * feat: 로그 MDC 설정 추가 * feat: 관측 설정 추가 * refactor: redisConnectionFactory 주입 방식으로 수정 * refactor: 도커 네트워크 접근 허용 * refactor: 로그백 설정 수정 * refactor: 환경 설정 * feat: 로컬 모니터링 완성 * feat: 로컬 모니터링 완성 * refactor: 도커 파일 Java Agent 설정 * refactor: 도커 컴포즈 Java Agent 설정 * feat: 개발 서버 모니터링 설정 추가 * feat: 개발 서버 도커 컴포즈 추가 * feat: 개발 서버 배포 스크립트 추가 * feat: 운영 서버 모니터링 설정 추가 * feat: 운영 서버 도커 컴포즈 추가 * feat: 운영 서버 배포 스크립트 추가 * feat: 개발/운영 깃액션 워크 플로우 추가 * context: 컨텍스트 업데이트 * refactor: 도커 컴포즈 오타 수정 * refactor: 리버스 프록시 엔드포인트 명시적 제한 * refactor: 로컬 환경변수 설정 추가 * refactor: 루트 경로 차단 추가 * refactor: 모니터링 환경변수 추가 * refactor: 디렉토리 오타 수정 * refactor: 환경변수 수정 * refactor: 로키 설정 수정 * refactor: 대기시간 증강 --- .claude/rules/architecture.md | 5 +- .github/workflows/deploy-monitoring-dev.yml | 40 ++ .github/workflows/deploy-monitoring-prod.yml | 42 ++ CLAUDE.md | 8 +- Dockerfile | 6 +- README.md | 21 +- build.gradle.kts | 8 + infra/dev/caddy/Caddyfile | 17 + infra/dev/docker-compose.yml | 31 ++ infra/dev/monitoring/alloy/config.alloy | 76 +++ infra/dev/monitoring/docker-compose.yml | 139 +++++ .../provisioning/dashboards/api-overview.json | 388 +++++++++++++ .../provisioning/dashboards/dashboards.yaml | 12 + .../dashboards/external-infra.json | 373 +++++++++++++ .../dashboards/internal-infra.json | 266 +++++++++ .../dashboards/logs-explorer.json | 403 ++++++++++++++ .../dashboards/trace-explorer.json | 513 ++++++++++++++++++ .../provisioning/datasources/datasources.yaml | 31 ++ infra/dev/monitoring/loki/loki-config.yaml | 43 ++ .../dev/monitoring/prometheus/prometheus.yml | 30 + infra/dev/monitoring/scripts/deploy.sh | 48 ++ infra/dev/monitoring/tempo/tempo-config.yaml | 35 ++ infra/local/monitoring/alloy/config.alloy | 75 +++ infra/local/monitoring/docker-compose.yml | 76 +++ .../provisioning/dashboards/api-overview.json | 388 +++++++++++++ .../provisioning/dashboards/dashboards.yaml | 12 + .../dashboards/external-infra.json | 373 +++++++++++++ .../dashboards/internal-infra.json | 266 +++++++++ .../dashboards/logs-explorer.json | 403 ++++++++++++++ .../dashboards/trace-explorer.json | 513 ++++++++++++++++++ .../provisioning/datasources/datasources.yaml | 31 ++ infra/local/monitoring/loki/loki-config.yaml | 41 ++ .../monitoring/prometheus/prometheus.yml | 18 + .../local/monitoring/tempo/tempo-config.yaml | 32 ++ infra/prod/caddy/Caddyfile | 17 + infra/prod/docker-compose.yml | 31 ++ infra/prod/monitoring/alloy/config.alloy | 76 +++ infra/prod/monitoring/docker-compose.yml | 139 +++++ .../provisioning/dashboards/api-overview.json | 388 +++++++++++++ .../provisioning/dashboards/dashboards.yaml | 12 + .../dashboards/external-infra.json | 373 +++++++++++++ .../dashboards/internal-infra.json | 266 +++++++++ .../dashboards/logs-explorer.json | 403 ++++++++++++++ .../dashboards/trace-explorer.json | 513 ++++++++++++++++++ .../provisioning/datasources/datasources.yaml | 31 ++ infra/prod/monitoring/loki/loki-config.yaml | 43 ++ .../prod/monitoring/prometheus/prometheus.yml | 30 + infra/prod/monitoring/scripts/deploy.sh | 48 ++ infra/prod/monitoring/tempo/tempo-config.yaml | 35 ++ .../CustomAccessDeniedHandler.kt | 20 +- .../CustomAuthenticationEntryPoint.kt | 18 +- .../JwtAuthenticationProcessingFilter.kt | 2 + .../exception/CommonExceptionHandler.kt | 37 +- .../global/config/ObservabilityConfig.kt | 28 + .../com/weeth/global/config/RedisConfig.kt | 4 +- .../com/weeth/global/config/SecurityConfig.kt | 13 +- .../weeth/global/logging/AccessLogFilter.kt | 115 ++++ .../logging/MaskingJsonGeneratorDecorator.kt | 51 ++ src/main/resources/application-prod.yml | 2 +- src/main/resources/application.yml | 27 + src/main/resources/logback-spring.xml | 65 +++ .../logging/MaskingJsonGeneratorTest.kt | 41 ++ 62 files changed, 7549 insertions(+), 42 deletions(-) create mode 100644 .github/workflows/deploy-monitoring-dev.yml create mode 100644 .github/workflows/deploy-monitoring-prod.yml create mode 100644 infra/dev/monitoring/alloy/config.alloy create mode 100644 infra/dev/monitoring/docker-compose.yml create mode 100644 infra/dev/monitoring/grafana/provisioning/dashboards/api-overview.json create mode 100644 infra/dev/monitoring/grafana/provisioning/dashboards/dashboards.yaml create mode 100644 infra/dev/monitoring/grafana/provisioning/dashboards/external-infra.json create mode 100644 infra/dev/monitoring/grafana/provisioning/dashboards/internal-infra.json create mode 100644 infra/dev/monitoring/grafana/provisioning/dashboards/logs-explorer.json create mode 100644 infra/dev/monitoring/grafana/provisioning/dashboards/trace-explorer.json create mode 100644 infra/dev/monitoring/grafana/provisioning/datasources/datasources.yaml create mode 100644 infra/dev/monitoring/loki/loki-config.yaml create mode 100644 infra/dev/monitoring/prometheus/prometheus.yml create mode 100644 infra/dev/monitoring/scripts/deploy.sh create mode 100644 infra/dev/monitoring/tempo/tempo-config.yaml create mode 100644 infra/local/monitoring/alloy/config.alloy create mode 100644 infra/local/monitoring/docker-compose.yml create mode 100644 infra/local/monitoring/grafana/provisioning/dashboards/api-overview.json create mode 100644 infra/local/monitoring/grafana/provisioning/dashboards/dashboards.yaml create mode 100644 infra/local/monitoring/grafana/provisioning/dashboards/external-infra.json create mode 100644 infra/local/monitoring/grafana/provisioning/dashboards/internal-infra.json create mode 100644 infra/local/monitoring/grafana/provisioning/dashboards/logs-explorer.json create mode 100644 infra/local/monitoring/grafana/provisioning/dashboards/trace-explorer.json create mode 100644 infra/local/monitoring/grafana/provisioning/datasources/datasources.yaml create mode 100644 infra/local/monitoring/loki/loki-config.yaml create mode 100644 infra/local/monitoring/prometheus/prometheus.yml create mode 100644 infra/local/monitoring/tempo/tempo-config.yaml create mode 100644 infra/prod/monitoring/alloy/config.alloy create mode 100644 infra/prod/monitoring/docker-compose.yml create mode 100644 infra/prod/monitoring/grafana/provisioning/dashboards/api-overview.json create mode 100644 infra/prod/monitoring/grafana/provisioning/dashboards/dashboards.yaml create mode 100644 infra/prod/monitoring/grafana/provisioning/dashboards/external-infra.json create mode 100644 infra/prod/monitoring/grafana/provisioning/dashboards/internal-infra.json create mode 100644 infra/prod/monitoring/grafana/provisioning/dashboards/logs-explorer.json create mode 100644 infra/prod/monitoring/grafana/provisioning/dashboards/trace-explorer.json create mode 100644 infra/prod/monitoring/grafana/provisioning/datasources/datasources.yaml create mode 100644 infra/prod/monitoring/loki/loki-config.yaml create mode 100644 infra/prod/monitoring/prometheus/prometheus.yml create mode 100644 infra/prod/monitoring/scripts/deploy.sh create mode 100644 infra/prod/monitoring/tempo/tempo-config.yaml create mode 100644 src/main/kotlin/com/weeth/global/config/ObservabilityConfig.kt create mode 100644 src/main/kotlin/com/weeth/global/logging/AccessLogFilter.kt create mode 100644 src/main/kotlin/com/weeth/global/logging/MaskingJsonGeneratorDecorator.kt create mode 100644 src/main/resources/logback-spring.xml create mode 100644 src/test/kotlin/com/weeth/global/logging/MaskingJsonGeneratorTest.kt diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index 47cfa649..13700ee1 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -26,7 +26,8 @@ src/main/kotlin/com/weeth/ └── global/ ├── auth/ ├── config/ - └── common/ + ├── common/ + └── logging/ ``` ## Layer Dependencies @@ -203,4 +204,4 @@ class User( 2. **UseCase = orchestration**: coordinates flow; "how" is decided by Entity 3. **No meaningless services**: Repository wrappers are eliminated; Domain Service only for multi-entity logic 4. **Port-Adapter**: domain owns Port interfaces; infrastructure implements them -5. **Incremental migration**: migrate Java → Kotlin preserving existing structure +5. **Kotlin-first**: Java → Kotlin migration complete; all new code in Kotlin diff --git a/.github/workflows/deploy-monitoring-dev.yml b/.github/workflows/deploy-monitoring-dev.yml new file mode 100644 index 00000000..78510d0c --- /dev/null +++ b/.github/workflows/deploy-monitoring-dev.yml @@ -0,0 +1,40 @@ +name: Deploy Monitoring (Dev) + +on: + push: + branches: [dev] + paths: + - "infra/dev/monitoring/**" + - ".github/workflows/deploy-monitoring-dev.yml" + +permissions: + contents: read + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Upload monitoring files to EC2 + uses: appleboy/scp-action@v1 + with: + host: ${{ secrets.DEV_EC2_HOST }} + username: ${{ secrets.DEV_EC2_USER }} + key: ${{ secrets.DEV_EC2_SSH_KEY }} + source: "infra/dev/monitoring" + target: "${{ secrets.DEV_DEPLOY_DIR }}" + + - name: Deploy monitoring stack + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.DEV_EC2_HOST }} + username: ${{ secrets.DEV_EC2_USER }} + key: ${{ secrets.DEV_EC2_SSH_KEY }} + script: | + set -euo pipefail + DEPLOY_DIR="${{ secrets.DEV_DEPLOY_DIR }}/infra/dev/monitoring" + chmod +x "$DEPLOY_DIR/scripts/deploy.sh" + DEPLOY_DIR="$DEPLOY_DIR" "$DEPLOY_DIR/scripts/deploy.sh" diff --git a/.github/workflows/deploy-monitoring-prod.yml b/.github/workflows/deploy-monitoring-prod.yml new file mode 100644 index 00000000..f5997c4f --- /dev/null +++ b/.github/workflows/deploy-monitoring-prod.yml @@ -0,0 +1,42 @@ +name: Deploy Monitoring (Prod) + +# 운영 서버가 설정되지 않았기 때문에 수동 실행 설정 +on: +# push: +# branches: [ main ] +# paths: +# - "infra/prod/monitoring/**" +# - ".github/workflows/deploy-monitoring-prod.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Upload monitoring files to EC2 + uses: appleboy/scp-action@v1 + with: + host: ${{ secrets.PROD_EC2_HOST }} + username: ${{ secrets.PROD_EC2_USER }} + key: ${{ secrets.PROD_EC2_SSH_KEY }} + source: "infra/prod/monitoring" + target: "${{ secrets.PROD_DEPLOY_DIR }}" + + - name: Deploy monitoring stack + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.PROD_EC2_HOST }} + username: ${{ secrets.PROD_EC2_USER }} + key: ${{ secrets.PROD_EC2_SSH_KEY }} + script: | + set -euo pipefail + DEPLOY_DIR="${{ secrets.PROD_DEPLOY_DIR }}/infra/prod/monitoring" + chmod +x "$DEPLOY_DIR/scripts/deploy.sh" + DEPLOY_DIR="$DEPLOY_DIR" "$DEPLOY_DIR/scripts/deploy.sh" diff --git a/CLAUDE.md b/CLAUDE.md index 519291c6..397539da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ Weeth Server is a community platform backend built with Spring Boot 3.5.10. The **Prerequisites:** JDK 21, MySQL 8.0, Redis 7.0+, environment variables configured in `.env` -**Profiles:** `local` (default dev), `dev` (dev server, ddl-auto: update), `prod` (Swagger disabled, ddl-auto: validate), `test` +**Profiles:** `local` (default dev), `local-monitoring` (local + monitoring stack), `dev` (dev server, ddl-auto: update), `prod` (Swagger disabled, ddl-auto: validate) ## Architecture @@ -35,7 +35,7 @@ presentation → application → domain ← infrastructure - **infrastructure/**: Port implementations (Adapters for S3, external APIs, etc.) ### Domain Package Layout -Each of the 10 domains (`user`, `attendance`, `session`, `schedule`, `board`, `comment`, `file`, `penalty`, `account`, `cardinal`) follows: +Each of the 13 domains (`user`, `attendance`, `session`, `schedule`, `board`, `comment`, `file`, `penalty`, `account`, `cardinal`, `club`, `dashboard`, `university`) follows: ``` domain/{name}/ ├── application/ @@ -79,11 +79,11 @@ JWT with symmetric key (JJWT 0.13.0), OAuth2 via Kakao and Apple. `@CurrentUser` ## Kotlin Migration Status -**✅ Complete** — 305 Kotlin files (100%) +**✅ Complete** — 452 Kotlin files (100%) - Java → Kotlin migration fully complete - Lombok and MapStruct dependencies removed -- All 13 mappers migrated to manual `@Component` Mapper classes (see `.claude/rules/mapper-dto.md`) +- All 16 mappers migrated to manual `@Component` Mapper classes (see `.claude/rules/mapper-dto.md`) - Entity fields use `private set` for Rich Domain Model pattern (see architecture.md) - OSIV disabled: `spring.jpa.open-in-view: false` in `application.yml` diff --git a/Dockerfile b/Dockerfile index afebd65d..9c26d6ba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,11 @@ FROM eclipse-temurin:21-jre-alpine +ARG OTEL_JAVA_AGENT_VERSION=2.26.1 + WORKDIR /app +ADD https://repo.maven.apache.org/maven2/io/opentelemetry/javaagent/opentelemetry-javaagent/${OTEL_JAVA_AGENT_VERSION}/opentelemetry-javaagent-${OTEL_JAVA_AGENT_VERSION}.jar /otel/opentelemetry-javaagent.jar + COPY build/libs/*.jar app.jar -ENTRYPOINT ["java", "-jar", "app.jar"] +ENTRYPOINT ["sh", "-c", "if [ \"${OTEL_JAVAAGENT_ENABLED:-true}\" = \"true\" ]; then exec java -javaagent:/otel/opentelemetry-javaagent.jar -jar /app/app.jar; else exec java -jar /app/app.jar; fi"] diff --git a/README.md b/README.md index 14cfc128..16dd7156 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Spring Boot 3.5.10 + Kotlin 기반 동아리 커뮤니티 플랫폼 백엔드 +Java -> Kotlin 마이그레이션이 완료되어 현재 모든 애플리케이션 코드는 Kotlin으로 구성되어 있습니다. + ## 기술 스택 | 분류 | 스택 | @@ -23,6 +25,7 @@ Spring Boot 3.5.10 + Kotlin 기반 동아리 커뮤니티 플랫폼 백엔드 ```bash ./gradlew clean build # 빌드 ./gradlew bootRun # 실행 (local 프로파일) +./gradlew bootRun --args='--spring.profiles.active=local-monitoring' # 로컬 모니터링 프로필 ./gradlew bootRun --args='--spring.profiles.active=dev' # 프로파일 지정 실행 ./gradlew test # 전체 테스트 ./gradlew ktlintFormat # 자동 포맷팅 @@ -33,9 +36,9 @@ Spring Boot 3.5.10 + Kotlin 기반 동아리 커뮤니티 플랫폼 백엔드 | Profile | DDL Auto | Swagger | |---------|----------|---------| | `local` (기본) | `update` | 활성화 | +| `local-monitoring` | `update` | 활성화 | | `dev` | `update` | 활성화 | | `prod` | `validate` | 비활성화 | -| `test` | `create-drop` | 비활성화 | ## 아키텍처 @@ -47,6 +50,7 @@ presentation → application → domain ← infrastructure - **UseCase = 오케스트레이션** — Command (`@Transactional`) / Query (`readOnly = true`) - **Port-Adapter** — domain이 Port 인터페이스 소유, infrastructure가 구현 - **No thin wrappers** — UseCase가 Repository를 직접 호출 +- **Kotlin-first** — 신규 코드는 Kotlin만 사용 ### 응답 코드 형식 (`XDDNN`) @@ -73,13 +77,24 @@ src/main/kotlin/com/weeth/ │ ├── file/ # 파일 업로드 (S3) │ ├── penalty/ # 페널티 관리 │ ├── account/ # 회계, 영수증 관리 -│ └── cardinal/ # 기수 관리 +│ ├── cardinal/ # 기수 관리 +│ ├── club/ # 동아리 관리 +│ ├── dashboard/ # 대시보드 집계/조회 +│ └── university/ # 대학 정보 관리 └── global/ ├── auth/ # JWT, OAuth2, @CurrentUser ├── config/ # Spring 설정 - └── common/ # 공통 유틸, 응답 포맷 + ├── common/ # 공통 유틸, 응답 포맷 + └── logging/ # 요청/모니터링 로깅 ``` +## Kotlin 마이그레이션 상태 + +- 452개 Kotlin 파일로 전환 완료 +- Lombok, MapStruct 제거 +- 16개 매퍼를 수동 `@Component` Mapper로 통일 +- Entity 필드는 `private set` 기반의 Rich Domain Model 패턴 적용 + ## 인프라 ### 배포 아키텍처 diff --git a/build.gradle.kts b/build.gradle.kts index 8db554d9..7b7a3257 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -48,6 +48,7 @@ dependencies { developmentOnly("org.springframework.boot:spring-boot-devtools") implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-aop") // Redis implementation("org.springframework.boot:spring-boot-starter-data-redis") @@ -59,6 +60,13 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-actuator") runtimeOnly("io.micrometer:micrometer-registry-prometheus") + // Logging + implementation("net.logstash.logback:logstash-logback-encoder:8.0") + implementation("com.github.loki4j:loki-logback-appender:1.5.2") + + // Tracing API (runtime spans are produced by the OpenTelemetry Java Agent) + implementation("io.opentelemetry:opentelemetry-api") + // --- JWT --- implementation("io.jsonwebtoken:jjwt-api:$jjwtVersion") runtimeOnly("io.jsonwebtoken:jjwt-impl:$jjwtVersion") diff --git a/infra/dev/caddy/Caddyfile b/infra/dev/caddy/Caddyfile index 6f84aa2d..9572b864 100644 --- a/infra/dev/caddy/Caddyfile +++ b/infra/dev/caddy/Caddyfile @@ -23,6 +23,23 @@ Referrer-Policy "strict-origin-when-cross-origin" } + # /actuator/** 외부 접근 차단 (Prometheus는 Docker 내부 네트워크로 직접 스크래핑) + handle /actuator/health { + import /etc/caddy/upstream.conf + } + handle /actuator { + respond 404 + } + handle /actuator/* { + respond 404 + } + + redir {$MONITORING_PATH} {$MONITORING_PATH}/ + + handle {$MONITORING_PATH}* { + reverse_proxy grafana:3000 + } + # 실제 reverse_proxy 설정은 upstream.conf 파일에서 불러옴 import /etc/caddy/upstream.conf } diff --git a/infra/dev/docker-compose.yml b/infra/dev/docker-compose.yml index 4a209d75..f5a4d515 100644 --- a/infra/dev/docker-compose.yml +++ b/infra/dev/docker-compose.yml @@ -15,6 +15,7 @@ services: - caddy_config:/config environment: DOMAIN: ${DOMAIN} + MONITORING_PATH: ${MONITORING_PATH} networks: - web @@ -55,6 +56,16 @@ services: environment: SPRING_PROFILES_ACTIVE: dev TZ: Asia/Seoul + OTEL_JAVAAGENT_ENABLED: ${OTEL_JAVAAGENT_ENABLED:-true} + OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-weeth-server} + OTEL_RESOURCE_ATTRIBUTES: ${OTEL_RESOURCE_ATTRIBUTES:-deployment.environment=dev} + OTEL_TRACES_EXPORTER: ${OTEL_TRACES_EXPORTER:-otlp} + OTEL_TRACES_SAMPLER: ${OTEL_TRACES_SAMPLER:-parentbased_traceidratio} + OTEL_TRACES_SAMPLER_ARG: ${OTEL_TRACES_SAMPLER_ARG:-1.0} + OTEL_METRICS_EXPORTER: ${OTEL_METRICS_EXPORTER:-none} + OTEL_LOGS_EXPORTER: ${OTEL_LOGS_EXPORTER:-none} + OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://alloy:4318} + OTEL_EXPORTER_OTLP_PROTOCOL: ${OTEL_EXPORTER_OTLP_PROTOCOL:-http/protobuf} volumes: - ${HOME}/keys:/app/keys:ro ports: @@ -64,6 +75,11 @@ services: condition: service_started redis: condition: service_started + logging: + driver: json-file + options: + max-size: "50m" + max-file: "3" networks: - web @@ -77,6 +93,16 @@ services: environment: SPRING_PROFILES_ACTIVE: dev TZ: Asia/Seoul + OTEL_JAVAAGENT_ENABLED: ${OTEL_JAVAAGENT_ENABLED:-true} + OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-weeth-server} + OTEL_RESOURCE_ATTRIBUTES: ${OTEL_RESOURCE_ATTRIBUTES:-deployment.environment=dev} + OTEL_TRACES_EXPORTER: ${OTEL_TRACES_EXPORTER:-otlp} + OTEL_TRACES_SAMPLER: ${OTEL_TRACES_SAMPLER:-parentbased_traceidratio} + OTEL_TRACES_SAMPLER_ARG: ${OTEL_TRACES_SAMPLER_ARG:-1.0} + OTEL_METRICS_EXPORTER: ${OTEL_METRICS_EXPORTER:-none} + OTEL_LOGS_EXPORTER: ${OTEL_LOGS_EXPORTER:-none} + OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://alloy:4318} + OTEL_EXPORTER_OTLP_PROTOCOL: ${OTEL_EXPORTER_OTLP_PROTOCOL:-http/protobuf} volumes: - ${HOME}/keys:/app/keys:ro ports: @@ -86,6 +112,11 @@ services: condition: service_started redis: condition: service_started + logging: + driver: json-file + options: + max-size: "50m" + max-file: "3" networks: - web diff --git a/infra/dev/monitoring/alloy/config.alloy b/infra/dev/monitoring/alloy/config.alloy new file mode 100644 index 00000000..98ee56fc --- /dev/null +++ b/infra/dev/monitoring/alloy/config.alloy @@ -0,0 +1,76 @@ +discovery.docker "weeth" { + host = "unix:///var/run/docker.sock" + + filter { + name = "label" + values = ["com.docker.compose.service=app-blue", "com.docker.compose.service=app-green"] + } +} + +loki.source.docker "app_logs" { + host = "unix:///var/run/docker.sock" + targets = discovery.docker.weeth.targets + forward_to = [loki.process.parse_json.receiver] +} + +loki.process "parse_json" { + stage.static_labels { + values = { app = "weeth", env = "dev" } + } + + stage.json { + expressions = { + level = "level", + logger_name = "logger_name", + } + } + + stage.labels { + values = { level = "", logger_name = "" } + } + + stage.match { + selector = "{logger_name=\"ACCESS_LOG\"}" + stage.static_labels { values = { log_type = "access" } } + } + stage.match { + selector = "{logger_name=\"AUDIT_LOG\"}" + stage.static_labels { values = { log_type = "audit" } } + } + stage.match { + selector = "{logger_name=\"ERROR_LOG\"}" + stage.static_labels { values = { log_type = "error" } } + } + stage.match { + selector = "{logger_name!=\"ACCESS_LOG\", logger_name!=\"AUDIT_LOG\", logger_name!=\"ERROR_LOG\"}" + stage.static_labels { values = { log_type = "application" } } + } + + stage.label_drop { values = ["logger_name"] } + + forward_to = [loki.write.default.receiver] +} + +loki.write "default" { + endpoint { + url = "http://loki:3100/loki/api/v1/push" + } +} + +otelcol.receiver.otlp "default" { + http { + endpoint = "0.0.0.0:4318" + } + output { + traces = [otelcol.exporter.otlp.tempo.input] + } +} + +otelcol.exporter.otlp "tempo" { + client { + endpoint = "tempo:4317" + tls { + insecure = true + } + } +} diff --git a/infra/dev/monitoring/docker-compose.yml b/infra/dev/monitoring/docker-compose.yml new file mode 100644 index 00000000..9742355f --- /dev/null +++ b/infra/dev/monitoring/docker-compose.yml @@ -0,0 +1,139 @@ +name: weeth-dev-monitoring + +services: + alloy: + image: grafana/alloy:v1.9.0 + env_file: + - ${MONITORING_ENV_FILE:-../.env.monitoring} + volumes: + - ./alloy/config.alloy:/etc/alloy/config.alloy:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + - /var/lib/docker/containers:/var/lib/docker/containers:ro + command: ["run", "--server.http.listen-addr=0.0.0.0:12345", "/etc/alloy/config.alloy"] + ports: + - "127.0.0.1:12345:12345" + - "127.0.0.1:4318:4318" + depends_on: + - loki + - tempo + networks: + - monitoring + - weeth-app + restart: unless-stopped + + loki: + image: grafana/loki:3.4.2 + env_file: + - ${MONITORING_ENV_FILE:-../.env.monitoring} + volumes: + - ./loki/loki-config.yaml:/etc/loki/loki-config.yaml:ro + - loki_data:/loki + command: ["-config.file=/etc/loki/loki-config.yaml", "-config.expand-env=true"] + ports: + - "127.0.0.1:3100:3100" + networks: + - monitoring + restart: unless-stopped + + tempo: + image: grafana/tempo:2.7.1 + env_file: + - ${MONITORING_ENV_FILE:-../.env.monitoring} + volumes: + - ./tempo/tempo-config.yaml:/etc/tempo/tempo-config.yaml:ro + - tempo_data:/var/tempo + command: ["-config.file=/etc/tempo/tempo-config.yaml", "-config.expand-env=true"] + ports: + - "127.0.0.1:3200:3200" + networks: + - monitoring + restart: unless-stopped + + redis-exporter: + image: oliver006/redis_exporter:v1.67.0 + environment: + REDIS_ADDR: redis:6379 + REDIS_PASSWORD: ${REDIS_PASSWORD:-} + networks: + - monitoring + - weeth-app + restart: unless-stopped + + prometheus: + image: prom/prometheus:v2.53.0 + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus_data:/prometheus + ports: + - "127.0.0.1:9090:9090" + networks: + - monitoring + - weeth-app + restart: unless-stopped + + node-exporter: + image: prom/node-exporter:v1.9.0 + pid: host + volumes: + - /proc:/host/proc:ro + - /sys:/host/sys:ro + - /:/rootfs:ro + command: + - "--path.procfs=/host/proc" + - "--path.sysfs=/host/sys" + - "--path.rootfs=/rootfs" + - "--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)" + networks: + - monitoring + restart: unless-stopped + + cadvisor: + image: gcr.io/cadvisor/cadvisor:v0.51.0 + privileged: true + devices: + - /dev/kmsg + volumes: + - /:/rootfs:ro + - /var/run:/var/run:ro + - /sys:/sys:ro + - /var/lib/docker:/var/lib/docker:ro + - /dev/disk:/dev/disk:ro + networks: + - monitoring + - weeth-app + restart: unless-stopped + + grafana: + image: grafana/grafana:11.5.2 + env_file: + - ${MONITORING_ENV_FILE:-../.env.monitoring} + volumes: + - grafana_data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning:ro + environment: + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD} + GF_SERVER_ROOT_URL: ${GRAFANA_ROOT_URL:-https://${DOMAIN}${MONITORING_PATH}/} + GF_SERVER_SERVE_FROM_SUB_PATH: "true" + GF_AUTH_ANONYMOUS_ENABLED: "false" + ports: + - "127.0.0.1:3000:3000" + depends_on: + - loki + - prometheus + - tempo + networks: + - monitoring + restart: unless-stopped + +networks: + monitoring: + driver: bridge + weeth-app: + external: true + name: weeth-dev_web + +volumes: + grafana_data: + loki_data: + prometheus_data: + tempo_data: diff --git a/infra/dev/monitoring/grafana/provisioning/dashboards/api-overview.json b/infra/dev/monitoring/grafana/provisioning/dashboards/api-overview.json new file mode 100644 index 00000000..f7cf5807 --- /dev/null +++ b/infra/dev/monitoring/grafana/provisioning/dashboards/api-overview.json @@ -0,0 +1,388 @@ +{ + "uid": "weeth-api-overview", + "title": "API Overview", + "tags": ["weeth", "api"], + "timezone": "Asia/Seoul", + "refresh": "10s", + "time": { "from": "now-1h", "to": "now" }, + "templating": { + "list": [ + { + "name": "uri", + "type": "query", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "query": "label_values(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\"}, uri)", + "includeAll": true, + "multi": true, + "current": { "text": "All", "value": "$__all" }, + "refresh": 2 + } + ] + }, + "panels": [ + { + "id": 1, + "title": "Total Requests / min", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 15, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1m])) * 60", + "legendFormat": "req/min" + } + ], + "fieldConfig": { + "defaults": { + "unit": "reqpm", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 100 }, + { "color": "red", "value": 500 } + ] + } + } + } + }, + { + "id": 2, + "title": "5xx Error Rate", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 0, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "((sum(rate(http_server_requests_seconds_count{status=~\"5..\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])) or vector(0)) / clamp_min(sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])), 0.001)) * 100", + "legendFormat": "5xx %" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "red", "value": 5 } + ] + } + } + } + }, + { + "id": 3, + "title": "4xx Error Rate", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 3, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "((sum(rate(http_server_requests_seconds_count{status=~\"4..\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])) or vector(0)) / clamp_min(sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])), 0.001)) * 100", + "legendFormat": "4xx %" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 5 }, + { "color": "red", "value": 20 } + ] + } + } + } + }, + { + "id": 16, + "title": "Avg Response Time", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 6, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_sum{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])) / clamp_min(sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])), 0.001)", + "legendFormat": "avg" + } + ], + "fieldConfig": { + "defaults": { + "unit": "s", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 0.3 }, + { "color": "red", "value": 0.5 } + ] + } + } + } + }, + { + "id": 4, + "title": "Apdex (0.5s)", + "type": "stat", + "gridPos": { "h": 4, "w": 6, "x": 18, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "description": "Apdex score (사용자 체감 만족도). 1.0=최고, >0.94=훌륭함, >0.85=좋음, >0.7=나쁨, <0.7=매우 나쁨", + "targets": [ + { + "expr": "(\n sum(rate(http_server_requests_seconds_bucket{le=\"0.5\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))\n + (\n sum(rate(http_server_requests_seconds_bucket{le=\"2.0\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))\n - sum(rate(http_server_requests_seconds_bucket{le=\"0.5\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))\n ) / 2\n) / sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))", + "legendFormat": "Apdex" + } + ], + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 2, + "min": 0, + "max": 1, + "thresholds": { + "steps": [ + { "color": "red", "value": null }, + { "color": "orange", "value": 0.7 }, + { "color": "yellow", "value": 0.85 }, + { "color": "green", "value": 0.94 } + ] + } + } + } + }, + { + "id": 5, + "title": "P95 Response Time", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 9, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])) by (le))", + "legendFormat": "P95" + } + ], + "fieldConfig": { + "defaults": { + "unit": "s", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 0.5 }, + { "color": "red", "value": 1 } + ] + } + } + } + }, + { + "id": 6, + "title": "P99 Response Time", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 12, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "histogram_quantile(0.99, sum(rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])) by (le))", + "legendFormat": "P99" + } + ], + "fieldConfig": { + "defaults": { + "unit": "s", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "red", "value": 2 } + ] + } + } + } + }, + { + "id": 7, + "title": "Requests per Second", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1m]))", + "legendFormat": "total" + }, + { + "expr": "sum(rate(http_server_requests_seconds_count{status=~\"2..\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1m])) or vector(0)", + "legendFormat": "2xx" + }, + { + "expr": "sum(rate(http_server_requests_seconds_count{status=~\"4..\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1m])) or vector(0)", + "legendFormat": "4xx" + }, + { + "expr": "sum(rate(http_server_requests_seconds_count{status=~\"5..\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1m])) or vector(0)", + "legendFormat": "5xx" + } + ], + "fieldConfig": { + "defaults": { "unit": "reqps", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 8, + "title": "Response Time Distribution", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "description": "엔드포인트별 P50/P95/P99 응답시간 추이. 기본(All)은 전체 엔드포인트별 분포를 표시하고, 상단 uri 필터에서 특정 엔드포인트를 선택하면 해당 API만 표시됩니다.", + "targets": [ + { + "expr": "histogram_quantile(0.50, sum by (le) (rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])))", + "legendFormat": "P50" + }, + { + "expr": "histogram_quantile(0.95, sum by (le) (rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])))", + "legendFormat": "P95" + }, + { + "expr": "histogram_quantile(0.99, sum by (le) (rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])))", + "legendFormat": "P99" + } + ], + "fieldConfig": { + "defaults": { "unit": "s", "custom": { "fillOpacity": 5 } } + } + }, + { + "id": 9, + "title": "HTTP Status Code Distribution", + "type": "piechart", + "gridPos": { "h": 8, "w": 8, "x": 16, "y": 12 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum by(status) (increase(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1h]))", + "legendFormat": "{{status}}" + } + ], + "fieldConfig": { + "overrides": [ + { "matcher": { "id": "byRegexp", "options": "^2\\d{2}$" }, "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }] }, + { "matcher": { "id": "byRegexp", "options": "^3\\d{2}$" }, "properties": [{ "id": "color", "value": { "fixedColor": "blue", "mode": "fixed" } }] }, + { "matcher": { "id": "byRegexp", "options": "^4\\d{2}$" }, "properties": [{ "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }] }, + { "matcher": { "id": "byRegexp", "options": "^5\\d{2}$" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] } + ] + } + }, + { + "id": 10, + "title": "Slowest Endpoints (P95)", + "type": "bargauge", + "gridPos": { "h": 8, "w": 8, "x": 8, "y": 12 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "topk(10, histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*|/health-check\"}[5m])) by (le, uri)))", + "legendFormat": "{{uri}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "s" } + }, + "options": { + "orientation": "horizontal", + "displayMode": "gradient" + } + }, + { + "id": 11, + "title": "Top 10 Error Endpoints (5xx)", + "type": "bargauge", + "gridPos": { "h": 8, "w": 8, "x": 0, "y": 12 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "topk(10, sum by(uri) (rate(http_server_requests_seconds_count{status=~\"5..\", uri!~\"/actuator.*|/health-check\"}[5m])))", + "legendFormat": "{{uri}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "reqps" } + }, + "options": { + "orientation": "horizontal", + "displayMode": "gradient" + } + }, + { + "id": 12, + "title": "Request Rate by Endpoint", + "type": "timeseries", + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 20 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum by(uri, method) (rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1m]))", + "legendFormat": "{{method}} {{uri}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "reqps", "custom": { "fillOpacity": 5 } } + } + }, + { + "id": 13, + "title": "Top 10 High-Traffic Endpoints (4xx)", + "type": "bargauge", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 28 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "topk(10, sum by(uri) (rate(http_server_requests_seconds_count{status=~\"4..\", uri!~\"/actuator.*|/health-check\"}[5m])))", + "legendFormat": "{{uri}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "reqps" } + }, + "options": { + "orientation": "horizontal", + "displayMode": "gradient" + } + }, + { + "id": 14, + "title": "Apdex Over Time (0.5s)", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 28 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "description": "Apdex trend. T=0.5s (satisfied), 4T=2.0s (tolerating). Below 0.85 = degraded experience.", + "targets": [ + { + "expr": "(\n sum(rate(http_server_requests_seconds_bucket{le=\"0.5\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))\n + (\n sum(rate(http_server_requests_seconds_bucket{le=\"2.0\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))\n - sum(rate(http_server_requests_seconds_bucket{le=\"0.5\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))\n ) / 2\n) / sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))", + "legendFormat": "Apdex" + } + ], + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 2, + "min": 0, + "max": 1, + "custom": { "fillOpacity": 10 }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": null }, + { "color": "orange", "value": 0.7 }, + { "color": "yellow", "value": 0.85 }, + { "color": "green", "value": 0.94 } + ] + }, + "color": { "mode": "continuous-GrYlRd" } + } + } + } + ], + "schemaVersion": 39 +} diff --git a/infra/dev/monitoring/grafana/provisioning/dashboards/dashboards.yaml b/infra/dev/monitoring/grafana/provisioning/dashboards/dashboards.yaml new file mode 100644 index 00000000..2a76fbbd --- /dev/null +++ b/infra/dev/monitoring/grafana/provisioning/dashboards/dashboards.yaml @@ -0,0 +1,12 @@ +apiVersion: 1 + +providers: + - name: weeth + orgId: 1 + folder: Weeth + type: file + disableDeletion: false + editable: true + options: + path: /etc/grafana/provisioning/dashboards + foldersFromFilesStructure: false diff --git a/infra/dev/monitoring/grafana/provisioning/dashboards/external-infra.json b/infra/dev/monitoring/grafana/provisioning/dashboards/external-infra.json new file mode 100644 index 00000000..f38d9eb7 --- /dev/null +++ b/infra/dev/monitoring/grafana/provisioning/dashboards/external-infra.json @@ -0,0 +1,373 @@ +{ + "uid": "weeth-external-infra", + "title": "External Infrastructure", + "tags": ["weeth", "db", "redis"], + "timezone": "Asia/Seoul", + "refresh": "10s", + "time": { "from": "now-1h", "to": "now" }, + "panels": [ + { + "id": 1, + "title": "HikariCP (MySQL)", + "type": "row", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, + "collapsed": false, + "panels": [] + }, + { + "id": 2, + "title": "Active", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 0, "y": 1 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "hikaricp_connections_active", + "legendFormat": "active" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 5 }, + { "color": "red", "value": 8 } + ] + } + } + } + }, + { + "id": 3, + "title": "Idle", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 4, "y": 1 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "hikaricp_connections_idle", + "legendFormat": "idle" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { "color": "red", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "green", "value": 3 } + ] + } + } + } + }, + { + "id": 4, + "title": "Pending", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 8, "y": 1 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "hikaricp_connections_pending", + "legendFormat": "pending" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "red", "value": 3 } + ] + } + } + } + }, + { + "id": 5, + "title": "Total / Max", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 12, "y": 1 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "hikaricp_connections", + "legendFormat": "total" + }, + { + "expr": "hikaricp_connections_max", + "legendFormat": "max" + } + ] + }, + { + "id": 6, + "title": "Timeout Total", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 16, "y": 1 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "description": "커넥션 획득 timeout 누적 횟수. 0이 아니면 커넥션 풀 고갈 발생.", + "targets": [ + { + "expr": "hikaricp_connections_timeout_total", + "legendFormat": "timeouts" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 1 } + ] + } + } + } + }, + { + "id": 7, + "title": "Timeout Rate / min", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 20, "y": 1 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(hikaricp_connections_timeout_total[1m]) * 60", + "legendFormat": "timeouts/min" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 0.1 } + ] + } + } + } + }, + { + "id": 8, + "title": "Connection Pool Over Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 5 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "hikaricp_connections_active", + "legendFormat": "active" + }, + { + "expr": "hikaricp_connections_idle", + "legendFormat": "idle" + }, + { + "expr": "hikaricp_connections_pending", + "legendFormat": "pending" + }, + { + "expr": "hikaricp_connections", + "legendFormat": "total" + } + ], + "fieldConfig": { + "defaults": { "unit": "short", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 9, + "title": "Connection Acquire Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 5 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(hikaricp_connections_acquire_seconds_sum[1m]) / rate(hikaricp_connections_acquire_seconds_count[1m])", + "legendFormat": "avg acquire time" + }, + { + "expr": "hikaricp_connections_acquire_seconds_max", + "legendFormat": "max acquire time" + } + ], + "fieldConfig": { + "defaults": { "unit": "s", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 10, + "title": "Connection Creation Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 30 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(hikaricp_connections_creation_seconds_sum[1m]) / rate(hikaricp_connections_creation_seconds_count[1m])", + "legendFormat": "avg creation time" + }, + { + "expr": "hikaricp_connections_creation_seconds_max", + "legendFormat": "max creation time" + } + ], + "fieldConfig": { + "defaults": { "unit": "s", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 11, + "title": "Connection Usage Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 30 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(hikaricp_connections_usage_seconds_sum[1m]) / rate(hikaricp_connections_usage_seconds_count[1m])", + "legendFormat": "avg usage time" + }, + { + "expr": "hikaricp_connections_usage_seconds_max", + "legendFormat": "max usage time" + } + ], + "fieldConfig": { + "defaults": { "unit": "s", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 12, + "title": "Redis", + "type": "row", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 13 }, + "collapsed": false, + "panels": [] + }, + { + "id": 13, + "title": "Redis Command Rate", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 22 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(rate(lettuce_command_completion_seconds_count[1m]))", + "legendFormat": "commands/sec" + } + ], + "fieldConfig": { + "defaults": { "unit": "ops", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 14, + "title": "Redis Command Latency", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 14 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(lettuce_command_completion_seconds_sum[1m]) / rate(lettuce_command_completion_seconds_count[1m])", + "legendFormat": "avg latency" + }, + { + "expr": "lettuce_command_completion_seconds_max", + "legendFormat": "max latency" + } + ], + "fieldConfig": { + "defaults": { "unit": "s", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 15, + "title": "Redis Command by Type", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 22 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum by(command) (rate(lettuce_command_completion_seconds_count[1m]))", + "legendFormat": "{{command}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "ops", "custom": { "fillOpacity": 5 } } + } + }, + { + "id": 16, + "title": "Redis Command Errors", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 14 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(rate(lettuce_command_firstresponse_seconds_count{outcome=\"ERROR\"}[1m]))", + "legendFormat": "errors/sec" + } + ], + "fieldConfig": { + "defaults": { + "unit": "ops", + "custom": { "fillOpacity": 10 }, + "color": { "fixedColor": "red", "mode": "fixed" } + } + } + }, + { + "id": 17, + "title": "Redis Cache Hit Rate", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 30 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "description": "Redis 서버 전체 캐시 히트율. redis_exporter의 keyspace_hits/misses 기반.", + "targets": [ + { + "expr": "rate(redis_keyspace_hits_total[5m]) / clamp_min(rate(redis_keyspace_hits_total[5m]) + rate(redis_keyspace_misses_total[5m]), 0.001) * 100", + "legendFormat": "hit rate" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "min": 0, + "max": 100, + "custom": { "fillOpacity": 10 }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": null }, + { "color": "yellow", "value": 50 }, + { "color": "green", "value": 80 } + ] + } + } + } + }, + { + "id": 18, + "title": "Redis Cache Hits / Misses", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 30 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(redis_keyspace_hits_total[1m])", + "legendFormat": "hits/sec" + }, + { + "expr": "rate(redis_keyspace_misses_total[1m])", + "legendFormat": "misses/sec" + } + ], + "fieldConfig": { + "defaults": { "unit": "ops", "custom": { "fillOpacity": 10 } } + } + } + ], + "schemaVersion": 39 +} diff --git a/infra/dev/monitoring/grafana/provisioning/dashboards/internal-infra.json b/infra/dev/monitoring/grafana/provisioning/dashboards/internal-infra.json new file mode 100644 index 00000000..4395f758 --- /dev/null +++ b/infra/dev/monitoring/grafana/provisioning/dashboards/internal-infra.json @@ -0,0 +1,266 @@ +{ + "uid": "weeth-internal-infra", + "title": "Internal Infrastructure", + "tags": ["weeth", "jvm", "infra"], + "timezone": "Asia/Seoul", + "refresh": "10s", + "time": { "from": "now-1h", "to": "now" }, + "panels": [ + { + "id": 1, + "title": "Uptime", + "type": "stat", + "gridPos": { "h": 3, "w": 6, "x": 0, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "process_uptime_seconds", + "legendFormat": "uptime" + } + ], + "fieldConfig": { + "defaults": { "unit": "s" } + } + }, + { + "id": 2, + "title": "Heap Usage %", + "type": "stat", + "gridPos": { "h": 3, "w": 6, "x": 6, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(jvm_memory_used_bytes{area=\"heap\"}) / sum(jvm_memory_max_bytes{area=\"heap\"}) * 100", + "legendFormat": "heap %" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 70 }, + { "color": "red", "value": 90 } + ] + } + } + } + }, + { + "id": 3, + "title": "Live Threads", + "type": "stat", + "gridPos": { "h": 3, "w": 6, "x": 12, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "jvm_threads_live_threads", + "legendFormat": "live" + } + ] + }, + { + "id": 4, + "title": "App CPU", + "type": "stat", + "gridPos": { "h": 3, "w": 6, "x": 18, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "process_cpu_usage * 100", + "legendFormat": "CPU %" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 60 }, + { "color": "red", "value": 85 } + ] + } + } + } + }, + { + "id": 5, + "title": "JVM", + "type": "row", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 12 }, + "collapsed": false, + "panels": [] + }, + { + "id": 6, + "title": "JVM Heap Used", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 13 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "jvm_memory_used_bytes{area=\"heap\"}", + "legendFormat": "{{id}} used" + }, + { + "expr": "jvm_memory_committed_bytes{area=\"heap\"}", + "legendFormat": "{{id}} committed" + } + ], + "fieldConfig": { + "defaults": { "unit": "bytes", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 7, + "title": "JVM Heap Summary", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 13 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(jvm_memory_used_bytes{area=\"heap\"})", + "legendFormat": "used" + }, + { + "expr": "sum(jvm_memory_committed_bytes{area=\"heap\"})", + "legendFormat": "committed" + }, + { + "expr": "sum(jvm_memory_max_bytes{area=\"heap\"})", + "legendFormat": "max" + } + ], + "fieldConfig": { + "defaults": { "unit": "bytes", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 8, + "title": "JVM Non-Heap Used", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 29 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "jvm_memory_used_bytes{area=\"nonheap\"}", + "legendFormat": "{{id}} used" + } + ], + "fieldConfig": { + "defaults": { "unit": "bytes", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 9, + "title": "GC Pause Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 21 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(jvm_gc_pause_seconds_sum[1m])", + "legendFormat": "{{action}} {{cause}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "s", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 10, + "title": "GC Count / min", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 21 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(jvm_gc_pause_seconds_count[1m]) * 60", + "legendFormat": "{{action}} {{cause}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "short", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 11, + "title": "Thread Count Over Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 29 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "jvm_threads_live_threads", + "legendFormat": "live" + }, + { + "expr": "jvm_threads_daemon_threads", + "legendFormat": "daemon" + }, + { + "expr": "jvm_threads_peak_threads", + "legendFormat": "peak" + } + ], + "fieldConfig": { + "defaults": { "unit": "short" } + } + }, + { + "id": 12, + "title": "Tomcat & System", + "type": "row", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 3 }, + "collapsed": false, + "panels": [] + }, + { + "id": 13, + "title": "Tomcat Threads", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "tomcat_threads_current_threads", + "legendFormat": "current" + }, + { + "expr": "tomcat_threads_busy_threads", + "legendFormat": "busy" + }, + { + "expr": "tomcat_threads_config_max_threads", + "legendFormat": "max" + } + ], + "fieldConfig": { + "defaults": { "unit": "short" } + } + }, + { + "id": 14, + "title": "CPU Usage", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "process_cpu_usage", + "legendFormat": "app CPU" + }, + { + "expr": "system_cpu_usage", + "legendFormat": "system CPU" + } + ], + "fieldConfig": { + "defaults": { "unit": "percentunit", "max": 1, "custom": { "fillOpacity": 10 } } + } + } + ], + "schemaVersion": 39 +} diff --git a/infra/dev/monitoring/grafana/provisioning/dashboards/logs-explorer.json b/infra/dev/monitoring/grafana/provisioning/dashboards/logs-explorer.json new file mode 100644 index 00000000..9b7f8669 --- /dev/null +++ b/infra/dev/monitoring/grafana/provisioning/dashboards/logs-explorer.json @@ -0,0 +1,403 @@ +{ + "uid": "weeth-logs-explorer", + "title": "Logs Explorer", + "tags": [ + "weeth", + "logs" + ], + "timezone": "Asia/Seoul", + "refresh": "30s", + "time": { + "from": "now-30m", + "to": "now" + }, + "templating": { + "list": [ + { + "name": "log_type", + "type": "custom", + "current": { + "text": "All", + "value": "$__all" + }, + "options": [ + { + "text": "All", + "value": "$__all", + "selected": true + }, + { + "text": "access", + "value": "access" + }, + { + "text": "audit", + "value": "audit" + }, + { + "text": "error", + "value": "error" + }, + { + "text": "application", + "value": "application" + } + ], + "includeAll": true, + "multi": false, + "query": "access,audit,error,application" + }, + { + "name": "level", + "type": "custom", + "current": { + "text": "All", + "value": "$__all" + }, + "options": [ + { + "text": "All", + "value": "$__all", + "selected": true + }, + { + "text": "ERROR", + "value": "ERROR" + }, + { + "text": "WARN", + "value": "WARN" + }, + { + "text": "INFO", + "value": "INFO" + }, + { + "text": "DEBUG", + "value": "DEBUG" + } + ], + "includeAll": true, + "multi": false, + "query": "ERROR,WARN,INFO,DEBUG" + }, + { + "name": "search", + "type": "textbox", + "current": { + "text": "", + "value": "" + }, + "label": "Search" + }, + { + "name": "requestId", + "type": "textbox", + "current": { + "text": "", + "value": "" + }, + "label": "Request ID" + } + ] + }, + "panels": [ + { + "id": 1, + "title": "Log Volume", + "type": "timeseries", + "gridPos": { + "h": 5, + "w": 24, + "x": 0, + "y": 0 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "sum by(level) (count_over_time({app=\"weeth\", log_type=~\"$log_type\", level=~\"$level\"} [$__auto]))", + "legendFormat": "{{level}}" + } + ], + "fieldConfig": { + "defaults": { + "custom": { + "fillOpacity": 30, + "stacking": { + "mode": "normal" + } + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "ERROR" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "WARN" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "INFO" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "DEBUG" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + } + ] + } + }, + { + "id": 2, + "title": "Live Logs", + "type": "logs", + "gridPos": { + "h": 14, + "w": 24, + "x": 0, + "y": 41 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "{app=\"weeth\", log_type=~\"$log_type\", level=~\"$level\"} | json | line_format \"{{.method}} {{.path}} {{.status}} {{.durationMs}}ms {{.message}}\" |= `$search` |= `$requestId`" + } + ], + "options": { + "showTime": true, + "showLabels": true, + "showCommonLabels": false, + "wrapLogMessage": true, + "prettifyLogMessage": false, + "enableLogDetails": true, + "sortOrder": "Descending", + "dedupStrategy": "none" + } + }, + { + "id": 4, + "title": "Error Logs", + "type": "logs", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 31 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "{app=\"weeth\", log_type=\"error\"} | json" + } + ], + "options": { + "showTime": true, + "showLabels": true, + "wrapLogMessage": true, + "enableLogDetails": true, + "sortOrder": "Descending" + } + }, + { + "id": 5, + "title": "Access Logs", + "type": "logs", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 21 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "{app=\"weeth\", log_type=\"access\"} | json" + } + ], + "options": { + "showTime": true, + "showLabels": true, + "wrapLogMessage": true, + "enableLogDetails": true, + "sortOrder": "Descending" + } + }, + { + "id": 6, + "title": "Error Rate Over Time", + "type": "timeseries", + "gridPos": { + "h": 6, + "w": 8, + "x": 0, + "y": 5 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "sum(count_over_time({app=\"weeth\", level=\"ERROR\"} [$__auto])) or vector(0)", + "legendFormat": "errors" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "red", + "mode": "fixed" + }, + "custom": { + "fillOpacity": 20 + } + } + } + }, + { + "id": 7, + "title": "Warn Rate Over Time", + "type": "timeseries", + "gridPos": { + "h": 6, + "w": 8, + "x": 8, + "y": 5 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "sum(count_over_time({app=\"weeth\", level=\"WARN\"} [$__auto])) or vector(0)", + "legendFormat": "warnings" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "yellow", + "mode": "fixed" + }, + "custom": { + "fillOpacity": 20 + } + } + } + }, + { + "id": 8, + "title": "HTTP Status Over Time", + "type": "timeseries", + "gridPos": { + "h": 6, + "w": 8, + "x": 16, + "y": 5 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "sum by(status) (count_over_time({app=\"weeth\", log_type=\"access\"} | json [$__auto]))", + "legendFormat": "{{status}}" + } + ], + "fieldConfig": { + "defaults": { + "custom": { + "fillOpacity": 20 + } + } + } + }, + { + "id": 9, + "title": "Slow Requests (> 1s)", + "type": "logs", + "gridPos": { + "h": 14, + "w": 24, + "x": 0, + "y": 55 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "{app=\"weeth\", log_type=\"access\"} | json | durationMs > 1000" + } + ], + "options": { + "showTime": true, + "showLabels": true, + "wrapLogMessage": true, + "enableLogDetails": true, + "sortOrder": "Descending" + } + } + ], + "schemaVersion": 39 +} diff --git a/infra/dev/monitoring/grafana/provisioning/dashboards/trace-explorer.json b/infra/dev/monitoring/grafana/provisioning/dashboards/trace-explorer.json new file mode 100644 index 00000000..8be83176 --- /dev/null +++ b/infra/dev/monitoring/grafana/provisioning/dashboards/trace-explorer.json @@ -0,0 +1,513 @@ +{ + "uid": "weeth-trace-explorer", + "title": "Trace Explorer", + "tags": [ + "weeth", + "trace" + ], + "timezone": "Asia/Seoul", + "refresh": "10s", + "time": { + "from": "now-6h", + "to": "now" + }, + "templating": { + "list": [ + { + "name": "traceId", + "type": "textbox", + "current": { + "text": "", + "value": "" + }, + "label": "Trace ID" + } + ] + }, + "panels": [ + { + "id": 1, + "title": "All Traces", + "description": "최근 전체 트레이스 목록입니다. actuator/health-check는 애플리케이션 observation 필터에서 제외합니다.", + "type": "table", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 0 + }, + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "targets": [ + { + "queryType": "traceql", + "query": "{}", + "limit": 200, + "tableType": "traces", + "refId": "A" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + }, + "indexByName": { + "Service": 0, + "TraceId": 1, + "Name": 2, + "StartTime": 3, + "Duration": 4, + "Service Name": 0, + "Trace ID": 1, + "Start time": 3, + "duration": 4, + "rootServiceName": 0, + "traceID": 1, + "rootTraceName": 2, + "startTime": 3 + }, + "renameByName": { + "Trace ID": "TraceId", + "TraceID": "TraceId", + "traceID": "TraceId", + "traceId": "TraceId", + "Service Name": "Service", + "Root Service Name": "Service", + "rootServiceName": "Service", + "serviceName": "Service", + "Root Trace Name": "Name", + "Trace Name": "Name", + "rootTraceName": "Name", + "name": "Name", + "Start time": "StartTime", + "Start Time": "StartTime", + "startTime": "StartTime", + "duration": "Duration", + "Duration": "Duration" + } + } + } + ], + "fieldConfig": { + "defaults": { + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "TraceId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Trace ID" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "traceID" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "traceId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + } + ] + } + }, + { + "id": 6, + "title": "Trace Search", + "description": "Tempo Explore에서 TraceQL로 검색합니다.", + "type": "text", + "gridPos": { + "h": 4, + "w": 24, + "x": 0, + "y": 10 + }, + "options": { + "mode": "markdown", + "content": "TraceId를 선택하면 Tempo Explore에서 해당 TraceId를 바로 조회합니다.\n\n기본 조회 범위는 최근 6시간, 목록 제한은 200건입니다." + } + }, + { + "id": 4, + "title": "Error Traces", + "description": "에러가 발생한 트레이스를 검색합니다.", + "type": "table", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 14 + }, + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "targets": [ + { + "queryType": "traceql", + "query": "{ status = error }", + "limit": 200, + "tableType": "traces", + "refId": "A" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + }, + "indexByName": { + "Service": 0, + "TraceId": 1, + "Name": 2, + "StartTime": 3, + "Duration": 4, + "Service Name": 0, + "Trace ID": 1, + "Start time": 3, + "duration": 4, + "rootServiceName": 0, + "traceID": 1, + "rootTraceName": 2, + "startTime": 3 + }, + "renameByName": { + "Trace ID": "TraceId", + "TraceID": "TraceId", + "traceID": "TraceId", + "traceId": "TraceId", + "Service Name": "Service", + "Root Service Name": "Service", + "rootServiceName": "Service", + "serviceName": "Service", + "Root Trace Name": "Name", + "Trace Name": "Name", + "rootTraceName": "Name", + "name": "Name", + "Start time": "StartTime", + "Start Time": "StartTime", + "startTime": "StartTime", + "duration": "Duration", + "Duration": "Duration" + } + } + } + ], + "fieldConfig": { + "defaults": { + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "TraceId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Trace ID" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "traceID" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "traceId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + } + ] + } + }, + { + "id": 3, + "title": "Slow Traces (> 500ms)", + "description": "500ms 이상 걸린 요청의 트레이스를 자동 검색합니다.", + "type": "table", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 24 + }, + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "targets": [ + { + "queryType": "traceql", + "query": "{ duration > 500ms }", + "limit": 200, + "tableType": "traces", + "refId": "A" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + }, + "indexByName": { + "Service": 0, + "TraceId": 1, + "Name": 2, + "StartTime": 3, + "Duration": 4, + "Service Name": 0, + "Trace ID": 1, + "Start time": 3, + "duration": 4, + "rootServiceName": 0, + "traceID": 1, + "rootTraceName": 2, + "startTime": 3 + }, + "renameByName": { + "Trace ID": "TraceId", + "TraceID": "TraceId", + "traceID": "TraceId", + "traceId": "TraceId", + "Service Name": "Service", + "Root Service Name": "Service", + "rootServiceName": "Service", + "serviceName": "Service", + "Root Trace Name": "Name", + "Trace Name": "Name", + "rootTraceName": "Name", + "name": "Name", + "Start time": "StartTime", + "Start Time": "StartTime", + "startTime": "StartTime", + "duration": "Duration", + "Duration": "Duration" + } + } + } + ], + "fieldConfig": { + "defaults": { + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "TraceId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Trace ID" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "traceID" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "traceId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + } + ] + } + }, + { + "id": 5, + "title": "Related Logs (by Trace ID)", + "description": "Trace ID를 입력하면 해당 트레이스와 연관된 로그를 확인합니다.", + "type": "logs", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 34 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "{app=\"weeth\"} |= `$traceId` | json", + "refId": "A" + } + ], + "options": { + "showTime": true, + "showLabels": true, + "wrapLogMessage": true, + "enableLogDetails": true, + "sortOrder": "Descending" + } + } + ], + "schemaVersion": 39 +} diff --git a/infra/dev/monitoring/grafana/provisioning/datasources/datasources.yaml b/infra/dev/monitoring/grafana/provisioning/datasources/datasources.yaml new file mode 100644 index 00000000..f6118e0a --- /dev/null +++ b/infra/dev/monitoring/grafana/provisioning/datasources/datasources.yaml @@ -0,0 +1,31 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + uid: prometheus + isDefault: true + editable: true + + - name: Tempo + type: tempo + access: proxy + url: http://tempo:3200 + uid: tempo + editable: true + + - name: Loki + type: loki + access: proxy + url: http://loki:3100 + uid: loki + editable: true + jsonData: + derivedFields: + - datasourceUid: tempo + matcherRegex: '"(?:traceId|trace_id|mdc_traceId|mdc_trace_id)"\s*:\s*"([^"]+)"' + name: traceId + url: "$${__value.raw}" + urlDisplayLabel: "View Trace" diff --git a/infra/dev/monitoring/loki/loki-config.yaml b/infra/dev/monitoring/loki/loki-config.yaml new file mode 100644 index 00000000..963e9db0 --- /dev/null +++ b/infra/dev/monitoring/loki/loki-config.yaml @@ -0,0 +1,43 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + +common: + ring: + kvstore: + store: inmemory + replication_factor: 1 + path_prefix: /loki + +schema_config: + configs: + - from: "2026-04-15" + store: tsdb + object_store: s3 + schema: v13 + index: + prefix: loki_index_ + period: 24h + +storage_config: + tsdb_shipper: + active_index_directory: /loki/index + cache_location: /loki/cache + aws: + s3: s3://${AWS_REGION}/${LOKI_S3_BUCKET} + s3forcepathstyle: false + +limits_config: + retention_period: 7d + max_label_names_per_series: 5 + max_label_value_length: 1024 + ingestion_rate_mb: 10 + ingestion_burst_size_mb: 20 + +compactor: + working_directory: /loki/compactor + compaction_interval: 10m + retention_enabled: true + retention_delete_delay: 2h + delete_request_store: filesystem diff --git a/infra/dev/monitoring/prometheus/prometheus.yml b/infra/dev/monitoring/prometheus/prometheus.yml new file mode 100644 index 00000000..9b96e40d --- /dev/null +++ b/infra/dev/monitoring/prometheus/prometheus.yml @@ -0,0 +1,30 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: "weeth-app" + metrics_path: "/actuator/prometheus" + static_configs: + - targets: ["app-blue:8080", "app-green:8080"] + labels: + app: weeth + env: dev + + - job_name: "node-exporter" + static_configs: + - targets: ["node-exporter:9100"] + labels: + env: dev + + - job_name: "cadvisor" + static_configs: + - targets: ["cadvisor:8080"] + labels: + env: dev + + - job_name: "redis" + static_configs: + - targets: ["redis-exporter:9121"] + labels: + env: dev diff --git a/infra/dev/monitoring/scripts/deploy.sh b/infra/dev/monitoring/scripts/deploy.sh new file mode 100644 index 00000000..c6640483 --- /dev/null +++ b/infra/dev/monitoring/scripts/deploy.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +DEPLOY_DIR="${DEPLOY_DIR:-/home/ubuntu/infra/dev/monitoring}" +APP_NETWORK="${APP_NETWORK:-weeth-dev_web}" +MONITORING_ENV_FILE="${MONITORING_ENV_FILE:-$DEPLOY_DIR/.env.monitoring}" + +cd "$DEPLOY_DIR" + +if [ ! -f "$MONITORING_ENV_FILE" ]; then + echo "[monitoring] env file not found: $MONITORING_ENV_FILE" + exit 1 +fi + +export MONITORING_ENV_FILE + +if ! docker network inspect "$APP_NETWORK" >/dev/null 2>&1; then + echo "[monitoring] required docker network not found: $APP_NETWORK" + echo "[monitoring] deploy the app stack first or create the network before deploying monitoring" + exit 1 +fi + +echo "[monitoring] pulling images..." +docker compose --env-file "$MONITORING_ENV_FILE" pull + +echo "[monitoring] starting monitoring stack..." +docker compose --env-file "$MONITORING_ENV_FILE" up -d + +echo "[monitoring] waiting for services to be healthy..." +for i in {1..30}; do + if curl -fsS "http://127.0.0.1:12345/-/ready" >/dev/null 2>&1 && + curl -fsS "http://127.0.0.1:9090/-/ready" >/dev/null 2>&1 && + curl -fsS "http://127.0.0.1:3100/ready" >/dev/null 2>&1 && + curl -fsS "http://127.0.0.1:3200/ready" >/dev/null 2>&1 && + curl -fsS "http://127.0.0.1:3000/api/health" >/dev/null 2>&1; then + echo "[monitoring] all services healthy" + break + fi + + if [ "$i" -eq 30 ]; then + echo "[monitoring] health check failed — check docker compose logs" + exit 1 + fi + + sleep 2 +done + +echo "[monitoring] deploy completed" diff --git a/infra/dev/monitoring/tempo/tempo-config.yaml b/infra/dev/monitoring/tempo/tempo-config.yaml new file mode 100644 index 00000000..fcd71035 --- /dev/null +++ b/infra/dev/monitoring/tempo/tempo-config.yaml @@ -0,0 +1,35 @@ +stream_over_http_enabled: true + +server: + http_listen_port: 3200 + +distributor: + receivers: + otlp: + protocols: + grpc: + endpoint: "0.0.0.0:4317" + +storage: + trace: + backend: s3 + s3: + bucket: ${TEMPO_S3_BUCKET} + endpoint: s3.${AWS_REGION}.amazonaws.com + wal: + path: /var/tempo/wal + local: + path: /var/tempo/blocks + +compactor: + compaction: + block_retention: 168h + +metrics_generator: + storage: + path: /var/tempo/generator/wal + registry: + external_labels: + source: tempo + traces_storage: + path: /var/tempo/generator/traces diff --git a/infra/local/monitoring/alloy/config.alloy b/infra/local/monitoring/alloy/config.alloy new file mode 100644 index 00000000..693ca47f --- /dev/null +++ b/infra/local/monitoring/alloy/config.alloy @@ -0,0 +1,75 @@ +// Loki push API 수신 — 앱에서 HTTP로 로그를 직접 push +loki.source.api "local_push" { + http { + listen_address = "0.0.0.0" + listen_port = 3101 + } + forward_to = [loki.process.parse_json.receiver] +} + +// JSON 파싱 및 라벨 매핑 +loki.process "parse_json" { + stage.json { + expressions = { + level = "level", + logger_name = "logger_name", + } + } + + stage.labels { + values = { level = "", logger_name = "" } + } + + // logger_name 기반으로 log_type 매핑 + stage.match { + selector = "{logger_name=\"ACCESS_LOG\"}" + stage.static_labels { values = { log_type = "access" } } + } + stage.match { + selector = "{logger_name=\"AUDIT_LOG\"}" + stage.static_labels { values = { log_type = "audit" } } + } + stage.match { + selector = "{logger_name=\"ERROR_LOG\"}" + stage.static_labels { values = { log_type = "error" } } + } + + // 위 매칭에 걸리지 않은 로그 → application + stage.match { + selector = "{logger_name!=\"ACCESS_LOG\", logger_name!=\"AUDIT_LOG\", logger_name!=\"ERROR_LOG\"}" + stage.static_labels { values = { log_type = "application" } } + } + + stage.label_drop { values = ["logger_name"] } + + forward_to = [loki.write.default.receiver] +} + +// Loki로 전송 +loki.write "default" { + endpoint { + url = "http://loki:3100/loki/api/v1/push" + } +} + +// === 트레이스 수집 파이프라인 === + +// OTLP HTTP receiver — 앱에서 트레이스 수신 +otelcol.receiver.otlp "default" { + http { + endpoint = "0.0.0.0:4318" + } + output { + traces = [otelcol.exporter.otlp.tempo.input] + } +} + +// Tempo로 트레이스 전달 +otelcol.exporter.otlp "tempo" { + client { + endpoint = "tempo:4317" + tls { + insecure = true + } + } +} diff --git a/infra/local/monitoring/docker-compose.yml b/infra/local/monitoring/docker-compose.yml new file mode 100644 index 00000000..4e22df6c --- /dev/null +++ b/infra/local/monitoring/docker-compose.yml @@ -0,0 +1,76 @@ +name: weeth-local-monitoring + +services: + alloy: + image: grafana/alloy:v1.9.0 + volumes: + - ./alloy/config.alloy:/etc/alloy/config.alloy + command: ["run", "--server.http.listen-addr=0.0.0.0:12345", "/etc/alloy/config.alloy"] + ports: + - "12345:12345" + - "3101:3101" + - "4318:4318" + depends_on: + - loki + - tempo + restart: unless-stopped + + loki: + image: grafana/loki:3.4.2 + volumes: + - ./loki/loki-config.yaml:/etc/loki/loki-config.yaml + - loki_data:/loki + command: ["-config.file=/etc/loki/loki-config.yaml"] + ports: + - "3100:3100" + restart: unless-stopped + + tempo: + image: grafana/tempo:2.7.1 + volumes: + - ./tempo/tempo-config.yaml:/etc/tempo/tempo-config.yaml + - tempo_data:/var/tempo + command: ["-config.file=/etc/tempo/tempo-config.yaml"] + ports: + - "127.0.0.1:3200:3200" + restart: unless-stopped + + redis-exporter: + image: oliver006/redis_exporter:v1.67.0 + environment: + - REDIS_ADDR=host.docker.internal:6379 + ports: + - "127.0.0.1:9121:9121" + extra_hosts: + - "host.docker.internal:host-gateway" + restart: unless-stopped + + prometheus: + image: prom/prometheus:v2.53.0 + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml + ports: + - "9090:9090" + extra_hosts: + - "host.docker.internal:host-gateway" + restart: unless-stopped + + grafana: + image: grafana/grafana:11.5.2 + volumes: + - ./grafana/provisioning:/etc/grafana/provisioning + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + - GF_AUTH_ANONYMOUS_ENABLED=true + - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin + ports: + - "3000:3000" + depends_on: + - loki + - prometheus + - tempo + restart: unless-stopped + +volumes: + loki_data: + tempo_data: diff --git a/infra/local/monitoring/grafana/provisioning/dashboards/api-overview.json b/infra/local/monitoring/grafana/provisioning/dashboards/api-overview.json new file mode 100644 index 00000000..f7cf5807 --- /dev/null +++ b/infra/local/monitoring/grafana/provisioning/dashboards/api-overview.json @@ -0,0 +1,388 @@ +{ + "uid": "weeth-api-overview", + "title": "API Overview", + "tags": ["weeth", "api"], + "timezone": "Asia/Seoul", + "refresh": "10s", + "time": { "from": "now-1h", "to": "now" }, + "templating": { + "list": [ + { + "name": "uri", + "type": "query", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "query": "label_values(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\"}, uri)", + "includeAll": true, + "multi": true, + "current": { "text": "All", "value": "$__all" }, + "refresh": 2 + } + ] + }, + "panels": [ + { + "id": 1, + "title": "Total Requests / min", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 15, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1m])) * 60", + "legendFormat": "req/min" + } + ], + "fieldConfig": { + "defaults": { + "unit": "reqpm", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 100 }, + { "color": "red", "value": 500 } + ] + } + } + } + }, + { + "id": 2, + "title": "5xx Error Rate", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 0, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "((sum(rate(http_server_requests_seconds_count{status=~\"5..\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])) or vector(0)) / clamp_min(sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])), 0.001)) * 100", + "legendFormat": "5xx %" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "red", "value": 5 } + ] + } + } + } + }, + { + "id": 3, + "title": "4xx Error Rate", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 3, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "((sum(rate(http_server_requests_seconds_count{status=~\"4..\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])) or vector(0)) / clamp_min(sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])), 0.001)) * 100", + "legendFormat": "4xx %" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 5 }, + { "color": "red", "value": 20 } + ] + } + } + } + }, + { + "id": 16, + "title": "Avg Response Time", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 6, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_sum{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])) / clamp_min(sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])), 0.001)", + "legendFormat": "avg" + } + ], + "fieldConfig": { + "defaults": { + "unit": "s", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 0.3 }, + { "color": "red", "value": 0.5 } + ] + } + } + } + }, + { + "id": 4, + "title": "Apdex (0.5s)", + "type": "stat", + "gridPos": { "h": 4, "w": 6, "x": 18, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "description": "Apdex score (사용자 체감 만족도). 1.0=최고, >0.94=훌륭함, >0.85=좋음, >0.7=나쁨, <0.7=매우 나쁨", + "targets": [ + { + "expr": "(\n sum(rate(http_server_requests_seconds_bucket{le=\"0.5\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))\n + (\n sum(rate(http_server_requests_seconds_bucket{le=\"2.0\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))\n - sum(rate(http_server_requests_seconds_bucket{le=\"0.5\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))\n ) / 2\n) / sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))", + "legendFormat": "Apdex" + } + ], + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 2, + "min": 0, + "max": 1, + "thresholds": { + "steps": [ + { "color": "red", "value": null }, + { "color": "orange", "value": 0.7 }, + { "color": "yellow", "value": 0.85 }, + { "color": "green", "value": 0.94 } + ] + } + } + } + }, + { + "id": 5, + "title": "P95 Response Time", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 9, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])) by (le))", + "legendFormat": "P95" + } + ], + "fieldConfig": { + "defaults": { + "unit": "s", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 0.5 }, + { "color": "red", "value": 1 } + ] + } + } + } + }, + { + "id": 6, + "title": "P99 Response Time", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 12, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "histogram_quantile(0.99, sum(rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])) by (le))", + "legendFormat": "P99" + } + ], + "fieldConfig": { + "defaults": { + "unit": "s", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "red", "value": 2 } + ] + } + } + } + }, + { + "id": 7, + "title": "Requests per Second", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1m]))", + "legendFormat": "total" + }, + { + "expr": "sum(rate(http_server_requests_seconds_count{status=~\"2..\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1m])) or vector(0)", + "legendFormat": "2xx" + }, + { + "expr": "sum(rate(http_server_requests_seconds_count{status=~\"4..\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1m])) or vector(0)", + "legendFormat": "4xx" + }, + { + "expr": "sum(rate(http_server_requests_seconds_count{status=~\"5..\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1m])) or vector(0)", + "legendFormat": "5xx" + } + ], + "fieldConfig": { + "defaults": { "unit": "reqps", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 8, + "title": "Response Time Distribution", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "description": "엔드포인트별 P50/P95/P99 응답시간 추이. 기본(All)은 전체 엔드포인트별 분포를 표시하고, 상단 uri 필터에서 특정 엔드포인트를 선택하면 해당 API만 표시됩니다.", + "targets": [ + { + "expr": "histogram_quantile(0.50, sum by (le) (rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])))", + "legendFormat": "P50" + }, + { + "expr": "histogram_quantile(0.95, sum by (le) (rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])))", + "legendFormat": "P95" + }, + { + "expr": "histogram_quantile(0.99, sum by (le) (rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])))", + "legendFormat": "P99" + } + ], + "fieldConfig": { + "defaults": { "unit": "s", "custom": { "fillOpacity": 5 } } + } + }, + { + "id": 9, + "title": "HTTP Status Code Distribution", + "type": "piechart", + "gridPos": { "h": 8, "w": 8, "x": 16, "y": 12 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum by(status) (increase(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1h]))", + "legendFormat": "{{status}}" + } + ], + "fieldConfig": { + "overrides": [ + { "matcher": { "id": "byRegexp", "options": "^2\\d{2}$" }, "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }] }, + { "matcher": { "id": "byRegexp", "options": "^3\\d{2}$" }, "properties": [{ "id": "color", "value": { "fixedColor": "blue", "mode": "fixed" } }] }, + { "matcher": { "id": "byRegexp", "options": "^4\\d{2}$" }, "properties": [{ "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }] }, + { "matcher": { "id": "byRegexp", "options": "^5\\d{2}$" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] } + ] + } + }, + { + "id": 10, + "title": "Slowest Endpoints (P95)", + "type": "bargauge", + "gridPos": { "h": 8, "w": 8, "x": 8, "y": 12 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "topk(10, histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*|/health-check\"}[5m])) by (le, uri)))", + "legendFormat": "{{uri}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "s" } + }, + "options": { + "orientation": "horizontal", + "displayMode": "gradient" + } + }, + { + "id": 11, + "title": "Top 10 Error Endpoints (5xx)", + "type": "bargauge", + "gridPos": { "h": 8, "w": 8, "x": 0, "y": 12 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "topk(10, sum by(uri) (rate(http_server_requests_seconds_count{status=~\"5..\", uri!~\"/actuator.*|/health-check\"}[5m])))", + "legendFormat": "{{uri}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "reqps" } + }, + "options": { + "orientation": "horizontal", + "displayMode": "gradient" + } + }, + { + "id": 12, + "title": "Request Rate by Endpoint", + "type": "timeseries", + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 20 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum by(uri, method) (rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1m]))", + "legendFormat": "{{method}} {{uri}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "reqps", "custom": { "fillOpacity": 5 } } + } + }, + { + "id": 13, + "title": "Top 10 High-Traffic Endpoints (4xx)", + "type": "bargauge", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 28 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "topk(10, sum by(uri) (rate(http_server_requests_seconds_count{status=~\"4..\", uri!~\"/actuator.*|/health-check\"}[5m])))", + "legendFormat": "{{uri}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "reqps" } + }, + "options": { + "orientation": "horizontal", + "displayMode": "gradient" + } + }, + { + "id": 14, + "title": "Apdex Over Time (0.5s)", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 28 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "description": "Apdex trend. T=0.5s (satisfied), 4T=2.0s (tolerating). Below 0.85 = degraded experience.", + "targets": [ + { + "expr": "(\n sum(rate(http_server_requests_seconds_bucket{le=\"0.5\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))\n + (\n sum(rate(http_server_requests_seconds_bucket{le=\"2.0\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))\n - sum(rate(http_server_requests_seconds_bucket{le=\"0.5\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))\n ) / 2\n) / sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))", + "legendFormat": "Apdex" + } + ], + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 2, + "min": 0, + "max": 1, + "custom": { "fillOpacity": 10 }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": null }, + { "color": "orange", "value": 0.7 }, + { "color": "yellow", "value": 0.85 }, + { "color": "green", "value": 0.94 } + ] + }, + "color": { "mode": "continuous-GrYlRd" } + } + } + } + ], + "schemaVersion": 39 +} diff --git a/infra/local/monitoring/grafana/provisioning/dashboards/dashboards.yaml b/infra/local/monitoring/grafana/provisioning/dashboards/dashboards.yaml new file mode 100644 index 00000000..2a76fbbd --- /dev/null +++ b/infra/local/monitoring/grafana/provisioning/dashboards/dashboards.yaml @@ -0,0 +1,12 @@ +apiVersion: 1 + +providers: + - name: weeth + orgId: 1 + folder: Weeth + type: file + disableDeletion: false + editable: true + options: + path: /etc/grafana/provisioning/dashboards + foldersFromFilesStructure: false diff --git a/infra/local/monitoring/grafana/provisioning/dashboards/external-infra.json b/infra/local/monitoring/grafana/provisioning/dashboards/external-infra.json new file mode 100644 index 00000000..f38d9eb7 --- /dev/null +++ b/infra/local/monitoring/grafana/provisioning/dashboards/external-infra.json @@ -0,0 +1,373 @@ +{ + "uid": "weeth-external-infra", + "title": "External Infrastructure", + "tags": ["weeth", "db", "redis"], + "timezone": "Asia/Seoul", + "refresh": "10s", + "time": { "from": "now-1h", "to": "now" }, + "panels": [ + { + "id": 1, + "title": "HikariCP (MySQL)", + "type": "row", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, + "collapsed": false, + "panels": [] + }, + { + "id": 2, + "title": "Active", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 0, "y": 1 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "hikaricp_connections_active", + "legendFormat": "active" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 5 }, + { "color": "red", "value": 8 } + ] + } + } + } + }, + { + "id": 3, + "title": "Idle", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 4, "y": 1 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "hikaricp_connections_idle", + "legendFormat": "idle" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { "color": "red", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "green", "value": 3 } + ] + } + } + } + }, + { + "id": 4, + "title": "Pending", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 8, "y": 1 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "hikaricp_connections_pending", + "legendFormat": "pending" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "red", "value": 3 } + ] + } + } + } + }, + { + "id": 5, + "title": "Total / Max", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 12, "y": 1 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "hikaricp_connections", + "legendFormat": "total" + }, + { + "expr": "hikaricp_connections_max", + "legendFormat": "max" + } + ] + }, + { + "id": 6, + "title": "Timeout Total", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 16, "y": 1 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "description": "커넥션 획득 timeout 누적 횟수. 0이 아니면 커넥션 풀 고갈 발생.", + "targets": [ + { + "expr": "hikaricp_connections_timeout_total", + "legendFormat": "timeouts" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 1 } + ] + } + } + } + }, + { + "id": 7, + "title": "Timeout Rate / min", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 20, "y": 1 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(hikaricp_connections_timeout_total[1m]) * 60", + "legendFormat": "timeouts/min" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 0.1 } + ] + } + } + } + }, + { + "id": 8, + "title": "Connection Pool Over Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 5 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "hikaricp_connections_active", + "legendFormat": "active" + }, + { + "expr": "hikaricp_connections_idle", + "legendFormat": "idle" + }, + { + "expr": "hikaricp_connections_pending", + "legendFormat": "pending" + }, + { + "expr": "hikaricp_connections", + "legendFormat": "total" + } + ], + "fieldConfig": { + "defaults": { "unit": "short", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 9, + "title": "Connection Acquire Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 5 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(hikaricp_connections_acquire_seconds_sum[1m]) / rate(hikaricp_connections_acquire_seconds_count[1m])", + "legendFormat": "avg acquire time" + }, + { + "expr": "hikaricp_connections_acquire_seconds_max", + "legendFormat": "max acquire time" + } + ], + "fieldConfig": { + "defaults": { "unit": "s", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 10, + "title": "Connection Creation Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 30 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(hikaricp_connections_creation_seconds_sum[1m]) / rate(hikaricp_connections_creation_seconds_count[1m])", + "legendFormat": "avg creation time" + }, + { + "expr": "hikaricp_connections_creation_seconds_max", + "legendFormat": "max creation time" + } + ], + "fieldConfig": { + "defaults": { "unit": "s", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 11, + "title": "Connection Usage Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 30 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(hikaricp_connections_usage_seconds_sum[1m]) / rate(hikaricp_connections_usage_seconds_count[1m])", + "legendFormat": "avg usage time" + }, + { + "expr": "hikaricp_connections_usage_seconds_max", + "legendFormat": "max usage time" + } + ], + "fieldConfig": { + "defaults": { "unit": "s", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 12, + "title": "Redis", + "type": "row", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 13 }, + "collapsed": false, + "panels": [] + }, + { + "id": 13, + "title": "Redis Command Rate", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 22 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(rate(lettuce_command_completion_seconds_count[1m]))", + "legendFormat": "commands/sec" + } + ], + "fieldConfig": { + "defaults": { "unit": "ops", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 14, + "title": "Redis Command Latency", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 14 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(lettuce_command_completion_seconds_sum[1m]) / rate(lettuce_command_completion_seconds_count[1m])", + "legendFormat": "avg latency" + }, + { + "expr": "lettuce_command_completion_seconds_max", + "legendFormat": "max latency" + } + ], + "fieldConfig": { + "defaults": { "unit": "s", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 15, + "title": "Redis Command by Type", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 22 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum by(command) (rate(lettuce_command_completion_seconds_count[1m]))", + "legendFormat": "{{command}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "ops", "custom": { "fillOpacity": 5 } } + } + }, + { + "id": 16, + "title": "Redis Command Errors", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 14 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(rate(lettuce_command_firstresponse_seconds_count{outcome=\"ERROR\"}[1m]))", + "legendFormat": "errors/sec" + } + ], + "fieldConfig": { + "defaults": { + "unit": "ops", + "custom": { "fillOpacity": 10 }, + "color": { "fixedColor": "red", "mode": "fixed" } + } + } + }, + { + "id": 17, + "title": "Redis Cache Hit Rate", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 30 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "description": "Redis 서버 전체 캐시 히트율. redis_exporter의 keyspace_hits/misses 기반.", + "targets": [ + { + "expr": "rate(redis_keyspace_hits_total[5m]) / clamp_min(rate(redis_keyspace_hits_total[5m]) + rate(redis_keyspace_misses_total[5m]), 0.001) * 100", + "legendFormat": "hit rate" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "min": 0, + "max": 100, + "custom": { "fillOpacity": 10 }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": null }, + { "color": "yellow", "value": 50 }, + { "color": "green", "value": 80 } + ] + } + } + } + }, + { + "id": 18, + "title": "Redis Cache Hits / Misses", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 30 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(redis_keyspace_hits_total[1m])", + "legendFormat": "hits/sec" + }, + { + "expr": "rate(redis_keyspace_misses_total[1m])", + "legendFormat": "misses/sec" + } + ], + "fieldConfig": { + "defaults": { "unit": "ops", "custom": { "fillOpacity": 10 } } + } + } + ], + "schemaVersion": 39 +} diff --git a/infra/local/monitoring/grafana/provisioning/dashboards/internal-infra.json b/infra/local/monitoring/grafana/provisioning/dashboards/internal-infra.json new file mode 100644 index 00000000..4395f758 --- /dev/null +++ b/infra/local/monitoring/grafana/provisioning/dashboards/internal-infra.json @@ -0,0 +1,266 @@ +{ + "uid": "weeth-internal-infra", + "title": "Internal Infrastructure", + "tags": ["weeth", "jvm", "infra"], + "timezone": "Asia/Seoul", + "refresh": "10s", + "time": { "from": "now-1h", "to": "now" }, + "panels": [ + { + "id": 1, + "title": "Uptime", + "type": "stat", + "gridPos": { "h": 3, "w": 6, "x": 0, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "process_uptime_seconds", + "legendFormat": "uptime" + } + ], + "fieldConfig": { + "defaults": { "unit": "s" } + } + }, + { + "id": 2, + "title": "Heap Usage %", + "type": "stat", + "gridPos": { "h": 3, "w": 6, "x": 6, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(jvm_memory_used_bytes{area=\"heap\"}) / sum(jvm_memory_max_bytes{area=\"heap\"}) * 100", + "legendFormat": "heap %" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 70 }, + { "color": "red", "value": 90 } + ] + } + } + } + }, + { + "id": 3, + "title": "Live Threads", + "type": "stat", + "gridPos": { "h": 3, "w": 6, "x": 12, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "jvm_threads_live_threads", + "legendFormat": "live" + } + ] + }, + { + "id": 4, + "title": "App CPU", + "type": "stat", + "gridPos": { "h": 3, "w": 6, "x": 18, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "process_cpu_usage * 100", + "legendFormat": "CPU %" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 60 }, + { "color": "red", "value": 85 } + ] + } + } + } + }, + { + "id": 5, + "title": "JVM", + "type": "row", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 12 }, + "collapsed": false, + "panels": [] + }, + { + "id": 6, + "title": "JVM Heap Used", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 13 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "jvm_memory_used_bytes{area=\"heap\"}", + "legendFormat": "{{id}} used" + }, + { + "expr": "jvm_memory_committed_bytes{area=\"heap\"}", + "legendFormat": "{{id}} committed" + } + ], + "fieldConfig": { + "defaults": { "unit": "bytes", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 7, + "title": "JVM Heap Summary", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 13 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(jvm_memory_used_bytes{area=\"heap\"})", + "legendFormat": "used" + }, + { + "expr": "sum(jvm_memory_committed_bytes{area=\"heap\"})", + "legendFormat": "committed" + }, + { + "expr": "sum(jvm_memory_max_bytes{area=\"heap\"})", + "legendFormat": "max" + } + ], + "fieldConfig": { + "defaults": { "unit": "bytes", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 8, + "title": "JVM Non-Heap Used", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 29 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "jvm_memory_used_bytes{area=\"nonheap\"}", + "legendFormat": "{{id}} used" + } + ], + "fieldConfig": { + "defaults": { "unit": "bytes", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 9, + "title": "GC Pause Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 21 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(jvm_gc_pause_seconds_sum[1m])", + "legendFormat": "{{action}} {{cause}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "s", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 10, + "title": "GC Count / min", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 21 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(jvm_gc_pause_seconds_count[1m]) * 60", + "legendFormat": "{{action}} {{cause}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "short", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 11, + "title": "Thread Count Over Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 29 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "jvm_threads_live_threads", + "legendFormat": "live" + }, + { + "expr": "jvm_threads_daemon_threads", + "legendFormat": "daemon" + }, + { + "expr": "jvm_threads_peak_threads", + "legendFormat": "peak" + } + ], + "fieldConfig": { + "defaults": { "unit": "short" } + } + }, + { + "id": 12, + "title": "Tomcat & System", + "type": "row", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 3 }, + "collapsed": false, + "panels": [] + }, + { + "id": 13, + "title": "Tomcat Threads", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "tomcat_threads_current_threads", + "legendFormat": "current" + }, + { + "expr": "tomcat_threads_busy_threads", + "legendFormat": "busy" + }, + { + "expr": "tomcat_threads_config_max_threads", + "legendFormat": "max" + } + ], + "fieldConfig": { + "defaults": { "unit": "short" } + } + }, + { + "id": 14, + "title": "CPU Usage", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "process_cpu_usage", + "legendFormat": "app CPU" + }, + { + "expr": "system_cpu_usage", + "legendFormat": "system CPU" + } + ], + "fieldConfig": { + "defaults": { "unit": "percentunit", "max": 1, "custom": { "fillOpacity": 10 } } + } + } + ], + "schemaVersion": 39 +} diff --git a/infra/local/monitoring/grafana/provisioning/dashboards/logs-explorer.json b/infra/local/monitoring/grafana/provisioning/dashboards/logs-explorer.json new file mode 100644 index 00000000..9b7f8669 --- /dev/null +++ b/infra/local/monitoring/grafana/provisioning/dashboards/logs-explorer.json @@ -0,0 +1,403 @@ +{ + "uid": "weeth-logs-explorer", + "title": "Logs Explorer", + "tags": [ + "weeth", + "logs" + ], + "timezone": "Asia/Seoul", + "refresh": "30s", + "time": { + "from": "now-30m", + "to": "now" + }, + "templating": { + "list": [ + { + "name": "log_type", + "type": "custom", + "current": { + "text": "All", + "value": "$__all" + }, + "options": [ + { + "text": "All", + "value": "$__all", + "selected": true + }, + { + "text": "access", + "value": "access" + }, + { + "text": "audit", + "value": "audit" + }, + { + "text": "error", + "value": "error" + }, + { + "text": "application", + "value": "application" + } + ], + "includeAll": true, + "multi": false, + "query": "access,audit,error,application" + }, + { + "name": "level", + "type": "custom", + "current": { + "text": "All", + "value": "$__all" + }, + "options": [ + { + "text": "All", + "value": "$__all", + "selected": true + }, + { + "text": "ERROR", + "value": "ERROR" + }, + { + "text": "WARN", + "value": "WARN" + }, + { + "text": "INFO", + "value": "INFO" + }, + { + "text": "DEBUG", + "value": "DEBUG" + } + ], + "includeAll": true, + "multi": false, + "query": "ERROR,WARN,INFO,DEBUG" + }, + { + "name": "search", + "type": "textbox", + "current": { + "text": "", + "value": "" + }, + "label": "Search" + }, + { + "name": "requestId", + "type": "textbox", + "current": { + "text": "", + "value": "" + }, + "label": "Request ID" + } + ] + }, + "panels": [ + { + "id": 1, + "title": "Log Volume", + "type": "timeseries", + "gridPos": { + "h": 5, + "w": 24, + "x": 0, + "y": 0 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "sum by(level) (count_over_time({app=\"weeth\", log_type=~\"$log_type\", level=~\"$level\"} [$__auto]))", + "legendFormat": "{{level}}" + } + ], + "fieldConfig": { + "defaults": { + "custom": { + "fillOpacity": 30, + "stacking": { + "mode": "normal" + } + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "ERROR" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "WARN" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "INFO" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "DEBUG" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + } + ] + } + }, + { + "id": 2, + "title": "Live Logs", + "type": "logs", + "gridPos": { + "h": 14, + "w": 24, + "x": 0, + "y": 41 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "{app=\"weeth\", log_type=~\"$log_type\", level=~\"$level\"} | json | line_format \"{{.method}} {{.path}} {{.status}} {{.durationMs}}ms {{.message}}\" |= `$search` |= `$requestId`" + } + ], + "options": { + "showTime": true, + "showLabels": true, + "showCommonLabels": false, + "wrapLogMessage": true, + "prettifyLogMessage": false, + "enableLogDetails": true, + "sortOrder": "Descending", + "dedupStrategy": "none" + } + }, + { + "id": 4, + "title": "Error Logs", + "type": "logs", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 31 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "{app=\"weeth\", log_type=\"error\"} | json" + } + ], + "options": { + "showTime": true, + "showLabels": true, + "wrapLogMessage": true, + "enableLogDetails": true, + "sortOrder": "Descending" + } + }, + { + "id": 5, + "title": "Access Logs", + "type": "logs", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 21 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "{app=\"weeth\", log_type=\"access\"} | json" + } + ], + "options": { + "showTime": true, + "showLabels": true, + "wrapLogMessage": true, + "enableLogDetails": true, + "sortOrder": "Descending" + } + }, + { + "id": 6, + "title": "Error Rate Over Time", + "type": "timeseries", + "gridPos": { + "h": 6, + "w": 8, + "x": 0, + "y": 5 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "sum(count_over_time({app=\"weeth\", level=\"ERROR\"} [$__auto])) or vector(0)", + "legendFormat": "errors" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "red", + "mode": "fixed" + }, + "custom": { + "fillOpacity": 20 + } + } + } + }, + { + "id": 7, + "title": "Warn Rate Over Time", + "type": "timeseries", + "gridPos": { + "h": 6, + "w": 8, + "x": 8, + "y": 5 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "sum(count_over_time({app=\"weeth\", level=\"WARN\"} [$__auto])) or vector(0)", + "legendFormat": "warnings" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "yellow", + "mode": "fixed" + }, + "custom": { + "fillOpacity": 20 + } + } + } + }, + { + "id": 8, + "title": "HTTP Status Over Time", + "type": "timeseries", + "gridPos": { + "h": 6, + "w": 8, + "x": 16, + "y": 5 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "sum by(status) (count_over_time({app=\"weeth\", log_type=\"access\"} | json [$__auto]))", + "legendFormat": "{{status}}" + } + ], + "fieldConfig": { + "defaults": { + "custom": { + "fillOpacity": 20 + } + } + } + }, + { + "id": 9, + "title": "Slow Requests (> 1s)", + "type": "logs", + "gridPos": { + "h": 14, + "w": 24, + "x": 0, + "y": 55 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "{app=\"weeth\", log_type=\"access\"} | json | durationMs > 1000" + } + ], + "options": { + "showTime": true, + "showLabels": true, + "wrapLogMessage": true, + "enableLogDetails": true, + "sortOrder": "Descending" + } + } + ], + "schemaVersion": 39 +} diff --git a/infra/local/monitoring/grafana/provisioning/dashboards/trace-explorer.json b/infra/local/monitoring/grafana/provisioning/dashboards/trace-explorer.json new file mode 100644 index 00000000..8be83176 --- /dev/null +++ b/infra/local/monitoring/grafana/provisioning/dashboards/trace-explorer.json @@ -0,0 +1,513 @@ +{ + "uid": "weeth-trace-explorer", + "title": "Trace Explorer", + "tags": [ + "weeth", + "trace" + ], + "timezone": "Asia/Seoul", + "refresh": "10s", + "time": { + "from": "now-6h", + "to": "now" + }, + "templating": { + "list": [ + { + "name": "traceId", + "type": "textbox", + "current": { + "text": "", + "value": "" + }, + "label": "Trace ID" + } + ] + }, + "panels": [ + { + "id": 1, + "title": "All Traces", + "description": "최근 전체 트레이스 목록입니다. actuator/health-check는 애플리케이션 observation 필터에서 제외합니다.", + "type": "table", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 0 + }, + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "targets": [ + { + "queryType": "traceql", + "query": "{}", + "limit": 200, + "tableType": "traces", + "refId": "A" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + }, + "indexByName": { + "Service": 0, + "TraceId": 1, + "Name": 2, + "StartTime": 3, + "Duration": 4, + "Service Name": 0, + "Trace ID": 1, + "Start time": 3, + "duration": 4, + "rootServiceName": 0, + "traceID": 1, + "rootTraceName": 2, + "startTime": 3 + }, + "renameByName": { + "Trace ID": "TraceId", + "TraceID": "TraceId", + "traceID": "TraceId", + "traceId": "TraceId", + "Service Name": "Service", + "Root Service Name": "Service", + "rootServiceName": "Service", + "serviceName": "Service", + "Root Trace Name": "Name", + "Trace Name": "Name", + "rootTraceName": "Name", + "name": "Name", + "Start time": "StartTime", + "Start Time": "StartTime", + "startTime": "StartTime", + "duration": "Duration", + "Duration": "Duration" + } + } + } + ], + "fieldConfig": { + "defaults": { + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "TraceId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Trace ID" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "traceID" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "traceId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + } + ] + } + }, + { + "id": 6, + "title": "Trace Search", + "description": "Tempo Explore에서 TraceQL로 검색합니다.", + "type": "text", + "gridPos": { + "h": 4, + "w": 24, + "x": 0, + "y": 10 + }, + "options": { + "mode": "markdown", + "content": "TraceId를 선택하면 Tempo Explore에서 해당 TraceId를 바로 조회합니다.\n\n기본 조회 범위는 최근 6시간, 목록 제한은 200건입니다." + } + }, + { + "id": 4, + "title": "Error Traces", + "description": "에러가 발생한 트레이스를 검색합니다.", + "type": "table", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 14 + }, + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "targets": [ + { + "queryType": "traceql", + "query": "{ status = error }", + "limit": 200, + "tableType": "traces", + "refId": "A" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + }, + "indexByName": { + "Service": 0, + "TraceId": 1, + "Name": 2, + "StartTime": 3, + "Duration": 4, + "Service Name": 0, + "Trace ID": 1, + "Start time": 3, + "duration": 4, + "rootServiceName": 0, + "traceID": 1, + "rootTraceName": 2, + "startTime": 3 + }, + "renameByName": { + "Trace ID": "TraceId", + "TraceID": "TraceId", + "traceID": "TraceId", + "traceId": "TraceId", + "Service Name": "Service", + "Root Service Name": "Service", + "rootServiceName": "Service", + "serviceName": "Service", + "Root Trace Name": "Name", + "Trace Name": "Name", + "rootTraceName": "Name", + "name": "Name", + "Start time": "StartTime", + "Start Time": "StartTime", + "startTime": "StartTime", + "duration": "Duration", + "Duration": "Duration" + } + } + } + ], + "fieldConfig": { + "defaults": { + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "TraceId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Trace ID" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "traceID" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "traceId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + } + ] + } + }, + { + "id": 3, + "title": "Slow Traces (> 500ms)", + "description": "500ms 이상 걸린 요청의 트레이스를 자동 검색합니다.", + "type": "table", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 24 + }, + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "targets": [ + { + "queryType": "traceql", + "query": "{ duration > 500ms }", + "limit": 200, + "tableType": "traces", + "refId": "A" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + }, + "indexByName": { + "Service": 0, + "TraceId": 1, + "Name": 2, + "StartTime": 3, + "Duration": 4, + "Service Name": 0, + "Trace ID": 1, + "Start time": 3, + "duration": 4, + "rootServiceName": 0, + "traceID": 1, + "rootTraceName": 2, + "startTime": 3 + }, + "renameByName": { + "Trace ID": "TraceId", + "TraceID": "TraceId", + "traceID": "TraceId", + "traceId": "TraceId", + "Service Name": "Service", + "Root Service Name": "Service", + "rootServiceName": "Service", + "serviceName": "Service", + "Root Trace Name": "Name", + "Trace Name": "Name", + "rootTraceName": "Name", + "name": "Name", + "Start time": "StartTime", + "Start Time": "StartTime", + "startTime": "StartTime", + "duration": "Duration", + "Duration": "Duration" + } + } + } + ], + "fieldConfig": { + "defaults": { + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "TraceId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Trace ID" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "traceID" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "traceId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + } + ] + } + }, + { + "id": 5, + "title": "Related Logs (by Trace ID)", + "description": "Trace ID를 입력하면 해당 트레이스와 연관된 로그를 확인합니다.", + "type": "logs", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 34 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "{app=\"weeth\"} |= `$traceId` | json", + "refId": "A" + } + ], + "options": { + "showTime": true, + "showLabels": true, + "wrapLogMessage": true, + "enableLogDetails": true, + "sortOrder": "Descending" + } + } + ], + "schemaVersion": 39 +} diff --git a/infra/local/monitoring/grafana/provisioning/datasources/datasources.yaml b/infra/local/monitoring/grafana/provisioning/datasources/datasources.yaml new file mode 100644 index 00000000..f6118e0a --- /dev/null +++ b/infra/local/monitoring/grafana/provisioning/datasources/datasources.yaml @@ -0,0 +1,31 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + uid: prometheus + isDefault: true + editable: true + + - name: Tempo + type: tempo + access: proxy + url: http://tempo:3200 + uid: tempo + editable: true + + - name: Loki + type: loki + access: proxy + url: http://loki:3100 + uid: loki + editable: true + jsonData: + derivedFields: + - datasourceUid: tempo + matcherRegex: '"(?:traceId|trace_id|mdc_traceId|mdc_trace_id)"\s*:\s*"([^"]+)"' + name: traceId + url: "$${__value.raw}" + urlDisplayLabel: "View Trace" diff --git a/infra/local/monitoring/loki/loki-config.yaml b/infra/local/monitoring/loki/loki-config.yaml new file mode 100644 index 00000000..857590d3 --- /dev/null +++ b/infra/local/monitoring/loki/loki-config.yaml @@ -0,0 +1,41 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + +common: + ring: + kvstore: + store: inmemory + replication_factor: 1 + path_prefix: /loki + +schema_config: + configs: + - from: "2026-04-15" + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: loki_index_ + period: 24h + +storage_config: + tsdb_shipper: + active_index_directory: /loki/index + cache_location: /loki/cache + filesystem: + directory: /loki/chunks + +limits_config: + retention_period: 3d + max_label_names_per_series: 5 + ingestion_rate_mb: 10 + ingestion_burst_size_mb: 20 + +compactor: + working_directory: /loki/compactor + compaction_interval: 10m + retention_enabled: true + retention_delete_delay: 2h + delete_request_store: filesystem diff --git a/infra/local/monitoring/prometheus/prometheus.yml b/infra/local/monitoring/prometheus/prometheus.yml new file mode 100644 index 00000000..1f861010 --- /dev/null +++ b/infra/local/monitoring/prometheus/prometheus.yml @@ -0,0 +1,18 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: "weeth-app" + metrics_path: "/actuator/prometheus" + static_configs: + - targets: ["host.docker.internal:8080"] + labels: + app: weeth + env: local + + - job_name: "redis" + static_configs: + - targets: ["redis-exporter:9121"] + labels: + env: local diff --git a/infra/local/monitoring/tempo/tempo-config.yaml b/infra/local/monitoring/tempo/tempo-config.yaml new file mode 100644 index 00000000..da5ddce8 --- /dev/null +++ b/infra/local/monitoring/tempo/tempo-config.yaml @@ -0,0 +1,32 @@ +stream_over_http_enabled: true + +server: + http_listen_port: 3200 + +distributor: + receivers: + otlp: + protocols: + grpc: + endpoint: "0.0.0.0:4317" + +storage: + trace: + backend: local + wal: + path: /var/tempo/wal + local: + path: /var/tempo/blocks + +compactor: + compaction: + block_retention: 72h + +metrics_generator: + storage: + path: /var/tempo/generator/wal + registry: + external_labels: + source: tempo + traces_storage: + path: /var/tempo/generator/traces diff --git a/infra/prod/caddy/Caddyfile b/infra/prod/caddy/Caddyfile index 6f84aa2d..9572b864 100644 --- a/infra/prod/caddy/Caddyfile +++ b/infra/prod/caddy/Caddyfile @@ -23,6 +23,23 @@ Referrer-Policy "strict-origin-when-cross-origin" } + # /actuator/** 외부 접근 차단 (Prometheus는 Docker 내부 네트워크로 직접 스크래핑) + handle /actuator/health { + import /etc/caddy/upstream.conf + } + handle /actuator { + respond 404 + } + handle /actuator/* { + respond 404 + } + + redir {$MONITORING_PATH} {$MONITORING_PATH}/ + + handle {$MONITORING_PATH}* { + reverse_proxy grafana:3000 + } + # 실제 reverse_proxy 설정은 upstream.conf 파일에서 불러옴 import /etc/caddy/upstream.conf } diff --git a/infra/prod/docker-compose.yml b/infra/prod/docker-compose.yml index 0c779c5b..82a13703 100644 --- a/infra/prod/docker-compose.yml +++ b/infra/prod/docker-compose.yml @@ -15,6 +15,7 @@ services: - caddy_config:/config environment: DOMAIN: ${DOMAIN} + MONITORING_PATH: ${MONITORING_PATH} networks: - web @@ -39,6 +40,16 @@ services: environment: SPRING_PROFILES_ACTIVE: prod TZ: Asia/Seoul + OTEL_JAVAAGENT_ENABLED: ${OTEL_JAVAAGENT_ENABLED:-true} + OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-weeth-server} + OTEL_RESOURCE_ATTRIBUTES: ${OTEL_RESOURCE_ATTRIBUTES:-deployment.environment=prod} + OTEL_TRACES_EXPORTER: ${OTEL_TRACES_EXPORTER:-otlp} + OTEL_TRACES_SAMPLER: ${OTEL_TRACES_SAMPLER:-parentbased_traceidratio} + OTEL_TRACES_SAMPLER_ARG: ${OTEL_TRACES_SAMPLER_ARG:-0.1} + OTEL_METRICS_EXPORTER: ${OTEL_METRICS_EXPORTER:-none} + OTEL_LOGS_EXPORTER: ${OTEL_LOGS_EXPORTER:-none} + OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://alloy:4318} + OTEL_EXPORTER_OTLP_PROTOCOL: ${OTEL_EXPORTER_OTLP_PROTOCOL:-http/protobuf} volumes: - ${HOME}/keys:/app/keys:ro ports: @@ -46,6 +57,11 @@ services: depends_on: redis: condition: service_started + logging: + driver: json-file + options: + max-size: "100m" + max-file: "5" networks: - web @@ -59,6 +75,16 @@ services: environment: SPRING_PROFILES_ACTIVE: prod TZ: Asia/Seoul + OTEL_JAVAAGENT_ENABLED: ${OTEL_JAVAAGENT_ENABLED:-true} + OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-weeth-server} + OTEL_RESOURCE_ATTRIBUTES: ${OTEL_RESOURCE_ATTRIBUTES:-deployment.environment=prod} + OTEL_TRACES_EXPORTER: ${OTEL_TRACES_EXPORTER:-otlp} + OTEL_TRACES_SAMPLER: ${OTEL_TRACES_SAMPLER:-parentbased_traceidratio} + OTEL_TRACES_SAMPLER_ARG: ${OTEL_TRACES_SAMPLER_ARG:-0.1} + OTEL_METRICS_EXPORTER: ${OTEL_METRICS_EXPORTER:-none} + OTEL_LOGS_EXPORTER: ${OTEL_LOGS_EXPORTER:-none} + OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://alloy:4318} + OTEL_EXPORTER_OTLP_PROTOCOL: ${OTEL_EXPORTER_OTLP_PROTOCOL:-http/protobuf} volumes: - ${HOME}/keys:/app/keys:ro ports: @@ -66,6 +92,11 @@ services: depends_on: redis: condition: service_started + logging: + driver: json-file + options: + max-size: "100m" + max-file: "5" networks: - web diff --git a/infra/prod/monitoring/alloy/config.alloy b/infra/prod/monitoring/alloy/config.alloy new file mode 100644 index 00000000..497184aa --- /dev/null +++ b/infra/prod/monitoring/alloy/config.alloy @@ -0,0 +1,76 @@ +discovery.docker "weeth" { + host = "unix:///var/run/docker.sock" + + filter { + name = "label" + values = ["com.docker.compose.service=app-blue", "com.docker.compose.service=app-green"] + } +} + +loki.source.docker "app_logs" { + host = "unix:///var/run/docker.sock" + targets = discovery.docker.weeth.targets + forward_to = [loki.process.parse_json.receiver] +} + +loki.process "parse_json" { + stage.static_labels { + values = { app = "weeth", env = "prod" } + } + + stage.json { + expressions = { + level = "level", + logger_name = "logger_name", + } + } + + stage.labels { + values = { level = "", logger_name = "" } + } + + stage.match { + selector = "{logger_name=\"ACCESS_LOG\"}" + stage.static_labels { values = { log_type = "access" } } + } + stage.match { + selector = "{logger_name=\"AUDIT_LOG\"}" + stage.static_labels { values = { log_type = "audit" } } + } + stage.match { + selector = "{logger_name=\"ERROR_LOG\"}" + stage.static_labels { values = { log_type = "error" } } + } + stage.match { + selector = "{logger_name!=\"ACCESS_LOG\", logger_name!=\"AUDIT_LOG\", logger_name!=\"ERROR_LOG\"}" + stage.static_labels { values = { log_type = "application" } } + } + + stage.label_drop { values = ["logger_name"] } + + forward_to = [loki.write.default.receiver] +} + +loki.write "default" { + endpoint { + url = "http://loki:3100/loki/api/v1/push" + } +} + +otelcol.receiver.otlp "default" { + http { + endpoint = "0.0.0.0:4318" + } + output { + traces = [otelcol.exporter.otlp.tempo.input] + } +} + +otelcol.exporter.otlp "tempo" { + client { + endpoint = "tempo:4317" + tls { + insecure = true + } + } +} diff --git a/infra/prod/monitoring/docker-compose.yml b/infra/prod/monitoring/docker-compose.yml new file mode 100644 index 00000000..b82e4e30 --- /dev/null +++ b/infra/prod/monitoring/docker-compose.yml @@ -0,0 +1,139 @@ +name: weeth-prod-monitoring + +services: + alloy: + image: grafana/alloy:v1.9.0 + env_file: + - ${MONITORING_ENV_FILE:-../.env.monitoring} + volumes: + - ./alloy/config.alloy:/etc/alloy/config.alloy:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + - /var/lib/docker/containers:/var/lib/docker/containers:ro + command: ["run", "--server.http.listen-addr=0.0.0.0:12345", "/etc/alloy/config.alloy"] + ports: + - "127.0.0.1:12345:12345" + - "127.0.0.1:4318:4318" + depends_on: + - loki + - tempo + networks: + - monitoring + - weeth-app + restart: unless-stopped + + loki: + image: grafana/loki:3.4.2 + env_file: + - ${MONITORING_ENV_FILE:-../.env.monitoring} + volumes: + - ./loki/loki-config.yaml:/etc/loki/loki-config.yaml:ro + - loki_data:/loki + command: ["-config.file=/etc/loki/loki-config.yaml", "-config.expand-env=true"] + ports: + - "127.0.0.1:3100:3100" + networks: + - monitoring + restart: unless-stopped + + tempo: + image: grafana/tempo:2.7.1 + env_file: + - ${MONITORING_ENV_FILE:-../.env.monitoring} + volumes: + - ./tempo/tempo-config.yaml:/etc/tempo/tempo-config.yaml:ro + - tempo_data:/var/tempo + command: ["-config.file=/etc/tempo/tempo-config.yaml", "-config.expand-env=true"] + ports: + - "127.0.0.1:3200:3200" + networks: + - monitoring + restart: unless-stopped + + redis-exporter: + image: oliver006/redis_exporter:v1.67.0 + environment: + REDIS_ADDR: redis:6379 + REDIS_PASSWORD: ${REDIS_PASSWORD:-} + networks: + - monitoring + - weeth-app + restart: unless-stopped + + prometheus: + image: prom/prometheus:v2.53.0 + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus_data:/prometheus + ports: + - "127.0.0.1:9090:9090" + networks: + - monitoring + - weeth-app + restart: unless-stopped + + node-exporter: + image: prom/node-exporter:v1.9.0 + pid: host + volumes: + - /proc:/host/proc:ro + - /sys:/host/sys:ro + - /:/rootfs:ro + command: + - "--path.procfs=/host/proc" + - "--path.sysfs=/host/sys" + - "--path.rootfs=/rootfs" + - "--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)" + networks: + - monitoring + restart: unless-stopped + + cadvisor: + image: gcr.io/cadvisor/cadvisor:v0.51.0 + privileged: true + devices: + - /dev/kmsg + volumes: + - /:/rootfs:ro + - /var/run:/var/run:ro + - /sys:/sys:ro + - /var/lib/docker:/var/lib/docker:ro + - /dev/disk:/dev/disk:ro + networks: + - monitoring + - weeth-app + restart: unless-stopped + + grafana: + image: grafana/grafana:11.5.2 + env_file: + - ${MONITORING_ENV_FILE:-../.env.monitoring} + volumes: + - grafana_data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning:ro + environment: + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD} + GF_SERVER_ROOT_URL: ${GRAFANA_ROOT_URL:-https://${DOMAIN}${MONITORING_PATH}/} + GF_SERVER_SERVE_FROM_SUB_PATH: "true" + GF_AUTH_ANONYMOUS_ENABLED: "false" + ports: + - "127.0.0.1:3000:3000" + depends_on: + - loki + - prometheus + - tempo + networks: + - monitoring + restart: unless-stopped + +networks: + monitoring: + driver: bridge + weeth-app: + external: true + name: weeth-prod_web + +volumes: + grafana_data: + loki_data: + prometheus_data: + tempo_data: diff --git a/infra/prod/monitoring/grafana/provisioning/dashboards/api-overview.json b/infra/prod/monitoring/grafana/provisioning/dashboards/api-overview.json new file mode 100644 index 00000000..f7cf5807 --- /dev/null +++ b/infra/prod/monitoring/grafana/provisioning/dashboards/api-overview.json @@ -0,0 +1,388 @@ +{ + "uid": "weeth-api-overview", + "title": "API Overview", + "tags": ["weeth", "api"], + "timezone": "Asia/Seoul", + "refresh": "10s", + "time": { "from": "now-1h", "to": "now" }, + "templating": { + "list": [ + { + "name": "uri", + "type": "query", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "query": "label_values(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\"}, uri)", + "includeAll": true, + "multi": true, + "current": { "text": "All", "value": "$__all" }, + "refresh": 2 + } + ] + }, + "panels": [ + { + "id": 1, + "title": "Total Requests / min", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 15, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1m])) * 60", + "legendFormat": "req/min" + } + ], + "fieldConfig": { + "defaults": { + "unit": "reqpm", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 100 }, + { "color": "red", "value": 500 } + ] + } + } + } + }, + { + "id": 2, + "title": "5xx Error Rate", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 0, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "((sum(rate(http_server_requests_seconds_count{status=~\"5..\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])) or vector(0)) / clamp_min(sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])), 0.001)) * 100", + "legendFormat": "5xx %" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "red", "value": 5 } + ] + } + } + } + }, + { + "id": 3, + "title": "4xx Error Rate", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 3, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "((sum(rate(http_server_requests_seconds_count{status=~\"4..\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])) or vector(0)) / clamp_min(sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])), 0.001)) * 100", + "legendFormat": "4xx %" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 5 }, + { "color": "red", "value": 20 } + ] + } + } + } + }, + { + "id": 16, + "title": "Avg Response Time", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 6, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_sum{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])) / clamp_min(sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])), 0.001)", + "legendFormat": "avg" + } + ], + "fieldConfig": { + "defaults": { + "unit": "s", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 0.3 }, + { "color": "red", "value": 0.5 } + ] + } + } + } + }, + { + "id": 4, + "title": "Apdex (0.5s)", + "type": "stat", + "gridPos": { "h": 4, "w": 6, "x": 18, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "description": "Apdex score (사용자 체감 만족도). 1.0=최고, >0.94=훌륭함, >0.85=좋음, >0.7=나쁨, <0.7=매우 나쁨", + "targets": [ + { + "expr": "(\n sum(rate(http_server_requests_seconds_bucket{le=\"0.5\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))\n + (\n sum(rate(http_server_requests_seconds_bucket{le=\"2.0\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))\n - sum(rate(http_server_requests_seconds_bucket{le=\"0.5\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))\n ) / 2\n) / sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))", + "legendFormat": "Apdex" + } + ], + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 2, + "min": 0, + "max": 1, + "thresholds": { + "steps": [ + { "color": "red", "value": null }, + { "color": "orange", "value": 0.7 }, + { "color": "yellow", "value": 0.85 }, + { "color": "green", "value": 0.94 } + ] + } + } + } + }, + { + "id": 5, + "title": "P95 Response Time", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 9, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])) by (le))", + "legendFormat": "P95" + } + ], + "fieldConfig": { + "defaults": { + "unit": "s", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 0.5 }, + { "color": "red", "value": 1 } + ] + } + } + } + }, + { + "id": 6, + "title": "P99 Response Time", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 12, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "histogram_quantile(0.99, sum(rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])) by (le))", + "legendFormat": "P99" + } + ], + "fieldConfig": { + "defaults": { + "unit": "s", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "red", "value": 2 } + ] + } + } + } + }, + { + "id": 7, + "title": "Requests per Second", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1m]))", + "legendFormat": "total" + }, + { + "expr": "sum(rate(http_server_requests_seconds_count{status=~\"2..\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1m])) or vector(0)", + "legendFormat": "2xx" + }, + { + "expr": "sum(rate(http_server_requests_seconds_count{status=~\"4..\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1m])) or vector(0)", + "legendFormat": "4xx" + }, + { + "expr": "sum(rate(http_server_requests_seconds_count{status=~\"5..\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1m])) or vector(0)", + "legendFormat": "5xx" + } + ], + "fieldConfig": { + "defaults": { "unit": "reqps", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 8, + "title": "Response Time Distribution", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "description": "엔드포인트별 P50/P95/P99 응답시간 추이. 기본(All)은 전체 엔드포인트별 분포를 표시하고, 상단 uri 필터에서 특정 엔드포인트를 선택하면 해당 API만 표시됩니다.", + "targets": [ + { + "expr": "histogram_quantile(0.50, sum by (le) (rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])))", + "legendFormat": "P50" + }, + { + "expr": "histogram_quantile(0.95, sum by (le) (rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])))", + "legendFormat": "P95" + }, + { + "expr": "histogram_quantile(0.99, sum by (le) (rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m])))", + "legendFormat": "P99" + } + ], + "fieldConfig": { + "defaults": { "unit": "s", "custom": { "fillOpacity": 5 } } + } + }, + { + "id": 9, + "title": "HTTP Status Code Distribution", + "type": "piechart", + "gridPos": { "h": 8, "w": 8, "x": 16, "y": 12 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum by(status) (increase(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1h]))", + "legendFormat": "{{status}}" + } + ], + "fieldConfig": { + "overrides": [ + { "matcher": { "id": "byRegexp", "options": "^2\\d{2}$" }, "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }] }, + { "matcher": { "id": "byRegexp", "options": "^3\\d{2}$" }, "properties": [{ "id": "color", "value": { "fixedColor": "blue", "mode": "fixed" } }] }, + { "matcher": { "id": "byRegexp", "options": "^4\\d{2}$" }, "properties": [{ "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }] }, + { "matcher": { "id": "byRegexp", "options": "^5\\d{2}$" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] } + ] + } + }, + { + "id": 10, + "title": "Slowest Endpoints (P95)", + "type": "bargauge", + "gridPos": { "h": 8, "w": 8, "x": 8, "y": 12 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "topk(10, histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{uri!~\"/actuator.*|/health-check\"}[5m])) by (le, uri)))", + "legendFormat": "{{uri}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "s" } + }, + "options": { + "orientation": "horizontal", + "displayMode": "gradient" + } + }, + { + "id": 11, + "title": "Top 10 Error Endpoints (5xx)", + "type": "bargauge", + "gridPos": { "h": 8, "w": 8, "x": 0, "y": 12 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "topk(10, sum by(uri) (rate(http_server_requests_seconds_count{status=~\"5..\", uri!~\"/actuator.*|/health-check\"}[5m])))", + "legendFormat": "{{uri}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "reqps" } + }, + "options": { + "orientation": "horizontal", + "displayMode": "gradient" + } + }, + { + "id": 12, + "title": "Request Rate by Endpoint", + "type": "timeseries", + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 20 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum by(uri, method) (rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[1m]))", + "legendFormat": "{{method}} {{uri}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "reqps", "custom": { "fillOpacity": 5 } } + } + }, + { + "id": 13, + "title": "Top 10 High-Traffic Endpoints (4xx)", + "type": "bargauge", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 28 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "topk(10, sum by(uri) (rate(http_server_requests_seconds_count{status=~\"4..\", uri!~\"/actuator.*|/health-check\"}[5m])))", + "legendFormat": "{{uri}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "reqps" } + }, + "options": { + "orientation": "horizontal", + "displayMode": "gradient" + } + }, + { + "id": 14, + "title": "Apdex Over Time (0.5s)", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 28 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "description": "Apdex trend. T=0.5s (satisfied), 4T=2.0s (tolerating). Below 0.85 = degraded experience.", + "targets": [ + { + "expr": "(\n sum(rate(http_server_requests_seconds_bucket{le=\"0.5\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))\n + (\n sum(rate(http_server_requests_seconds_bucket{le=\"2.0\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))\n - sum(rate(http_server_requests_seconds_bucket{le=\"0.5\", uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))\n ) / 2\n) / sum(rate(http_server_requests_seconds_count{uri!~\"/actuator.*|/health-check\", uri=~\"$uri\"}[5m]))", + "legendFormat": "Apdex" + } + ], + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 2, + "min": 0, + "max": 1, + "custom": { "fillOpacity": 10 }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": null }, + { "color": "orange", "value": 0.7 }, + { "color": "yellow", "value": 0.85 }, + { "color": "green", "value": 0.94 } + ] + }, + "color": { "mode": "continuous-GrYlRd" } + } + } + } + ], + "schemaVersion": 39 +} diff --git a/infra/prod/monitoring/grafana/provisioning/dashboards/dashboards.yaml b/infra/prod/monitoring/grafana/provisioning/dashboards/dashboards.yaml new file mode 100644 index 00000000..2a76fbbd --- /dev/null +++ b/infra/prod/monitoring/grafana/provisioning/dashboards/dashboards.yaml @@ -0,0 +1,12 @@ +apiVersion: 1 + +providers: + - name: weeth + orgId: 1 + folder: Weeth + type: file + disableDeletion: false + editable: true + options: + path: /etc/grafana/provisioning/dashboards + foldersFromFilesStructure: false diff --git a/infra/prod/monitoring/grafana/provisioning/dashboards/external-infra.json b/infra/prod/monitoring/grafana/provisioning/dashboards/external-infra.json new file mode 100644 index 00000000..f38d9eb7 --- /dev/null +++ b/infra/prod/monitoring/grafana/provisioning/dashboards/external-infra.json @@ -0,0 +1,373 @@ +{ + "uid": "weeth-external-infra", + "title": "External Infrastructure", + "tags": ["weeth", "db", "redis"], + "timezone": "Asia/Seoul", + "refresh": "10s", + "time": { "from": "now-1h", "to": "now" }, + "panels": [ + { + "id": 1, + "title": "HikariCP (MySQL)", + "type": "row", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, + "collapsed": false, + "panels": [] + }, + { + "id": 2, + "title": "Active", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 0, "y": 1 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "hikaricp_connections_active", + "legendFormat": "active" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 5 }, + { "color": "red", "value": 8 } + ] + } + } + } + }, + { + "id": 3, + "title": "Idle", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 4, "y": 1 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "hikaricp_connections_idle", + "legendFormat": "idle" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { "color": "red", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "green", "value": 3 } + ] + } + } + } + }, + { + "id": 4, + "title": "Pending", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 8, "y": 1 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "hikaricp_connections_pending", + "legendFormat": "pending" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "red", "value": 3 } + ] + } + } + } + }, + { + "id": 5, + "title": "Total / Max", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 12, "y": 1 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "hikaricp_connections", + "legendFormat": "total" + }, + { + "expr": "hikaricp_connections_max", + "legendFormat": "max" + } + ] + }, + { + "id": 6, + "title": "Timeout Total", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 16, "y": 1 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "description": "커넥션 획득 timeout 누적 횟수. 0이 아니면 커넥션 풀 고갈 발생.", + "targets": [ + { + "expr": "hikaricp_connections_timeout_total", + "legendFormat": "timeouts" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 1 } + ] + } + } + } + }, + { + "id": 7, + "title": "Timeout Rate / min", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 20, "y": 1 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(hikaricp_connections_timeout_total[1m]) * 60", + "legendFormat": "timeouts/min" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 0.1 } + ] + } + } + } + }, + { + "id": 8, + "title": "Connection Pool Over Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 5 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "hikaricp_connections_active", + "legendFormat": "active" + }, + { + "expr": "hikaricp_connections_idle", + "legendFormat": "idle" + }, + { + "expr": "hikaricp_connections_pending", + "legendFormat": "pending" + }, + { + "expr": "hikaricp_connections", + "legendFormat": "total" + } + ], + "fieldConfig": { + "defaults": { "unit": "short", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 9, + "title": "Connection Acquire Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 5 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(hikaricp_connections_acquire_seconds_sum[1m]) / rate(hikaricp_connections_acquire_seconds_count[1m])", + "legendFormat": "avg acquire time" + }, + { + "expr": "hikaricp_connections_acquire_seconds_max", + "legendFormat": "max acquire time" + } + ], + "fieldConfig": { + "defaults": { "unit": "s", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 10, + "title": "Connection Creation Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 30 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(hikaricp_connections_creation_seconds_sum[1m]) / rate(hikaricp_connections_creation_seconds_count[1m])", + "legendFormat": "avg creation time" + }, + { + "expr": "hikaricp_connections_creation_seconds_max", + "legendFormat": "max creation time" + } + ], + "fieldConfig": { + "defaults": { "unit": "s", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 11, + "title": "Connection Usage Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 30 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(hikaricp_connections_usage_seconds_sum[1m]) / rate(hikaricp_connections_usage_seconds_count[1m])", + "legendFormat": "avg usage time" + }, + { + "expr": "hikaricp_connections_usage_seconds_max", + "legendFormat": "max usage time" + } + ], + "fieldConfig": { + "defaults": { "unit": "s", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 12, + "title": "Redis", + "type": "row", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 13 }, + "collapsed": false, + "panels": [] + }, + { + "id": 13, + "title": "Redis Command Rate", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 22 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(rate(lettuce_command_completion_seconds_count[1m]))", + "legendFormat": "commands/sec" + } + ], + "fieldConfig": { + "defaults": { "unit": "ops", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 14, + "title": "Redis Command Latency", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 14 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(lettuce_command_completion_seconds_sum[1m]) / rate(lettuce_command_completion_seconds_count[1m])", + "legendFormat": "avg latency" + }, + { + "expr": "lettuce_command_completion_seconds_max", + "legendFormat": "max latency" + } + ], + "fieldConfig": { + "defaults": { "unit": "s", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 15, + "title": "Redis Command by Type", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 22 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum by(command) (rate(lettuce_command_completion_seconds_count[1m]))", + "legendFormat": "{{command}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "ops", "custom": { "fillOpacity": 5 } } + } + }, + { + "id": 16, + "title": "Redis Command Errors", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 14 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(rate(lettuce_command_firstresponse_seconds_count{outcome=\"ERROR\"}[1m]))", + "legendFormat": "errors/sec" + } + ], + "fieldConfig": { + "defaults": { + "unit": "ops", + "custom": { "fillOpacity": 10 }, + "color": { "fixedColor": "red", "mode": "fixed" } + } + } + }, + { + "id": 17, + "title": "Redis Cache Hit Rate", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 30 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "description": "Redis 서버 전체 캐시 히트율. redis_exporter의 keyspace_hits/misses 기반.", + "targets": [ + { + "expr": "rate(redis_keyspace_hits_total[5m]) / clamp_min(rate(redis_keyspace_hits_total[5m]) + rate(redis_keyspace_misses_total[5m]), 0.001) * 100", + "legendFormat": "hit rate" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "min": 0, + "max": 100, + "custom": { "fillOpacity": 10 }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": null }, + { "color": "yellow", "value": 50 }, + { "color": "green", "value": 80 } + ] + } + } + } + }, + { + "id": 18, + "title": "Redis Cache Hits / Misses", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 30 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(redis_keyspace_hits_total[1m])", + "legendFormat": "hits/sec" + }, + { + "expr": "rate(redis_keyspace_misses_total[1m])", + "legendFormat": "misses/sec" + } + ], + "fieldConfig": { + "defaults": { "unit": "ops", "custom": { "fillOpacity": 10 } } + } + } + ], + "schemaVersion": 39 +} diff --git a/infra/prod/monitoring/grafana/provisioning/dashboards/internal-infra.json b/infra/prod/monitoring/grafana/provisioning/dashboards/internal-infra.json new file mode 100644 index 00000000..4395f758 --- /dev/null +++ b/infra/prod/monitoring/grafana/provisioning/dashboards/internal-infra.json @@ -0,0 +1,266 @@ +{ + "uid": "weeth-internal-infra", + "title": "Internal Infrastructure", + "tags": ["weeth", "jvm", "infra"], + "timezone": "Asia/Seoul", + "refresh": "10s", + "time": { "from": "now-1h", "to": "now" }, + "panels": [ + { + "id": 1, + "title": "Uptime", + "type": "stat", + "gridPos": { "h": 3, "w": 6, "x": 0, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "process_uptime_seconds", + "legendFormat": "uptime" + } + ], + "fieldConfig": { + "defaults": { "unit": "s" } + } + }, + { + "id": 2, + "title": "Heap Usage %", + "type": "stat", + "gridPos": { "h": 3, "w": 6, "x": 6, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(jvm_memory_used_bytes{area=\"heap\"}) / sum(jvm_memory_max_bytes{area=\"heap\"}) * 100", + "legendFormat": "heap %" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 70 }, + { "color": "red", "value": 90 } + ] + } + } + } + }, + { + "id": 3, + "title": "Live Threads", + "type": "stat", + "gridPos": { "h": 3, "w": 6, "x": 12, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "jvm_threads_live_threads", + "legendFormat": "live" + } + ] + }, + { + "id": 4, + "title": "App CPU", + "type": "stat", + "gridPos": { "h": 3, "w": 6, "x": 18, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "process_cpu_usage * 100", + "legendFormat": "CPU %" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 60 }, + { "color": "red", "value": 85 } + ] + } + } + } + }, + { + "id": 5, + "title": "JVM", + "type": "row", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 12 }, + "collapsed": false, + "panels": [] + }, + { + "id": 6, + "title": "JVM Heap Used", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 13 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "jvm_memory_used_bytes{area=\"heap\"}", + "legendFormat": "{{id}} used" + }, + { + "expr": "jvm_memory_committed_bytes{area=\"heap\"}", + "legendFormat": "{{id}} committed" + } + ], + "fieldConfig": { + "defaults": { "unit": "bytes", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 7, + "title": "JVM Heap Summary", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 13 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "sum(jvm_memory_used_bytes{area=\"heap\"})", + "legendFormat": "used" + }, + { + "expr": "sum(jvm_memory_committed_bytes{area=\"heap\"})", + "legendFormat": "committed" + }, + { + "expr": "sum(jvm_memory_max_bytes{area=\"heap\"})", + "legendFormat": "max" + } + ], + "fieldConfig": { + "defaults": { "unit": "bytes", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 8, + "title": "JVM Non-Heap Used", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 29 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "jvm_memory_used_bytes{area=\"nonheap\"}", + "legendFormat": "{{id}} used" + } + ], + "fieldConfig": { + "defaults": { "unit": "bytes", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 9, + "title": "GC Pause Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 21 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(jvm_gc_pause_seconds_sum[1m])", + "legendFormat": "{{action}} {{cause}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "s", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 10, + "title": "GC Count / min", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 21 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "rate(jvm_gc_pause_seconds_count[1m]) * 60", + "legendFormat": "{{action}} {{cause}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "short", "custom": { "fillOpacity": 10 } } + } + }, + { + "id": 11, + "title": "Thread Count Over Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 29 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "jvm_threads_live_threads", + "legendFormat": "live" + }, + { + "expr": "jvm_threads_daemon_threads", + "legendFormat": "daemon" + }, + { + "expr": "jvm_threads_peak_threads", + "legendFormat": "peak" + } + ], + "fieldConfig": { + "defaults": { "unit": "short" } + } + }, + { + "id": 12, + "title": "Tomcat & System", + "type": "row", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 3 }, + "collapsed": false, + "panels": [] + }, + { + "id": 13, + "title": "Tomcat Threads", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "tomcat_threads_current_threads", + "legendFormat": "current" + }, + { + "expr": "tomcat_threads_busy_threads", + "legendFormat": "busy" + }, + { + "expr": "tomcat_threads_config_max_threads", + "legendFormat": "max" + } + ], + "fieldConfig": { + "defaults": { "unit": "short" } + } + }, + { + "id": 14, + "title": "CPU Usage", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "process_cpu_usage", + "legendFormat": "app CPU" + }, + { + "expr": "system_cpu_usage", + "legendFormat": "system CPU" + } + ], + "fieldConfig": { + "defaults": { "unit": "percentunit", "max": 1, "custom": { "fillOpacity": 10 } } + } + } + ], + "schemaVersion": 39 +} diff --git a/infra/prod/monitoring/grafana/provisioning/dashboards/logs-explorer.json b/infra/prod/monitoring/grafana/provisioning/dashboards/logs-explorer.json new file mode 100644 index 00000000..9b7f8669 --- /dev/null +++ b/infra/prod/monitoring/grafana/provisioning/dashboards/logs-explorer.json @@ -0,0 +1,403 @@ +{ + "uid": "weeth-logs-explorer", + "title": "Logs Explorer", + "tags": [ + "weeth", + "logs" + ], + "timezone": "Asia/Seoul", + "refresh": "30s", + "time": { + "from": "now-30m", + "to": "now" + }, + "templating": { + "list": [ + { + "name": "log_type", + "type": "custom", + "current": { + "text": "All", + "value": "$__all" + }, + "options": [ + { + "text": "All", + "value": "$__all", + "selected": true + }, + { + "text": "access", + "value": "access" + }, + { + "text": "audit", + "value": "audit" + }, + { + "text": "error", + "value": "error" + }, + { + "text": "application", + "value": "application" + } + ], + "includeAll": true, + "multi": false, + "query": "access,audit,error,application" + }, + { + "name": "level", + "type": "custom", + "current": { + "text": "All", + "value": "$__all" + }, + "options": [ + { + "text": "All", + "value": "$__all", + "selected": true + }, + { + "text": "ERROR", + "value": "ERROR" + }, + { + "text": "WARN", + "value": "WARN" + }, + { + "text": "INFO", + "value": "INFO" + }, + { + "text": "DEBUG", + "value": "DEBUG" + } + ], + "includeAll": true, + "multi": false, + "query": "ERROR,WARN,INFO,DEBUG" + }, + { + "name": "search", + "type": "textbox", + "current": { + "text": "", + "value": "" + }, + "label": "Search" + }, + { + "name": "requestId", + "type": "textbox", + "current": { + "text": "", + "value": "" + }, + "label": "Request ID" + } + ] + }, + "panels": [ + { + "id": 1, + "title": "Log Volume", + "type": "timeseries", + "gridPos": { + "h": 5, + "w": 24, + "x": 0, + "y": 0 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "sum by(level) (count_over_time({app=\"weeth\", log_type=~\"$log_type\", level=~\"$level\"} [$__auto]))", + "legendFormat": "{{level}}" + } + ], + "fieldConfig": { + "defaults": { + "custom": { + "fillOpacity": 30, + "stacking": { + "mode": "normal" + } + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "ERROR" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "WARN" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "INFO" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "DEBUG" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + } + ] + } + }, + { + "id": 2, + "title": "Live Logs", + "type": "logs", + "gridPos": { + "h": 14, + "w": 24, + "x": 0, + "y": 41 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "{app=\"weeth\", log_type=~\"$log_type\", level=~\"$level\"} | json | line_format \"{{.method}} {{.path}} {{.status}} {{.durationMs}}ms {{.message}}\" |= `$search` |= `$requestId`" + } + ], + "options": { + "showTime": true, + "showLabels": true, + "showCommonLabels": false, + "wrapLogMessage": true, + "prettifyLogMessage": false, + "enableLogDetails": true, + "sortOrder": "Descending", + "dedupStrategy": "none" + } + }, + { + "id": 4, + "title": "Error Logs", + "type": "logs", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 31 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "{app=\"weeth\", log_type=\"error\"} | json" + } + ], + "options": { + "showTime": true, + "showLabels": true, + "wrapLogMessage": true, + "enableLogDetails": true, + "sortOrder": "Descending" + } + }, + { + "id": 5, + "title": "Access Logs", + "type": "logs", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 21 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "{app=\"weeth\", log_type=\"access\"} | json" + } + ], + "options": { + "showTime": true, + "showLabels": true, + "wrapLogMessage": true, + "enableLogDetails": true, + "sortOrder": "Descending" + } + }, + { + "id": 6, + "title": "Error Rate Over Time", + "type": "timeseries", + "gridPos": { + "h": 6, + "w": 8, + "x": 0, + "y": 5 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "sum(count_over_time({app=\"weeth\", level=\"ERROR\"} [$__auto])) or vector(0)", + "legendFormat": "errors" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "red", + "mode": "fixed" + }, + "custom": { + "fillOpacity": 20 + } + } + } + }, + { + "id": 7, + "title": "Warn Rate Over Time", + "type": "timeseries", + "gridPos": { + "h": 6, + "w": 8, + "x": 8, + "y": 5 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "sum(count_over_time({app=\"weeth\", level=\"WARN\"} [$__auto])) or vector(0)", + "legendFormat": "warnings" + } + ], + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "yellow", + "mode": "fixed" + }, + "custom": { + "fillOpacity": 20 + } + } + } + }, + { + "id": 8, + "title": "HTTP Status Over Time", + "type": "timeseries", + "gridPos": { + "h": 6, + "w": 8, + "x": 16, + "y": 5 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "sum by(status) (count_over_time({app=\"weeth\", log_type=\"access\"} | json [$__auto]))", + "legendFormat": "{{status}}" + } + ], + "fieldConfig": { + "defaults": { + "custom": { + "fillOpacity": 20 + } + } + } + }, + { + "id": 9, + "title": "Slow Requests (> 1s)", + "type": "logs", + "gridPos": { + "h": 14, + "w": 24, + "x": 0, + "y": 55 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "{app=\"weeth\", log_type=\"access\"} | json | durationMs > 1000" + } + ], + "options": { + "showTime": true, + "showLabels": true, + "wrapLogMessage": true, + "enableLogDetails": true, + "sortOrder": "Descending" + } + } + ], + "schemaVersion": 39 +} diff --git a/infra/prod/monitoring/grafana/provisioning/dashboards/trace-explorer.json b/infra/prod/monitoring/grafana/provisioning/dashboards/trace-explorer.json new file mode 100644 index 00000000..8be83176 --- /dev/null +++ b/infra/prod/monitoring/grafana/provisioning/dashboards/trace-explorer.json @@ -0,0 +1,513 @@ +{ + "uid": "weeth-trace-explorer", + "title": "Trace Explorer", + "tags": [ + "weeth", + "trace" + ], + "timezone": "Asia/Seoul", + "refresh": "10s", + "time": { + "from": "now-6h", + "to": "now" + }, + "templating": { + "list": [ + { + "name": "traceId", + "type": "textbox", + "current": { + "text": "", + "value": "" + }, + "label": "Trace ID" + } + ] + }, + "panels": [ + { + "id": 1, + "title": "All Traces", + "description": "최근 전체 트레이스 목록입니다. actuator/health-check는 애플리케이션 observation 필터에서 제외합니다.", + "type": "table", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 0 + }, + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "targets": [ + { + "queryType": "traceql", + "query": "{}", + "limit": 200, + "tableType": "traces", + "refId": "A" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + }, + "indexByName": { + "Service": 0, + "TraceId": 1, + "Name": 2, + "StartTime": 3, + "Duration": 4, + "Service Name": 0, + "Trace ID": 1, + "Start time": 3, + "duration": 4, + "rootServiceName": 0, + "traceID": 1, + "rootTraceName": 2, + "startTime": 3 + }, + "renameByName": { + "Trace ID": "TraceId", + "TraceID": "TraceId", + "traceID": "TraceId", + "traceId": "TraceId", + "Service Name": "Service", + "Root Service Name": "Service", + "rootServiceName": "Service", + "serviceName": "Service", + "Root Trace Name": "Name", + "Trace Name": "Name", + "rootTraceName": "Name", + "name": "Name", + "Start time": "StartTime", + "Start Time": "StartTime", + "startTime": "StartTime", + "duration": "Duration", + "Duration": "Duration" + } + } + } + ], + "fieldConfig": { + "defaults": { + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "TraceId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Trace ID" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "traceID" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "traceId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + } + ] + } + }, + { + "id": 6, + "title": "Trace Search", + "description": "Tempo Explore에서 TraceQL로 검색합니다.", + "type": "text", + "gridPos": { + "h": 4, + "w": 24, + "x": 0, + "y": 10 + }, + "options": { + "mode": "markdown", + "content": "TraceId를 선택하면 Tempo Explore에서 해당 TraceId를 바로 조회합니다.\n\n기본 조회 범위는 최근 6시간, 목록 제한은 200건입니다." + } + }, + { + "id": 4, + "title": "Error Traces", + "description": "에러가 발생한 트레이스를 검색합니다.", + "type": "table", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 14 + }, + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "targets": [ + { + "queryType": "traceql", + "query": "{ status = error }", + "limit": 200, + "tableType": "traces", + "refId": "A" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + }, + "indexByName": { + "Service": 0, + "TraceId": 1, + "Name": 2, + "StartTime": 3, + "Duration": 4, + "Service Name": 0, + "Trace ID": 1, + "Start time": 3, + "duration": 4, + "rootServiceName": 0, + "traceID": 1, + "rootTraceName": 2, + "startTime": 3 + }, + "renameByName": { + "Trace ID": "TraceId", + "TraceID": "TraceId", + "traceID": "TraceId", + "traceId": "TraceId", + "Service Name": "Service", + "Root Service Name": "Service", + "rootServiceName": "Service", + "serviceName": "Service", + "Root Trace Name": "Name", + "Trace Name": "Name", + "rootTraceName": "Name", + "name": "Name", + "Start time": "StartTime", + "Start Time": "StartTime", + "startTime": "StartTime", + "duration": "Duration", + "Duration": "Duration" + } + } + } + ], + "fieldConfig": { + "defaults": { + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "TraceId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Trace ID" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "traceID" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "traceId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + } + ] + } + }, + { + "id": 3, + "title": "Slow Traces (> 500ms)", + "description": "500ms 이상 걸린 요청의 트레이스를 자동 검색합니다.", + "type": "table", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 24 + }, + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "targets": [ + { + "queryType": "traceql", + "query": "{ duration > 500ms }", + "limit": 200, + "tableType": "traces", + "refId": "A" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + }, + "indexByName": { + "Service": 0, + "TraceId": 1, + "Name": 2, + "StartTime": 3, + "Duration": 4, + "Service Name": 0, + "Trace ID": 1, + "Start time": 3, + "duration": 4, + "rootServiceName": 0, + "traceID": 1, + "rootTraceName": 2, + "startTime": 3 + }, + "renameByName": { + "Trace ID": "TraceId", + "TraceID": "TraceId", + "traceID": "TraceId", + "traceId": "TraceId", + "Service Name": "Service", + "Root Service Name": "Service", + "rootServiceName": "Service", + "serviceName": "Service", + "Root Trace Name": "Name", + "Trace Name": "Name", + "rootTraceName": "Name", + "name": "Name", + "Start time": "StartTime", + "Start Time": "StartTime", + "startTime": "StartTime", + "duration": "Duration", + "Duration": "Duration" + } + } + } + ], + "fieldConfig": { + "defaults": { + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "TraceId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Trace ID" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "traceID" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "traceId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explore", + "url": "/explore?schemaVersion=1&panes=%7B%22trace%22:%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22traceId%22,%22query%22:%22${__value.raw}%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1", + "targetBlank": false + } + ] + } + ] + } + ] + } + }, + { + "id": 5, + "title": "Related Logs (by Trace ID)", + "description": "Trace ID를 입력하면 해당 트레이스와 연관된 로그를 확인합니다.", + "type": "logs", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 34 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "expr": "{app=\"weeth\"} |= `$traceId` | json", + "refId": "A" + } + ], + "options": { + "showTime": true, + "showLabels": true, + "wrapLogMessage": true, + "enableLogDetails": true, + "sortOrder": "Descending" + } + } + ], + "schemaVersion": 39 +} diff --git a/infra/prod/monitoring/grafana/provisioning/datasources/datasources.yaml b/infra/prod/monitoring/grafana/provisioning/datasources/datasources.yaml new file mode 100644 index 00000000..f6118e0a --- /dev/null +++ b/infra/prod/monitoring/grafana/provisioning/datasources/datasources.yaml @@ -0,0 +1,31 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + uid: prometheus + isDefault: true + editable: true + + - name: Tempo + type: tempo + access: proxy + url: http://tempo:3200 + uid: tempo + editable: true + + - name: Loki + type: loki + access: proxy + url: http://loki:3100 + uid: loki + editable: true + jsonData: + derivedFields: + - datasourceUid: tempo + matcherRegex: '"(?:traceId|trace_id|mdc_traceId|mdc_trace_id)"\s*:\s*"([^"]+)"' + name: traceId + url: "$${__value.raw}" + urlDisplayLabel: "View Trace" diff --git a/infra/prod/monitoring/loki/loki-config.yaml b/infra/prod/monitoring/loki/loki-config.yaml new file mode 100644 index 00000000..4dfe06ce --- /dev/null +++ b/infra/prod/monitoring/loki/loki-config.yaml @@ -0,0 +1,43 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + +common: + ring: + kvstore: + store: inmemory + replication_factor: 1 + path_prefix: /loki + +schema_config: + configs: + - from: "2026-04-15" + store: tsdb + object_store: s3 + schema: v13 + index: + prefix: loki_index_ + period: 24h + +storage_config: + tsdb_shipper: + active_index_directory: /loki/index + cache_location: /loki/cache + aws: + s3: s3://${AWS_REGION}/${LOKI_S3_BUCKET} + s3forcepathstyle: false + +limits_config: + retention_period: 30d + max_label_names_per_series: 5 + max_label_value_length: 1024 + ingestion_rate_mb: 10 + ingestion_burst_size_mb: 20 + +compactor: + working_directory: /loki/compactor + compaction_interval: 10m + retention_enabled: true + retention_delete_delay: 2h + delete_request_store: filesystem diff --git a/infra/prod/monitoring/prometheus/prometheus.yml b/infra/prod/monitoring/prometheus/prometheus.yml new file mode 100644 index 00000000..e2b0b89f --- /dev/null +++ b/infra/prod/monitoring/prometheus/prometheus.yml @@ -0,0 +1,30 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: "weeth-app" + metrics_path: "/actuator/prometheus" + static_configs: + - targets: ["app-blue:8080", "app-green:8080"] + labels: + app: weeth + env: prod + + - job_name: "node-exporter" + static_configs: + - targets: ["node-exporter:9100"] + labels: + env: prod + + - job_name: "cadvisor" + static_configs: + - targets: ["cadvisor:8080"] + labels: + env: prod + + - job_name: "redis" + static_configs: + - targets: ["redis-exporter:9121"] + labels: + env: prod diff --git a/infra/prod/monitoring/scripts/deploy.sh b/infra/prod/monitoring/scripts/deploy.sh new file mode 100644 index 00000000..101583f6 --- /dev/null +++ b/infra/prod/monitoring/scripts/deploy.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +DEPLOY_DIR="${DEPLOY_DIR:-/home/ubuntu/infra/prod/monitoring}" +APP_NETWORK="${APP_NETWORK:-weeth-prod_web}" +MONITORING_ENV_FILE="${MONITORING_ENV_FILE:-$DEPLOY_DIR/.env.monitoring}" + +cd "$DEPLOY_DIR" + +if [ ! -f "$MONITORING_ENV_FILE" ]; then + echo "[monitoring] env file not found: $MONITORING_ENV_FILE" + exit 1 +fi + +export MONITORING_ENV_FILE + +if ! docker network inspect "$APP_NETWORK" >/dev/null 2>&1; then + echo "[monitoring] required docker network not found: $APP_NETWORK" + echo "[monitoring] deploy the app stack first or create the network before deploying monitoring" + exit 1 +fi + +echo "[monitoring] pulling images..." +docker compose --env-file "$MONITORING_ENV_FILE" pull + +echo "[monitoring] starting monitoring stack..." +docker compose --env-file "$MONITORING_ENV_FILE" up -d + +echo "[monitoring] waiting for services to be healthy..." +for i in {1..30}; do + if curl -fsS "http://127.0.0.1:12345/-/ready" >/dev/null 2>&1 && + curl -fsS "http://127.0.0.1:9090/-/ready" >/dev/null 2>&1 && + curl -fsS "http://127.0.0.1:3100/ready" >/dev/null 2>&1 && + curl -fsS "http://127.0.0.1:3200/ready" >/dev/null 2>&1 && + curl -fsS "http://127.0.0.1:3000/api/health" >/dev/null 2>&1; then + echo "[monitoring] all services healthy" + break + fi + + if [ "$i" -eq 30 ]; then + echo "[monitoring] health check failed — check docker compose logs" + exit 1 + fi + + sleep 2 +done + +echo "[monitoring] deploy completed" diff --git a/infra/prod/monitoring/tempo/tempo-config.yaml b/infra/prod/monitoring/tempo/tempo-config.yaml new file mode 100644 index 00000000..de48ebc7 --- /dev/null +++ b/infra/prod/monitoring/tempo/tempo-config.yaml @@ -0,0 +1,35 @@ +stream_over_http_enabled: true + +server: + http_listen_port: 3200 + +distributor: + receivers: + otlp: + protocols: + grpc: + endpoint: "0.0.0.0:4317" + +storage: + trace: + backend: s3 + s3: + bucket: ${TEMPO_S3_BUCKET} + endpoint: s3.${AWS_REGION}.amazonaws.com + wal: + path: /var/tempo/wal + local: + path: /var/tempo/blocks + +compactor: + compaction: + block_retention: 720h + +metrics_generator: + storage: + path: /var/tempo/generator/wal + registry: + external_labels: + source: tempo + traces_storage: + path: /var/tempo/generator/traces diff --git a/src/main/kotlin/com/weeth/global/auth/authentication/CustomAccessDeniedHandler.kt b/src/main/kotlin/com/weeth/global/auth/authentication/CustomAccessDeniedHandler.kt index a3f27c53..c00619df 100644 --- a/src/main/kotlin/com/weeth/global/auth/authentication/CustomAccessDeniedHandler.kt +++ b/src/main/kotlin/com/weeth/global/auth/authentication/CustomAccessDeniedHandler.kt @@ -6,6 +6,7 @@ import com.weeth.global.common.response.CommonResponse import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import org.slf4j.LoggerFactory +import org.slf4j.MDC import org.springframework.security.access.AccessDeniedException import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.web.access.AccessDeniedHandler @@ -15,24 +16,29 @@ import org.springframework.stereotype.Component class CustomAccessDeniedHandler( private val objectMapper: ObjectMapper, ) : AccessDeniedHandler { - private val log = LoggerFactory.getLogger(javaClass) + private val errorLog = LoggerFactory.getLogger("ERROR_LOG") override fun handle( request: HttpServletRequest, response: HttpServletResponse, accessDeniedException: AccessDeniedException, ) { - log.error( - "ExceptionClass: {}, Message: {}", - accessDeniedException::class.simpleName, - accessDeniedException.message, - ) - if (isTemporaryUser()) { setRegistrationIncompleteResponse(response) } else { setForbiddenResponse(response) } + + try { + MDC.put("status", response.status.toString()) + MDC.put("errorType", accessDeniedException::class.simpleName ?: "AccessDeniedException") + MDC.put("errorMessage", accessDeniedException.message ?: ErrorMessage.FORBIDDEN.message) + errorLog.warn("Access Denied") + } finally { + MDC.remove("status") + MDC.remove("errorType") + MDC.remove("errorMessage") + } } private fun isTemporaryUser(): Boolean = diff --git a/src/main/kotlin/com/weeth/global/auth/authentication/CustomAuthenticationEntryPoint.kt b/src/main/kotlin/com/weeth/global/auth/authentication/CustomAuthenticationEntryPoint.kt index dcdbffa4..4b50d4ea 100644 --- a/src/main/kotlin/com/weeth/global/auth/authentication/CustomAuthenticationEntryPoint.kt +++ b/src/main/kotlin/com/weeth/global/auth/authentication/CustomAuthenticationEntryPoint.kt @@ -5,6 +5,7 @@ import com.weeth.global.common.response.CommonResponse import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import org.slf4j.LoggerFactory +import org.slf4j.MDC import org.springframework.security.core.AuthenticationException import org.springframework.security.web.AuthenticationEntryPoint import org.springframework.stereotype.Component @@ -13,7 +14,7 @@ import org.springframework.stereotype.Component class CustomAuthenticationEntryPoint( private val objectMapper: ObjectMapper, ) : AuthenticationEntryPoint { - private val log = LoggerFactory.getLogger(javaClass) + private val errorLog = LoggerFactory.getLogger("ERROR_LOG") override fun commence( request: HttpServletRequest, @@ -21,11 +22,16 @@ class CustomAuthenticationEntryPoint( authException: AuthenticationException, ) { setResponse(response) - log.error( - "ExceptionClass: {}, Message: {}", - authException::class.simpleName, - authException.message, - ) + try { + MDC.put("status", response.status.toString()) + MDC.put("errorType", authException::class.simpleName ?: "AuthenticationException") + MDC.put("errorMessage", authException.message ?: ErrorMessage.UNAUTHORIZED.message) + errorLog.warn("Authentication Failed") + } finally { + MDC.remove("status") + MDC.remove("errorType") + MDC.remove("errorMessage") + } } private fun setResponse(response: HttpServletResponse) { diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt b/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt index 95cf87d1..9cb494ce 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt @@ -9,6 +9,7 @@ import jakarta.servlet.FilterChain import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import org.slf4j.LoggerFactory +import org.slf4j.MDC import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.security.core.context.SecurityContextHolder @@ -56,5 +57,6 @@ class JwtAuthenticationProcessingFilter( ) SecurityContextHolder.getContext().authentication = authentication + MDC.put("userId", claims.id.toString()) } } diff --git a/src/main/kotlin/com/weeth/global/common/exception/CommonExceptionHandler.kt b/src/main/kotlin/com/weeth/global/common/exception/CommonExceptionHandler.kt index ed89b2df..7bf7f67e 100644 --- a/src/main/kotlin/com/weeth/global/common/exception/CommonExceptionHandler.kt +++ b/src/main/kotlin/com/weeth/global/common/exception/CommonExceptionHandler.kt @@ -2,6 +2,7 @@ package com.weeth.global.common.exception import com.weeth.global.common.response.CommonResponse import org.slf4j.LoggerFactory +import org.slf4j.MDC import org.springframework.http.ResponseEntity import org.springframework.validation.BindException import org.springframework.web.ErrorResponse @@ -11,12 +12,11 @@ import org.springframework.web.method.annotation.MethodArgumentTypeMismatchExcep @RestControllerAdvice class CommonExceptionHandler { - private val log = LoggerFactory.getLogger(javaClass) + private val errorLog = LoggerFactory.getLogger("ERROR_LOG") @ExceptionHandler(BaseException::class) fun handle(ex: BaseException): ResponseEntity> { - log.warn("예외 처리(BaseException)", ex) - log.warn(LOG_FORMAT, ex::class.simpleName, ex.statusCode, ex.message) + logException(ex.statusCode, ex, ex.message) val response = if (ex.data != null) { @@ -46,8 +46,7 @@ class CommonExceptionHandler { } } - log.warn("예외 처리(BindException)", ex) - log.warn(LOG_FORMAT, ex::class.simpleName, statusCode, exceptionResponses) + logException(statusCode, ex, exceptionResponses) val response = CommonResponse.createFailure(statusCode, "bindException", exceptionResponses.toList()) @@ -60,8 +59,7 @@ class CommonExceptionHandler { fun handle(ex: MethodArgumentTypeMismatchException): ResponseEntity> { val statusCode = if (ex is ErrorResponse) ex.statusCode.value() else 400 - log.warn("예외 처리(MethodArgumentTypeMismatchException)", ex) - log.warn(LOG_FORMAT, ex::class.simpleName, statusCode, ex.message) + logException(statusCode, ex, ex.message) val response = CommonResponse.createFailure(statusCode, INPUT_FORMAT_ERROR_MESSAGE) @@ -74,8 +72,7 @@ class CommonExceptionHandler { fun handle(ex: Exception): ResponseEntity> { val statusCode = if (ex is ErrorResponse) ex.statusCode.value() else 500 - log.warn("예외 처리(Exception)", ex) - log.warn(LOG_FORMAT, ex::class.simpleName, statusCode, ex.message) + logException(statusCode, ex, ex.message, error = true) val response = CommonResponse.createFailure(statusCode, ex.message ?: "") @@ -88,4 +85,26 @@ class CommonExceptionHandler { private const val INPUT_FORMAT_ERROR_MESSAGE = "입력 포맷이 올바르지 않습니다." private const val LOG_FORMAT = "Class : {}, Code : {}, Message : {}" } + + private fun logException( + statusCode: Int, + ex: Throwable, + message: Any?, + error: Boolean = false, + ) { + try { + MDC.put("status", statusCode.toString()) + MDC.put("errorType", ex::class.simpleName ?: "Exception") + MDC.put("errorMessage", ex.message ?: message?.toString().orEmpty()) + if (error) { + errorLog.error(LOG_FORMAT, ex::class.simpleName, statusCode, message, ex) + } else { + errorLog.warn(LOG_FORMAT, ex::class.simpleName, statusCode, message) + } + } finally { + MDC.remove("status") + MDC.remove("errorType") + MDC.remove("errorMessage") + } + } } diff --git a/src/main/kotlin/com/weeth/global/config/ObservabilityConfig.kt b/src/main/kotlin/com/weeth/global/config/ObservabilityConfig.kt new file mode 100644 index 00000000..58fd92e5 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/config/ObservabilityConfig.kt @@ -0,0 +1,28 @@ +package com.weeth.global.config + +import io.micrometer.observation.ObservationPredicate +import io.micrometer.observation.ObservationRegistry +import org.springframework.boot.web.client.RestClientCustomizer +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.server.observation.ServerRequestObservationContext + +@Configuration +class ObservabilityConfig { + @Bean + fun restClientObservationCustomizer(observationRegistry: ObservationRegistry): RestClientCustomizer = + RestClientCustomizer { builder -> + builder.observationRegistry(observationRegistry) + } + + @Bean + fun actuatorObservationPredicate(): ObservationPredicate = + ObservationPredicate { _, context -> + if (context is ServerRequestObservationContext) { + val path = context.carrier?.requestURI ?: return@ObservationPredicate true + return@ObservationPredicate !path.startsWith("/actuator") && !path.startsWith("/health-check") + } + + true + } +} diff --git a/src/main/kotlin/com/weeth/global/config/RedisConfig.kt b/src/main/kotlin/com/weeth/global/config/RedisConfig.kt index cffd6992..2ef526ea 100644 --- a/src/main/kotlin/com/weeth/global/config/RedisConfig.kt +++ b/src/main/kotlin/com/weeth/global/config/RedisConfig.kt @@ -31,10 +31,10 @@ class RedisConfig( } @Bean - fun redisTemplate(): RedisTemplate = + fun redisTemplate(redisConnectionFactory: RedisConnectionFactory): RedisTemplate = RedisTemplate().apply { keySerializer = StringRedisSerializer() valueSerializer = StringRedisSerializer() - connectionFactory = redisConnectionFactory() + connectionFactory = redisConnectionFactory } } diff --git a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt index 4bf82bc3..74ceac9c 100644 --- a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt +++ b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt @@ -61,14 +61,13 @@ class SecurityConfig( "/swagger-ui/**", "/swagger/**", ).permitAll() - .requestMatchers("/actuator/prometheus") - .access { _, context -> - val ip = context.request.remoteAddr - val allowed = ip.startsWith("172.") || ip == "127.0.0.1" - AuthorizationDecision(allowed) - }.requestMatchers("/actuator/health") + .requestMatchers("/actuator/health") .permitAll() - .requestMatchers("/api/v4/users/terms") + .requestMatchers("/actuator/**") + .access { _, context -> + val address = java.net.InetAddress.getByName(context.request.remoteAddr) + AuthorizationDecision(address.isLoopbackAddress || address.isSiteLocalAddress) + }.requestMatchers("/api/v4/users/terms") .hasAnyRole("TEMPORARY", "USER") .anyRequest() .hasRole("USER") diff --git a/src/main/kotlin/com/weeth/global/logging/AccessLogFilter.kt b/src/main/kotlin/com/weeth/global/logging/AccessLogFilter.kt new file mode 100644 index 00000000..24f4aff8 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/logging/AccessLogFilter.kt @@ -0,0 +1,115 @@ +package com.weeth.global.logging + +import io.opentelemetry.api.trace.Span +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.LoggerFactory +import org.slf4j.MDC +import org.springframework.core.Ordered +import org.springframework.core.annotation.Order +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter +import java.util.UUID + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE + 2) +class AccessLogFilter : OncePerRequestFilter() { + private val accessLog = LoggerFactory.getLogger("ACCESS_LOG") + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + val requestId = + request.getHeader("X-Request-Id") + ?: UUID + .randomUUID() + .toString() + val startTime = System.currentTimeMillis() + + var failed = false + try { + MDC.put("requestId", requestId) + MDC.put("path", request.requestURI) + MDC.put("method", request.method) + putTraceMdc(request) + response.setHeader("X-Request-Id", requestId) + filterChain.doFilter(request, response) + } catch (ex: Throwable) { + failed = true + throw ex + } finally { + val durationMs = System.currentTimeMillis() - startTime + val status = if (failed) "500" else response.status.toString() + MDC.put("status", status) + MDC.put("durationMs", durationMs.toString()) + putTraceMdc(request) + + accessLog.info("HTTP Request Completed") + + MDC.remove("requestId") + MDC.remove("path") + MDC.remove("method") + MDC.remove("status") + MDC.remove("durationMs") + MDC.remove("traceId") + MDC.remove("spanId") + MDC.remove("userId") + } + } + + override fun shouldNotFilter(request: HttpServletRequest): Boolean { + val path = request.requestURI + return path.startsWith("/actuator") || path.startsWith("/health-check") + } + + private fun putTraceMdc(request: HttpServletRequest) { + if (!MDC.get("traceId").isNullOrBlank() && !MDC.get("spanId").isNullOrBlank()) { + return + } + + val context = Span.current().spanContext + if (context.isValid) { + putTraceContext(context.traceId, context.spanId) + return + } + + putTraceparentContext(request.getHeader(TRACEPARENT_HEADER)) + } + + private fun putTraceContext( + traceId: String?, + spanId: String?, + ) { + if (!traceId.isNullOrBlank()) { + MDC.put("traceId", traceId) + } + if (!spanId.isNullOrBlank()) { + MDC.put("spanId", spanId) + } + } + + private fun putTraceparentContext(traceparent: String?) { + val parts = traceparent?.split("-") ?: return + if (parts.size < TRACEPARENT_PARTS) { + return + } + + val traceId = parts[TRACEPARENT_TRACE_ID_INDEX] + val spanId = parts[TRACEPARENT_SPAN_ID_INDEX] + if (traceId.length == TRACE_ID_LENGTH && spanId.length == SPAN_ID_LENGTH) { + putTraceContext(traceId, spanId) + } + } + + companion object { + private const val TRACEPARENT_HEADER = "traceparent" + private const val TRACEPARENT_PARTS = 4 + private const val TRACEPARENT_TRACE_ID_INDEX = 1 + private const val TRACEPARENT_SPAN_ID_INDEX = 2 + private const val TRACE_ID_LENGTH = 32 + private const val SPAN_ID_LENGTH = 16 + } +} diff --git a/src/main/kotlin/com/weeth/global/logging/MaskingJsonGeneratorDecorator.kt b/src/main/kotlin/com/weeth/global/logging/MaskingJsonGeneratorDecorator.kt new file mode 100644 index 00000000..fc3481e0 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/logging/MaskingJsonGeneratorDecorator.kt @@ -0,0 +1,51 @@ +package com.weeth.global.logging + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.util.JsonGeneratorDelegate +import net.logstash.logback.decorate.JsonGeneratorDecorator + +class MaskingJsonGeneratorDecorator : JsonGeneratorDecorator { + override fun decorate(generator: JsonGenerator): JsonGenerator = MaskingJsonGenerator(generator) +} + +class MaskingJsonGenerator( + delegate: JsonGenerator, +) : JsonGeneratorDelegate(delegate) { + private val sensitiveFields = setOf("password", "token", "accesstoken", "refreshtoken", "secret", "authorization") + private var currentFieldName: String? = null + + override fun writeFieldName(name: String?) { + currentFieldName = name?.lowercase()?.filter(Char::isLetterOrDigit) + super.writeFieldName(name) + } + + override fun writeString(text: String?) { + if (text == null) { + super.writeString(null as String?) + return + } + + val masked = + when { + currentFieldName in sensitiveFields -> "***" + else -> maskPatterns(text) + } + currentFieldName = null + super.writeString(masked as String?) + } + + private fun maskPatterns(value: String): String = + value + .replace(EMAIL_PATTERN) { "${it.groupValues[1]}***${it.groupValues[3]}" } + .replace(PHONE_PATTERN) { "${it.groupValues[1]}-****-${it.groupValues[3]}" } + .replace(TOKEN_PATTERN) { "${it.groupValues[1]}***" } + + companion object { + private val EMAIL_PATTERN = + Regex("""([a-zA-Z0-9._%+-])([a-zA-Z0-9._%+-]*)(@[a-zA-Z0-9.-]+)""") + private val PHONE_PATTERN = + Regex("""(01[0-9])-?(\d{3,4})-?(\d{4})""") + private val TOKEN_PATTERN = + Regex("""(eyJ[a-zA-Z0-9_-]{7})[a-zA-Z0-9_.-]+""") + } +} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 93d3783d..e60408ca 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -49,4 +49,4 @@ cloud: static: ap-northeast-2 auto: false stack: - auto: false \ No newline at end of file + auto: false diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9bd2cbb5..47388035 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,6 +1,16 @@ +server: + tomcat: + mbeanregistry: # Tomcat Threads 확인을 위한 설정 + enabled: true + spring: + application: + name: weeth-server profiles: active: local + group: + local-monitoring: + - local jpa: open-in-view: false @@ -37,10 +47,27 @@ management: include: - health - prometheus + - info + endpoint: + health: + show-details: when-authorized + info: + env: + enabled: true + build: + enabled: true prometheus: metrics: export: enabled: true + metrics: + tags: + application: weeth-server + distribution: + percentiles-histogram: + http.server.requests: true + slo: + http.server.requests: 50ms,100ms,200ms,500ms,1s app: file: diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 00000000..af67b860 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,65 @@ + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n + + + + + + requestId + traceId + spanId + trace_id + span_id + userId + path + method + status + durationMs + errorType + errorMessage + + + + + + + + + + + + + + + + http://localhost:${LOKI_PORT}/loki/api/v1/push + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/kotlin/com/weeth/global/logging/MaskingJsonGeneratorTest.kt b/src/test/kotlin/com/weeth/global/logging/MaskingJsonGeneratorTest.kt new file mode 100644 index 00000000..9723853a --- /dev/null +++ b/src/test/kotlin/com/weeth/global/logging/MaskingJsonGeneratorTest.kt @@ -0,0 +1,41 @@ +package com.weeth.global.logging + +import com.fasterxml.jackson.core.JsonFactory +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldNotContain +import java.io.StringWriter + +class MaskingJsonGeneratorTest : + StringSpec({ + "snake_case token field values are masked" { + val json = writeJson("access_token", "eyJabcdefghi.secret.payload") + + json shouldContain """"access_token":"***"""" + json shouldNotContain "eyJabcdefghi.secret.payload" + } + + "email and phone number patterns are masked inside log messages" { + val json = writeJson("message", "contact test@example.com or 010-1234-5678") + + json shouldContain "t***@example.com" + json shouldContain "010-****-5678" + json shouldNotContain "test@example.com" + json shouldNotContain "010-1234-5678" + } + }) + +private fun writeJson( + fieldName: String, + value: String, +): String { + val writer = StringWriter() + val generator = MaskingJsonGenerator(JsonFactory().createGenerator(writer)) + + generator.writeStartObject() + generator.writeStringField(fieldName, value) + generator.writeEndObject() + generator.close() + + return writer.toString() +} From 756bfb22b66b05892bb84e4699f369f286c2b168 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Fri, 24 Apr 2026 19:40:14 +0900 Subject: [PATCH 59/73] =?UTF-8?q?HOTFIX:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EC=9C=A0=EC=A0=80=20=EC=9D=B4=EB=A6=84=20=ED=99=98=EA=B2=BD=20?= =?UTF-8?q?=EB=B3=80=EC=88=98=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/dev/monitoring/docker-compose.yml | 2 ++ infra/prod/monitoring/docker-compose.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/infra/dev/monitoring/docker-compose.yml b/infra/dev/monitoring/docker-compose.yml index 9742355f..9bcad6b1 100644 --- a/infra/dev/monitoring/docker-compose.yml +++ b/infra/dev/monitoring/docker-compose.yml @@ -111,6 +111,7 @@ services: - grafana_data:/var/lib/grafana - ./grafana/provisioning:/etc/grafana/provisioning:ro environment: + GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER} GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD} GF_SERVER_ROOT_URL: ${GRAFANA_ROOT_URL:-https://${DOMAIN}${MONITORING_PATH}/} GF_SERVER_SERVE_FROM_SUB_PATH: "true" @@ -123,6 +124,7 @@ services: - tempo networks: - monitoring + - weeth-app restart: unless-stopped networks: diff --git a/infra/prod/monitoring/docker-compose.yml b/infra/prod/monitoring/docker-compose.yml index b82e4e30..cd24d24e 100644 --- a/infra/prod/monitoring/docker-compose.yml +++ b/infra/prod/monitoring/docker-compose.yml @@ -111,6 +111,7 @@ services: - grafana_data:/var/lib/grafana - ./grafana/provisioning:/etc/grafana/provisioning:ro environment: + GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER} GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD} GF_SERVER_ROOT_URL: ${GRAFANA_ROOT_URL:-https://${DOMAIN}${MONITORING_PATH}/} GF_SERVER_SERVE_FROM_SUB_PATH: "true" @@ -123,6 +124,7 @@ services: - tempo networks: - monitoring + - weeth-app restart: unless-stopped networks: From f0053064e2b8855415290ffd02c9a224e304705a Mon Sep 17 00:00:00 2001 From: hyxklee Date: Sat, 25 Apr 2026 13:18:45 +0900 Subject: [PATCH 60/73] =?UTF-8?q?HOTFIX:=20=EB=AA=A8=EB=8B=88=ED=84=B0?= =?UTF-8?q?=EB=A7=81=20=ED=95=AB=ED=94=BD=EC=8A=A4=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(Log=20=EC=88=98=EC=A7=91=20=EC=95=88=EB=90=A8,=20Lettuce=20?= =?UTF-8?q?=EC=88=98=EC=A7=91=20=EC=95=88=EB=90=A8=20=ED=95=B4=EA=B2=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/dev/monitoring/alloy/config.alloy | 4 ++-- infra/dev/monitoring/docker-compose.yml | 2 ++ infra/dev/monitoring/scripts/deploy.sh | 13 ++++++++++++- infra/prod/monitoring/alloy/config.alloy | 4 ++-- infra/prod/monitoring/docker-compose.yml | 2 ++ infra/prod/monitoring/scripts/deploy.sh | 13 ++++++++++++- .../com/weeth/global/config/RedisConfig.kt | 19 ++++++++++++++++++- 7 files changed, 50 insertions(+), 7 deletions(-) diff --git a/infra/dev/monitoring/alloy/config.alloy b/infra/dev/monitoring/alloy/config.alloy index 98ee56fc..c501520d 100644 --- a/infra/dev/monitoring/alloy/config.alloy +++ b/infra/dev/monitoring/alloy/config.alloy @@ -2,8 +2,8 @@ discovery.docker "weeth" { host = "unix:///var/run/docker.sock" filter { - name = "label" - values = ["com.docker.compose.service=app-blue", "com.docker.compose.service=app-green"] + name = "name" + values = ["weeth-dev-app"] } } diff --git a/infra/dev/monitoring/docker-compose.yml b/infra/dev/monitoring/docker-compose.yml index 9bcad6b1..cfb93f78 100644 --- a/infra/dev/monitoring/docker-compose.yml +++ b/infra/dev/monitoring/docker-compose.yml @@ -3,6 +3,8 @@ name: weeth-dev-monitoring services: alloy: image: grafana/alloy:v1.9.0 + group_add: + - "${DOCKER_GID}" env_file: - ${MONITORING_ENV_FILE:-../.env.monitoring} volumes: diff --git a/infra/dev/monitoring/scripts/deploy.sh b/infra/dev/monitoring/scripts/deploy.sh index c6640483..9a273d6a 100644 --- a/infra/dev/monitoring/scripts/deploy.sh +++ b/infra/dev/monitoring/scripts/deploy.sh @@ -12,7 +12,17 @@ if [ ! -f "$MONITORING_ENV_FILE" ]; then exit 1 fi -export MONITORING_ENV_FILE +if [ -z "${DOCKER_GID:-}" ]; then + DOCKER_GID="$(stat -c '%g' /var/run/docker.sock 2>/dev/null || stat -f '%g' /var/run/docker.sock 2>/dev/null || true)" +fi + +if [ -z "${DOCKER_GID:-}" ]; then + echo "[monitoring] failed to detect docker.sock group id" + echo "[monitoring] set DOCKER_GID explicitly in $MONITORING_ENV_FILE or the shell environment" + exit 1 +fi + +export MONITORING_ENV_FILE DOCKER_GID if ! docker network inspect "$APP_NETWORK" >/dev/null 2>&1; then echo "[monitoring] required docker network not found: $APP_NETWORK" @@ -20,6 +30,7 @@ if ! docker network inspect "$APP_NETWORK" >/dev/null 2>&1; then exit 1 fi +echo "[monitoring] docker.sock gid=$DOCKER_GID" echo "[monitoring] pulling images..." docker compose --env-file "$MONITORING_ENV_FILE" pull diff --git a/infra/prod/monitoring/alloy/config.alloy b/infra/prod/monitoring/alloy/config.alloy index 497184aa..7bfcc2c9 100644 --- a/infra/prod/monitoring/alloy/config.alloy +++ b/infra/prod/monitoring/alloy/config.alloy @@ -2,8 +2,8 @@ discovery.docker "weeth" { host = "unix:///var/run/docker.sock" filter { - name = "label" - values = ["com.docker.compose.service=app-blue", "com.docker.compose.service=app-green"] + name = "name" + values = ["weeth-prod-app"] } } diff --git a/infra/prod/monitoring/docker-compose.yml b/infra/prod/monitoring/docker-compose.yml index cd24d24e..e66f3238 100644 --- a/infra/prod/monitoring/docker-compose.yml +++ b/infra/prod/monitoring/docker-compose.yml @@ -3,6 +3,8 @@ name: weeth-prod-monitoring services: alloy: image: grafana/alloy:v1.9.0 + group_add: + - "${DOCKER_GID}" env_file: - ${MONITORING_ENV_FILE:-../.env.monitoring} volumes: diff --git a/infra/prod/monitoring/scripts/deploy.sh b/infra/prod/monitoring/scripts/deploy.sh index 101583f6..005a91a4 100644 --- a/infra/prod/monitoring/scripts/deploy.sh +++ b/infra/prod/monitoring/scripts/deploy.sh @@ -12,7 +12,17 @@ if [ ! -f "$MONITORING_ENV_FILE" ]; then exit 1 fi -export MONITORING_ENV_FILE +if [ -z "${DOCKER_GID:-}" ]; then + DOCKER_GID="$(stat -c '%g' /var/run/docker.sock 2>/dev/null || stat -f '%g' /var/run/docker.sock 2>/dev/null || true)" +fi + +if [ -z "${DOCKER_GID:-}" ]; then + echo "[monitoring] failed to detect docker.sock group id" + echo "[monitoring] set DOCKER_GID explicitly in $MONITORING_ENV_FILE or the shell environment" + exit 1 +fi + +export MONITORING_ENV_FILE DOCKER_GID if ! docker network inspect "$APP_NETWORK" >/dev/null 2>&1; then echo "[monitoring] required docker network not found: $APP_NETWORK" @@ -20,6 +30,7 @@ if ! docker network inspect "$APP_NETWORK" >/dev/null 2>&1; then exit 1 fi +echo "[monitoring] docker.sock gid=$DOCKER_GID" echo "[monitoring] pulling images..." docker compose --env-file "$MONITORING_ENV_FILE" pull diff --git a/src/main/kotlin/com/weeth/global/config/RedisConfig.kt b/src/main/kotlin/com/weeth/global/config/RedisConfig.kt index 2ef526ea..daf291ec 100644 --- a/src/main/kotlin/com/weeth/global/config/RedisConfig.kt +++ b/src/main/kotlin/com/weeth/global/config/RedisConfig.kt @@ -1,10 +1,15 @@ package com.weeth.global.config import com.weeth.global.config.properties.RedisProperties +import io.lettuce.core.metrics.MicrometerCommandLatencyRecorder +import io.lettuce.core.metrics.MicrometerOptions +import io.micrometer.core.instrument.MeterRegistry +import org.springframework.boot.autoconfigure.data.redis.LettuceClientConfigurationBuilderCustomizer import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.data.redis.connection.RedisConnectionFactory import org.springframework.data.redis.connection.RedisStandaloneConfiguration +import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory import org.springframework.data.redis.core.RedisKeyValueAdapter import org.springframework.data.redis.core.RedisTemplate @@ -15,6 +20,7 @@ import org.springframework.data.redis.serializer.StringRedisSerializer @EnableRedisRepositories(enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP) class RedisConfig( private val redisProperties: RedisProperties, + private val meterRegistry: MeterRegistry, ) { @Bean fun redisConnectionFactory(): RedisConnectionFactory { @@ -27,7 +33,18 @@ class RedisConfig( } } - return LettuceConnectionFactory(redisConfiguration) + val clientConfig = + LettuceClientConfiguration + .builder() + .clientResources( + io.lettuce.core.resource.ClientResources + .builder() + .commandLatencyRecorder( + MicrometerCommandLatencyRecorder(meterRegistry, MicrometerOptions.defaults()), + ).build(), + ).build() + + return LettuceConnectionFactory(redisConfiguration, clientConfig) } @Bean From 305b3e046ffbb8cccc23ba4953371eb336b3ddaa Mon Sep 17 00:00:00 2001 From: hyxklee Date: Sat, 25 Apr 2026 13:22:43 +0900 Subject: [PATCH 61/73] =?UTF-8?q?HOTFIX:=20=EB=B9=8C=EB=93=9C=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=ED=95=B4=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/com/weeth/global/config/RedisConfig.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/kotlin/com/weeth/global/config/RedisConfig.kt b/src/main/kotlin/com/weeth/global/config/RedisConfig.kt index daf291ec..aaa5aefe 100644 --- a/src/main/kotlin/com/weeth/global/config/RedisConfig.kt +++ b/src/main/kotlin/com/weeth/global/config/RedisConfig.kt @@ -4,7 +4,6 @@ import com.weeth.global.config.properties.RedisProperties import io.lettuce.core.metrics.MicrometerCommandLatencyRecorder import io.lettuce.core.metrics.MicrometerOptions import io.micrometer.core.instrument.MeterRegistry -import org.springframework.boot.autoconfigure.data.redis.LettuceClientConfigurationBuilderCustomizer import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.data.redis.connection.RedisConnectionFactory @@ -40,7 +39,7 @@ class RedisConfig( io.lettuce.core.resource.ClientResources .builder() .commandLatencyRecorder( - MicrometerCommandLatencyRecorder(meterRegistry, MicrometerOptions.defaults()), + MicrometerCommandLatencyRecorder(meterRegistry, MicrometerOptions.create()), ).build(), ).build() From ec2ee848c2b40c76e1d0ba7c4c72a9afc17be4a7 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Sat, 25 Apr 2026 13:32:50 +0900 Subject: [PATCH 62/73] =?UTF-8?q?HOTFIX:=20=EB=B6=88=ED=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=A0=95=EB=A6=AC=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/dev/scripts/deploy.sh | 1 + infra/prod/scripts/deploy.sh | 1 + 2 files changed, 2 insertions(+) diff --git a/infra/dev/scripts/deploy.sh b/infra/dev/scripts/deploy.sh index 9ed3c0c9..f62f04dd 100755 --- a/infra/dev/scripts/deploy.sh +++ b/infra/dev/scripts/deploy.sh @@ -63,4 +63,5 @@ fi docker compose --profile "$OLD_COLOR" -f docker-compose.yml stop "app-$OLD_COLOR" || true docker compose --profile "$OLD_COLOR" -f docker-compose.yml rm -f "app-$OLD_COLOR" || true +docker image prune -f echo "[deploy] completed" diff --git a/infra/prod/scripts/deploy.sh b/infra/prod/scripts/deploy.sh index 5e9b4bc9..bd623675 100755 --- a/infra/prod/scripts/deploy.sh +++ b/infra/prod/scripts/deploy.sh @@ -63,4 +63,5 @@ fi docker compose --profile "$OLD_COLOR" -f docker-compose.yml stop "app-$OLD_COLOR" || true docker compose --profile "$OLD_COLOR" -f docker-compose.yml rm -f "app-$OLD_COLOR" || true +docker image prune -f echo "[deploy] completed" From 252ababef7a7e60634d0f95f631357daded74113 Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Sun, 26 Apr 2026 01:13:56 +0900 Subject: [PATCH 63/73] =?UTF-8?q?[WTH-327]=20=EC=96=B4=EB=93=9C=EB=AF=BC?= =?UTF-8?q?=20=EA=B2=8C=EC=8B=9C=ED=8C=90=20=EA=B4=80=EB=A6=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 게시판 이름 중복 API 추가 및 설명 추가 * refactor: 기본 설정 게시판 설명 추가 * fix: DTO 추가 --- .../dto/request/CreateBoardRequest.kt | 4 +++ .../dto/request/UpdateBoardRequest.kt | 3 ++ .../dto/response/BoardDetailResponse.kt | 2 ++ .../response/BoardNameDuplicateResponse.kt | 8 ++++++ .../board/application/mapper/BoardMapper.kt | 2 ++ .../usecase/command/ManageBoardUseCase.kt | 3 ++ .../usecase/query/GetBoardQueryService.kt | 20 +++++++++++++ .../weeth/domain/board/domain/entity/Board.kt | 11 ++++++++ .../presentation/BoardAdminController.kt | 16 +++++++++++ .../board/presentation/BoardResponseCode.kt | 1 + .../usecase/command/ManageClubUseCase.kt | 1 + .../usecase/command/ManageBoardUseCaseTest.kt | 17 ++++++++++- .../usecase/query/GetBoardQueryServiceTest.kt | 28 +++++++++++++++++++ .../board/domain/entity/BoardEntityTest.kt | 10 ++++++- .../domain/board/fixture/BoardTestFixture.kt | 4 +++ .../usecase/command/CommentConcurrencyTest.kt | 1 + .../query/CommentQueryPerformanceTest.kt | 1 + 17 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardNameDuplicateResponse.kt diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/request/CreateBoardRequest.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/request/CreateBoardRequest.kt index fb710e9b..2f6ea8f5 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/dto/request/CreateBoardRequest.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/request/CreateBoardRequest.kt @@ -12,6 +12,10 @@ data class CreateBoardRequest( @field:NotBlank @field:Size(max = 100) val name: String, + @field:Schema(description = "게시판 설명", example = "공지사항 게시판입니다.") + @field:NotBlank + @field:Size(max = 30) + val description: String, @field:Schema(description = "게시판 타입", example = "NOTICE") @field:NotNull var type: BoardType, diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdateBoardRequest.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdateBoardRequest.kt index 8943a2ef..f78b0b2b 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdateBoardRequest.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdateBoardRequest.kt @@ -8,6 +8,9 @@ data class UpdateBoardRequest( @field:Schema(description = "게시판 이름", example = "새 공지사항", nullable = true) @field:Size(max = 100) val name: String? = null, + @field:Schema(description = "게시판 설명", example = "운영 관련 새 공지사항입니다.", nullable = true) + @field:Size(max = 30) + val description: String? = null, @field:Schema(description = "댓글 허용 여부", example = "true", nullable = true) val commentEnabled: Boolean? = null, @field:Schema(description = "게시글 작성 권한", example = "USER", nullable = true) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardDetailResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardDetailResponse.kt index c4608d03..c64065f3 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardDetailResponse.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardDetailResponse.kt @@ -11,6 +11,8 @@ data class BoardDetailResponse( val id: Long?, @field:Schema(description = "게시판 이름") val name: String, + @field:Schema(description = "게시판 설명 (관리자만 조회 가능)") + val description: String?, @field:Schema(description = "게시판 타입") val type: BoardType, @field:Schema(description = "댓글 허용 여부 (전체 게시판은 null)") diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardNameDuplicateResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardNameDuplicateResponse.kt new file mode 100644 index 00000000..9daca5d9 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardNameDuplicateResponse.kt @@ -0,0 +1,8 @@ +package com.weeth.domain.board.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class BoardNameDuplicateResponse( + @field:Schema(description = "게시판 이름 중복 여부", example = "true") + val duplicated: Boolean, +) diff --git a/src/main/kotlin/com/weeth/domain/board/application/mapper/BoardMapper.kt b/src/main/kotlin/com/weeth/domain/board/application/mapper/BoardMapper.kt index 4e2a8510..1e949ed9 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/mapper/BoardMapper.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/mapper/BoardMapper.kt @@ -18,6 +18,7 @@ class BoardMapper { BoardDetailResponse( id = board.id, name = board.name, + description = board.description, type = board.type, commentEnabled = board.config.commentEnabled, writePermission = board.config.writePermission, @@ -32,6 +33,7 @@ class BoardMapper { ) = BoardDetailResponse( id = board.id, name = board.name, + description = board.description, type = board.type, commentEnabled = board.config.commentEnabled, writePermission = board.config.writePermission, diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt index 60e157f9..8197d8a6 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt @@ -67,6 +67,7 @@ class ManageBoardUseCase( Board( club = club, name = request.name, + description = request.description, type = request.type, config = BoardConfig( @@ -105,6 +106,8 @@ class ManageBoardUseCase( board.rename(it) } + request.description?.let(board::updateDescription) + // BoardConfig는 불변 VO이므로 개별 필드 수정이 불가능하여 copy()로 새 객체를 만들어 통째로 교체한다. null이면 기존 값을 명시적으로 채운다. // 바깥 if 문은 config 관련 필드가 전부 null인 요청에서 불필요한 VO 생성을 방지한다. if (request.commentEnabled != null || request.writePermission != null || request.isPrivate != null) { diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt index ebc82c2a..ac441f6c 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt @@ -2,6 +2,7 @@ package com.weeth.domain.board.application.usecase.query import com.weeth.domain.board.application.dto.response.BoardDetailResponse import com.weeth.domain.board.application.dto.response.BoardListResponse +import com.weeth.domain.board.application.dto.response.BoardNameDuplicateResponse import com.weeth.domain.board.application.exception.BoardNotFoundException import com.weeth.domain.board.application.mapper.BoardMapper import com.weeth.domain.board.domain.enums.BoardType @@ -80,6 +81,24 @@ class GetBoardQueryService( return noticeBoards + virtualAllBoardForAdmin(totalPostCount) + otherBoards } + fun checkBoardNameDuplicate( + clubId: Long, + userId: Long, + name: String, + boardId: Long? = null, + ): BoardNameDuplicateResponse { + clubPermissionPolicy.requireAdmin(clubId, userId) + + val duplicated = + if (boardId == null) { + boardRepository.existsByClubIdAndNameAndIsDeletedFalse(clubId, name) + } else { + boardRepository.existsByClubIdAndNameAndIsDeletedFalseAndIdNot(clubId, name, boardId) + } + + return BoardNameDuplicateResponse(duplicated = duplicated) + } + companion object { private val VIRTUAL_ALL_BOARD = BoardListResponse(id = null, name = "전체", type = BoardType.ALL) @@ -87,6 +106,7 @@ class GetBoardQueryService( BoardDetailResponse( id = null, name = "전체", + description = "모든 게시글을 확인할 수 있는 게시판입니다.", // 우선 통일을 위해 백엔드에서 설정, 추후 변경될 수 있음 type = BoardType.ALL, commentEnabled = null, writePermission = null, diff --git a/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt b/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt index aa985dd4..d6c6dd44 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt @@ -24,11 +24,13 @@ import jakarta.persistence.Table class Board( club: Club, name: String, + description: String, type: BoardType, config: BoardConfig = BoardConfig(), ) : BaseEntity() { init { require(name.isNotBlank()) { "게시판 이름은 공백이 될 수 없습니다" } + require(description.isNotBlank()) { "게시판 설명은 공백이 될 수 없습니다" } require(type != BoardType.ALL) { "ALL은 가상 타입으로 게시판을 생성할 수 없습니다" } } @@ -46,6 +48,10 @@ class Board( var name: String = name private set + @Column(nullable = false, length = 500) + var description: String = description + private set + @Enumerated(EnumType.STRING) @Column(nullable = false) var type: BoardType = type @@ -84,6 +90,11 @@ class Board( name = newName } + fun updateDescription(newDescription: String) { + require(newDescription.isNotBlank()) { "게시판 설명은 공백이 될 수 없습니다." } + description = newDescription + } + fun markDeleted() { isDeleted = true } diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt b/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt index 2710a048..7835ed3f 100644 --- a/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt @@ -4,6 +4,7 @@ import com.weeth.domain.board.application.dto.request.CreateBoardRequest import com.weeth.domain.board.application.dto.request.ReorderBoardsRequest import com.weeth.domain.board.application.dto.request.UpdateBoardRequest import com.weeth.domain.board.application.dto.response.BoardDetailResponse +import com.weeth.domain.board.application.dto.response.BoardNameDuplicateResponse import com.weeth.domain.board.application.exception.BoardErrorCode import com.weeth.domain.board.application.usecase.command.ManageBoardUseCase import com.weeth.domain.board.application.usecase.query.GetBoardQueryService @@ -24,6 +25,7 @@ import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController @Tag(name = "Board-Admin", description = "Board Admin API") @@ -59,6 +61,20 @@ class BoardAdminController( getBoardQueryService.findBoardDetailForAdmin(clubId, userId, boardId), ) + @GetMapping("/name-duplicate") + @Operation(summary = "게시판 이름 중복 체크") + fun checkBoardNameDuplicate( + @TsidParam + @TsidPathVariable clubId: Long, + @RequestParam name: String, + @RequestParam(required = false) boardId: Long?, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success( + BoardResponseCode.BOARD_NAME_DUPLICATE_CHECK_SUCCESS, + getBoardQueryService.checkBoardNameDuplicate(clubId, userId, name, boardId), + ) + @PostMapping @Operation(summary = "게시판 생성") fun createBoard( diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt b/src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt index ec3b1a16..de4a1bb6 100644 --- a/src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt @@ -24,4 +24,5 @@ enum class BoardResponseCode( BOARD_REORDERED_SUCCESS(10413, HttpStatus.OK, "게시판 순서가 성공적으로 변경되었습니다."), POST_LIKE_SUCCESS(10414, HttpStatus.OK, "게시글에 좋아요를 눌렀습니다."), POST_UNLIKE_SUCCESS(10415, HttpStatus.OK, "게시글 좋아요를 취소했습니다."), + BOARD_NAME_DUPLICATE_CHECK_SUCCESS(10416, HttpStatus.OK, "게시판 이름 중복 여부를 성공적으로 확인했습니다."), } diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt index 29d8b341..886a263a 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt @@ -97,6 +97,7 @@ class ManageClubUseCase( Board( club = club, name = "공지사항", + description = "운영진이 공지사항을 올리는 게시판입니다.", type = BoardType.NOTICE, config = BoardConfig(writePermission = MemberRole.ADMIN), ) diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt index 4dafd299..d1c9e260 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt @@ -59,6 +59,7 @@ class ManageBoardUseCaseTest : val request = CreateBoardRequest( name = "운영 게시판", + description = "운영진만 사용하는 게시판입니다.", type = BoardType.GENERAL, commentEnabled = false, writePermission = MemberRole.ADMIN, @@ -68,6 +69,7 @@ class ManageBoardUseCaseTest : val result = useCase.create(clubId, request, userId) result.name shouldBe "운영 게시판" + result.description shouldBe "운영진만 사용하는 게시판입니다." result.type shouldBe BoardType.GENERAL result.commentEnabled shouldBe false result.writePermission shouldBe MemberRole.ADMIN @@ -79,6 +81,7 @@ class ManageBoardUseCaseTest : val request = CreateBoardRequest( name = "첫 게시판", + description = "첫 번째 게시판 설명", type = BoardType.GENERAL, commentEnabled = true, writePermission = MemberRole.USER, @@ -95,6 +98,7 @@ class ManageBoardUseCaseTest : val request = CreateBoardRequest( name = "새 게시판", + description = "새 게시판 설명", type = BoardType.GENERAL, commentEnabled = true, writePermission = MemberRole.USER, @@ -111,6 +115,7 @@ class ManageBoardUseCaseTest : val request = CreateBoardRequest( name = "새 게시판", + description = "새 게시판 설명", type = BoardType.GENERAL, commentEnabled = true, writePermission = MemberRole.USER, @@ -127,6 +132,7 @@ class ManageBoardUseCaseTest : val request = CreateBoardRequest( name = "초과 게시판", + description = "초과 게시판 설명", type = BoardType.GENERAL, commentEnabled = true, writePermission = MemberRole.USER, @@ -143,6 +149,7 @@ class ManageBoardUseCaseTest : val request = CreateBoardRequest( name = "중복 이름", + description = "중복 이름 설명", type = BoardType.GENERAL, commentEnabled = true, writePermission = MemberRole.USER, @@ -160,9 +167,16 @@ class ManageBoardUseCaseTest : val board = BoardTestFixture.create(club = club, name = "기존", type = BoardType.GENERAL) every { boardRepository.findByIdAndIsDeletedFalse(1L) } returns board - val result = useCase.update(clubId, 1L, UpdateBoardRequest(name = "변경", isPrivate = true), userId) + val result = + useCase.update( + clubId, + 1L, + UpdateBoardRequest(name = "변경", description = "변경된 설명", isPrivate = true), + userId, + ) result.name shouldBe "변경" + result.description shouldBe "변경된 설명" result.commentEnabled shouldBe true result.writePermission shouldBe MemberRole.USER result.isPrivate shouldBe true @@ -175,6 +189,7 @@ class ManageBoardUseCaseTest : val result = useCase.update(clubId, 1L, UpdateBoardRequest(), userId) result.name shouldBe "기존" + result.description shouldBe "일반 게시판 설명" result.commentEnabled shouldBe true result.writePermission shouldBe MemberRole.USER result.isPrivate shouldBe false diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt index 40274aea..8dd9ee78 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt @@ -139,6 +139,14 @@ class GetBoardQueryServiceTest : result.first().postCount shouldBe 5 } + it("가상 전체 게시판은 기본 설명을 포함한다") { + every { boardRepository.findAllByClubIdOrderByDisplayOrderAscIdAsc(clubId) } returns emptyList() + + val result = queryService.findAllBoardsForAdmin(clubId, userId) + + result.first().description shouldBe "모든 게시글을 확인할 수 있는 게시판입니다." + } + it("게시판이 없으면 postRepository를 호출하지 않는다") { every { boardRepository.findAllByClubIdOrderByDisplayOrderAscIdAsc(clubId) } returns emptyList() @@ -192,4 +200,24 @@ class GetBoardQueryServiceTest : result.postCount shouldBe 3 } } + + describe("checkBoardNameDuplicate") { + it("같은 클럽의 활성 게시판에 같은 이름이 있으면 중복으로 반환한다") { + every { boardRepository.existsByClubIdAndNameAndIsDeletedFalse(clubId, "운영") } returns true + + val result = queryService.checkBoardNameDuplicate(clubId, userId, "운영") + + result.duplicated shouldBe true + } + + it("수정 대상 boardId가 있으면 자기 자신은 중복 검사에서 제외한다") { + every { + boardRepository.existsByClubIdAndNameAndIsDeletedFalseAndIdNot(clubId, "운영", 3L) + } returns false + + val result = queryService.checkBoardNameDuplicate(clubId, userId, "운영", 3L) + + result.duplicated shouldBe false + } + } }) diff --git a/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt b/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt index bc343a30..1bf35050 100644 --- a/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt @@ -30,6 +30,14 @@ class BoardEntityTest : } } + "updateDescription은 빈 설명이면 예외를 던진다" { + val board = BoardTestFixture.create(name = "게시판", type = BoardType.GENERAL) + + shouldThrow { + board.updateDescription(" ") + } + } + "isAdminOnly는 writePermission이 ADMIN일 때 true를 반환한다" { val board = BoardTestFixture.create( @@ -112,7 +120,7 @@ class BoardEntityTest : val club = ClubTestFixture.createClub() shouldThrow { - Board(club = club, name = "전체", type = BoardType.ALL) + Board(club = club, name = "전체", description = "전체 게시판 설명", type = BoardType.ALL) } } }) diff --git a/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt b/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt index 5703238c..5e99f2ca 100644 --- a/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt @@ -13,6 +13,7 @@ object BoardTestFixture { id: Long = 0L, club: Club = ClubTestFixture.createClub(), name: String = "일반 게시판", + description: String = "일반 게시판 설명", type: BoardType = BoardType.GENERAL, config: BoardConfig = BoardConfig(), ): Board { @@ -20,6 +21,7 @@ object BoardTestFixture { Board( club = club, name = name, + description = description, type = type, config = config, ) @@ -30,10 +32,12 @@ object BoardTestFixture { fun createNoticeBoard( club: Club = ClubTestFixture.createClub(), name: String = "공지사항", + description: String = "운영진이 공지사항을 올리는 게시판입니다.", ): Board = create( club = club, name = name, + description = description, type = BoardType.NOTICE, config = BoardConfig(writePermission = MemberRole.ADMIN), ) diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt index c6605053..e6a777d0 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt @@ -102,6 +102,7 @@ class CommentConcurrencyTest( Board( club = club, name = "concurrency-board-$runId", + description = "동시성 테스트용 게시판 설명", type = BoardType.GENERAL, ), ) diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt index 45b75ace..6ef57039 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt @@ -66,6 +66,7 @@ class CommentQueryPerformanceTest( Board( club = club, name = "perf-board", + description = "성능 테스트용 게시판 설명", type = BoardType.GENERAL, ), ) From 45c8e74b6c3d51a811f6ef907a0513a2278c6cf3 Mon Sep 17 00:00:00 2001 From: Jeon Soo Hyeon <128474444+soo0711@users.noreply.github.com> Date: Mon, 27 Apr 2026 23:09:15 +0900 Subject: [PATCH 64/73] =?UTF-8?q?[WTH-326]=20=EA=B2=8C=EC=8B=9C=ED=8C=90?= =?UTF-8?q?=20=EA=B4=80=EB=A0=A8=20API=EC=97=90=20boardId=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80=20(#58)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 게시판 관련 응답에 게시판 ID 추가 * fix: 게시판 관련 응답에 게시판 ID 추가 * refactor: 게시판 관련 요청에 게시판 ID 추가 * test: 관련 테스트 수정 * refactor: 좋아요 응답 DTO를 PostLikeActionResponse로 분리, 게시글 수정 boardId 추가 * refactor: 게시판 글자수 20자로 제한 --- .../dto/request/CreateBoardRequest.kt | 2 +- .../dto/request/UpdateBoardRequest.kt | 2 +- .../dto/response/PostLikeActionResponse.kt | 12 ++++ .../dto/response/PostSaveResponse.kt | 2 + .../board/application/mapper/PostMapper.kt | 8 ++- .../usecase/command/ManagePostLikeUseCase.kt | 19 +++--- .../usecase/command/ManagePostUseCase.kt | 4 ++ .../usecase/query/GetPostQueryService.kt | 2 + .../board/presentation/PostController.kt | 31 +++++----- .../dto/response/DashboardNoticeResponse.kt | 2 + .../dto/response/DashboardPostResponse.kt | 2 + .../response/DashboardUnreadNoticeResponse.kt | 2 + .../application/mapper/DashboardMapper.kt | 3 + .../command/ManagePostLikeUseCaseTest.kt | 58 +++++++++++++------ .../usecase/command/ManagePostUseCaseTest.kt | 48 +++++++++++---- .../usecase/query/GetPostQueryServiceTest.kt | 21 +++++-- .../query/GetDashboardQueryServiceTest.kt | 1 + 17 files changed, 163 insertions(+), 56 deletions(-) create mode 100644 src/main/kotlin/com/weeth/domain/board/application/dto/response/PostLikeActionResponse.kt diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/request/CreateBoardRequest.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/request/CreateBoardRequest.kt index 2f6ea8f5..06613d62 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/dto/request/CreateBoardRequest.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/request/CreateBoardRequest.kt @@ -10,7 +10,7 @@ import jakarta.validation.constraints.Size data class CreateBoardRequest( @field:Schema(description = "게시판 이름", example = "공지사항") @field:NotBlank - @field:Size(max = 100) + @field:Size(max = 20) val name: String, @field:Schema(description = "게시판 설명", example = "공지사항 게시판입니다.") @field:NotBlank diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdateBoardRequest.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdateBoardRequest.kt index f78b0b2b..199897c6 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdateBoardRequest.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdateBoardRequest.kt @@ -6,7 +6,7 @@ import jakarta.validation.constraints.Size data class UpdateBoardRequest( @field:Schema(description = "게시판 이름", example = "새 공지사항", nullable = true) - @field:Size(max = 100) + @field:Size(max = 20) val name: String? = null, @field:Schema(description = "게시판 설명", example = "운영 관련 새 공지사항입니다.", nullable = true) @field:Size(max = 30) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostLikeActionResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostLikeActionResponse.kt new file mode 100644 index 00000000..2e76510d --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostLikeActionResponse.kt @@ -0,0 +1,12 @@ +package com.weeth.domain.board.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class PostLikeActionResponse( + @field:Schema(description = "게시판 ID", example = "1") + val boardId: Long, + @field:Schema(description = "좋아요 여부", example = "true") + val isLiked: Boolean, + @field:Schema(description = "좋아요 수", example = "5") + val likeCount: Int, +) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostSaveResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostSaveResponse.kt index e13b78f0..450713d9 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostSaveResponse.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostSaveResponse.kt @@ -5,4 +5,6 @@ import io.swagger.v3.oas.annotations.media.Schema data class PostSaveResponse( @field:Schema(description = "게시글 ID", example = "1") val id: Long, + @field:Schema(description = "게시판 ID", example = "1") + val boardId: Long, ) diff --git a/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt b/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt index 3a31eb4c..ae85e4a0 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt @@ -1,6 +1,7 @@ package com.weeth.domain.board.application.mapper import com.weeth.domain.board.application.dto.response.PostDetailResponse +import com.weeth.domain.board.application.dto.response.PostLikeActionResponse import com.weeth.domain.board.application.dto.response.PostLikeResponse import com.weeth.domain.board.application.dto.response.PostListResponse import com.weeth.domain.board.application.dto.response.PostSaveResponse @@ -17,13 +18,18 @@ import java.time.LocalDateTime class PostMapper( private val fileAccessUrlPort: FileAccessUrlPort, ) { - fun toSaveResponse(post: Post) = PostSaveResponse(id = post.id) + fun toSaveResponse(post: Post) = PostSaveResponse(id = post.id, boardId = post.board.id) fun toLikeResponse( post: Post, isLiked: Boolean, ) = PostLikeResponse(isLiked = isLiked, likeCount = post.likeCount) + fun toLikeActionResponse( + post: Post, + isLiked: Boolean, + ) = PostLikeActionResponse(boardId = post.board.id, isLiked = isLiked, likeCount = post.likeCount) + fun toDetailResponse( post: Post, comments: List, diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostLikeUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostLikeUseCase.kt index 85486e19..3c795ac7 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostLikeUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostLikeUseCase.kt @@ -1,6 +1,7 @@ package com.weeth.domain.board.application.usecase.command -import com.weeth.domain.board.application.dto.response.PostLikeResponse +import com.weeth.domain.board.application.dto.response.PostLikeActionResponse +import com.weeth.domain.board.application.exception.BoardNotFoundException import com.weeth.domain.board.application.exception.CategoryAccessDeniedException import com.weeth.domain.board.application.exception.PostLikeLockTimeoutException import com.weeth.domain.board.application.exception.PostNotFoundException @@ -24,10 +25,11 @@ class ManagePostLikeUseCase( @Transactional fun like( clubId: Long, + boardId: Long, postId: Long, userId: Long, - ): PostLikeResponse { - val (post, existingLike) = getValidatedPostWithLike(clubId, postId, userId) + ): PostLikeActionResponse { + val (post, existingLike) = getValidatedPostWithLike(clubId, boardId, postId, userId) when { existingLike == null -> { @@ -41,27 +43,29 @@ class ManagePostLikeUseCase( } } - return postMapper.toLikeResponse(post, isLiked = true) + return postMapper.toLikeActionResponse(post, isLiked = true) } @Transactional fun unlike( clubId: Long, + boardId: Long, postId: Long, userId: Long, - ): PostLikeResponse { - val (post, existingLike) = getValidatedPostWithLike(clubId, postId, userId) + ): PostLikeActionResponse { + val (post, existingLike) = getValidatedPostWithLike(clubId, boardId, postId, userId) if (existingLike?.isActive == true) { existingLike.deactivate() post.decreaseLikeCount() } - return postMapper.toLikeResponse(post, isLiked = false) + return postMapper.toLikeActionResponse(post, isLiked = false) } private fun getValidatedPostWithLike( clubId: Long, + boardId: Long, postId: Long, userId: Long, ): Pair { @@ -73,6 +77,7 @@ class ManagePostLikeUseCase( throw PostLikeLockTimeoutException() } + if (post.board.id != boardId) throw BoardNotFoundException() if (!post.belongsToClub(clubId)) throw PostNotFoundException() if (!post.board.isAccessibleBy(member.memberRole)) throw CategoryAccessDeniedException() diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt index cae12c93..cb434657 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt @@ -67,12 +67,14 @@ class ManagePostUseCase( @Transactional fun update( clubId: Long, + boardId: Long, postId: Long, request: UpdatePostRequest, userId: Long, ): PostSaveResponse { val member = clubMemberPolicy.getActiveMember(clubId, userId) val post = findPost(postId) + if (post.board.id != boardId) throw BoardNotFoundException() if (post.board.club.id != clubId) throw PostNotFoundException() validateOwner(post, userId) validateWritePermission(post.board, member) @@ -89,11 +91,13 @@ class ManagePostUseCase( @Transactional fun delete( clubId: Long, + boardId: Long, postId: Long, userId: Long, ) { val member = clubMemberPolicy.getActiveMember(clubId, userId) val post = findPost(postId) + if (post.board.id != boardId) throw BoardNotFoundException() if (post.board.club.id != clubId) throw PostNotFoundException() validateOwner(post, userId) validateWritePermission(post.board, member) diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt index 3addef61..0d151c35 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt @@ -47,11 +47,13 @@ class GetPostQueryService( fun findPost( clubId: Long, userId: Long, + boardId: Long, postId: Long, ): PostDetailResponse { val member = clubMemberPolicy.getActiveMember(clubId, userId) val post = postRepository.findByIdAndIsDeletedFalse(postId) ?: throw PostNotFoundException() + if (post.board.id != boardId) throw BoardNotFoundException() if (post.board.club.id != clubId || post.board.isDeleted || !post.board.isAccessibleBy(member.memberRole)) { throw PostNotFoundException() } diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt b/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt index f58b1846..d3842ed1 100644 --- a/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt +++ b/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt @@ -3,7 +3,7 @@ package com.weeth.domain.board.presentation import com.weeth.domain.board.application.dto.request.CreatePostRequest import com.weeth.domain.board.application.dto.request.UpdatePostRequest import com.weeth.domain.board.application.dto.response.PostDetailResponse -import com.weeth.domain.board.application.dto.response.PostLikeResponse +import com.weeth.domain.board.application.dto.response.PostLikeActionResponse import com.weeth.domain.board.application.dto.response.PostListResponse import com.weeth.domain.board.application.dto.response.PostSaveResponse import com.weeth.domain.board.application.exception.BoardErrorCode @@ -85,42 +85,45 @@ class PostController( getPostQueryService.findPosts(clubId, userId, boardId, pageNumber, pageSize), ) - @GetMapping("/posts/{postId}") + @GetMapping("/{boardId}/posts/{postId}") @Operation(summary = "게시글 상세 조회") fun findPost( @TsidParam @TsidPathVariable clubId: Long, + @PathVariable boardId: Long, @PathVariable postId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse = CommonResponse.success( BoardResponseCode.POST_FIND_BY_ID_SUCCESS, - getPostQueryService.findPost(clubId, userId, postId), + getPostQueryService.findPost(clubId, userId, boardId, postId), ) - @PatchMapping("/posts/{postId}") + @PatchMapping("/{boardId}/posts/{postId}") @Operation(summary = "게시글 수정") fun update( @TsidParam @TsidPathVariable clubId: Long, + @PathVariable boardId: Long, @PathVariable postId: Long, @RequestBody @Valid request: UpdatePostRequest, @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse = CommonResponse.success( BoardResponseCode.POST_UPDATED_SUCCESS, - managePostUseCase.update(clubId, postId, request, userId), + managePostUseCase.update(clubId, boardId, postId, request, userId), ) - @DeleteMapping("/posts/{postId}") + @DeleteMapping("/{boardId}/posts/{postId}") @Operation(summary = "게시글 삭제") fun delete( @TsidParam @TsidPathVariable clubId: Long, + @PathVariable boardId: Long, @PathVariable postId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse { - managePostUseCase.delete(clubId, postId, userId) + managePostUseCase.delete(clubId, boardId, postId, userId) return CommonResponse.success(BoardResponseCode.POST_DELETED_SUCCESS) } @@ -152,29 +155,31 @@ class PostController( return CommonResponse.success(BoardResponseCode.BOARD_NOTICE_READ_SUCCESS) } - @PostMapping("/posts/{postId}/like") + @PostMapping("/{boardId}/posts/{postId}/like") @Operation(summary = "게시글 좋아요") fun like( @TsidParam @TsidPathVariable clubId: Long, + @PathVariable boardId: Long, @PathVariable postId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, - ): CommonResponse = + ): CommonResponse = CommonResponse.success( BoardResponseCode.POST_LIKE_SUCCESS, - managePostLikeUseCase.like(clubId, postId, userId), + managePostLikeUseCase.like(clubId, boardId, postId, userId), ) - @DeleteMapping("/posts/{postId}/like") + @DeleteMapping("/{boardId}/posts/{postId}/like") @Operation(summary = "게시글 좋아요 취소") fun unlike( @TsidParam @TsidPathVariable clubId: Long, + @PathVariable boardId: Long, @PathVariable postId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, - ): CommonResponse = + ): CommonResponse = CommonResponse.success( BoardResponseCode.POST_UNLIKE_SUCCESS, - managePostLikeUseCase.unlike(clubId, postId, userId), + managePostLikeUseCase.unlike(clubId, boardId, postId, userId), ) } diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardNoticeResponse.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardNoticeResponse.kt index 207b19b2..3dbb0eb1 100644 --- a/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardNoticeResponse.kt +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardNoticeResponse.kt @@ -6,6 +6,8 @@ import java.time.LocalDateTime data class DashboardNoticeResponse( @field:Schema(description = "게시글 ID", example = "1") val id: Long, + @field:Schema(description = "게시판 ID", example = "1") + val boardId: Long, @field:Schema(description = "공지 제목", example = "중간고사 기간 공지") val title: String, @field:Schema(description = "공지 내용", example = "이번 주 정기 모임은 중간고사 기간으로 인해 쉬어갑니다.") diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardPostResponse.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardPostResponse.kt index db715b72..d155a6e8 100644 --- a/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardPostResponse.kt +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardPostResponse.kt @@ -9,6 +9,8 @@ import java.time.LocalDateTime data class DashboardPostResponse( @field:Schema(description = "게시글 ID", example = "1") val id: Long, + @field:Schema(description = "게시판 ID", example = "1") + val boardId: Long, @field:Schema(description = "작성자 정보") val author: UserInfo, @field:Schema(description = "제목", example = "안녕하세요") diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardUnreadNoticeResponse.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardUnreadNoticeResponse.kt index 6111e0ef..23bbf59c 100644 --- a/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardUnreadNoticeResponse.kt +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardUnreadNoticeResponse.kt @@ -5,6 +5,8 @@ import io.swagger.v3.oas.annotations.media.Schema data class DashboardUnreadNoticeResponse( @field:Schema(description = "게시글 ID", example = "1") val id: Long, + @field:Schema(description = "게시판 ID", example = "1") + val boardId: Long, @field:Schema(description = "공지 제목", example = "중간고사 기간 공지") val title: String, @field:Schema(description = "공지 내용", example = "이번 주 정기 모임은 중간고사 기간으로 인해 쉬어갑니다.") diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/mapper/DashboardMapper.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/mapper/DashboardMapper.kt index d1f8952d..27d5767f 100644 --- a/src/main/kotlin/com/weeth/domain/dashboard/application/mapper/DashboardMapper.kt +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/mapper/DashboardMapper.kt @@ -105,6 +105,7 @@ class DashboardMapper( isLiked: Boolean, ) = DashboardPostResponse( id = post.id, + boardId = post.board.id, author = UserInfo.of(post.clubMember.user, post.clubMember.memberRole, resolveProfileImage(post.clubMember)), title = post.title, content = post.content, @@ -120,6 +121,7 @@ class DashboardMapper( now: LocalDateTime, ) = DashboardNoticeResponse( id = post.id, + boardId = post.board.id, title = post.title, content = post.content, time = post.createdAt, @@ -129,6 +131,7 @@ class DashboardMapper( fun toUnreadNoticeResponse(post: Post) = DashboardUnreadNoticeResponse( id = post.id, + boardId = post.board.id, title = post.title, content = post.content, ) diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostLikeUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostLikeUseCaseTest.kt index 90deab05..53037af5 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostLikeUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostLikeUseCaseTest.kt @@ -1,6 +1,7 @@ package com.weeth.domain.board.application.usecase.command -import com.weeth.domain.board.application.dto.response.PostLikeResponse +import com.weeth.domain.board.application.dto.response.PostLikeActionResponse +import com.weeth.domain.board.application.exception.BoardNotFoundException import com.weeth.domain.board.application.exception.CategoryAccessDeniedException import com.weeth.domain.board.application.exception.PostLikeLockTimeoutException import com.weeth.domain.board.application.exception.PostNotFoundException @@ -39,6 +40,7 @@ class ManagePostLikeUseCaseTest : val club = ClubTestFixture.createClub(id = clubId) val board = BoardTestFixture.create(club = club) + val boardId = board.id val member = ClubMemberTestFixture.createActiveMember(club = club) val otherPost = PostTestFixture.create( @@ -54,8 +56,12 @@ class ManagePostLikeUseCaseTest : clearMocks(postRepository, postLikeRepository, clubMemberPolicy, postMapper) every { clubMemberPolicy.getActiveMember(clubId, userId) } returns member every { postLikeRepository.save(any()) } answers { firstArg() } - every { postMapper.toLikeResponse(any(), any()) } answers { - PostLikeResponse(isLiked = secondArg(), likeCount = firstArg().likeCount) + every { postMapper.toLikeActionResponse(any(), any()) } answers { + PostLikeActionResponse( + boardId = firstArg().board.id, + isLiked = secondArg(), + likeCount = firstArg().likeCount, + ) } } @@ -64,7 +70,7 @@ class ManagePostLikeUseCaseTest : it("PostNotFoundException을 던진다") { every { postRepository.findByIdWithLock(postId) } returns null - shouldThrow { useCase.like(clubId, postId, userId) } + shouldThrow { useCase.like(clubId, boardId, postId, userId) } } } @@ -73,7 +79,16 @@ class ManagePostLikeUseCaseTest : every { postRepository.findByIdWithLock(postId) } throws PessimisticLockingFailureException("lock timeout") - shouldThrow { useCase.like(clubId, postId, userId) } + shouldThrow { useCase.like(clubId, boardId, postId, userId) } + } + } + + context("URL의 boardId와 게시글의 게시판이 다를 때") { + it("BoardNotFoundException을 던진다") { + val post = PostTestFixture.create(board = board) + every { postRepository.findByIdWithLock(postId) } returns post + + shouldThrow { useCase.like(clubId, boardId + 1, postId, userId) } } } @@ -81,7 +96,7 @@ class ManagePostLikeUseCaseTest : it("PostNotFoundException을 던진다") { every { postRepository.findByIdWithLock(postId) } returns otherPost - shouldThrow { useCase.like(clubId, postId, userId) } + shouldThrow { useCase.like(clubId, boardId, postId, userId) } } } @@ -90,7 +105,7 @@ class ManagePostLikeUseCaseTest : every { clubMemberPolicy.getActiveMember(clubId, userId) } returns userMember every { postRepository.findByIdWithLock(postId) } returns privatePost - shouldThrow { useCase.like(clubId, postId, userId) } + shouldThrow { useCase.like(clubId, boardId, postId, userId) } } } @@ -100,7 +115,7 @@ class ManagePostLikeUseCaseTest : every { postRepository.findByIdWithLock(postId) } returns post every { postLikeRepository.findByPostAndUserId(post, userId) } returns null - val result = useCase.like(clubId, postId, userId) + val result = useCase.like(clubId, boardId, postId, userId) result.isLiked shouldBe true result.likeCount shouldBe 1 @@ -115,7 +130,7 @@ class ManagePostLikeUseCaseTest : every { postRepository.findByIdWithLock(postId) } returns post every { postLikeRepository.findByPostAndUserId(post, userId) } returns existingLike - val result = useCase.like(clubId, postId, userId) + val result = useCase.like(clubId, boardId, postId, userId) result.isLiked shouldBe true result.likeCount shouldBe 1 @@ -130,7 +145,7 @@ class ManagePostLikeUseCaseTest : every { postRepository.findByIdWithLock(postId) } returns post every { postLikeRepository.findByPostAndUserId(post, userId) } returns existingLike - val result = useCase.like(clubId, postId, userId) + val result = useCase.like(clubId, boardId, postId, userId) result.isLiked shouldBe true result.likeCount shouldBe 1 @@ -144,7 +159,7 @@ class ManagePostLikeUseCaseTest : it("PostNotFoundException을 던진다") { every { postRepository.findByIdWithLock(postId) } returns null - shouldThrow { useCase.unlike(clubId, postId, userId) } + shouldThrow { useCase.unlike(clubId, boardId, postId, userId) } } } @@ -153,7 +168,16 @@ class ManagePostLikeUseCaseTest : every { postRepository.findByIdWithLock(postId) } throws PessimisticLockingFailureException("lock timeout") - shouldThrow { useCase.unlike(clubId, postId, userId) } + shouldThrow { useCase.unlike(clubId, boardId, postId, userId) } + } + } + + context("URL의 boardId와 게시글의 게시판이 다를 때") { + it("BoardNotFoundException을 던진다") { + val post = PostTestFixture.create(board = board) + every { postRepository.findByIdWithLock(postId) } returns post + + shouldThrow { useCase.unlike(clubId, boardId + 1, postId, userId) } } } @@ -161,7 +185,7 @@ class ManagePostLikeUseCaseTest : it("PostNotFoundException을 던진다") { every { postRepository.findByIdWithLock(postId) } returns otherPost - shouldThrow { useCase.unlike(clubId, postId, userId) } + shouldThrow { useCase.unlike(clubId, boardId, postId, userId) } } } @@ -170,7 +194,7 @@ class ManagePostLikeUseCaseTest : every { clubMemberPolicy.getActiveMember(clubId, userId) } returns userMember every { postRepository.findByIdWithLock(postId) } returns privatePost - shouldThrow { useCase.unlike(clubId, postId, userId) } + shouldThrow { useCase.unlike(clubId, boardId, postId, userId) } } } @@ -181,7 +205,7 @@ class ManagePostLikeUseCaseTest : every { postRepository.findByIdWithLock(postId) } returns post every { postLikeRepository.findByPostAndUserId(post, userId) } returns existingLike - val result = useCase.unlike(clubId, postId, userId) + val result = useCase.unlike(clubId, boardId, postId, userId) result.isLiked shouldBe false result.likeCount shouldBe 0 @@ -195,7 +219,7 @@ class ManagePostLikeUseCaseTest : every { postRepository.findByIdWithLock(postId) } returns post every { postLikeRepository.findByPostAndUserId(post, userId) } returns existingLike - val result = useCase.unlike(clubId, postId, userId) + val result = useCase.unlike(clubId, boardId, postId, userId) result.isLiked shouldBe false result.likeCount shouldBe 0 @@ -208,7 +232,7 @@ class ManagePostLikeUseCaseTest : every { postRepository.findByIdWithLock(postId) } returns post every { postLikeRepository.findByPostAndUserId(post, userId) } returns null - val result = useCase.unlike(clubId, postId, userId) + val result = useCase.unlike(clubId, boardId, postId, userId) result.isLiked shouldBe false result.likeCount shouldBe 0 diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt index ce6025cc..0fd0ea15 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt @@ -89,7 +89,7 @@ class ManagePostUseCaseTest : every { fileMapper.toFileList(any(), any(), any()) } returns emptyList() every { fileRepository.saveAll(any>()) } returns emptyList() every { fileReader.findAll(any(), any(), any()) } returns emptyList() - every { postMapper.toSaveResponse(any()) } returns PostSaveResponse(1L) + every { postMapper.toSaveResponse(any()) } returns PostSaveResponse(id = 1L, boardId = 1L) every { fileRepository.delete(any()) } just runs every { fileRepository.flush() } just runs every { clubMemberCardinalReader.findLatestCardinalByClubMember(any()) } returns null @@ -194,7 +194,7 @@ class ManagePostUseCaseTest : every { postRepository.findActivePostById(1L) } returns post - useCase.update(clubId, 1L, request, 1L) + useCase.update(clubId, board.id, 1L, request, 1L) verify(exactly = 0) { fileReader.findAll(any(), any(), any()) } verify(exactly = 0) { fileRepository.saveAll(any>()) } @@ -229,7 +229,7 @@ class ManagePostUseCaseTest : every { fileMapper.toFileList(request.files, FileOwnerType.POST, any()) } returns newFiles every { fileRepository.saveAll(newFiles) } returns newFiles - useCase.update(clubId, 1L, request, 1L) + useCase.update(clubId, board.id, 1L, request, 1L) post.title shouldBe "수정" post.content shouldBe "수정" @@ -246,7 +246,7 @@ class ManagePostUseCaseTest : every { postRepository.findActivePostById(1L) } returns post - useCase.update(clubId, 1L, request, 1L) + useCase.update(clubId, board.id, 1L, request, 1L) post.title shouldBe "원래 제목" post.content shouldBe "수정된 내용" @@ -261,7 +261,7 @@ class ManagePostUseCaseTest : every { postRepository.findActivePostById(1L) } returns post - useCase.update(clubId, 1L, request, 1L) + useCase.update(clubId, board.id, 1L, request, 1L) post.title shouldBe "수정된 제목" post.content shouldBe "원래 내용" @@ -271,7 +271,19 @@ class ManagePostUseCaseTest : every { postRepository.findActivePostById(1L) } returns null shouldThrow { - useCase.update(1L, 1L, UpdatePostRequest(title = "수정"), 1L) + useCase.update(1L, 0L, 1L, UpdatePostRequest(title = "수정"), 1L) + } + } + + it("URL의 boardId와 게시글의 게시판이 다르면 BoardNotFoundException을 던진다") { + val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val clubId = board.club.id + val post = PostTestFixture.create(title = "제목", content = "내용", board = board) + + every { postRepository.findActivePostById(1L) } returns post + + shouldThrow { + useCase.update(clubId, board.id + 1, 1L, UpdatePostRequest(title = "수정"), 1L) } } @@ -289,7 +301,7 @@ class ManagePostUseCaseTest : every { postRepository.findActivePostById(1L) } returns post shouldThrow { - useCase.update(clubId, 1L, UpdatePostRequest(title = "수정"), 1L) + useCase.update(clubId, board.id, 1L, UpdatePostRequest(title = "수정"), 1L) } } @@ -307,7 +319,7 @@ class ManagePostUseCaseTest : every { postRepository.findActivePostById(1L) } returns post shouldThrow { - useCase.update(clubId, 1L, UpdatePostRequest(title = "수정"), 1L) + useCase.update(clubId, board.id, 1L, UpdatePostRequest(title = "수정"), 1L) } } } @@ -325,7 +337,7 @@ class ManagePostUseCaseTest : every { fileReader.findAll(FileOwnerType.POST, any(), any()) } returns listOf(oldFile) every { fileRepository.deleteAll(any>()) } just runs - useCase.delete(clubId, 1L, 1L) + useCase.delete(clubId, board.id, 1L, 1L) post.isDeleted shouldBe true verify(exactly = 1) { fileRepository.deleteAll(listOf(oldFile)) } @@ -336,7 +348,19 @@ class ManagePostUseCaseTest : every { postRepository.findActivePostById(1L) } returns null shouldThrow { - useCase.delete(1L, 1L, 1L) + useCase.delete(1L, 0L, 1L, 1L) + } + } + + it("URL의 boardId와 게시글의 게시판이 다르면 BoardNotFoundException을 던진다") { + val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val clubId = board.club.id + val post = PostTestFixture.create(title = "제목", content = "내용", board = board) + + every { postRepository.findActivePostById(1L) } returns post + + shouldThrow { + useCase.delete(clubId, board.id + 1, 1L, 1L) } } @@ -354,7 +378,7 @@ class ManagePostUseCaseTest : every { postRepository.findActivePostById(1L) } returns post shouldThrow { - useCase.delete(clubId, 1L, 1L) + useCase.delete(clubId, board.id, 1L, 1L) } } } @@ -370,7 +394,7 @@ class ManagePostUseCaseTest : every { postRepository.findActivePostById(1L) } returns post shouldThrow { - useCase.update(clubId, 1L, request, 2L) + useCase.update(clubId, board.id, 1L, request, 2L) } } } diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt index 2046bcc5..b97aa3da 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt @@ -86,7 +86,20 @@ class GetPostQueryServiceTest : every { postRepository.findByIdAndIsDeletedFalse(1L) } returns null shouldThrow { - queryService.findPost(clubId, userId, 1L) + queryService.findPost(clubId, userId, 0L, 1L) + } + } + + it("URL의 boardId와 게시글의 게시판이 다르면 BoardNotFoundException을 던진다") { + val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) + val member = ClubMemberTestFixture.createActiveMember(club = board.club) + val post = PostTestFixture.create(title = "제목", content = "내용", clubMember = member, board = board) + + every { clubMemberPolicy.getActiveMember(board.club.id, userId) } returns member + every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post + + shouldThrow { + queryService.findPost(board.club.id, userId, board.id + 1, 1L) } } @@ -145,7 +158,7 @@ class GetPostQueryServiceTest : every { postMapper.toDetailResponse(post, comments, fileResponses, false, any()) } returns detail every { fileMapper.toFileResponse(files.first()) } returns fileResponses.first() - val result = queryService.findPost(actualClubId, userId, 1L) + val result = queryService.findPost(actualClubId, userId, board.id, 1L) result.id shouldBe 1L result.comments.size shouldBe 1 @@ -170,7 +183,7 @@ class GetPostQueryServiceTest : every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post shouldThrow { - queryService.findPost(actualClubId, userId, 1L) + queryService.findPost(actualClubId, userId, privateBoard.id, 1L) } } @@ -194,7 +207,7 @@ class GetPostQueryServiceTest : every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post shouldThrow { - queryService.findPost(actualClubId, userId, 1L) + queryService.findPost(actualClubId, userId, deletedBoard.id, 1L) } } } diff --git a/src/test/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryServiceTest.kt index 9d5f4067..1121be26 100644 --- a/src/test/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryServiceTest.kt @@ -191,6 +191,7 @@ class GetDashboardQueryServiceTest : val result = queryService.getRecentPosts(clubId, userId, 0, 10) result.content.size shouldBe 1 + result.content[0].boardId shouldBe board.id result.content[0].fileUrls.isEmpty() shouldBe true result.content[0].like.isLiked shouldBe false } From db4bfd35bbfac94d1686761e63e68de7654bb48c Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:37:23 +0900 Subject: [PATCH 65/73] =?UTF-8?q?[WTH-350]=20=EA=B2=8C=EC=8B=9C=ED=8C=90?= =?UTF-8?q?=20=EA=B6=8C=ED=95=9C=20=EC=B6=94=EA=B0=80=20(#60)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 게시판 설정에 따른 권한 표시 * refactor: 댓글 허용에 따른 권한 확인 --- .../dto/response/BoardConfigResponse.kt | 22 +++++++++++++++++++ .../dto/response/BoardListResponse.kt | 2 ++ .../dto/response/PostDetailResponse.kt | 2 ++ .../dto/response/PostListResponse.kt | 2 ++ .../board/application/mapper/BoardMapper.kt | 17 +++++++++----- .../board/application/mapper/PostMapper.kt | 6 +++++ .../usecase/query/GetBoardQueryService.kt | 14 +++++++++--- .../usecase/query/GetPostQueryService.kt | 10 +++++---- .../application/exception/CommentErrorCode.kt | 3 +++ .../exception/CommentNotAllowedException.kt | 5 +++++ .../usecase/command/ManageCommentUseCase.kt | 9 ++++++++ .../dto/response/DashboardPostResponse.kt | 3 +++ .../application/mapper/DashboardMapper.kt | 4 ++++ .../usecase/query/GetDashboardQueryService.kt | 1 + .../application/mapper/PostMapperTest.kt | 13 ++++++++++- .../usecase/query/GetPostQueryServiceTest.kt | 12 +++++++--- 16 files changed, 108 insertions(+), 17 deletions(-) create mode 100644 src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardConfigResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/comment/application/exception/CommentNotAllowedException.kt diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardConfigResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardConfigResponse.kt new file mode 100644 index 00000000..83078405 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardConfigResponse.kt @@ -0,0 +1,22 @@ +package com.weeth.domain.board.application.dto.response + +import com.weeth.domain.board.domain.entity.Board +import com.weeth.domain.club.domain.enums.MemberRole +import io.swagger.v3.oas.annotations.media.Schema + +data class BoardConfigResponse( + @field:Schema(description = "글 작성 가능 여부") + val canWrite: Boolean, + @field:Schema(description = "댓글 작성 가능 여부") + val canComment: Boolean, +) { + companion object { + fun of( + board: Board, + memberRole: MemberRole, + ) = BoardConfigResponse( + canWrite = board.canWriteBy(memberRole), + canComment = board.isCommentEnabled, + ) + } +} diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardListResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardListResponse.kt index 082af45b..909c7935 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardListResponse.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardListResponse.kt @@ -10,4 +10,6 @@ data class BoardListResponse( val name: String, @field:Schema(description = "게시판 타입") val type: BoardType, + @field:Schema(description = "게시판 설정") + val boardConfig: BoardConfigResponse, ) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt index c3827381..1ca99440 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt @@ -31,4 +31,6 @@ data class PostDetailResponse( val fileUrls: List, @field:Schema(description = "신규 게시글 여부 (24시간 이내)") val isNew: Boolean, + @field:Schema(description = "게시판 설정") + val boardConfig: BoardConfigResponse, ) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt index df9f57ea..90247aa2 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt @@ -28,4 +28,6 @@ data class PostListResponse( val fileUrls: List, @field:Schema(description = "신규 게시글 여부 (24시간 이내)") val isNew: Boolean, + @field:Schema(description = "게시판 설정") + val boardConfig: BoardConfigResponse, ) diff --git a/src/main/kotlin/com/weeth/domain/board/application/mapper/BoardMapper.kt b/src/main/kotlin/com/weeth/domain/board/application/mapper/BoardMapper.kt index 1e949ed9..d0481375 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/mapper/BoardMapper.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/mapper/BoardMapper.kt @@ -1,18 +1,23 @@ package com.weeth.domain.board.application.mapper +import com.weeth.domain.board.application.dto.response.BoardConfigResponse import com.weeth.domain.board.application.dto.response.BoardDetailResponse import com.weeth.domain.board.application.dto.response.BoardListResponse import com.weeth.domain.board.domain.entity.Board +import com.weeth.domain.club.domain.enums.MemberRole import org.springframework.stereotype.Component @Component class BoardMapper { - fun toListResponse(board: Board) = - BoardListResponse( - id = board.id, - name = board.name, - type = board.type, - ) + fun toListResponse( + board: Board, + memberRole: MemberRole, + ) = BoardListResponse( + id = board.id, + name = board.name, + type = board.type, + boardConfig = BoardConfigResponse.of(board, memberRole), + ) fun toDetailResponse(board: Board) = BoardDetailResponse( diff --git a/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt b/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt index ae85e4a0..af1d6344 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt @@ -1,5 +1,6 @@ package com.weeth.domain.board.application.mapper +import com.weeth.domain.board.application.dto.response.BoardConfigResponse import com.weeth.domain.board.application.dto.response.PostDetailResponse import com.weeth.domain.board.application.dto.response.PostLikeActionResponse import com.weeth.domain.board.application.dto.response.PostLikeResponse @@ -7,6 +8,7 @@ import com.weeth.domain.board.application.dto.response.PostListResponse import com.weeth.domain.board.application.dto.response.PostSaveResponse import com.weeth.domain.board.domain.entity.Post import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.enums.MemberRole import com.weeth.domain.comment.application.dto.response.CommentResponse import com.weeth.domain.file.application.dto.response.FileResponse import com.weeth.domain.file.domain.port.FileAccessUrlPort @@ -36,6 +38,7 @@ class PostMapper( files: List, isLiked: Boolean, now: LocalDateTime, + memberRole: MemberRole, ) = PostDetailResponse( id = post.id, boardId = post.board.id, @@ -49,6 +52,7 @@ class PostMapper( comments = comments, fileUrls = files, isNew = post.createdAt.isAfter(now.minusHours(24)), + boardConfig = BoardConfigResponse.of(post.board, memberRole), ) fun toListResponse( @@ -56,6 +60,7 @@ class PostMapper( files: List, now: LocalDateTime, isLiked: Boolean, + memberRole: MemberRole, ) = PostListResponse( id = post.id, author = UserInfo.of(post.clubMember.user, post.clubMember.memberRole, resolveProfileImage(post.clubMember)), @@ -68,6 +73,7 @@ class PostMapper( like = toLikeResponse(post, isLiked), fileUrls = files, isNew = post.createdAt.isAfter(now.minusHours(24)), + boardConfig = BoardConfigResponse.of(post.board, memberRole), ) private fun resolveProfileImage(member: ClubMember): String? = diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt index ac441f6c..bb85798e 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt @@ -1,5 +1,6 @@ package com.weeth.domain.board.application.usecase.query +import com.weeth.domain.board.application.dto.response.BoardConfigResponse import com.weeth.domain.board.application.dto.response.BoardDetailResponse import com.weeth.domain.board.application.dto.response.BoardListResponse import com.weeth.domain.board.application.dto.response.BoardNameDuplicateResponse @@ -34,9 +35,10 @@ class GetBoardQueryService( .filter { it.isAccessibleBy(member.memberRole) } // 공지사항 고정 첫 번째, 전체(가상) 두 번째, 나머지는 displayOrder 순 + val memberRole = member.memberRole val (noticeList, otherList) = realBoards.partition { it.type == BoardType.NOTICE } - val noticeBoards = noticeList.map(boardMapper::toListResponse) - val otherBoards = otherList.map(boardMapper::toListResponse) + val noticeBoards = noticeList.map { boardMapper.toListResponse(it, memberRole) } + val otherBoards = otherList.map { boardMapper.toListResponse(it, memberRole) } return noticeBoards + VIRTUAL_ALL_BOARD + otherBoards } @@ -100,7 +102,13 @@ class GetBoardQueryService( } companion object { - private val VIRTUAL_ALL_BOARD = BoardListResponse(id = null, name = "전체", type = BoardType.ALL) + private val VIRTUAL_ALL_BOARD = + BoardListResponse( + id = null, + name = "전체", + type = BoardType.ALL, + boardConfig = BoardConfigResponse(canWrite = false, canComment = false), + ) private fun virtualAllBoardForAdmin(totalPostCount: Int) = BoardDetailResponse( diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt index 0d151c35..fdfa6df1 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt @@ -64,7 +64,7 @@ class GetPostQueryService( val isLiked = postLikeRepository.existsByPostAndUserIdAndIsActiveTrue(post, userId) val now = LocalDateTime.now() - return postMapper.toDetailResponse(post, commentTree, files, isLiked, now) + return postMapper.toDetailResponse(post, commentTree, files, isLiked, now, member.memberRole) } fun findAllPosts( @@ -90,7 +90,7 @@ class GetPostQueryService( val posts = postRepository.findAllActiveByBoardIds(accessibleBoardIds, pageable) - return toPostListResponses(posts, userId) + return toPostListResponses(posts, userId, member.memberRole) } fun findPosts( @@ -107,7 +107,7 @@ class GetPostQueryService( val pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")) val posts = postRepository.findAllActiveByBoardId(boardId, pageable) - return toPostListResponses(posts, userId) + return toPostListResponses(posts, userId, member.memberRole) } fun searchPosts( @@ -128,12 +128,13 @@ class GetPostQueryService( throw NoSearchResultException() } - return toPostListResponses(posts, userId) + return toPostListResponses(posts, userId, member.memberRole) } private fun toPostListResponses( posts: Slice, userId: Long, + memberRole: MemberRole, ): Slice { val postIds = posts.content.map { it.id } val filesByPostId = buildFileMap(postIds) @@ -146,6 +147,7 @@ class GetPostQueryService( filesByPostId[post.id]?.map(fileMapper::toFileResponse) ?: emptyList(), now, post.id in likedPostIds, + memberRole, ) } } diff --git a/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentErrorCode.kt b/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentErrorCode.kt index be155174..0db78c4e 100644 --- a/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentErrorCode.kt @@ -17,4 +17,7 @@ enum class CommentErrorCode( @ExplainError("이미 삭제된 댓글에 대해 삭제를 재시도할 때 발생합니다.") COMMENT_ALREADY_DELETED(20502, HttpStatus.BAD_REQUEST, "이미 삭제된 댓글입니다."), + + @ExplainError("댓글이 허용되지 않은 게시판의 게시글에 댓글을 작성하려 할 때 발생합니다.") + COMMENT_NOT_ALLOWED(20503, HttpStatus.FORBIDDEN, "댓글이 허용되지 않은 게시판입니다."), } diff --git a/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentNotAllowedException.kt b/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentNotAllowedException.kt new file mode 100644 index 00000000..8efcc103 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/comment/application/exception/CommentNotAllowedException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.comment.application.exception + +import com.weeth.global.common.exception.BaseException + +class CommentNotAllowedException : BaseException(CommentErrorCode.COMMENT_NOT_ALLOWED) diff --git a/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt b/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt index e682cb7e..ffdc7170 100644 --- a/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt @@ -7,6 +7,7 @@ import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.comment.application.dto.request.CommentSaveRequest import com.weeth.domain.comment.application.dto.request.CommentUpdateRequest import com.weeth.domain.comment.application.exception.CommentAlreadyDeletedException +import com.weeth.domain.comment.application.exception.CommentNotAllowedException import com.weeth.domain.comment.application.exception.CommentNotFoundException import com.weeth.domain.comment.application.exception.CommentNotOwnedException import com.weeth.domain.comment.domain.entity.Comment @@ -35,6 +36,8 @@ class ManageCommentUseCase( userId: Long, ) { val post = findPostWithLock(postId) + ensureCommentAllowed(post) + val clubId = post.board.club.id val clubMember = clubMemberPolicy.getActiveMember(clubId, userId) val parent = @@ -156,6 +159,12 @@ class ManageCommentUseCase( } } + private fun ensureCommentAllowed(post: Post) { + if (!post.board.isCommentEnabled) { + throw CommentNotAllowedException() + } + } + private fun findPostWithLock(postId: Long): Post = postRepository.findByIdWithLock(postId) ?: throw PostNotFoundException() } diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardPostResponse.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardPostResponse.kt index d155a6e8..a8447d61 100644 --- a/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardPostResponse.kt +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/dto/response/DashboardPostResponse.kt @@ -1,5 +1,6 @@ package com.weeth.domain.dashboard.application.dto.response +import com.weeth.domain.board.application.dto.response.BoardConfigResponse import com.weeth.domain.board.application.dto.response.PostLikeResponse import com.weeth.domain.file.application.dto.response.FileResponse import com.weeth.domain.user.application.dto.response.UserInfo @@ -27,4 +28,6 @@ data class DashboardPostResponse( val fileUrls: List, @field:Schema(description = "24시간 내 새 게시글 여부", example = "true") val isNew: Boolean, + @field:Schema(description = "게시판 설정") + val boardConfig: BoardConfigResponse, ) diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/mapper/DashboardMapper.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/mapper/DashboardMapper.kt index 27d5767f..7c2e4693 100644 --- a/src/main/kotlin/com/weeth/domain/dashboard/application/mapper/DashboardMapper.kt +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/mapper/DashboardMapper.kt @@ -1,9 +1,11 @@ package com.weeth.domain.dashboard.application.mapper +import com.weeth.domain.board.application.dto.response.BoardConfigResponse import com.weeth.domain.board.application.dto.response.PostLikeResponse import com.weeth.domain.board.domain.entity.Post import com.weeth.domain.club.domain.entity.Club import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.enums.MemberRole import com.weeth.domain.dashboard.application.dto.response.DashboardClubInfoResponse import com.weeth.domain.dashboard.application.dto.response.DashboardHomeResponse import com.weeth.domain.dashboard.application.dto.response.DashboardMyClubResponse @@ -103,6 +105,7 @@ class DashboardMapper( files: List, now: LocalDateTime, isLiked: Boolean, + memberRole: MemberRole, ) = DashboardPostResponse( id = post.id, boardId = post.board.id, @@ -114,6 +117,7 @@ class DashboardMapper( like = PostLikeResponse(isLiked = isLiked, likeCount = post.likeCount), fileUrls = files.map(fileMapper::toFileResponse), isNew = post.createdAt.isAfter(now.minusHours(24)), + boardConfig = BoardConfigResponse.of(post.board, memberRole), ) fun toNoticeResponse( diff --git a/src/main/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryService.kt b/src/main/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryService.kt index b6b97099..b2310e4f 100644 --- a/src/main/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/dashboard/application/usecase/query/GetDashboardQueryService.kt @@ -102,6 +102,7 @@ class GetDashboardQueryService( files = filesByPostId[post.id] ?: emptyList(), now = now, isLiked = post.id in likedPostIds, + memberRole = member.memberRole, ) } } diff --git a/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt b/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt index 0d8c8b13..e2869af4 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt @@ -32,6 +32,8 @@ class PostMapperTest : every { board.id } returns 10L every { board.name } returns "일반 게시판" + every { board.canWriteBy(any()) } returns true + every { board.isCommentEnabled } returns true every { authorMember.memberRole } returns MemberRole.USER every { authorMember.profileImageStorageKey } returns null @@ -55,6 +57,7 @@ class PostMapperTest : files = emptyList(), now = now, isLiked = false, + memberRole = MemberRole.USER, ) response.id shouldBe 1L @@ -89,7 +92,15 @@ class PostMapperTest : ), ) - val response = mapper.toDetailResponse(post, comments, files, isLiked = false, now = now) + val response = + mapper.toDetailResponse( + post, + comments, + files, + isLiked = false, + now = now, + memberRole = MemberRole.USER, + ) response.id shouldBe 1L response.commentCount shouldBe 2 diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt index b97aa3da..1b63e676 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt @@ -1,5 +1,6 @@ package com.weeth.domain.board.application.usecase.query +import com.weeth.domain.board.application.dto.response.BoardConfigResponse import com.weeth.domain.board.application.dto.response.PostLikeResponse import com.weeth.domain.board.application.exception.BoardNotFoundException import com.weeth.domain.board.application.exception.NoSearchResultException @@ -147,6 +148,7 @@ class GetPostQueryServiceTest : comments = comments, fileUrls = fileResponses, isNew = false, + boardConfig = BoardConfigResponse(canWrite = true, canComment = true), ) every { clubMemberPolicy.getActiveMember(actualClubId, userId) } returns member @@ -155,7 +157,9 @@ class GetPostQueryServiceTest : every { getCommentQueryService.toCommentTreeResponses(any()) } returns comments every { fileReader.findAll(FileOwnerType.POST, any(), any()) } returns files every { postLikeRepository.existsByPostAndUserIdAndIsActiveTrue(post, userId) } returns false - every { postMapper.toDetailResponse(post, comments, fileResponses, false, any()) } returns detail + every { + postMapper.toDetailResponse(post, comments, fileResponses, false, any(), MemberRole.USER) + } returns detail every { fileMapper.toFileResponse(files.first()) } returns fileResponses.first() val result = queryService.findPost(actualClubId, userId, board.id, 1L) @@ -287,6 +291,7 @@ class GetPostQueryServiceTest : like = PostLikeResponse(isLiked = false, likeCount = 0), fileUrls = emptyList(), isNew = true, + boardConfig = BoardConfigResponse(canWrite = true, canComment = true), ) every { clubMemberPolicy.getActiveMember(clubId, userId) } returns member @@ -295,7 +300,7 @@ class GetPostQueryServiceTest : every { postRepository.findAllActiveByBoardIds(any(), any()) } returns postSlice every { fileReader.findAll(FileOwnerType.POST, any>(), any()) } returns emptyList() every { postLikeRepository.findLikedPostIds(any(), any()) } returns emptySet() - every { postMapper.toListResponse(any(), any(), any(), any()) } returns response + every { postMapper.toListResponse(any(), any(), any(), any(), any()) } returns response val result = queryService.findAllPosts(clubId, userId, 0, 10) @@ -338,6 +343,7 @@ class GetPostQueryServiceTest : like = PostLikeResponse(isLiked = false, likeCount = 0), fileUrls = emptyList(), isNew = false, + boardConfig = BoardConfigResponse(canWrite = true, canComment = true), ) every { clubMemberPolicy.getActiveMember(clubId, userId) } returns member @@ -345,7 +351,7 @@ class GetPostQueryServiceTest : every { postRepository.findAllActiveByBoardId(1L, any()) } returns postSlice every { fileReader.findAll(FileOwnerType.POST, any>(), any()) } returns emptyList() every { postLikeRepository.findLikedPostIds(any(), any()) } returns emptySet() - every { postMapper.toListResponse(any(), any(), any(), any()) } returns response + every { postMapper.toListResponse(any(), any(), any(), any(), any()) } returns response val result = queryService.findPosts(clubId, userId, 1L, 0, 10) From 1904da8ba7611dec378ac7b532942f16253535c8 Mon Sep 17 00:00:00 2001 From: Jeon Soo Hyeon <128474444+soo0711@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:15:54 +0900 Subject: [PATCH 66/73] =?UTF-8?q?[WTH-347]=20sse=20is=20empty=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20(#59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: SSE emitter 교체/삭제 시 메모리 누수 방지 * feat: SSE 구독 시 QR 상태 즉시 전송 추가 * feat: SSE 구독 시 QR 상태 즉시 전송 추가 * feat: QR 만료 시 SSE로 qr-close 이벤트 broadcast * refactor: 코드 리뷰 반영 --- .../application/event/AttendanceSseEvent.kt | 7 ++ .../usecase/command/GenerateQrTokenUseCase.kt | 28 ++++--- .../command/ManageAttendanceUseCase.kt | 28 +++---- .../command/SubscribeAttendanceSseUseCase.kt | 24 +++++- .../domain/port/QrAttendancePort.kt | 9 +++ .../domain/port/SseBroadcastPort.kt | 9 ++- .../domain/repository/AttendanceRepository.kt | 12 +++ .../infrastructure/QrExpiredEventListener.kt | 35 ++++++++ .../RedisQrAttendanceAdapter.kt | 10 ++- .../infrastructure/SseAttendanceAdapter.kt | 30 +++++-- .../infrastructure/SseEmitterStore.kt | 26 +++--- .../domain/repository/SessionReader.kt | 4 + .../domain/repository/SessionRepository.kt | 14 ++++ .../com/weeth/global/config/RedisConfig.kt | 13 +++ .../command/GenerateQrTokenUseCaseTest.kt | 20 ++++- .../SubscribeAttendanceSseUseCaseTest.kt | 80 ++++++++++++++++--- .../QrExpiredEventListenerTest.kt | 70 ++++++++++++++++ .../infrastructure/SseEmitterStoreTest.kt | 62 +++++++------- 18 files changed, 388 insertions(+), 93 deletions(-) create mode 100644 src/main/kotlin/com/weeth/domain/attendance/application/event/AttendanceSseEvent.kt create mode 100644 src/main/kotlin/com/weeth/domain/attendance/infrastructure/QrExpiredEventListener.kt create mode 100644 src/test/kotlin/com/weeth/domain/attendance/infrastructure/QrExpiredEventListenerTest.kt diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/event/AttendanceSseEvent.kt b/src/main/kotlin/com/weeth/domain/attendance/application/event/AttendanceSseEvent.kt new file mode 100644 index 00000000..5aa6642c --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/application/event/AttendanceSseEvent.kt @@ -0,0 +1,7 @@ +package com.weeth.domain.attendance.application.event + +object AttendanceSseEvent { + const val QR_OPEN = "qr-open" + const val QR_NONE = "qr-none" + const val QR_CLOSE = "qr-close" +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCase.kt b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCase.kt index a0cd0b3c..4f642873 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCase.kt @@ -2,12 +2,15 @@ package com.weeth.domain.attendance.application.usecase.command import com.weeth.domain.attendance.application.dto.response.QrTokenResponse import com.weeth.domain.attendance.application.event.AttendanceOpenEvent +import com.weeth.domain.attendance.application.event.AttendanceSseEvent import com.weeth.domain.attendance.application.mapper.AttendanceMapper import com.weeth.domain.attendance.domain.port.QrAttendancePort import com.weeth.domain.attendance.domain.port.SseBroadcastPort import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.session.domain.repository.SessionReader import org.springframework.stereotype.Service +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.support.TransactionTemplate import java.time.LocalDateTime @Service @@ -17,25 +20,26 @@ class GenerateQrTokenUseCase( private val attendanceMapper: AttendanceMapper, private val clubPermissionPolicy: ClubPermissionPolicy, private val ssePort: SseBroadcastPort, + transactionManager: PlatformTransactionManager, ) { + private val txTemplate = TransactionTemplate(transactionManager).apply { isReadOnly = true } + fun execute( sessionId: Long, clubId: Long, userId: Long, ): QrTokenResponse { - clubPermissionPolicy.requireAdmin(clubId, userId) - - val session = sessionReader.getById(sessionId) + val session = + requireNotNull( + txTemplate.execute { + clubPermissionPolicy.requireAdmin(clubId, userId) + sessionReader.getById(sessionId) + }, + ) - val expiredAt = LocalDateTime.now().plusSeconds(QrAttendancePort.TTL_SECONDS) qrAttendancePort.store(sessionId, session.code) - - val response = attendanceMapper.toQrTokenResponse(session, expiredAt) - ssePort.broadcast(clubId, EVENT_QR_OPEN, AttendanceOpenEvent(expiredAt)) - return response - } - - companion object { - internal const val EVENT_QR_OPEN = "qr-open" + val expiredAt = LocalDateTime.now().plusSeconds(QrAttendancePort.TTL_SECONDS) + ssePort.broadcast(clubId, AttendanceSseEvent.QR_OPEN, AttendanceOpenEvent(expiredAt)) + return attendanceMapper.toQrTokenResponse(session, expiredAt) } } diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCase.kt b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCase.kt index b8d4e244..1ca779b0 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/ManageAttendanceUseCase.kt @@ -14,7 +14,6 @@ import com.weeth.domain.club.domain.enums.MemberStatus import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.session.application.exception.SessionNotInProgressException -import com.weeth.domain.session.domain.entity.Session import com.weeth.domain.session.domain.enums.SessionStatus import com.weeth.domain.session.domain.repository.SessionReader import org.springframework.stereotype.Service @@ -62,14 +61,15 @@ class ManageAttendanceUseCase( @Transactional fun autoClose() { val sessions = sessionReader.findAllByStatusAndEndBeforeOrderByEndAsc(SessionStatus.OPEN, LocalDateTime.now()) - sessions.forEach { session -> closeSingleSession(session) } - } - - private fun closeSingleSession(session: Session) { - session.close() - val attendances = - attendanceRepository.findAllBySessionAndClubMemberMemberStatusWithLock(session, MemberStatus.ACTIVE) - closePendingAttendances(attendances) + if (sessions.isEmpty()) return + sessions.forEach { it.close() } + val pendingAttendances = + attendanceRepository.findPendingBySessionInAndMemberStatusWithLock( + sessions, + MemberStatus.ACTIVE, + AttendanceStatus.PENDING, + ) + closePendingAttendances(pendingAttendances) } @Transactional @@ -115,11 +115,9 @@ class ManageAttendanceUseCase( } private fun closePendingAttendances(attendances: List) { - attendances - .filter { it.isPending() } - .forEach { attendance -> - attendance.close() - attendance.clubMember.absent() - } + attendances.forEach { attendance -> + attendance.close() + attendance.clubMember.absent() + } } } diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/SubscribeAttendanceSseUseCase.kt b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/SubscribeAttendanceSseUseCase.kt index ac850f29..5ffb4e9d 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/SubscribeAttendanceSseUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/command/SubscribeAttendanceSseUseCase.kt @@ -1,7 +1,12 @@ package com.weeth.domain.attendance.application.usecase.command +import com.weeth.domain.attendance.application.event.AttendanceOpenEvent +import com.weeth.domain.attendance.application.event.AttendanceSseEvent +import com.weeth.domain.attendance.domain.port.QrAttendancePort +import com.weeth.domain.attendance.domain.port.SseBroadcastPort import com.weeth.domain.attendance.domain.port.SseSubscribePort import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.session.domain.repository.SessionReader import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import org.springframework.web.servlet.mvc.method.annotation.SseEmitter @@ -9,14 +14,29 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter @Service class SubscribeAttendanceSseUseCase( private val sseSubscribePort: SseSubscribePort, + private val sseBroadcastPort: SseBroadcastPort, private val clubMemberPolicy: ClubMemberPolicy, + private val sessionReader: SessionReader, + private val qrAttendancePort: QrAttendancePort, ) { - @Transactional + @Transactional(readOnly = true) fun execute( clubId: Long, userId: Long, ): SseEmitter { clubMemberPolicy.getActiveMember(clubId, userId) - return sseSubscribePort.subscribe(clubId, userId) + + val emitter = sseSubscribePort.subscribe(clubId, userId) + + val openSession = sessionReader.findOpenByClubId(clubId) + val expiredAt = openSession?.let { qrAttendancePort.getExpiredAt(it.id) } + + if (expiredAt != null) { + sseBroadcastPort.sendToUser(clubId, userId, AttendanceSseEvent.QR_OPEN, AttendanceOpenEvent(expiredAt)) + } else { + sseBroadcastPort.sendToUser(clubId, userId, AttendanceSseEvent.QR_NONE, null) + } + + return emitter } } diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/port/QrAttendancePort.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/port/QrAttendancePort.kt index e14f7f05..8d4d9f4a 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/domain/port/QrAttendancePort.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/port/QrAttendancePort.kt @@ -1,8 +1,11 @@ package com.weeth.domain.attendance.domain.port +import java.time.LocalDateTime + interface QrAttendancePort { companion object { const val TTL_SECONDS = 600L + const val KEY_PREFIX = "qr:" } /** @@ -19,4 +22,10 @@ interface QrAttendancePort { * QR이 생성된 적 없거나 TTL이 만료된 경우 null을 반환합니다. */ fun getCode(sessionId: Long): Int? + + /** + * sessionId에 해당하는 QR 코드의 만료 시각을 반환합니다. + * QR이 없거나 TTL이 만료된 경우 null을 반환합니다. + */ + fun getExpiredAt(sessionId: Long): LocalDateTime? } diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/port/SseBroadcastPort.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/port/SseBroadcastPort.kt index cee06b51..39cdae25 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/domain/port/SseBroadcastPort.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/port/SseBroadcastPort.kt @@ -4,6 +4,13 @@ interface SseBroadcastPort { fun broadcast( clubId: Long, eventName: String, - data: Any, + data: Any?, + ) + + fun sendToUser( + clubId: Long, + userId: Long, + eventName: String, + data: Any?, ) } diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt index 6456648b..7a8a9258 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt @@ -1,6 +1,7 @@ package com.weeth.domain.attendance.domain.repository import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.attendance.domain.enums.AttendanceStatus import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.club.domain.enums.MemberStatus import com.weeth.domain.session.domain.entity.Session @@ -111,6 +112,17 @@ interface AttendanceRepository : JpaRepository { @Param("status") status: MemberStatus, ): List + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query( + "SELECT a FROM Attendance a JOIN FETCH a.clubMember cm JOIN FETCH cm.user WHERE a.session IN :sessions AND cm.memberStatus = :memberStatus AND a.status = :attendanceStatus ORDER BY a.id ASC", + ) + fun findPendingBySessionInAndMemberStatusWithLock( + @Param("sessions") sessions: List, + @Param("memberStatus") memberStatus: MemberStatus, + @Param("attendanceStatus") attendanceStatus: AttendanceStatus, + ): List + @Modifying(flushAutomatically = true, clearAutomatically = true) @Query("DELETE FROM Attendance a WHERE a.session = :session") fun deleteAllBySession(session: Session) diff --git a/src/main/kotlin/com/weeth/domain/attendance/infrastructure/QrExpiredEventListener.kt b/src/main/kotlin/com/weeth/domain/attendance/infrastructure/QrExpiredEventListener.kt new file mode 100644 index 00000000..c47b99cc --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/attendance/infrastructure/QrExpiredEventListener.kt @@ -0,0 +1,35 @@ +package com.weeth.domain.attendance.infrastructure + +import com.weeth.domain.attendance.application.event.AttendanceSseEvent +import com.weeth.domain.attendance.domain.port.QrAttendancePort +import com.weeth.domain.attendance.domain.port.SseBroadcastPort +import com.weeth.domain.session.domain.repository.SessionReader +import org.slf4j.LoggerFactory +import org.springframework.data.redis.connection.Message +import org.springframework.data.redis.connection.MessageListener +import org.springframework.stereotype.Component + +@Component +class QrExpiredEventListener( + private val sessionReader: SessionReader, + private val sseBroadcastPort: SseBroadcastPort, +) : MessageListener { + private val log = LoggerFactory.getLogger(javaClass) + + override fun onMessage( + message: Message, + pattern: ByteArray?, + ) { + val key = message.body.decodeToString() + if (!key.startsWith(QrAttendancePort.KEY_PREFIX)) return + + val sessionId = key.removePrefix(QrAttendancePort.KEY_PREFIX).toLongOrNull() ?: return + + val clubId = sessionReader.findClubIdById(sessionId) ?: return + runCatching { + sseBroadcastPort.broadcast(clubId, AttendanceSseEvent.QR_CLOSE, null) + }.onFailure { e -> + log.error("QR 만료 이벤트 처리 실패: sessionId={}", sessionId, e) + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/attendance/infrastructure/RedisQrAttendanceAdapter.kt b/src/main/kotlin/com/weeth/domain/attendance/infrastructure/RedisQrAttendanceAdapter.kt index 7d6fb480..0f1c8346 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/infrastructure/RedisQrAttendanceAdapter.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/infrastructure/RedisQrAttendanceAdapter.kt @@ -3,6 +3,7 @@ package com.weeth.domain.attendance.infrastructure import com.weeth.domain.attendance.domain.port.QrAttendancePort import org.springframework.data.redis.core.RedisTemplate import org.springframework.stereotype.Component +import java.time.LocalDateTime import java.util.concurrent.TimeUnit @Component @@ -18,9 +19,10 @@ class RedisQrAttendanceAdapter( override fun getCode(sessionId: Long): Int? = redisTemplate.opsForValue().get(key(sessionId))?.toIntOrNull() - private fun key(sessionId: Long) = "$PREFIX$sessionId" - - companion object { - private const val PREFIX = "qr:" + override fun getExpiredAt(sessionId: Long): LocalDateTime? { + val ttl = redisTemplate.getExpire(key(sessionId), TimeUnit.SECONDS) + return if (ttl > 0) LocalDateTime.now().plusSeconds(ttl) else null } + + private fun key(sessionId: Long) = "${QrAttendancePort.KEY_PREFIX}$sessionId" } diff --git a/src/main/kotlin/com/weeth/domain/attendance/infrastructure/SseAttendanceAdapter.kt b/src/main/kotlin/com/weeth/domain/attendance/infrastructure/SseAttendanceAdapter.kt index cdc60687..aeeba944 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/infrastructure/SseAttendanceAdapter.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/infrastructure/SseAttendanceAdapter.kt @@ -3,6 +3,7 @@ package com.weeth.domain.attendance.infrastructure import com.fasterxml.jackson.databind.ObjectMapper import com.weeth.domain.attendance.domain.port.SseBroadcastPort import com.weeth.domain.attendance.domain.port.SseSubscribePort +import org.slf4j.LoggerFactory import org.springframework.stereotype.Component import org.springframework.web.servlet.mvc.method.annotation.SseEmitter @@ -13,6 +14,7 @@ class SseAttendanceAdapter( ) : SseBroadcastPort, SseSubscribePort { companion object { + private val log = LoggerFactory.getLogger(SseAttendanceAdapter::class.java) private const val TIMEOUT = 30 * 60 * 1000L private const val EVENT_CONNECT = "connect" } @@ -24,7 +26,7 @@ class SseAttendanceAdapter( val emitter = SseEmitter(TIMEOUT) val cleanup = { store.remove(clubId, userId, emitter) } - store.add(clubId, userId, emitter) + store.replace(clubId, userId, emitter) emitter.onCompletion(cleanup) emitter.onTimeout(cleanup) emitter.onError { cleanup() } @@ -39,15 +41,33 @@ class SseAttendanceAdapter( override fun broadcast( clubId: Long, eventName: String, - data: Any, + data: Any?, ) { - val payload = runCatching { objectMapper.writeValueAsString(data) }.getOrElse { return } + val payload = + runCatching { objectMapper.writeValueAsString(data) } + .onFailure { log.error("SSE payload 직렬화 실패: eventName={}", eventName, it) } + .getOrElse { return } store.getAllByClub(clubId).forEach { (userId, emitter) -> - val cleanup = { store.remove(clubId, userId, emitter) } runCatching { emitter.send(SseEmitter.event().name(eventName).data(payload)) - }.onFailure { cleanup() } + }.onFailure { store.remove(clubId, userId, emitter) } } } + + override fun sendToUser( + clubId: Long, + userId: Long, + eventName: String, + data: Any?, + ) { + val emitter = store.getByUser(clubId, userId) ?: return + val payload = + runCatching { objectMapper.writeValueAsString(data) } + .onFailure { log.error("SSE payload 직렬화 실패: eventName={}", eventName, it) } + .getOrElse { return } + runCatching { + emitter.send(SseEmitter.event().name(eventName).data(payload)) + }.onFailure { store.remove(clubId, userId, emitter) } + } } diff --git a/src/main/kotlin/com/weeth/domain/attendance/infrastructure/SseEmitterStore.kt b/src/main/kotlin/com/weeth/domain/attendance/infrastructure/SseEmitterStore.kt index 94257d60..092595e0 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/infrastructure/SseEmitterStore.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/infrastructure/SseEmitterStore.kt @@ -3,21 +3,22 @@ package com.weeth.domain.attendance.infrastructure import org.springframework.stereotype.Component import org.springframework.web.servlet.mvc.method.annotation.SseEmitter import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicReference @Component class SseEmitterStore { - private val store = ConcurrentHashMap>>() + private val store = ConcurrentHashMap>() - fun add( + fun replace( clubId: Long, userId: Long, emitter: SseEmitter, ) { - store - .computeIfAbsent(clubId) { ConcurrentHashMap() } - .computeIfAbsent(userId) { CopyOnWriteArrayList() } - .add(emitter) + val oldRef = AtomicReference() + store.compute(clubId) { _, userMap -> + (userMap ?: ConcurrentHashMap()).apply { oldRef.set(put(userId, emitter)) } + } + oldRef.get()?.complete() } fun remove( @@ -26,15 +27,18 @@ class SseEmitterStore { emitter: SseEmitter, ) { store.computeIfPresent(clubId) { _, userMap -> - userMap.compute(userId) { _, emitters -> - emitters?.apply { remove(emitter) }?.ifEmpty { null } - } + userMap.remove(userId, emitter) userMap.takeUnless { it.isEmpty() } } } + fun getByUser( + clubId: Long, + userId: Long, + ): SseEmitter? = store[clubId]?.get(userId) + fun getAllByClub(clubId: Long): List> = store[clubId] - ?.flatMap { (userId, emitters) -> emitters.map { emitter -> userId to emitter } } + ?.map { (userId, emitter) -> userId to emitter } ?: emptyList() } diff --git a/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionReader.kt b/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionReader.kt index ec7a0367..b270b389 100644 --- a/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionReader.kt +++ b/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionReader.kt @@ -32,4 +32,8 @@ interface SessionReader { clubId: Long, cardinals: List, ): List + + fun findOpenByClubId(clubId: Long): Session? + + fun findClubIdById(sessionId: Long): Long? } diff --git a/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionRepository.kt b/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionRepository.kt index 3f2c21a1..22796d31 100644 --- a/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionRepository.kt +++ b/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionRepository.kt @@ -72,6 +72,20 @@ interface SessionRepository : cardinals: List, ): List + fun findFirstByClubIdAndStatusOrderByIdAsc( + clubId: Long, + status: SessionStatus, + ): Session? + + // OPEN 세션이 복수인 경우 id가 가장 작은 것 반환 + override fun findOpenByClubId(clubId: Long): Session? = + findFirstByClubIdAndStatusOrderByIdAsc(clubId, SessionStatus.OPEN) + + @Query("SELECT s.club.id FROM Session s WHERE s.id = :sessionId") + override fun findClubIdById( + @Param("sessionId") sessionId: Long, + ): Long? + // 기준 시작시각 이후 세션 조회 @Lock(LockModeType.PESSIMISTIC_WRITE) @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) diff --git a/src/main/kotlin/com/weeth/global/config/RedisConfig.kt b/src/main/kotlin/com/weeth/global/config/RedisConfig.kt index aaa5aefe..768351fa 100644 --- a/src/main/kotlin/com/weeth/global/config/RedisConfig.kt +++ b/src/main/kotlin/com/weeth/global/config/RedisConfig.kt @@ -1,5 +1,6 @@ package com.weeth.global.config +import com.weeth.domain.attendance.infrastructure.QrExpiredEventListener import com.weeth.global.config.properties.RedisProperties import io.lettuce.core.metrics.MicrometerCommandLatencyRecorder import io.lettuce.core.metrics.MicrometerOptions @@ -12,6 +13,8 @@ import org.springframework.data.redis.connection.lettuce.LettuceClientConfigurat import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory import org.springframework.data.redis.core.RedisKeyValueAdapter import org.springframework.data.redis.core.RedisTemplate +import org.springframework.data.redis.listener.PatternTopic +import org.springframework.data.redis.listener.RedisMessageListenerContainer import org.springframework.data.redis.repository.configuration.EnableRedisRepositories import org.springframework.data.redis.serializer.StringRedisSerializer @@ -53,4 +56,14 @@ class RedisConfig( valueSerializer = StringRedisSerializer() connectionFactory = redisConnectionFactory } + + @Bean + fun redisMessageListenerContainer( + redisConnectionFactory: RedisConnectionFactory, + qrKeyExpiredListener: QrExpiredEventListener, + ): RedisMessageListenerContainer = + RedisMessageListenerContainer().apply { + setConnectionFactory(redisConnectionFactory) + addMessageListener(qrKeyExpiredListener, PatternTopic("__keyevent@0__:expired")) + } } diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCaseTest.kt index d59acfb0..cc9eb595 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/GenerateQrTokenUseCaseTest.kt @@ -2,6 +2,7 @@ package com.weeth.domain.attendance.application.usecase.command import com.weeth.domain.attendance.application.dto.response.QrTokenResponse import com.weeth.domain.attendance.application.event.AttendanceOpenEvent +import com.weeth.domain.attendance.application.event.AttendanceSseEvent import com.weeth.domain.attendance.application.mapper.AttendanceMapper import com.weeth.domain.attendance.domain.port.QrAttendancePort import com.weeth.domain.attendance.domain.port.SseBroadcastPort @@ -18,6 +19,7 @@ import io.mockk.every import io.mockk.just import io.mockk.mockk import io.mockk.verify +import org.springframework.transaction.PlatformTransactionManager import java.time.LocalDateTime class GenerateQrTokenUseCaseTest : @@ -27,11 +29,21 @@ class GenerateQrTokenUseCaseTest : val attendanceMapper = mockk() val clubPermissionPolicy = mockk(relaxed = true) val ssePort = mockk(relaxed = true) + val transactionManager = mockk(relaxed = true) val useCase = - GenerateQrTokenUseCase(sessionReader, qrAttendancePort, attendanceMapper, clubPermissionPolicy, ssePort) + GenerateQrTokenUseCase( + sessionReader, + qrAttendancePort, + attendanceMapper, + clubPermissionPolicy, + ssePort, + transactionManager, + ) - beforeTest { clearMocks(sessionReader, qrAttendancePort, attendanceMapper, clubPermissionPolicy, ssePort) } + beforeTest { + clearMocks(sessionReader, qrAttendancePort, attendanceMapper, clubPermissionPolicy, ssePort) + } describe("execute") { val sessionId = 1L @@ -44,7 +56,7 @@ class GenerateQrTokenUseCaseTest : QrTokenResponse( sessionId = sessionId, code = code, - expiredAt = LocalDateTime.now().plusSeconds(600), + expiredAt = LocalDateTime.now().plusSeconds(QrAttendancePort.TTL_SECONDS), ) every { sessionReader.getById(sessionId) } returns session @@ -57,7 +69,7 @@ class GenerateQrTokenUseCaseTest : verify(exactly = 1) { clubPermissionPolicy.requireAdmin(10L, 20L) } verify(exactly = 1) { qrAttendancePort.store(sessionId, code) } verify(exactly = 1) { - ssePort.broadcast(10L, GenerateQrTokenUseCase.EVENT_QR_OPEN, any()) + ssePort.broadcast(10L, AttendanceSseEvent.QR_OPEN, any()) } } } diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/SubscribeAttendanceSseUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/SubscribeAttendanceSseUseCaseTest.kt index 150fa3aa..fe35f96c 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/SubscribeAttendanceSseUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/command/SubscribeAttendanceSseUseCaseTest.kt @@ -1,8 +1,13 @@ package com.weeth.domain.attendance.application.usecase.command +import com.weeth.domain.attendance.application.event.AttendanceOpenEvent +import com.weeth.domain.attendance.application.event.AttendanceSseEvent +import com.weeth.domain.attendance.domain.port.QrAttendancePort +import com.weeth.domain.attendance.domain.port.SseBroadcastPort import com.weeth.domain.attendance.domain.port.SseSubscribePort import com.weeth.domain.club.application.exception.MemberNotActiveException import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.session.domain.repository.SessionReader import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe @@ -11,31 +16,87 @@ import io.mockk.every import io.mockk.mockk import io.mockk.verify import org.springframework.web.servlet.mvc.method.annotation.SseEmitter +import java.time.LocalDateTime class SubscribeAttendanceSseUseCaseTest : DescribeSpec({ val sseSubscribePort = mockk() + val sseBroadcastPort = mockk(relaxed = true) val clubMemberPolicy = mockk() - val useCase = SubscribeAttendanceSseUseCase(sseSubscribePort, clubMemberPolicy) + val sessionReader = mockk() + val qrAttendancePort = mockk() + val useCase = + SubscribeAttendanceSseUseCase( + sseSubscribePort, + sseBroadcastPort, + clubMemberPolicy, + sessionReader, + qrAttendancePort, + ) - beforeTest { clearMocks(sseSubscribePort, clubMemberPolicy) } + beforeTest { clearMocks(sseSubscribePort, sseBroadcastPort, clubMemberPolicy, sessionReader, qrAttendancePort) } describe("execute") { val clubId = 1L val userId = 100L + val emitter = mockk(relaxed = true) - context("활성 멤버인 경우") { - it("SseSubscribePort를 호출하고 SseEmitter를 반환한다") { - val emitter = mockk(relaxed = true) + beforeTest { + every { clubMemberPolicy.getActiveMember(clubId, userId) } returns mockk() + every { sseSubscribePort.subscribe(clubId, userId) } returns emitter + } + + context("활성 QR이 없는 경우") { + it("qr-none 이벤트를 전송하고 emitter를 반환한다") { + every { sessionReader.findOpenByClubId(clubId) } returns null + + val result = useCase.execute(clubId, userId) + + result shouldBe emitter + verify( + exactly = 1, + ) { sseBroadcastPort.sendToUser(clubId, userId, AttendanceSseEvent.QR_NONE, null) } + verify( + exactly = 0, + ) { sseBroadcastPort.sendToUser(clubId, userId, AttendanceSseEvent.QR_OPEN, any()) } + } + } + + context("열린 세션이 있지만 QR이 만료된 경우") { + it("qr-none 이벤트를 전송한다") { + val session = mockk { every { id } returns 10L } + every { sessionReader.findOpenByClubId(clubId) } returns session + every { qrAttendancePort.getExpiredAt(10L) } returns null + + useCase.execute(clubId, userId) + + verify( + exactly = 1, + ) { sseBroadcastPort.sendToUser(clubId, userId, AttendanceSseEvent.QR_NONE, null) } + } + } - every { clubMemberPolicy.getActiveMember(clubId, userId) } returns mockk() - every { sseSubscribePort.subscribe(clubId, userId) } returns emitter + context("활성 QR이 있는 경우") { + it("qr-open 이벤트를 전송하고 emitter를 반환한다") { + val session = mockk { every { id } returns 10L } + val expiredAt = LocalDateTime.now().plusMinutes(5) + every { sessionReader.findOpenByClubId(clubId) } returns session + every { qrAttendancePort.getExpiredAt(10L) } returns expiredAt val result = useCase.execute(clubId, userId) result shouldBe emitter - verify(exactly = 1) { clubMemberPolicy.getActiveMember(clubId, userId) } - verify(exactly = 1) { sseSubscribePort.subscribe(clubId, userId) } + verify(exactly = 1) { + sseBroadcastPort.sendToUser( + clubId, + userId, + AttendanceSseEvent.QR_OPEN, + AttendanceOpenEvent(expiredAt), + ) + } + verify( + exactly = 0, + ) { sseBroadcastPort.sendToUser(clubId, userId, AttendanceSseEvent.QR_NONE, any()) } } } @@ -46,6 +107,7 @@ class SubscribeAttendanceSseUseCaseTest : shouldThrow { useCase.execute(clubId, userId) } verify(exactly = 0) { sseSubscribePort.subscribe(any(), any()) } + verify(exactly = 0) { sseBroadcastPort.sendToUser(any(), any(), any(), any()) } } } } diff --git a/src/test/kotlin/com/weeth/domain/attendance/infrastructure/QrExpiredEventListenerTest.kt b/src/test/kotlin/com/weeth/domain/attendance/infrastructure/QrExpiredEventListenerTest.kt new file mode 100644 index 00000000..ab11cff7 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/attendance/infrastructure/QrExpiredEventListenerTest.kt @@ -0,0 +1,70 @@ +package com.weeth.domain.attendance.infrastructure + +import com.weeth.domain.attendance.application.event.AttendanceSseEvent +import com.weeth.domain.attendance.domain.port.SseBroadcastPort +import com.weeth.domain.session.domain.repository.SessionReader +import io.kotest.assertions.throwables.shouldNotThrow +import io.kotest.core.spec.style.DescribeSpec +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.springframework.data.redis.connection.Message + +class QrExpiredEventListenerTest : + DescribeSpec({ + val sessionReader = mockk() + val sseBroadcastPort = mockk(relaxed = true) + val listener = QrExpiredEventListener(sessionReader, sseBroadcastPort) + + beforeTest { clearMocks(sessionReader, sseBroadcastPort) } + + fun message(key: String): Message = mockk { every { body } returns key.toByteArray() } + + describe("onMessage") { + context("qr:{sessionId} 키가 만료된 경우") { + it("해당 클럽에 qr-close를 broadcast한다") { + every { sessionReader.findClubIdById(42L) } returns 7L + + listener.onMessage(message("qr:42"), null) + + verify { sseBroadcastPort.broadcast(7L, AttendanceSseEvent.QR_CLOSE, null) } + } + } + + context("qr: 접두사가 아닌 키가 만료된 경우") { + it("broadcast하지 않는다") { + listener.onMessage(message("other:42"), null) + + verify(exactly = 0) { sseBroadcastPort.broadcast(any(), any(), any()) } + } + } + + context("sessionId가 숫자가 아닌 경우") { + it("broadcast하지 않는다") { + listener.onMessage(message("qr:invalid"), null) + + verify(exactly = 0) { sseBroadcastPort.broadcast(any(), any(), any()) } + } + } + + context("세션이 존재하지 않는 경우") { + it("broadcast하지 않는다") { + every { sessionReader.findClubIdById(99L) } returns null + + listener.onMessage(message("qr:99"), null) + + verify(exactly = 0) { sseBroadcastPort.broadcast(any(), any(), any()) } + } + } + + context("broadcast 중 예외가 발생하는 경우") { + it("예외가 전파되지 않는다") { + every { sessionReader.findClubIdById(42L) } returns 7L + every { sseBroadcastPort.broadcast(any(), any(), any()) } throws RuntimeException("network error") + + shouldNotThrow { listener.onMessage(message("qr:42"), null) } + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/attendance/infrastructure/SseEmitterStoreTest.kt b/src/test/kotlin/com/weeth/domain/attendance/infrastructure/SseEmitterStoreTest.kt index a4644d57..1f4c906f 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/infrastructure/SseEmitterStoreTest.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/infrastructure/SseEmitterStoreTest.kt @@ -5,6 +5,7 @@ import io.kotest.matchers.collections.shouldBeEmpty import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe import io.mockk.mockk +import io.mockk.verify import org.springframework.web.servlet.mvc.method.annotation.SseEmitter import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors @@ -15,30 +16,32 @@ class SseEmitterStoreTest : val clubId = 1L val userId = 100L - "emitter를 추가하면 getAllByClub에서 조회된다" { + "emitter를 replace하면 getAllByClub에서 조회된다" { val store = SseEmitterStore() val emitter = mockk(relaxed = true) - store.add(clubId, userId, emitter) + store.replace(clubId, userId, emitter) store.getAllByClub(clubId) shouldHaveSize 1 } - "같은 userId로 여러 emitter를 추가하면 멀티탭이 지원된다" { + "같은 userId로 replace하면 기존 emitter가 complete된다" { val store = SseEmitterStore() - val emitter1 = mockk(relaxed = true) - val emitter2 = mockk(relaxed = true) + val oldEmitter = mockk(relaxed = true) + val newEmitter = mockk(relaxed = true) - store.add(clubId, userId, emitter1) - store.add(clubId, userId, emitter2) + store.replace(clubId, userId, oldEmitter) + store.replace(clubId, userId, newEmitter) - store.getAllByClub(clubId) shouldHaveSize 2 + verify(exactly = 1) { oldEmitter.complete() } + store.getAllByClub(clubId) shouldHaveSize 1 + store.getAllByClub(clubId).first().second shouldBe newEmitter } "emitter를 제거하면 조회되지 않는다" { val store = SseEmitterStore() val emitter = mockk(relaxed = true) - store.add(clubId, userId, emitter) + store.replace(clubId, userId, emitter) store.remove(clubId, userId, emitter) @@ -48,29 +51,30 @@ class SseEmitterStoreTest : "마지막 emitter 제거 시 내부 map 엔트리가 정리된다" { val store = SseEmitterStore() val emitter = mockk(relaxed = true) - store.add(clubId, userId, emitter) + store.replace(clubId, userId, emitter) store.remove(clubId, userId, emitter) store.getAllByClub(clubId).shouldBeEmpty() } - "여러 emitter 중 하나만 제거하면 나머지는 유지된다" { + "재연결 시 old emitter의 cleanup이 new emitter를 제거하지 않는다" { val store = SseEmitterStore() - val emitter1 = mockk(relaxed = true) - val emitter2 = mockk(relaxed = true) - store.add(clubId, userId, emitter1) - store.add(clubId, userId, emitter2) + val oldEmitter = mockk(relaxed = true) + val newEmitter = mockk(relaxed = true) - store.remove(clubId, userId, emitter1) + store.replace(clubId, userId, oldEmitter) + store.replace(clubId, userId, newEmitter) + store.remove(clubId, userId, oldEmitter) // old emitter의 onCompletion 시뮬레이션 store.getAllByClub(clubId) shouldHaveSize 1 + store.getAllByClub(clubId).first().second shouldBe newEmitter } "getAllByClub은 userId와 emitter 쌍을 반환한다" { val store = SseEmitterStore() val emitter = mockk(relaxed = true) - store.add(clubId, userId, emitter) + store.replace(clubId, userId, emitter) val result = store.getAllByClub(clubId) @@ -84,7 +88,7 @@ class SseEmitterStoreTest : store.getAllByClub(999L).shouldBeEmpty() } - "동시에 여러 스레드에서 add를 호출해도 emitter가 유실되지 않는다" { + "동시에 여러 스레드에서 replace를 호출해도 유저별 emitter가 유실되지 않는다" { val store = SseEmitterStore() val threadCount = 100 val latch = CountDownLatch(threadCount) @@ -93,7 +97,7 @@ class SseEmitterStoreTest : repeat(threadCount) { i -> executor.submit { try { - store.add(clubId, userId + i, mockk(relaxed = true)) + store.replace(clubId, userId + i, mockk(relaxed = true)) } finally { latch.countDown() } @@ -110,29 +114,29 @@ class SseEmitterStoreTest : store.getAllByClub(clubId) shouldHaveSize threadCount } - "동시에 add와 remove를 호출해도 store 상태가 일관성을 유지한다" { + "동시에 replace와 remove를 호출해도 store 상태가 일관성을 유지한다" { val store = SseEmitterStore() val threadCount = 50 val executor = Executors.newFixedThreadPool(threadCount * 2) - // 제거할 emitter를 사전에 store에 등록해 remove가 항상 유효한 대상을 갖도록 보장 - val toRemove = List(threadCount) { mockk(relaxed = true) } - toRemove.forEach { store.add(clubId, userId, it) } + val userIds = List(threadCount) { userId + it } + val initialEmitters = List(threadCount) { mockk(relaxed = true) } + userIds.forEachIndexed { i, uid -> store.replace(clubId, uid, initialEmitters[i]) } - val toAdd = List(threadCount) { mockk(relaxed = true) } + val newEmitters = List(threadCount) { mockk(relaxed = true) } val latch = CountDownLatch(threadCount * 2) repeat(threadCount) { i -> executor.submit { try { - store.add(clubId, userId, toAdd[i]) + store.replace(clubId, userIds[i], newEmitters[i]) } finally { latch.countDown() } } executor.submit { try { - store.remove(clubId, userId, toRemove[i]) + store.remove(clubId, userIds[i], initialEmitters[i]) } finally { latch.countDown() } @@ -146,10 +150,8 @@ class SseEmitterStoreTest : executor.awaitTermination(5, TimeUnit.SECONDS) } - // O(1) 조회를 위해 Set으로 변환 + // replace 후 remove가 실행됐거나, remove 후 replace가 실행된 상태 — 어느 쪽이든 초기 emitter는 없어야 함 val inStore = store.getAllByClub(clubId).map { it.second }.toSet() - - toAdd.all { it in inStore } shouldBe true - toRemove.none { it in inStore } shouldBe true + initialEmitters.none { it in inStore } shouldBe true } }) From 5471b1dfbb461850cbfe247356059548a017aa4e Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:28:35 +0900 Subject: [PATCH 67/73] =?UTF-8?q?[WTH-349]=20=EB=AA=A8=EB=8B=88=ED=84=B0?= =?UTF-8?q?=EB=A7=81=20=EC=9D=B8=ED=94=84=EB=9D=BC=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?(#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/dev/docker-compose.yml | 8 ++- .../dashboards/internal-infra.json | 68 +++++++++++++++++++ .../dev/monitoring/prometheus/prometheus.yml | 22 +++++- infra/dev/monitoring/tempo/tempo-config.yaml | 19 +++--- infra/prod/docker-compose.yml | 4 ++ .../dashboards/internal-infra.json | 68 +++++++++++++++++++ .../prod/monitoring/prometheus/prometheus.yml | 22 +++++- infra/prod/monitoring/tempo/tempo-config.yaml | 19 +++--- 8 files changed, 208 insertions(+), 22 deletions(-) diff --git a/infra/dev/docker-compose.yml b/infra/dev/docker-compose.yml index f5a4d515..84d3d5ff 100644 --- a/infra/dev/docker-compose.yml +++ b/infra/dev/docker-compose.yml @@ -61,11 +61,13 @@ services: OTEL_RESOURCE_ATTRIBUTES: ${OTEL_RESOURCE_ATTRIBUTES:-deployment.environment=dev} OTEL_TRACES_EXPORTER: ${OTEL_TRACES_EXPORTER:-otlp} OTEL_TRACES_SAMPLER: ${OTEL_TRACES_SAMPLER:-parentbased_traceidratio} - OTEL_TRACES_SAMPLER_ARG: ${OTEL_TRACES_SAMPLER_ARG:-1.0} + OTEL_TRACES_SAMPLER_ARG: ${OTEL_TRACES_SAMPLER_ARG:-0.1} + OTEL_BSP_SCHEDULE_DELAY: ${OTEL_BSP_SCHEDULE_DELAY:-15000} OTEL_METRICS_EXPORTER: ${OTEL_METRICS_EXPORTER:-none} OTEL_LOGS_EXPORTER: ${OTEL_LOGS_EXPORTER:-none} OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://alloy:4318} OTEL_EXPORTER_OTLP_PROTOCOL: ${OTEL_EXPORTER_OTLP_PROTOCOL:-http/protobuf} + OTEL_EXPORTER_OTLP_COMPRESSION: ${OTEL_EXPORTER_OTLP_COMPRESSION:-gzip} volumes: - ${HOME}/keys:/app/keys:ro ports: @@ -98,11 +100,13 @@ services: OTEL_RESOURCE_ATTRIBUTES: ${OTEL_RESOURCE_ATTRIBUTES:-deployment.environment=dev} OTEL_TRACES_EXPORTER: ${OTEL_TRACES_EXPORTER:-otlp} OTEL_TRACES_SAMPLER: ${OTEL_TRACES_SAMPLER:-parentbased_traceidratio} - OTEL_TRACES_SAMPLER_ARG: ${OTEL_TRACES_SAMPLER_ARG:-1.0} + OTEL_TRACES_SAMPLER_ARG: ${OTEL_TRACES_SAMPLER_ARG:-0.1} + OTEL_BSP_SCHEDULE_DELAY: ${OTEL_BSP_SCHEDULE_DELAY:-15000} OTEL_METRICS_EXPORTER: ${OTEL_METRICS_EXPORTER:-none} OTEL_LOGS_EXPORTER: ${OTEL_LOGS_EXPORTER:-none} OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://alloy:4318} OTEL_EXPORTER_OTLP_PROTOCOL: ${OTEL_EXPORTER_OTLP_PROTOCOL:-http/protobuf} + OTEL_EXPORTER_OTLP_COMPRESSION: ${OTEL_EXPORTER_OTLP_COMPRESSION:-gzip} volumes: - ${HOME}/keys:/app/keys:ro ports: diff --git a/infra/dev/monitoring/grafana/provisioning/dashboards/internal-infra.json b/infra/dev/monitoring/grafana/provisioning/dashboards/internal-infra.json index 4395f758..50ed2b9e 100644 --- a/infra/dev/monitoring/grafana/provisioning/dashboards/internal-infra.json +++ b/infra/dev/monitoring/grafana/provisioning/dashboards/internal-infra.json @@ -260,6 +260,74 @@ "fieldConfig": { "defaults": { "unit": "percentunit", "max": 1, "custom": { "fillOpacity": 10 } } } + }, + { + "id": 15, + "title": "Host & Docker", + "type": "row", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 37 }, + "collapsed": false, + "panels": [] + }, + { + "id": 16, + "title": "Host Memory Used / Total", + "type": "stat", + "gridPos": { "h": 4, "w": 12, "x": 0, "y": 38 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes", + "legendFormat": "used" + }, + { + "expr": "node_memory_MemTotal_bytes", + "legendFormat": "total" + } + ], + "fieldConfig": { + "defaults": { "unit": "bytes" } + }, + "options": { + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "orientation": "auto", + "textMode": "value_and_name", + "wideLayout": true + } + }, + { + "id": 17, + "title": "Swap Used / Total", + "type": "stat", + "gridPos": { "h": 4, "w": 12, "x": 12, "y": 38 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "node_memory_SwapTotal_bytes - node_memory_SwapFree_bytes", + "legendFormat": "used" + }, + { + "expr": "node_memory_SwapTotal_bytes", + "legendFormat": "total" + } + ], + "fieldConfig": { + "defaults": { "unit": "bytes" } + }, + "options": { + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "orientation": "auto", + "textMode": "value_and_name", + "wideLayout": true + } } ], "schemaVersion": 39 diff --git a/infra/dev/monitoring/prometheus/prometheus.yml b/infra/dev/monitoring/prometheus/prometheus.yml index 9b96e40d..f2424afa 100644 --- a/infra/dev/monitoring/prometheus/prometheus.yml +++ b/infra/dev/monitoring/prometheus/prometheus.yml @@ -1,6 +1,6 @@ global: - scrape_interval: 15s - evaluation_interval: 15s + scrape_interval: 30s + evaluation_interval: 30s scrape_configs: - job_name: "weeth-app" @@ -23,6 +23,24 @@ scrape_configs: labels: env: dev + - job_name: "prometheus" + static_configs: + - targets: ["prometheus:9090"] + labels: + env: dev + + - job_name: "loki" + static_configs: + - targets: ["loki:3100"] + labels: + env: dev + + - job_name: "tempo" + static_configs: + - targets: ["tempo:3200"] + labels: + env: dev + - job_name: "redis" static_configs: - targets: ["redis-exporter:9121"] diff --git a/infra/dev/monitoring/tempo/tempo-config.yaml b/infra/dev/monitoring/tempo/tempo-config.yaml index fcd71035..ce62c027 100644 --- a/infra/dev/monitoring/tempo/tempo-config.yaml +++ b/infra/dev/monitoring/tempo/tempo-config.yaml @@ -4,12 +4,19 @@ server: http_listen_port: 3200 distributor: + max_attribute_bytes: 1024 receivers: otlp: protocols: grpc: endpoint: "0.0.0.0:4317" +query_frontend: + max_query_expression_size_bytes: 32768 + search: + default_spans_per_span_set: 1 + max_spans_per_span_set: 20 + storage: trace: backend: s3 @@ -25,11 +32,7 @@ compactor: compaction: block_retention: 168h -metrics_generator: - storage: - path: /var/tempo/generator/wal - registry: - external_labels: - source: tempo - traces_storage: - path: /var/tempo/generator/traces +overrides: + defaults: + global: + max_bytes_per_trace: 3000000 diff --git a/infra/prod/docker-compose.yml b/infra/prod/docker-compose.yml index 82a13703..e345a5bd 100644 --- a/infra/prod/docker-compose.yml +++ b/infra/prod/docker-compose.yml @@ -46,10 +46,12 @@ services: OTEL_TRACES_EXPORTER: ${OTEL_TRACES_EXPORTER:-otlp} OTEL_TRACES_SAMPLER: ${OTEL_TRACES_SAMPLER:-parentbased_traceidratio} OTEL_TRACES_SAMPLER_ARG: ${OTEL_TRACES_SAMPLER_ARG:-0.1} + OTEL_BSP_SCHEDULE_DELAY: ${OTEL_BSP_SCHEDULE_DELAY:-15000} OTEL_METRICS_EXPORTER: ${OTEL_METRICS_EXPORTER:-none} OTEL_LOGS_EXPORTER: ${OTEL_LOGS_EXPORTER:-none} OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://alloy:4318} OTEL_EXPORTER_OTLP_PROTOCOL: ${OTEL_EXPORTER_OTLP_PROTOCOL:-http/protobuf} + OTEL_EXPORTER_OTLP_COMPRESSION: ${OTEL_EXPORTER_OTLP_COMPRESSION:-gzip} volumes: - ${HOME}/keys:/app/keys:ro ports: @@ -81,10 +83,12 @@ services: OTEL_TRACES_EXPORTER: ${OTEL_TRACES_EXPORTER:-otlp} OTEL_TRACES_SAMPLER: ${OTEL_TRACES_SAMPLER:-parentbased_traceidratio} OTEL_TRACES_SAMPLER_ARG: ${OTEL_TRACES_SAMPLER_ARG:-0.1} + OTEL_BSP_SCHEDULE_DELAY: ${OTEL_BSP_SCHEDULE_DELAY:-15000} OTEL_METRICS_EXPORTER: ${OTEL_METRICS_EXPORTER:-none} OTEL_LOGS_EXPORTER: ${OTEL_LOGS_EXPORTER:-none} OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://alloy:4318} OTEL_EXPORTER_OTLP_PROTOCOL: ${OTEL_EXPORTER_OTLP_PROTOCOL:-http/protobuf} + OTEL_EXPORTER_OTLP_COMPRESSION: ${OTEL_EXPORTER_OTLP_COMPRESSION:-gzip} volumes: - ${HOME}/keys:/app/keys:ro ports: diff --git a/infra/prod/monitoring/grafana/provisioning/dashboards/internal-infra.json b/infra/prod/monitoring/grafana/provisioning/dashboards/internal-infra.json index 4395f758..50ed2b9e 100644 --- a/infra/prod/monitoring/grafana/provisioning/dashboards/internal-infra.json +++ b/infra/prod/monitoring/grafana/provisioning/dashboards/internal-infra.json @@ -260,6 +260,74 @@ "fieldConfig": { "defaults": { "unit": "percentunit", "max": 1, "custom": { "fillOpacity": 10 } } } + }, + { + "id": 15, + "title": "Host & Docker", + "type": "row", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 37 }, + "collapsed": false, + "panels": [] + }, + { + "id": 16, + "title": "Host Memory Used / Total", + "type": "stat", + "gridPos": { "h": 4, "w": 12, "x": 0, "y": 38 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes", + "legendFormat": "used" + }, + { + "expr": "node_memory_MemTotal_bytes", + "legendFormat": "total" + } + ], + "fieldConfig": { + "defaults": { "unit": "bytes" } + }, + "options": { + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "orientation": "auto", + "textMode": "value_and_name", + "wideLayout": true + } + }, + { + "id": 17, + "title": "Swap Used / Total", + "type": "stat", + "gridPos": { "h": 4, "w": 12, "x": 12, "y": 38 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "expr": "node_memory_SwapTotal_bytes - node_memory_SwapFree_bytes", + "legendFormat": "used" + }, + { + "expr": "node_memory_SwapTotal_bytes", + "legendFormat": "total" + } + ], + "fieldConfig": { + "defaults": { "unit": "bytes" } + }, + "options": { + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "orientation": "auto", + "textMode": "value_and_name", + "wideLayout": true + } } ], "schemaVersion": 39 diff --git a/infra/prod/monitoring/prometheus/prometheus.yml b/infra/prod/monitoring/prometheus/prometheus.yml index e2b0b89f..1c94de7f 100644 --- a/infra/prod/monitoring/prometheus/prometheus.yml +++ b/infra/prod/monitoring/prometheus/prometheus.yml @@ -1,6 +1,6 @@ global: - scrape_interval: 15s - evaluation_interval: 15s + scrape_interval: 30s + evaluation_interval: 30s scrape_configs: - job_name: "weeth-app" @@ -23,6 +23,24 @@ scrape_configs: labels: env: prod + - job_name: "prometheus" + static_configs: + - targets: ["prometheus:9090"] + labels: + env: prod + + - job_name: "loki" + static_configs: + - targets: ["loki:3100"] + labels: + env: prod + + - job_name: "tempo" + static_configs: + - targets: ["tempo:3200"] + labels: + env: prod + - job_name: "redis" static_configs: - targets: ["redis-exporter:9121"] diff --git a/infra/prod/monitoring/tempo/tempo-config.yaml b/infra/prod/monitoring/tempo/tempo-config.yaml index de48ebc7..c6ef04c4 100644 --- a/infra/prod/monitoring/tempo/tempo-config.yaml +++ b/infra/prod/monitoring/tempo/tempo-config.yaml @@ -4,12 +4,19 @@ server: http_listen_port: 3200 distributor: + max_attribute_bytes: 1024 receivers: otlp: protocols: grpc: endpoint: "0.0.0.0:4317" +query_frontend: + max_query_expression_size_bytes: 32768 + search: + default_spans_per_span_set: 1 + max_spans_per_span_set: 20 + storage: trace: backend: s3 @@ -25,11 +32,7 @@ compactor: compaction: block_retention: 720h -metrics_generator: - storage: - path: /var/tempo/generator/wal - registry: - external_labels: - source: tempo - traces_storage: - path: /var/tempo/generator/traces +overrides: + defaults: + global: + max_bytes_per_trace: 3000000 From 157fa1573e038c43eca2f1bfca4af5d33014c303 Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:37:39 +0900 Subject: [PATCH 68/73] =?UTF-8?q?[WTH-356]=20=EB=A6=AC=EB=8D=94=20?= =?UTF-8?q?=EC=B6=94=EB=B0=A9=EC=9D=B4=20=EB=B6=88=EA=B0=80=EB=8A=A5?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20(#62)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 리더 추방이 불가능하도록 수정 * test: 테스트 수정 --- .../exception/CannotBanLeadException.kt | 5 +++++ .../club/application/exception/ClubErrorCode.kt | 3 +++ .../usecase/command/AdminClubMemberUseCase.kt | 4 +++- .../command/AdminClubMemberUseCaseTest.kt | 16 ++++++++++++++-- 4 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 src/main/kotlin/com/weeth/domain/club/application/exception/CannotBanLeadException.kt diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/CannotBanLeadException.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/CannotBanLeadException.kt new file mode 100644 index 00000000..f32dc0b4 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/CannotBanLeadException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.club.application.exception + +import com.weeth.global.common.exception.BaseException + +class CannotBanLeadException : BaseException(ClubErrorCode.CANNOT_BAN_LEAD) diff --git a/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt index 45b8091b..fe94effd 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/exception/ClubErrorCode.kt @@ -30,6 +30,9 @@ enum class ClubErrorCode( @ExplainError("비활성 멤버가 동아리 리소스에 접근할 때 발생합니다.") MEMBER_NOT_ACTIVE(21106, HttpStatus.FORBIDDEN, "비활성 멤버입니다."), + @ExplainError("리더를 권한 이양 없이 추방하려 할 때 발생합니다.") + CANNOT_BAN_LEAD(21107, HttpStatus.BAD_REQUEST, "리더는 권한 이양 후 추방할 수 있습니다."), + @ExplainError("요청한 멤버가 해당 동아리에 속하지 않을 때 발생합니다.") CLUB_MEMBER_NOT_IN_CLUB(21108, HttpStatus.BAD_REQUEST, "해당 동아리에 속한 멤버가 아닙니다."), diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt index 5688bf09..c56a5961 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCase.kt @@ -9,6 +9,7 @@ import com.weeth.domain.cardinal.domain.repository.CardinalReader import com.weeth.domain.club.application.dto.request.ClubMemberApplyObRequest import com.weeth.domain.club.application.dto.request.ClubMemberRoleUpdateRequest import com.weeth.domain.club.application.dto.request.UpdateMemberCardinalRequest +import com.weeth.domain.club.application.exception.CannotBanLeadException import com.weeth.domain.club.application.exception.CardinalRemovalHasAttendanceException import com.weeth.domain.club.application.exception.ClubMemberNotFoundException import com.weeth.domain.club.application.exception.ClubMemberNotInClubException @@ -65,8 +66,9 @@ class AdminClubMemberUseCase( ) { val adminMember = clubPermissionPolicy.requireAdmin(clubId, userId) - val member = clubMemberPolicy.getMemberInClub(clubId, clubMemberId) + val member = clubMemberPolicy.getActiveMemberInClubWithLock(clubId, clubMemberId) if (adminMember.id == member.id) throw SelfBanNotAllowedException() + if (member.isLead()) throw CannotBanLeadException() member.ban() } diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCaseTest.kt index bee3ab8b..c930f166 100644 --- a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/AdminClubMemberUseCaseTest.kt @@ -8,6 +8,7 @@ import com.weeth.domain.cardinal.fixture.CardinalTestFixture import com.weeth.domain.club.application.dto.request.ClubMemberApplyObRequest import com.weeth.domain.club.application.dto.request.ClubMemberRoleUpdateRequest import com.weeth.domain.club.application.dto.request.UpdateMemberCardinalRequest +import com.weeth.domain.club.application.exception.CannotBanLeadException import com.weeth.domain.club.application.exception.CardinalRemovalHasAttendanceException import com.weeth.domain.club.application.exception.ClubMemberNotInClubException import com.weeth.domain.club.application.exception.LeadSelfTransferException @@ -111,7 +112,7 @@ class AdminClubMemberUseCaseTest : ReflectionTestUtils.setField(adminMember, "id", 10L) val member = ClubMemberTestFixture.createActiveMember(id = 20L) every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember - every { clubMemberPolicy.getMemberInClub(1L, 20L) } returns member + every { clubMemberPolicy.getActiveMemberInClubWithLock(1L, 20L) } returns member useCase.ban(1L, 10L, 20L) @@ -121,12 +122,23 @@ class AdminClubMemberUseCaseTest : it("자기 자신은 추방할 수 없다") { ReflectionTestUtils.setField(adminMember, "id", 10L) every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember - every { clubMemberPolicy.getMemberInClub(1L, 10L) } returns adminMember + every { clubMemberPolicy.getActiveMemberInClubWithLock(1L, 10L) } returns adminMember shouldThrow { useCase.ban(1L, 10L, 10L) } } + + it("리더는 권한 이양 전 추방할 수 없다") { + ReflectionTestUtils.setField(adminMember, "id", 10L) + val leadMember = ClubMemberTestFixture.createLeadMember(club = club) + every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember + every { clubMemberPolicy.getActiveMemberInClubWithLock(1L, 20L) } returns leadMember + + shouldThrow { + useCase.ban(1L, 10L, 20L) + } + } } describe("restore") { From a5cce266ba17ea7d202d37b3a26bb0cacd71a7ec Mon Sep 17 00:00:00 2001 From: Jeon Soo Hyeon <128474444+soo0711@users.noreply.github.com> Date: Wed, 29 Apr 2026 23:30:50 +0900 Subject: [PATCH 69/73] =?UTF-8?q?[WTH]=20=EC=98=A4=EB=8A=98=EC=9D=98=20?= =?UTF-8?q?=EC=9D=BC=EC=A0=95=20=EB=8B=A8=EA=B1=B4=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95=20(#64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 오늘의 일정 응답 1개면 바로 반환, 여러개면 현재 시간 이후에 가까운 1개로 반환하게 수정 * test: 관련 테스트 수정 --- .../query/GetAttendanceQueryService.kt | 20 ++++- .../domain/repository/AttendanceRepository.kt | 3 +- .../query/GetAttendanceQueryServiceTest.kt | 84 ++++++++++++++++++- 3 files changed, 100 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt index b9c88ac5..1bb00427 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryService.kt @@ -13,7 +13,7 @@ import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.session.domain.repository.SessionReader import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import java.time.LocalDate +import java.time.LocalDateTime @Service @Transactional(readOnly = true) @@ -30,15 +30,27 @@ class GetAttendanceQueryService( userId: Long, ): AttendanceSummaryResponse { val clubMember = clubMemberPolicy.getActiveMember(clubId, userId) - val today = LocalDate.now() - - val todayAttendance = + val now = LocalDateTime.now() + val today = now.toLocalDate() + val todayAttendances = attendanceRepository.findTodayByClubMemberId( clubMember.id, today.atStartOfDay(), today.plusDays(1).atStartOfDay(), ) + val todayAttendance = + when { + todayAttendances.size <= 1 -> { + todayAttendances.firstOrNull() + } + + else -> { + todayAttendances.firstOrNull { it.session.start >= now } + ?: todayAttendances.last() + } + } + return attendanceMapper.toSummaryResponse(clubMember, todayAttendance) } diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt index 7a8a9258..8ca23fec 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt @@ -75,13 +75,14 @@ interface AttendanceRepository : JpaRepository { WHERE a.clubMember.id = :clubMemberId AND s.start >= :dayStart AND s.end < :dayEnd + ORDER BY s.start ASC """, ) fun findTodayByClubMemberId( @Param("clubMemberId") clubMemberId: Long, @Param("dayStart") dayStart: LocalDateTime, @Param("dayEnd") dayEnd: LocalDateTime, - ): Attendance? + ): List @Query( """ diff --git a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt index 528cb6a3..4340b291 100644 --- a/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/attendance/application/usecase/query/GetAttendanceQueryServiceTest.kt @@ -16,9 +16,11 @@ import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe +import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk import io.mockk.verify +import java.time.LocalDateTime class GetAttendanceQueryServiceTest : DescribeSpec({ @@ -40,7 +42,11 @@ class GetAttendanceQueryServiceTest : ) describe("findAttendance") { - it("오늘 출석 요약을 ClubMember 기준으로 반환한다") { + beforeTest { + clearMocks(clubMemberPolicy, attendanceRepository) + } + + it("오늘 출석이 1개이면 해당 출석을 반환한다") { val member = ClubMemberTestFixture.createActiveMember() member.attend() val session = @@ -53,7 +59,8 @@ class GetAttendanceQueryServiceTest : val attendance = Attendance.create(session, member) every { clubMemberPolicy.getActiveMember(member.club.id, member.user.id) } returns member - every { attendanceRepository.findTodayByClubMemberId(member.id, any(), any()) } returns attendance + every { attendanceRepository.findTodayByClubMemberId(member.id, any(), any()) } returns + listOf(attendance) val result = queryService.findAttendance(member.club.id, member.user.id) @@ -62,6 +69,79 @@ class GetAttendanceQueryServiceTest : result.status shouldBe AttendanceStatus.PENDING verify(exactly = 1) { clubMemberPolicy.getActiveMember(member.club.id, member.user.id) } } + + it("오늘 출석이 없으면 세션 관련 필드를 null로 반환한다") { + val member = ClubMemberTestFixture.createActiveMember() + + every { clubMemberPolicy.getActiveMember(member.club.id, member.user.id) } returns member + every { attendanceRepository.findTodayByClubMemberId(member.id, any(), any()) } returns emptyList() + + val result = queryService.findAttendance(member.club.id, member.user.id) + + result.title shouldBe null + result.status shouldBe null + result.sessionId shouldBe null + } + + it("오늘 세션이 여러 개이면 현재 시각 이후 가장 가까운 세션을 반환한다") { + val member = ClubMemberTestFixture.createActiveMember() + val now = LocalDateTime.now() + val pastSession = + SessionTestFixture.createSession( + title = "오전 세션", + start = now.minusHours(3), + end = now.minusHours(1), + club = member.club, + ) + val upcomingSession = + SessionTestFixture.createSession( + title = "오후 세션", + start = now.plusHours(1), + end = now.plusHours(3), + club = member.club, + ) + + every { clubMemberPolicy.getActiveMember(member.club.id, member.user.id) } returns member + every { attendanceRepository.findTodayByClubMemberId(member.id, any(), any()) } returns + listOf( + Attendance.create(pastSession, member), + Attendance.create(upcomingSession, member), + ) + + val result = queryService.findAttendance(member.club.id, member.user.id) + + result.title shouldBe "오후 세션" + } + + it("오늘 세션이 여러 개이고 모두 현재 시각 이전이면 마지막 세션을 반환한다") { + val member = ClubMemberTestFixture.createActiveMember() + val now = LocalDateTime.now() + val morningSession = + SessionTestFixture.createSession( + title = "오전 세션", + start = now.minusHours(5), + end = now.minusHours(3), + club = member.club, + ) + val afternoonSession = + SessionTestFixture.createSession( + title = "오후 세션", + start = now.minusHours(2), + end = now.minusHours(1), + club = member.club, + ) + + every { clubMemberPolicy.getActiveMember(member.club.id, member.user.id) } returns member + every { attendanceRepository.findTodayByClubMemberId(member.id, any(), any()) } returns + listOf( + Attendance.create(morningSession, member), + Attendance.create(afternoonSession, member), + ) + + val result = queryService.findAttendance(member.club.id, member.user.id) + + result.title shouldBe "오후 세션" + } } describe("findAllDetailsByCurrentCardinal") { From 82cbce1a2b00c28c50c5334fa887c9e83405bc2e Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:50:12 +0900 Subject: [PATCH 70/73] =?UTF-8?q?[WTH-358]=20=EC=B6=94=EB=B0=A9=EB=90=9C?= =?UTF-8?q?=20=EA=B2=BD=EC=9A=B0=20=EB=8F=99=EC=95=84=EB=A6=AC=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=AF=B8=ED=91=9C=EA=B8=B0=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 내 동아리 조회 시 활성화된 동아리만 조회 * refactor: mvp 후 추방된 상태까지 반환하도록 설정 --- .../dto/response/ClubMembershipStatusResponse.kt | 2 ++ .../domain/club/application/mapper/ClubMapper.kt | 2 ++ .../usecase/query/GetClubQueryService.kt | 3 ++- .../club/domain/repository/ClubMemberReader.kt | 5 +++++ .../club/domain/repository/ClubMemberRepository.kt | 14 ++++++++++++++ 5 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMembershipStatusResponse.kt b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMembershipStatusResponse.kt index 4ff9880f..3f878f48 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMembershipStatusResponse.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/dto/response/ClubMembershipStatusResponse.kt @@ -7,6 +7,8 @@ data class ClubMembershipStatusResponse( val hasActiveClub: Boolean, @field:Schema(description = "WAITING 상태 동아리 존재 여부", example = "false") val hasWaitingClub: Boolean, +// @field:Schema(description = "BANNED 상태 동아리 존재 여부", example = "false") 추후 추가 +// val hasBannedClub: Boolean, @field:Schema(description = "ACTIVE 동아리 정보 (없으면 null)") val activeClub: ClubInfoResponse?, @field:Schema(description = "WAITING 동아리 정보 (없으면 null)") diff --git a/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt b/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt index e3478f15..19b4a975 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/mapper/ClubMapper.kt @@ -118,10 +118,12 @@ class ClubMapper( ): ClubMembershipStatusResponse { val activeMember = members.firstOrNull { it.memberStatus == MemberStatus.ACTIVE } val waitingMember = members.firstOrNull { it.memberStatus == MemberStatus.WAITING } + val bannedMember = members.firstOrNull { it.memberStatus == MemberStatus.BANNED } return ClubMembershipStatusResponse( hasActiveClub = activeMember != null, hasWaitingClub = waitingMember != null, +// hasBannedClub = bannedMember != null, 추후 추가 activeClub = activeMember?.let { toInfoResponse( diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubQueryService.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubQueryService.kt index 6ef178e8..9126b1c0 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/query/GetClubQueryService.kt @@ -5,6 +5,7 @@ import com.weeth.domain.club.application.dto.response.ClubInfoResponse import com.weeth.domain.club.application.dto.response.ClubMembershipStatusResponse import com.weeth.domain.club.application.dto.response.ClubPublicResponse import com.weeth.domain.club.application.mapper.ClubMapper +import com.weeth.domain.club.domain.enums.MemberStatus import com.weeth.domain.club.domain.repository.ClubMemberCardinalReader import com.weeth.domain.club.domain.repository.ClubMemberReader import com.weeth.domain.club.domain.repository.ClubReader @@ -22,7 +23,7 @@ class GetClubQueryService( private val clubMapper: ClubMapper, ) { fun findMyClubs(userId: Long): List { - val members = clubMemberReader.findAllByUserIdWithClub(userId) + val members = clubMemberReader.findAllByUserIdAndMemberStatusWithClub(userId, MemberStatus.ACTIVE) if (members.isEmpty()) return emptyList() val cardinalsByMemberId = diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt index 29b1f853..15533b86 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt @@ -31,6 +31,11 @@ interface ClubMemberReader { fun findAllByUserIdWithClub(userId: Long): List + fun findAllByUserIdAndMemberStatusWithClub( + userId: Long, + memberStatus: MemberStatus, + ): List + fun findActiveByUserId(userId: Long): List fun countActiveByClubId(clubId: Long): Long diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt index 3a75ed05..9b0e810a 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt @@ -74,6 +74,20 @@ interface ClubMemberRepository : @Param("userId") userId: Long, ): List + @Query( + """ + SELECT cm + FROM ClubMember cm + JOIN FETCH cm.club + WHERE cm.user.id = :userId + AND cm.memberStatus = :memberStatus + """, + ) + override fun findAllByUserIdAndMemberStatusWithClub( + @Param("userId") userId: Long, + @Param("memberStatus") memberStatus: MemberStatus, + ): List + @Query( """ SELECT cm From 89ea8b5e670efc9eae121bc1530c10b5b5bcd649 Mon Sep 17 00:00:00 2001 From: Lee Kang Hyuk <77369759+hyxklee@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:27:16 +0900 Subject: [PATCH 71/73] =?UTF-8?q?[WTH-361]=20=EC=9A=B4=EC=98=81=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EB=AA=A8=EB=8B=88=ED=84=B0=EB=A7=81=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20(#66)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 운영 환경 템포 제거 * refactor: 운영 CORS 추가 --- infra/prod/monitoring/alloy/config.alloy | 18 ---------- infra/prod/monitoring/docker-compose.yml | 34 ------------------- .../provisioning/datasources/datasources.yaml | 14 -------- .../prod/monitoring/prometheus/prometheus.yml | 12 ------- .../com/weeth/global/config/SecurityConfig.kt | 2 ++ 5 files changed, 2 insertions(+), 78 deletions(-) diff --git a/infra/prod/monitoring/alloy/config.alloy b/infra/prod/monitoring/alloy/config.alloy index 7bfcc2c9..2f80f6dd 100644 --- a/infra/prod/monitoring/alloy/config.alloy +++ b/infra/prod/monitoring/alloy/config.alloy @@ -56,21 +56,3 @@ loki.write "default" { url = "http://loki:3100/loki/api/v1/push" } } - -otelcol.receiver.otlp "default" { - http { - endpoint = "0.0.0.0:4318" - } - output { - traces = [otelcol.exporter.otlp.tempo.input] - } -} - -otelcol.exporter.otlp "tempo" { - client { - endpoint = "tempo:4317" - tls { - insecure = true - } - } -} diff --git a/infra/prod/monitoring/docker-compose.yml b/infra/prod/monitoring/docker-compose.yml index e66f3238..e2a26cd2 100644 --- a/infra/prod/monitoring/docker-compose.yml +++ b/infra/prod/monitoring/docker-compose.yml @@ -14,10 +14,8 @@ services: command: ["run", "--server.http.listen-addr=0.0.0.0:12345", "/etc/alloy/config.alloy"] ports: - "127.0.0.1:12345:12345" - - "127.0.0.1:4318:4318" depends_on: - loki - - tempo networks: - monitoring - weeth-app @@ -37,20 +35,6 @@ services: - monitoring restart: unless-stopped - tempo: - image: grafana/tempo:2.7.1 - env_file: - - ${MONITORING_ENV_FILE:-../.env.monitoring} - volumes: - - ./tempo/tempo-config.yaml:/etc/tempo/tempo-config.yaml:ro - - tempo_data:/var/tempo - command: ["-config.file=/etc/tempo/tempo-config.yaml", "-config.expand-env=true"] - ports: - - "127.0.0.1:3200:3200" - networks: - - monitoring - restart: unless-stopped - redis-exporter: image: oliver006/redis_exporter:v1.67.0 environment: @@ -89,22 +73,6 @@ services: - monitoring restart: unless-stopped - cadvisor: - image: gcr.io/cadvisor/cadvisor:v0.51.0 - privileged: true - devices: - - /dev/kmsg - volumes: - - /:/rootfs:ro - - /var/run:/var/run:ro - - /sys:/sys:ro - - /var/lib/docker:/var/lib/docker:ro - - /dev/disk:/dev/disk:ro - networks: - - monitoring - - weeth-app - restart: unless-stopped - grafana: image: grafana/grafana:11.5.2 env_file: @@ -123,7 +91,6 @@ services: depends_on: - loki - prometheus - - tempo networks: - monitoring - weeth-app @@ -140,4 +107,3 @@ volumes: grafana_data: loki_data: prometheus_data: - tempo_data: diff --git a/infra/prod/monitoring/grafana/provisioning/datasources/datasources.yaml b/infra/prod/monitoring/grafana/provisioning/datasources/datasources.yaml index f6118e0a..8711dde7 100644 --- a/infra/prod/monitoring/grafana/provisioning/datasources/datasources.yaml +++ b/infra/prod/monitoring/grafana/provisioning/datasources/datasources.yaml @@ -9,23 +9,9 @@ datasources: isDefault: true editable: true - - name: Tempo - type: tempo - access: proxy - url: http://tempo:3200 - uid: tempo - editable: true - - name: Loki type: loki access: proxy url: http://loki:3100 uid: loki editable: true - jsonData: - derivedFields: - - datasourceUid: tempo - matcherRegex: '"(?:traceId|trace_id|mdc_traceId|mdc_trace_id)"\s*:\s*"([^"]+)"' - name: traceId - url: "$${__value.raw}" - urlDisplayLabel: "View Trace" diff --git a/infra/prod/monitoring/prometheus/prometheus.yml b/infra/prod/monitoring/prometheus/prometheus.yml index 1c94de7f..9f16b399 100644 --- a/infra/prod/monitoring/prometheus/prometheus.yml +++ b/infra/prod/monitoring/prometheus/prometheus.yml @@ -17,12 +17,6 @@ scrape_configs: labels: env: prod - - job_name: "cadvisor" - static_configs: - - targets: ["cadvisor:8080"] - labels: - env: prod - - job_name: "prometheus" static_configs: - targets: ["prometheus:9090"] @@ -35,12 +29,6 @@ scrape_configs: labels: env: prod - - job_name: "tempo" - static_configs: - - targets: ["tempo:3200"] - labels: - env: prod - - job_name: "redis" static_configs: - targets: ["redis-exporter:9121"] diff --git a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt index 74ceac9c..4bd4f248 100644 --- a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt +++ b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt @@ -89,6 +89,8 @@ class SecurityConfig( "https://*.v4.weeth.kr", "https://landing.weeth.kr", "https://www.landing.weeth.kr", + "https://weeth.kr", + "https://www.weeth.kr", "https://appleid.apple.com", ) allowedMethods = listOf("GET", "POST", "PATCH", "DELETE", "OPTIONS") From d0670cd65241a7b0ba5d8253fcbcd339b56ccebc Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 30 Apr 2026 15:59:05 +0900 Subject: [PATCH 72/73] =?UTF-8?q?HOTFIX:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=93=9C=EB=9E=98=ED=94=84=ED=84=B0=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/release-drafter.yml | 4 ++++ .github/workflows/release-drafter.yml | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 2235b905..df80f183 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -1,5 +1,9 @@ name-template: "v$RESOLVED_VERSION" tag-template: "v$RESOLVED_VERSION" +commitish: "dev" +filter-by-commitish: true +pull-request-limit: 50 +history-limit: 200 categories: - title: "✨ Features" diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index bf4d23a3..5a334b6d 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -2,7 +2,8 @@ name: Release Drafter on: push: - branches: [main] + branches: [dev] + workflow_dispatch: permissions: contents: write From 5c480ff12337a01069de56bc90fd75afddc63eef Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 30 Apr 2026 16:00:34 +0900 Subject: [PATCH 73/73] =?UTF-8?q?HOTFIX:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=93=9C=EB=9E=98=ED=94=84=ED=84=B0=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/release-drafter.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index df80f183..61e737f6 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -2,8 +2,8 @@ name-template: "v$RESOLVED_VERSION" tag-template: "v$RESOLVED_VERSION" commitish: "dev" filter-by-commitish: true -pull-request-limit: 50 -history-limit: 200 +pull-request-limit: 20 +history-limit: 100 categories: - title: "✨ Features"