diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..47ad3113c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +** + +!Dockerfile +!.dockerignore +!build/ +!build/libs/ +!build/libs/KONECT_API.jar +!opentelemetry-javaagent.jar diff --git a/.github/workflows/checkstyle.yml b/.github/workflows/checkstyle.yml index 804f43630..91c97442c 100644 --- a/.github/workflows/checkstyle.yml +++ b/.github/workflows/checkstyle.yml @@ -1,80 +1,80 @@ -name: Checkstyle - -on: - pull_request: - branches: - - main - - develop - push: - branches: - - main - - develop - -permissions: - contents: read - pull-requests: write - -jobs: - checkstyle: - name: Code Style Check - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - submodules: recursive - token: ${{ secrets.SUBMODULE_TOKEN }} - - - name: Set up JDK 21 - uses: actions/setup-java@v4 - with: - java-version: '21' - distribution: 'temurin' - - - name: Cache Gradle packages - uses: actions/cache@v3 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- - - - name: Grant execute permission for gradlew - run: chmod +x gradlew - - - name: Run Checkstyle - run: ./gradlew checkstyleMain --no-daemon - - - name: Upload Checkstyle Report - if: failure() - uses: actions/upload-artifact@v4 - with: - name: checkstyle-report - path: | - build/reports/checkstyle/main.html - build/reports/checkstyle/main.xml - retention-days: 7 - - - name: Comment PR with Checkstyle Results - if: github.event_name == 'pull_request' && failure() - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const fs = require('fs'); - const path = require('path'); - - let comment = '## ⚠️ Checkstyle 위반 사항 발견\n\n'; - comment += 'Checkstyle 검사에서 코딩 컨벤션 위반이 발견되었습니다.\n\n'; - comment += '### 📋 상세 리포트\n'; - comment += '- [Main 소스 리포트 다운로드](../actions/runs/${{ github.run_id }})\n'; - - github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: comment - }); +name: Checkstyle + +on: + pull_request: + branches: + - main + - develop + push: + branches: + - main + - develop + +permissions: + contents: read + pull-requests: write + +jobs: + checkstyle: + name: Code Style Check + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + submodules: recursive + token: ${{ secrets.SUBMODULE_TOKEN }} + + - name: Set up JDK 21 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Cache Gradle packages + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + gradle-${{ runner.os }}- + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Run Checkstyle + run: ./gradlew checkstyleMain --no-daemon + + - name: Upload Checkstyle Report + if: failure() + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4 + with: + name: checkstyle-report + path: | + build/reports/checkstyle/main.html + build/reports/checkstyle/main.xml + retention-days: 7 + + - name: Comment PR with Checkstyle Results + if: github.event_name == 'pull_request' && failure() + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + const path = require('path'); + + let comment = '## ⚠️ Checkstyle 위반 사항 발견\n\n'; + comment += 'Checkstyle 검사에서 코딩 컨벤션 위반이 발견되었습니다.\n\n'; + comment += '### 📋 상세 리포트\n'; + comment += '- [Main 소스 리포트 다운로드](../actions/runs/${{ github.run_id }})\n'; + + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: comment + }); \ No newline at end of file diff --git a/.github/workflows/deploy-monitoring.yml b/.github/workflows/deploy-monitoring.yml index ceda976c3..77d0d595b 100644 --- a/.github/workflows/deploy-monitoring.yml +++ b/.github/workflows/deploy-monitoring.yml @@ -13,10 +13,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Transfer monitoring configs to server - uses: appleboy/scp-action@v0.1.7 + uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7 with: host: ${{ secrets.SERVER_IP }} username: ${{ secrets.SERVER_USER }} @@ -27,7 +27,7 @@ jobs: rm: false - name: Deploy monitoring stack - uses: appleboy/ssh-action@v0.1.8 + uses: appleboy/ssh-action@v1.2.0 env: WORK_DIR: ${{ secrets.PROD_WORK_DIR }} MONITORING_ENV: ${{ secrets.MONITORING_ENV }} diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 3f9088bd3..9f64a0522 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -11,16 +11,16 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up JDK 21 - uses: actions/setup-java@v4 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 with: java-version: '21' distribution: 'temurin' - name: Cache Gradle packages - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: | ~/.gradle/caches @@ -29,6 +29,23 @@ jobs: restore-keys: | gradle-${{ runner.os }}- + - name: Cache OpenTelemetry Java Agent + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.cache/otel-java-agent + key: otel-agent-${{ runner.os }}-${{ vars.OTEL_JAVA_AGENT_VERSION }}-${{ vars.OTEL_JAVA_AGENT_SHA256 }} + + - name: Prepare OpenTelemetry Agent + run: | + mkdir -p ~/.cache/otel-java-agent + if [ ! -f ~/.cache/otel-java-agent/opentelemetry-javaagent.jar ]; then + wget -O ~/.cache/otel-java-agent/opentelemetry-javaagent.jar \ + "https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v${{ vars.OTEL_JAVA_AGENT_VERSION }}/opentelemetry-javaagent.jar" + fi + # Verify checksum + echo "${{ vars.OTEL_JAVA_AGENT_SHA256 }} $HOME/.cache/otel-java-agent/opentelemetry-javaagent.jar" | sha256sum -c - + cp ~/.cache/otel-java-agent/opentelemetry-javaagent.jar . + - name: Grant execute permission for gradlew run: chmod +x gradlew @@ -38,20 +55,20 @@ jobs: source .env.example set +a unset JAVA_TOOL_OPTIONS - ./gradlew clean build -x test -Dspring.profiles.active=prod + ./gradlew bootJar -x test --build-cache -Dspring.profiles.active=prod - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Extract metadata for Docker id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 with: images: soundbar91/konect-prod tags: | @@ -59,18 +76,20 @@ jobs: type=sha,prefix=sha- - name: Build and push Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 with: context: . - platforms: linux/amd64,linux/arm64 + platforms: linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max + build-args: | + OTEL_JAVA_AGENT_VERSION=${{ vars.OTEL_JAVA_AGENT_VERSION }} - name: Backup prod MySQL before deploy - uses: appleboy/ssh-action@v0.1.8 + uses: appleboy/ssh-action@v1.2.0 with: host: ${{ secrets.PROD_SERVER_IP }} username: ${{ secrets.PROD_SERVER_USER }} @@ -78,6 +97,7 @@ jobs: port: ${{ secrets.PROD_SERVER_PORT }} script: | set -euo pipefail + START_TIME=$(date +%s) WORK_DIR="${{ secrets.PROD_WORK_DIR }}" MYSQL_CONTAINER="mysql-prod" @@ -96,13 +116,22 @@ jobs: find "$BACKUP_DIR" -type f -name '*.sql' -mtime +30 -delete + END_TIME=$(date +%s) + echo "Prod MySQL backup completed in $((END_TIME - START_TIME))s" + - name: Deploy to prod server - uses: appleboy/ssh-action@v0.1.8 + uses: appleboy/ssh-action@v1.2.0 with: host: ${{ secrets.PROD_SERVER_IP }} username: ${{ secrets.PROD_SERVER_USER }} key: ${{ secrets.PROD_SERVER_SSH_KEY }} port: ${{ secrets.PROD_SERVER_PORT }} script: | + set -euo pipefail + START_TIME=$(date +%s) + cd ${{ secrets.PROD_WORK_DIR }} ./deploy.sh + + END_TIME=$(date +%s) + echo "Prod deploy.sh completed in $((END_TIME - START_TIME))s" diff --git a/.github/workflows/deploy-stage.yml b/.github/workflows/deploy-stage.yml index 44e43bc26..d5c8630ad 100644 --- a/.github/workflows/deploy-stage.yml +++ b/.github/workflows/deploy-stage.yml @@ -11,16 +11,16 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up JDK 21 - uses: actions/setup-java@v4 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 with: java-version: '21' distribution: 'temurin' - name: Cache Gradle packages - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: | ~/.gradle/caches @@ -29,6 +29,23 @@ jobs: restore-keys: | gradle-${{ runner.os }}- + - name: Cache OpenTelemetry Java Agent + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.cache/otel-java-agent + key: otel-agent-${{ runner.os }}-${{ vars.OTEL_JAVA_AGENT_VERSION }}-${{ vars.OTEL_JAVA_AGENT_SHA256 }} + + - name: Prepare OpenTelemetry Agent + run: | + mkdir -p ~/.cache/otel-java-agent + if [ ! -f ~/.cache/otel-java-agent/opentelemetry-javaagent.jar ]; then + wget -O ~/.cache/otel-java-agent/opentelemetry-javaagent.jar \ + "https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v${{ vars.OTEL_JAVA_AGENT_VERSION }}/opentelemetry-javaagent.jar" + fi + # Verify checksum + echo "${{ vars.OTEL_JAVA_AGENT_SHA256 }} $HOME/.cache/otel-java-agent/opentelemetry-javaagent.jar" | sha256sum -c - + cp ~/.cache/otel-java-agent/opentelemetry-javaagent.jar . + - name: Grant execute permission for gradlew run: chmod +x gradlew @@ -38,20 +55,20 @@ jobs: source .env.example set +a unset JAVA_TOOL_OPTIONS - ./gradlew clean build -x test -Dspring.profiles.active=stage + ./gradlew bootJar -x test --build-cache -Dspring.profiles.active=stage - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Extract metadata for Docker id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 with: images: soundbar91/konect-stage tags: | @@ -59,18 +76,20 @@ jobs: type=sha,prefix=sha- - name: Build and push Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 with: context: . - platforms: linux/amd64,linux/arm64 + platforms: linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max + build-args: | + OTEL_JAVA_AGENT_VERSION=${{ vars.OTEL_JAVA_AGENT_VERSION }} - name: Backup stage MySQL before deploy - uses: appleboy/ssh-action@v0.1.8 + uses: appleboy/ssh-action@v1.2.0 with: host: ${{ secrets.STAGE_SERVER_IP }} username: ${{ secrets.STAGE_SERVER_USER }} @@ -78,6 +97,7 @@ jobs: port: ${{ secrets.STAGE_SERVER_PORT }} script: | set -euo pipefail + START_TIME=$(date +%s) WORK_DIR="${{ secrets.STAGE_WORK_DIR }}" MYSQL_CONTAINER="mysql-stage" @@ -96,13 +116,22 @@ jobs: find "$BACKUP_DIR" -type f -name '*.sql' -mtime +5 -delete + END_TIME=$(date +%s) + echo "Stage MySQL backup completed in $((END_TIME - START_TIME))s" + - name: Deploy to stage server - uses: appleboy/ssh-action@v0.1.8 + uses: appleboy/ssh-action@v1.2.0 with: host: ${{ secrets.STAGE_SERVER_IP }} username: ${{ secrets.STAGE_SERVER_USER }} key: ${{ secrets.STAGE_SERVER_SSH_KEY }} port: ${{ secrets.STAGE_SERVER_PORT }} script: | + set -euo pipefail + START_TIME=$(date +%s) + cd ${{ secrets.STAGE_WORK_DIR }} ./deploy.sh + + END_TIME=$(date +%s) + echo "Stage deploy.sh completed in $((END_TIME - START_TIME))s" diff --git a/.github/workflows/flyway-version-validator.yml b/.github/workflows/flyway-version-validator.yml index 6578dba50..913b7db1f 100644 --- a/.github/workflows/flyway-version-validator.yml +++ b/.github/workflows/flyway-version-validator.yml @@ -8,7 +8,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Fetch all branches run: git fetch --all diff --git a/.gitignore b/.gitignore index e6cc17501..5f7c49772 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,5 @@ logs ### MCP Bridge ### mcp-bridge/node_modules/ mcp-bridge/.env + +**/google-service-account.json diff --git a/Dockerfile b/Dockerfile index 11cab8f3c..5da5f12f5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,8 +7,7 @@ WORKDIR /app RUN addgroup -S konect && adduser -S konect -G konect COPY build/libs/KONECT_API.jar KONECT_API.jar - -RUN wget -O opentelemetry-javaagent.jar "https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v${OTEL_JAVA_AGENT_VERSION}/opentelemetry-javaagent.jar" +COPY opentelemetry-javaagent.jar opentelemetry-javaagent.jar RUN chown -R konect:konect /app diff --git a/build.gradle b/build.gradle index 26bef4651..1b34a555c 100644 --- a/build.gradle +++ b/build.gradle @@ -64,17 +64,19 @@ dependencies { implementation platform('software.amazon.awssdk:bom:2.41.14') implementation 'software.amazon.awssdk:s3' - // 이미지 WEBP 변환 - implementation 'com.sksamuel.scrimage:scrimage-core:4.3.2' - implementation 'com.sksamuel.scrimage:scrimage-webp:4.3.2' - implementation 'com.twelvemonkeys.imageio:imageio-jpeg:3.13.1' - // monitoring implementation 'io.micrometer:micrometer-registry-prometheus' // notification implementation 'com.google.firebase:firebase-admin:9.2.0' + // Google Sheets API + implementation 'com.google.apis:google-api-services-sheets:v4-rev20251110-2.0.0' + implementation 'com.google.auth:google-auth-library-oauth2-http:1.23.0' + + // Google Drive API + implementation 'com.google.apis:google-api-services-drive:v3-rev20250723-2.0.0' + // Gemini AI - using REST API directly (no SDK dependency) // test diff --git a/src/main/java/gg/agit/konect/admin/advertisement/controller/AdminAdvertisementApi.java b/src/main/java/gg/agit/konect/admin/advertisement/controller/AdminAdvertisementApi.java new file mode 100644 index 000000000..229e26d36 --- /dev/null +++ b/src/main/java/gg/agit/konect/admin/advertisement/controller/AdminAdvertisementApi.java @@ -0,0 +1,49 @@ +package gg.agit.konect.admin.advertisement.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.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 gg.agit.konect.admin.advertisement.dto.AdminAdvertisementCreateRequest; +import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementResponse; +import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementUpdateRequest; +import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementsResponse; +import gg.agit.konect.domain.user.enums.UserRole; +import gg.agit.konect.global.auth.annotation.Auth; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@Tag(name = "(Admin) Advertisement: 광고", description = "어드민 광고 API") +@RequestMapping("/admin/advertisements") +@Auth(roles = {UserRole.ADMIN}) +public interface AdminAdvertisementApi { + + @Operation(summary = "광고 목록을 조회한다.") + @GetMapping + ResponseEntity getAdvertisements(); + + @Operation(summary = "광고 단건을 조회한다.") + @GetMapping("/{id}") + ResponseEntity getAdvertisement(@PathVariable Integer id); + + @Operation(summary = "광고를 생성한다.") + @PostMapping + ResponseEntity createAdvertisement(@Valid @RequestBody AdminAdvertisementCreateRequest request); + + @Operation(summary = "광고를 수정한다.") + @PutMapping("/{id}") + ResponseEntity updateAdvertisement( + @PathVariable Integer id, + @Valid @RequestBody AdminAdvertisementUpdateRequest request + ); + + @Operation(summary = "광고를 삭제한다.") + @DeleteMapping("/{id}") + ResponseEntity deleteAdvertisement(@PathVariable Integer id); +} diff --git a/src/main/java/gg/agit/konect/admin/advertisement/controller/AdminAdvertisementController.java b/src/main/java/gg/agit/konect/admin/advertisement/controller/AdminAdvertisementController.java new file mode 100644 index 000000000..85ba7cb6c --- /dev/null +++ b/src/main/java/gg/agit/konect/admin/advertisement/controller/AdminAdvertisementController.java @@ -0,0 +1,58 @@ +package gg.agit.konect.admin.advertisement.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementCreateRequest; +import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementResponse; +import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementUpdateRequest; +import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementsResponse; +import gg.agit.konect.admin.advertisement.service.AdminAdvertisementService; +import jakarta.validation.Valid; + +@RestController +@Validated +public class AdminAdvertisementController implements AdminAdvertisementApi { + + private final AdminAdvertisementService adminAdvertisementService; + + public AdminAdvertisementController(AdminAdvertisementService adminAdvertisementService) { + this.adminAdvertisementService = adminAdvertisementService; + } + + @Override + public ResponseEntity getAdvertisements() { + AdminAdvertisementsResponse response = adminAdvertisementService.getAdvertisements(); + return ResponseEntity.ok(response); + } + + @Override + public ResponseEntity getAdvertisement(@PathVariable Integer id) { + AdminAdvertisementResponse response = adminAdvertisementService.getAdvertisement(id); + return ResponseEntity.ok(response); + } + + @Override + public ResponseEntity createAdvertisement(@Valid @RequestBody AdminAdvertisementCreateRequest request) { + adminAdvertisementService.createAdvertisement(request); + return ResponseEntity.ok().build(); + } + + @Override + public ResponseEntity updateAdvertisement( + @PathVariable Integer id, + @Valid @RequestBody AdminAdvertisementUpdateRequest request + ) { + adminAdvertisementService.updateAdvertisement(id, request); + return ResponseEntity.ok().build(); + } + + @Override + public ResponseEntity deleteAdvertisement(@PathVariable Integer id) { + adminAdvertisementService.deleteAdvertisement(id); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/gg/agit/konect/admin/advertisement/dto/AdminAdvertisementCreateRequest.java b/src/main/java/gg/agit/konect/admin/advertisement/dto/AdminAdvertisementCreateRequest.java new file mode 100644 index 000000000..83f1625bd --- /dev/null +++ b/src/main/java/gg/agit/konect/admin/advertisement/dto/AdminAdvertisementCreateRequest.java @@ -0,0 +1,35 @@ +package gg.agit.konect.admin.advertisement.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record AdminAdvertisementCreateRequest( + @NotBlank(message = "광고 제목은 필수 입력입니다.") + @Size(max = 100, message = "광고 제목은 100자를 초과할 수 없습니다.") + @Schema(description = "광고 제목", example = "개발자pick", requiredMode = REQUIRED) + String title, + + @NotBlank(message = "광고 설명은 필수 입력입니다.") + @Size(max = 255, message = "광고 설명은 255자를 초과할 수 없습니다.") + @Schema(description = "광고 설명", example = "부회장이 추천하는 노트북 LG Gram", requiredMode = REQUIRED) + String description, + + @NotBlank(message = "광고 이미지는 필수 입력입니다.") + @Size(max = 255, message = "광고 이미지 URL은 255자를 초과할 수 없습니다.") + @Schema(description = "광고 이미지 URL", example = "https://example.com/advertisement.png", requiredMode = REQUIRED) + String imageUrl, + + @NotBlank(message = "광고 링크는 필수 입력입니다.") + @Size(max = 255, message = "광고 링크 URL은 255자를 초과할 수 없습니다.") + @Schema(description = "광고 링크 URL", example = "https://www.example.com", requiredMode = REQUIRED) + String linkUrl, + + @NotNull(message = "광고 노출 여부는 필수 입력입니다.") + @Schema(description = "광고 노출 여부", example = "true", requiredMode = REQUIRED) + Boolean isVisible +) { +} diff --git a/src/main/java/gg/agit/konect/admin/advertisement/dto/AdminAdvertisementResponse.java b/src/main/java/gg/agit/konect/admin/advertisement/dto/AdminAdvertisementResponse.java new file mode 100644 index 000000000..238bba9d2 --- /dev/null +++ b/src/main/java/gg/agit/konect/admin/advertisement/dto/AdminAdvertisementResponse.java @@ -0,0 +1,55 @@ +package gg.agit.konect.admin.advertisement.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import gg.agit.konect.domain.advertisement.model.Advertisement; +import io.swagger.v3.oas.annotations.media.Schema; + +public record AdminAdvertisementResponse( + @Schema(description = "광고 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "광고 제목", example = "개발자pick", requiredMode = REQUIRED) + String title, + + @Schema(description = "광고 설명", example = "부회장이 추천하는 노트북 LG Gram", requiredMode = REQUIRED) + String description, + + @Schema(description = "광고 이미지 URL", example = "https://example.com/advertisement.png", requiredMode = REQUIRED) + String imageUrl, + + @Schema(description = "광고 링크 URL", example = "https://www.example.com", requiredMode = REQUIRED) + String linkUrl, + + @Schema(description = "광고 노출 여부", example = "true", requiredMode = REQUIRED) + Boolean isVisible, + + @Schema(description = "광고 클릭 수", example = "3", requiredMode = REQUIRED) + Integer clickCount, + + @JsonFormat(pattern = "yyyy.MM.dd HH:mm") + @Schema(description = "생성 일시", example = "2026.03.18 14:00", requiredMode = REQUIRED) + LocalDateTime createdAt, + + @JsonFormat(pattern = "yyyy.MM.dd HH:mm") + @Schema(description = "수정 일시", example = "2026.03.18 14:00", requiredMode = REQUIRED) + LocalDateTime updatedAt +) { + public static AdminAdvertisementResponse from(Advertisement advertisement) { + return new AdminAdvertisementResponse( + advertisement.getId(), + advertisement.getTitle(), + advertisement.getDescription(), + advertisement.getImageUrl(), + advertisement.getLinkUrl(), + advertisement.getIsVisible(), + advertisement.getClickCount(), + advertisement.getCreatedAt(), + advertisement.getUpdatedAt() + ); + } +} diff --git a/src/main/java/gg/agit/konect/admin/advertisement/dto/AdminAdvertisementUpdateRequest.java b/src/main/java/gg/agit/konect/admin/advertisement/dto/AdminAdvertisementUpdateRequest.java new file mode 100644 index 000000000..c502f3c76 --- /dev/null +++ b/src/main/java/gg/agit/konect/admin/advertisement/dto/AdminAdvertisementUpdateRequest.java @@ -0,0 +1,35 @@ +package gg.agit.konect.admin.advertisement.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record AdminAdvertisementUpdateRequest( + @NotBlank(message = "광고 제목은 필수 입력입니다.") + @Size(max = 100, message = "광고 제목은 100자를 초과할 수 없습니다.") + @Schema(description = "광고 제목", example = "개발자pick", requiredMode = REQUIRED) + String title, + + @NotBlank(message = "광고 설명은 필수 입력입니다.") + @Size(max = 255, message = "광고 설명은 255자를 초과할 수 없습니다.") + @Schema(description = "광고 설명", example = "부회장이 추천하는 노트북 LG Gram", requiredMode = REQUIRED) + String description, + + @NotBlank(message = "광고 이미지는 필수 입력입니다.") + @Size(max = 255, message = "광고 이미지 URL은 255자를 초과할 수 없습니다.") + @Schema(description = "광고 이미지 URL", example = "https://example.com/advertisement.png", requiredMode = REQUIRED) + String imageUrl, + + @NotBlank(message = "광고 링크는 필수 입력입니다.") + @Size(max = 255, message = "광고 링크 URL은 255자를 초과할 수 없습니다.") + @Schema(description = "광고 링크 URL", example = "https://www.example.com", requiredMode = REQUIRED) + String linkUrl, + + @NotNull(message = "광고 노출 여부는 필수 입력입니다.") + @Schema(description = "광고 노출 여부", example = "true", requiredMode = REQUIRED) + Boolean isVisible +) { +} diff --git a/src/main/java/gg/agit/konect/admin/advertisement/dto/AdminAdvertisementsResponse.java b/src/main/java/gg/agit/konect/admin/advertisement/dto/AdminAdvertisementsResponse.java new file mode 100644 index 000000000..318a469ee --- /dev/null +++ b/src/main/java/gg/agit/konect/admin/advertisement/dto/AdminAdvertisementsResponse.java @@ -0,0 +1,21 @@ +package gg.agit.konect.admin.advertisement.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import gg.agit.konect.domain.advertisement.model.Advertisement; +import io.swagger.v3.oas.annotations.media.Schema; + +public record AdminAdvertisementsResponse( + @Schema(description = "광고 리스트", requiredMode = REQUIRED) + List advertisements +) { + public static AdminAdvertisementsResponse from(List advertisements) { + return new AdminAdvertisementsResponse( + advertisements.stream() + .map(AdminAdvertisementResponse::from) + .toList() + ); + } +} diff --git a/src/main/java/gg/agit/konect/admin/advertisement/service/AdminAdvertisementService.java b/src/main/java/gg/agit/konect/admin/advertisement/service/AdminAdvertisementService.java new file mode 100644 index 000000000..9791643d2 --- /dev/null +++ b/src/main/java/gg/agit/konect/admin/advertisement/service/AdminAdvertisementService.java @@ -0,0 +1,61 @@ +package gg.agit.konect.admin.advertisement.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementCreateRequest; +import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementResponse; +import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementUpdateRequest; +import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementsResponse; +import gg.agit.konect.domain.advertisement.model.Advertisement; +import gg.agit.konect.domain.advertisement.repository.AdvertisementRepository; + +@Service +@Transactional(readOnly = true) +public class AdminAdvertisementService { + + private final AdvertisementRepository advertisementRepository; + + public AdminAdvertisementService(AdvertisementRepository advertisementRepository) { + this.advertisementRepository = advertisementRepository; + } + + public AdminAdvertisementsResponse getAdvertisements() { + return AdminAdvertisementsResponse.from(advertisementRepository.findAllByOrderByCreatedAtDesc()); + } + + public AdminAdvertisementResponse getAdvertisement(Integer id) { + Advertisement advertisement = advertisementRepository.getById(id); + return AdminAdvertisementResponse.from(advertisement); + } + + @Transactional + public void createAdvertisement(AdminAdvertisementCreateRequest request) { + Advertisement advertisement = Advertisement.of( + request.title(), + request.description(), + request.imageUrl(), + request.linkUrl(), + request.isVisible() + ); + advertisementRepository.save(advertisement); + } + + @Transactional + public void updateAdvertisement(Integer id, AdminAdvertisementUpdateRequest request) { + Advertisement advertisement = advertisementRepository.getById(id); + advertisement.update( + request.title(), + request.description(), + request.imageUrl(), + request.linkUrl(), + request.isVisible() + ); + } + + @Transactional + public void deleteAdvertisement(Integer id) { + Advertisement advertisement = advertisementRepository.getById(id); + advertisementRepository.delete(advertisement); + } +} diff --git a/src/main/java/gg/agit/konect/domain/advertisement/controller/AdvertisementApi.java b/src/main/java/gg/agit/konect/domain/advertisement/controller/AdvertisementApi.java new file mode 100644 index 000000000..04051d034 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/advertisement/controller/AdvertisementApi.java @@ -0,0 +1,34 @@ +package gg.agit.konect.domain.advertisement.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import gg.agit.konect.domain.advertisement.dto.AdvertisementsResponse; +import gg.agit.konect.global.auth.annotation.PublicApi; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +@Tag(name = "(Normal) Advertisement: 광고", description = "광고 API") +@RequestMapping("/advertisements") +public interface AdvertisementApi { + + @PublicApi + @Operation(summary = "노출 가능한 광고를 랜덤으로 조회한다. 필요로 하는 수가 더 큰 경우 중복 허용.") + @GetMapping + ResponseEntity getAdvertisements( + @Parameter(description = "조회할 광고 개수 (1~10)", example = "1") + @RequestParam(defaultValue = "1") @Min(1) @Max(10) int count + ); + + @PublicApi + @Operation(summary = "광고 클릭 수를 증가시킨다.") + @PostMapping("/{id}/clicks") + ResponseEntity increaseClickCount(@PathVariable Integer id); +} diff --git a/src/main/java/gg/agit/konect/domain/advertisement/controller/AdvertisementController.java b/src/main/java/gg/agit/konect/domain/advertisement/controller/AdvertisementController.java new file mode 100644 index 000000000..09d6f7a07 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/advertisement/controller/AdvertisementController.java @@ -0,0 +1,32 @@ +package gg.agit.konect.domain.advertisement.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +import gg.agit.konect.domain.advertisement.dto.AdvertisementsResponse; +import gg.agit.konect.domain.advertisement.service.AdvertisementService; + +@RestController +@Validated +public class AdvertisementController implements AdvertisementApi { + + private final AdvertisementService advertisementService; + + public AdvertisementController(AdvertisementService advertisementService) { + this.advertisementService = advertisementService; + } + + @Override + public ResponseEntity getAdvertisements(int count) { + AdvertisementsResponse response = advertisementService.getRandomAdvertisements(count); + return ResponseEntity.ok(response); + } + + @Override + public ResponseEntity increaseClickCount(@PathVariable Integer id) { + advertisementService.increaseClickCount(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/gg/agit/konect/domain/advertisement/dto/AdvertisementResponse.java b/src/main/java/gg/agit/konect/domain/advertisement/dto/AdvertisementResponse.java new file mode 100644 index 000000000..ae1fa4736 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/advertisement/dto/AdvertisementResponse.java @@ -0,0 +1,33 @@ +package gg.agit.konect.domain.advertisement.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import gg.agit.konect.domain.advertisement.model.Advertisement; +import io.swagger.v3.oas.annotations.media.Schema; + +public record AdvertisementResponse( + @Schema(description = "광고 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "광고 제목", example = "개발자pick", requiredMode = REQUIRED) + String title, + + @Schema(description = "광고 설명", example = "부회장이 추천하는 노트북 LG Gram", requiredMode = REQUIRED) + String description, + + @Schema(description = "광고 이미지 URL", example = "https://example.com/advertisement.png", requiredMode = REQUIRED) + String imageUrl, + + @Schema(description = "광고 링크 URL", example = "https://www.example.com", requiredMode = REQUIRED) + String linkUrl +) { + public static AdvertisementResponse from(Advertisement advertisement) { + return new AdvertisementResponse( + advertisement.getId(), + advertisement.getTitle(), + advertisement.getDescription(), + advertisement.getImageUrl(), + advertisement.getLinkUrl() + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/advertisement/dto/AdvertisementsResponse.java b/src/main/java/gg/agit/konect/domain/advertisement/dto/AdvertisementsResponse.java new file mode 100644 index 000000000..206b61884 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/advertisement/dto/AdvertisementsResponse.java @@ -0,0 +1,21 @@ +package gg.agit.konect.domain.advertisement.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import gg.agit.konect.domain.advertisement.model.Advertisement; +import io.swagger.v3.oas.annotations.media.Schema; + +public record AdvertisementsResponse( + @Schema(description = "광고 리스트", requiredMode = REQUIRED) + List advertisements +) { + public static AdvertisementsResponse from(List advertisements) { + return new AdvertisementsResponse( + advertisements.stream() + .map(AdvertisementResponse::from) + .toList() + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/advertisement/model/Advertisement.java b/src/main/java/gg/agit/konect/domain/advertisement/model/Advertisement.java new file mode 100644 index 000000000..ab742c8df --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/advertisement/model/Advertisement.java @@ -0,0 +1,90 @@ +package gg.agit.konect.domain.advertisement.model; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import gg.agit.konect.global.model.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "advertisement") +@NoArgsConstructor(access = PROTECTED) +public class Advertisement extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + @Column(name = "id", nullable = false, updatable = false, unique = true) + private Integer id; + + @Column(name = "title", length = 100, nullable = false) + private String title; + + @Column(name = "description", length = 255, nullable = false) + private String description; + + @Column(name = "image_url", length = 255, nullable = false) + private String imageUrl; + + @Column(name = "link_url", length = 255, nullable = false) + private String linkUrl; + + @Column(name = "is_visible", nullable = false) + private Boolean isVisible; + + @Column(name = "click_count", nullable = false) + private Integer clickCount; + + private Advertisement( + String title, + String description, + String imageUrl, + String linkUrl, + Boolean isVisible, + Integer clickCount + ) { + this.title = title; + this.description = description; + this.imageUrl = imageUrl; + this.linkUrl = linkUrl; + this.isVisible = isVisible; + this.clickCount = clickCount; + } + + public static Advertisement of( + String title, + String description, + String imageUrl, + String linkUrl, + Boolean isVisible + ) { + return new Advertisement( + title, + description, + imageUrl, + linkUrl, + isVisible, + 0 + ); + } + + public void update( + String title, + String description, + String imageUrl, + String linkUrl, + Boolean isVisible + ) { + this.title = title; + this.description = description; + this.imageUrl = imageUrl; + this.linkUrl = linkUrl; + this.isVisible = isVisible; + } +} diff --git a/src/main/java/gg/agit/konect/domain/advertisement/repository/AdvertisementRepository.java b/src/main/java/gg/agit/konect/domain/advertisement/repository/AdvertisementRepository.java new file mode 100644 index 000000000..46de3b53a --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/advertisement/repository/AdvertisementRepository.java @@ -0,0 +1,42 @@ +package gg.agit.konect.domain.advertisement.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; + +import gg.agit.konect.domain.advertisement.model.Advertisement; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; + +public interface AdvertisementRepository extends Repository { + + Advertisement save(Advertisement advertisement); + + Optional findById(Integer id); + + List findAllByOrderByCreatedAtDesc(); + + List findAllByIsVisibleTrueOrderByCreatedAtDesc(); + + void delete(Advertisement advertisement); + + /** + * 노출 중인 광고의 클릭 수를 원자적으로 증가시킵니다. + * 동시성 문제를 방지하기 위해 DB 레벨에서 UPDATE ... SET click_count = click_count + 1을 수행합니다. + * isVisible=true인 광고만 클릭 수를 증가시키며, 해당하는 광고가 없으면 0을 반환합니다. + * + * @return 업데이트된 행 수 (0이면 노출 중인 광고가 없음) + */ + @Modifying + @Query("UPDATE Advertisement a SET a.clickCount = a.clickCount + 1 WHERE a.id = :id AND a.isVisible = true") + int incrementClickCount(@Param("id") Integer id); + + default Advertisement getById(Integer id) { + return findById(id) + .orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_ADVERTISEMENT)); + } +} diff --git a/src/main/java/gg/agit/konect/domain/advertisement/service/AdvertisementService.java b/src/main/java/gg/agit/konect/domain/advertisement/service/AdvertisementService.java new file mode 100644 index 000000000..ea531f654 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/advertisement/service/AdvertisementService.java @@ -0,0 +1,60 @@ +package gg.agit.konect.domain.advertisement.service; + +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_ADVERTISEMENT; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import gg.agit.konect.domain.advertisement.dto.AdvertisementsResponse; +import gg.agit.konect.domain.advertisement.model.Advertisement; +import gg.agit.konect.domain.advertisement.repository.AdvertisementRepository; +import gg.agit.konect.global.exception.CustomException; + +@Service +@Transactional(readOnly = true) +public class AdvertisementService { + + private final AdvertisementRepository advertisementRepository; + + public AdvertisementService(AdvertisementRepository advertisementRepository) { + this.advertisementRepository = advertisementRepository; + } + + public AdvertisementsResponse getRandomAdvertisements(int count) { + List visibleAdvertisements = + advertisementRepository.findAllByIsVisibleTrueOrderByCreatedAtDesc(); + + if (visibleAdvertisements.isEmpty()) { + return AdvertisementsResponse.from(List.of()); + } + + List selectedAdvertisements = new ArrayList<>(); + + if (visibleAdvertisements.size() >= count) { + List shuffledAdvertisements = new ArrayList<>(visibleAdvertisements); + Collections.shuffle(shuffledAdvertisements); + selectedAdvertisements.addAll(shuffledAdvertisements.subList(0, count)); + } else { + for (int i = 0; i < count; i++) { + // 등록된 노출 광고 수보다 많은 개수를 요청하면 기존 정책대로 중복 선택을 허용한다. + int randomIndex = ThreadLocalRandom.current().nextInt(visibleAdvertisements.size()); + selectedAdvertisements.add(visibleAdvertisements.get(randomIndex)); + } + } + + return AdvertisementsResponse.from(selectedAdvertisements); + } + + @Transactional + public void increaseClickCount(Integer id) { + int updatedCount = advertisementRepository.incrementClickCount(id); + if (updatedCount == 0) { + throw CustomException.of(NOT_FOUND_ADVERTISEMENT); + } + } +} diff --git a/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java b/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java index 6b1b5a6f8..3a3e74267 100644 --- a/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java +++ b/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java @@ -1,30 +1,40 @@ package gg.agit.konect.domain.chat.controller; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.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 gg.agit.konect.domain.chat.dto.ChatMessageSendRequest; -import gg.agit.konect.domain.chat.dto.ChatRoomCreateRequest; +import gg.agit.konect.domain.chat.dto.ChatInvitableUsersResponse; import gg.agit.konect.domain.chat.dto.ChatMessageDetailResponse; import gg.agit.konect.domain.chat.dto.ChatMessagePageResponse; +import gg.agit.konect.domain.chat.dto.ChatMessageSendRequest; import gg.agit.konect.domain.chat.dto.ChatMuteResponse; -import gg.agit.konect.domain.chat.dto.ChatRoomsSummaryResponse; +import gg.agit.konect.domain.chat.dto.ChatRoomCreateRequest; +import gg.agit.konect.domain.chat.dto.ChatRoomNameUpdateRequest; import gg.agit.konect.domain.chat.dto.ChatRoomResponse; +import gg.agit.konect.domain.chat.dto.ChatRoomsSummaryResponse; +import gg.agit.konect.domain.chat.dto.ChatSearchResponse; +import gg.agit.konect.domain.chat.enums.ChatInviteSortBy; import gg.agit.konect.global.auth.annotation.UserId; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; @Tag(name = "(Normal) Chat: 채팅", description = "채팅 API") @RequestMapping("/chats") public interface ChatApi { + int MAX_SEARCH_LIMIT = 100; + @Operation(summary = "채팅방을 생성하거나 기존 채팅방을 반환한다.", description = """ ## 설명 - 특정 유저와의 1:1 채팅방을 생성하거나 기존 채팅방을 반환합니다. @@ -47,10 +57,10 @@ ResponseEntity createOrGetChatRoom( @Operation(summary = "어드민과의 채팅방을 생성하거나 기존 채팅방을 반환한다.", description = """ ## 설명 - 문의하기 버튼에서 즉시 어드민과의 1:1 채팅으로 이동할 때 사용합니다. - + ## 로직 - 시스템의 기준 어드민 계정을 찾아 해당 계정과의 채팅방을 생성하거나 기존 채팅방을 반환합니다. - + ## 에러 - NOT_FOUND_USER (404): 어드민 계정을 찾을 수 없습니다. - CANNOT_CREATE_CHAT_ROOM_WITH_SELF (400): 자기 자신과는 채팅방을 만들 수 없습니다. @@ -74,6 +84,50 @@ ResponseEntity getChatRooms( @UserId Integer userId ); + @Operation(summary = "채팅방 이름과 메시지 내용으로 채팅방을 검색한다.", description = """ + ## 설명 + - 현재 사용자가 접근 가능한 채팅방만 검색합니다. + - 채팅방 이름 매칭 결과와 메시지 내용 매칭 결과를 분리해서 반환합니다. + + ## 로직 + - 1:1 채팅은 상대방 이름과 사용자가 지정한 채팅방 이름으로 검색합니다. + - 그룹 채팅은 동아리 이름과 사용자가 지정한 채팅방 이름으로 검색합니다. + - 메시지 검색 결과는 채팅방별 최신 매칭 메시지 1개만 반환합니다. + - page, limit는 채팅방 이름 검색 결과와 메시지 검색 결과에 각각 동일하게 적용됩니다. + - limit는 최대 100까지 허용됩니다. + """) + @GetMapping("/rooms/search") + ResponseEntity searchChats( + @NotBlank(message = "검색어는 필수입니다.") + @RequestParam(name = "keyword") String keyword, + @Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.") + @RequestParam(name = "page", defaultValue = "1") Integer page, + @Min(value = 1, message = "페이지 당 항목 수는 1 이상이어야 합니다.") + @Max(value = MAX_SEARCH_LIMIT, message = "페이지 당 항목 수는 100 이하여야 합니다.") + @RequestParam(name = "limit", defaultValue = "20") Integer limit, + @UserId Integer userId + ); + + @Operation(summary = "새 채팅방에 초대할 수 있는 사용자 목록을 조회한다.", description = """ + ## 설명 + - 현재 사용자가 속해 있는 채팅방들의 멤버를 기반으로 초대 가능 사용자 목록을 조회합니다. + - 자기 자신, 탈퇴 사용자, 채팅방을 떠난 사용자는 제외됩니다. + - 관리자 계정은 초대 대상에서 제외됩니다. + - `sortBy=CLUB`이면 동아리 섹션별로 그룹핑되어 응답합니다. + - `sortBy=NAME`이면 동아리 섹션 없이 이름순 단일 리스트로 응답합니다. + - 검색어(query)는 이름과 학번에 대해 부분 일치로 동작합니다. + """) + @GetMapping("/rooms/invitables") + ResponseEntity getInvitableUsers( + @RequestParam(name = "query", required = false) String query, + @RequestParam(name = "sortBy", defaultValue = "CLUB") ChatInviteSortBy sortBy, + @Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.") + @RequestParam(name = "page", defaultValue = "1") Integer page, + @Min(value = 1, message = "페이지 당 항목 수는 1 이상이어야 합니다.") + @RequestParam(name = "limit", defaultValue = "20", required = false) Integer limit, + @UserId Integer userId + ); + @Operation(summary = "채팅방 메시지 리스트를 조회한다.", description = """ ## 설명 - 특정 채팅방의 메시지 목록을 페이지네이션으로 조회합니다. @@ -85,7 +139,7 @@ ResponseEntity getChatRooms( - 채팅방 참여자만 메시지를 조회할 수 있습니다. - 일반 유저는 자신이 참여한 채팅방만 조회할 수 있습니다. - 어드민은 모든 어드민 채팅방을 조회할 수 있습니다. - + ## 에러 - FORBIDDEN_CHAT_ROOM_ACCESS (403): 채팅방에 접근할 권한이 없습니다. """) @@ -126,4 +180,86 @@ ResponseEntity toggleChatMute( @PathVariable(value = "chatRoomId") Integer chatRoomId, @UserId Integer userId ); + + @Operation(summary = "내가 보는 채팅방 이름을 수정한다.", description = """ + ## 설명 + - 현재 사용자 기준으로만 보이는 채팅방 이름을 수정합니다. + - 다른 참여자에게는 영향을 주지 않습니다. + - null 또는 공백으로 보내면 기본 이름으로 되돌립니다. + + ## 에러 + - NOT_FOUND_CHAT_ROOM (404): 채팅방을 찾을 수 없습니다. + - FORBIDDEN_CHAT_ROOM_ACCESS (403): 채팅방에 접근할 권한이 없습니다. + """) + @PatchMapping("/rooms/{chatRoomId}/name") + ResponseEntity updateChatRoomName( + @PathVariable(value = "chatRoomId") Integer chatRoomId, + @Valid @RequestBody ChatRoomNameUpdateRequest request, + @UserId Integer userId + ); + + @Operation(summary = "채팅방에서 나간다.", description = """ + ## 설명 + - 동아리 채팅방은 나갈 수 없습니다. + - 1:1 채팅방은 소프트 딜리트 방식으로 나갑니다. + - 향후 일반 그룹 채팅방은 멤버십 제거 방식으로 나갈 수 있도록 설계합니다. + + ## 로직 + - 1:1 채팅방에서 나간 사용자는 기존 메시지를 숨기고 채팅방 목록에서도 제거됩니다. + - 상대방이 이후 새 메시지를 보내면 나간 사용자는 새 대화처럼 그 메시지부터 다시 보게 됩니다. + - 사용자가 다시 1:1 채팅을 열면 이전 대화가 아니라 새로 시작한 것처럼 보입니다. + + ## 에러 + - CANNOT_LEAVE_GROUP_CHAT_ROOM (400): 동아리 채팅방은 나갈 수 없습니다. + - FORBIDDEN_CHAT_ROOM_ACCESS (403): 채팅방에 접근할 권한이 없습니다. + - NOT_FOUND_CHAT_ROOM (404): 채팅방을 찾을 수 없습니다. + """) + @DeleteMapping("/rooms/{chatRoomId}") + ResponseEntity leaveChatRoom( + @PathVariable(value = "chatRoomId") Integer chatRoomId, + @UserId Integer userId + ); + + @Operation(summary = "채팅방 멤버를 강퇴한다.", description = """ + ## 설명 + - 그룹 채팅방에서 방장이 특정 멤버를 강퇴합니다. + + ## 로직 + - 방장(owner)만 멤버를 강퇴할 수 있습니다. + - 1:1 채팅방과 동아리 채팅방에서는 강퇴할 수 없습니다. + - 자기 자신(방장)은 강퇴할 수 없습니다. + - 이미 채팅방에 없는 멤버는 강퇴할 수 없습니다. + + ## 에러 + - NOT_FOUND_CHAT_ROOM (404): 채팅방을 찾을 수 없습니다. + - FORBIDDEN_CHAT_ROOM_ACCESS (403): 채팅방에 접근할 권한이 없습니다. + - FORBIDDEN_CHAT_ROOM_KICK (403): 채팅방 방장만 멤버를 강퇴할 수 있습니다. + - CANNOT_KICK_SELF (400): 자기 자신을 강퇴할 수 없습니다. + - CANNOT_KICK_ROOM_OWNER (400): 방장은 강퇴할 수 없습니다. + - CANNOT_KICK_IN_NON_GROUP_ROOM (400): 그룹 채팅방에서만 강퇴할 수 있습니다. + """) + @DeleteMapping("/rooms/{chatRoomId}/members/{targetUserId}") + ResponseEntity kickMember( + @PathVariable(value = "chatRoomId") Integer chatRoomId, + @PathVariable(value = "targetUserId") Integer targetUserId, + @UserId Integer userId + ); + + @Operation(summary = "그룹 채팅방을 생성한다.", description = """ + ## 설명 + - 여러 유저를 초대하여 그룹 채팅방을 생성합니다. + + ## 로직 + - 요청자(방장)를 포함하여 선택된 모든 유저가 참여하는 그룹 채팅방을 생성합니다. + - 방장은 채팅방을 생성한 사용자입니다. + + ## 에러 + - CANNOT_CREATE_CHAT_ROOM_WITH_SELF (400): 자기 자신만으로는 채팅방을 만들 수 없습니다. + - NOT_FOUND_USER (404): 유저를 찾을 수 없습니다. + """) + @PostMapping("/rooms/group") + ResponseEntity createGroupChatRoom( + @Valid @RequestBody ChatRoomCreateRequest.Group request, + @UserId Integer userId + ); } diff --git a/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java b/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java index ef919c574..4fe35f06e 100644 --- a/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java +++ b/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java @@ -8,13 +8,17 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import gg.agit.konect.domain.chat.dto.ChatMessageSendRequest; +import gg.agit.konect.domain.chat.dto.ChatInvitableUsersResponse; import gg.agit.konect.domain.chat.dto.ChatMessageDetailResponse; import gg.agit.konect.domain.chat.dto.ChatMessagePageResponse; +import gg.agit.konect.domain.chat.dto.ChatMessageSendRequest; import gg.agit.konect.domain.chat.dto.ChatMuteResponse; import gg.agit.konect.domain.chat.dto.ChatRoomCreateRequest; +import gg.agit.konect.domain.chat.dto.ChatRoomNameUpdateRequest; import gg.agit.konect.domain.chat.dto.ChatRoomResponse; import gg.agit.konect.domain.chat.dto.ChatRoomsSummaryResponse; +import gg.agit.konect.domain.chat.dto.ChatSearchResponse; +import gg.agit.konect.domain.chat.enums.ChatInviteSortBy; import gg.agit.konect.domain.chat.service.ChatService; import gg.agit.konect.global.auth.annotation.UserId; import jakarta.validation.Valid; @@ -53,6 +57,29 @@ public ResponseEntity getChatRooms( return ResponseEntity.ok(response); } + @Override + public ResponseEntity searchChats( + @RequestParam(name = "keyword") String keyword, + @RequestParam(name = "page", defaultValue = "1") Integer page, + @RequestParam(name = "limit", defaultValue = "20") Integer limit, + @UserId Integer userId + ) { + ChatSearchResponse response = chatService.searchChats(userId, keyword, page, limit); + return ResponseEntity.ok(response); + } + + @Override + public ResponseEntity getInvitableUsers( + @RequestParam(name = "query", required = false) String query, + @RequestParam(name = "sortBy", defaultValue = "CLUB") ChatInviteSortBy sortBy, + @RequestParam(name = "page", defaultValue = "1") Integer page, + @RequestParam(name = "limit", defaultValue = "20", required = false) Integer limit, + @UserId Integer userId + ) { + ChatInvitableUsersResponse response = chatService.getInvitableUsers(userId, query, sortBy, page, limit); + return ResponseEntity.ok(response); + } + @Override public ResponseEntity getChatRoomMessages( @RequestParam(name = "page", defaultValue = "1") Integer page, @@ -81,4 +108,42 @@ public ResponseEntity toggleChatMute( ) { return ResponseEntity.ok(chatService.toggleMute(userId, chatRoomId)); } + + @Override + public ResponseEntity updateChatRoomName( + @PathVariable(value = "chatRoomId") Integer chatRoomId, + @Valid @RequestBody ChatRoomNameUpdateRequest request, + @UserId Integer userId + ) { + chatService.updateChatRoomName(userId, chatRoomId, request); + return ResponseEntity.ok().build(); + } + + @Override + public ResponseEntity leaveChatRoom( + @PathVariable(value = "chatRoomId") Integer chatRoomId, + @UserId Integer userId + ) { + chatService.leaveChatRoom(userId, chatRoomId); + return ResponseEntity.noContent().build(); + } + + @Override + public ResponseEntity kickMember( + @PathVariable(value = "chatRoomId") Integer chatRoomId, + @PathVariable(value = "targetUserId") Integer targetUserId, + @UserId Integer userId + ) { + chatService.kickMember(userId, chatRoomId, targetUserId); + return ResponseEntity.noContent().build(); + } + + @Override + public ResponseEntity createGroupChatRoom( + @Valid @RequestBody ChatRoomCreateRequest.Group request, + @UserId Integer userId + ) { + ChatRoomResponse response = chatService.createGroupChatRoom(userId, request); + return ResponseEntity.ok(response); + } } diff --git a/src/main/java/gg/agit/konect/domain/chat/dto/AdminChatRoomProjection.java b/src/main/java/gg/agit/konect/domain/chat/dto/AdminChatRoomProjection.java new file mode 100644 index 000000000..a277a19a6 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/dto/AdminChatRoomProjection.java @@ -0,0 +1,19 @@ +package gg.agit.konect.domain.chat.dto; + +import java.time.LocalDateTime; + +/** + * 관리자용 1:1 채팅방 목록 조회를 위한 Projection DTO + * 필드 순서와 타입이 JPQL SELECT 절과 정확히 일치해야 합니다. + */ +public record AdminChatRoomProjection( + Integer roomId, + String lastMessage, + LocalDateTime lastSentAt, + LocalDateTime createdAt, + Integer nonAdminUserId, + String nonAdminUserName, + String nonAdminImageUrl, + Long unreadCount +) { +} diff --git a/src/main/java/gg/agit/konect/domain/chat/dto/ChatInvitableUsersResponse.java b/src/main/java/gg/agit/konect/domain/chat/dto/ChatInvitableUsersResponse.java new file mode 100644 index 000000000..cadcd02e7 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/dto/ChatInvitableUsersResponse.java @@ -0,0 +1,100 @@ +package gg.agit.konect.domain.chat.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import org.springframework.data.domain.Page; + +import gg.agit.konect.domain.chat.enums.ChatInviteSortBy; +import gg.agit.konect.domain.user.model.User; +import io.swagger.v3.oas.annotations.media.Schema; + +public record ChatInvitableUsersResponse( + @Schema(description = "조건에 해당하는 전체 초대 가능 사용자 수", example = "10", requiredMode = REQUIRED) + Long totalCount, + + @Schema(description = "현재 페이지에서 조회된 초대 가능 사용자 수", example = "5", requiredMode = REQUIRED) + Integer currentCount, + + @Schema(description = "최대 페이지", example = "2", requiredMode = REQUIRED) + Integer totalPage, + + @Schema(description = "현재 페이지", example = "1", requiredMode = REQUIRED) + Integer currentPage, + + @Schema(description = "정렬 기준", example = "CLUB", requiredMode = REQUIRED) + ChatInviteSortBy sortBy, + + @Schema(description = "동아리 섹션 그룹핑 여부", example = "true", requiredMode = REQUIRED) + boolean grouped, + + @Schema(description = "이름순 정렬일 때 반환되는 초대 가능 사용자 리스트", requiredMode = REQUIRED) + List users, + + @Schema(description = "동아리순 정렬일 때 반환되는 섹션 리스트", requiredMode = REQUIRED) + List sections +) { + + public record InvitableUser( + @Schema(description = "유저 ID", example = "1", requiredMode = REQUIRED) + Integer userId, + + @Schema(description = "이름", example = "최승운", requiredMode = REQUIRED) + String name, + + @Schema(description = "프로필 이미지 URL", example = "https://example.com/profile.png", requiredMode = NOT_REQUIRED) + String imageUrl, + + @Schema(description = "학번", example = "2021234567", requiredMode = REQUIRED) + String studentNumber + ) { + public static InvitableUser from(User user) { + return new InvitableUser( + user.getId(), + user.getName(), + user.getImageUrl(), + user.getStudentNumber() + ); + } + } + + public record InvitableSection( + @Schema(description = "동아리 ID, 기타 섹션이면 null", example = "3", requiredMode = NOT_REQUIRED) + Integer clubId, + + @Schema(description = "섹션 이름", example = "BCSD", requiredMode = REQUIRED) + String clubName, + + @Schema(description = "해당 섹션의 초대 가능 사용자 리스트", requiredMode = REQUIRED) + List users + ) { + } + + public static ChatInvitableUsersResponse forNameSort(Page page) { + return new ChatInvitableUsersResponse( + page.getTotalElements(), + page.getNumberOfElements(), + page.getTotalPages(), + page.getNumber() + 1, + ChatInviteSortBy.NAME, + false, + page.getContent(), + List.of() + ); + } + + public static ChatInvitableUsersResponse forClubSort(Page page, List sections) { + return new ChatInvitableUsersResponse( + page.getTotalElements(), + page.getNumberOfElements(), + page.getTotalPages(), + page.getNumber() + 1, + ChatInviteSortBy.CLUB, + true, + List.of(), + sections + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchResult.java b/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchResult.java new file mode 100644 index 000000000..91a56d4af --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchResult.java @@ -0,0 +1,45 @@ +package gg.agit.konect.domain.chat.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import gg.agit.konect.domain.chat.enums.ChatType; +import gg.agit.konect.domain.chat.model.ChatMessage; +import io.swagger.v3.oas.annotations.media.Schema; + +public record ChatMessageMatchResult( + @Schema(description = "채팅방 ID", example = "1", requiredMode = REQUIRED) + Integer roomId, + + @Schema(description = "채팅 타입", example = "DIRECT", requiredMode = REQUIRED) + ChatType chatType, + + @Schema(description = "채팅방 이름", example = "개발팀", requiredMode = REQUIRED) + String roomName, + + @Schema(description = "채팅방 이미지 URL", example = "https://example.com/image.png", requiredMode = NOT_REQUIRED) + String roomImageUrl, + + @Schema(description = "검색에 매칭된 메시지 내용", example = "안녕하세요", requiredMode = REQUIRED) + String matchedMessage, + + @Schema(description = "매칭된 메시지 전송 시간", example = "2025.12.19 23:21", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy.MM.dd HH:mm") + LocalDateTime matchedMessageSentAt +) { + + public static ChatMessageMatchResult from(ChatRoomSummaryResponse room, ChatMessage message) { + return new ChatMessageMatchResult( + room.roomId(), + room.chatType(), + room.roomName(), + room.roomImageUrl(), + message.getContent(), + message.getCreatedAt() + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchesResponse.java b/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchesResponse.java new file mode 100644 index 000000000..78b6360e1 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchesResponse.java @@ -0,0 +1,37 @@ +package gg.agit.konect.domain.chat.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import org.springframework.data.domain.Page; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record ChatMessageMatchesResponse( + @Schema(description = "조건에 해당하는 메시지 매칭 총 개수", example = "10", requiredMode = REQUIRED) + Long totalCount, + + @Schema(description = "현재 페이지에서 조회된 메시지 매칭 개수", example = "5", requiredMode = REQUIRED) + Integer currentCount, + + @Schema(description = "최대 페이지", example = "2", requiredMode = REQUIRED) + Integer totalPage, + + @Schema(description = "현재 페이지", example = "1", requiredMode = REQUIRED) + Integer currentPage, + + @Schema(description = "메시지 내용으로 매칭된 채팅방 목록", requiredMode = REQUIRED) + List messages +) { + + public static ChatMessageMatchesResponse from(Page page) { + return new ChatMessageMatchesResponse( + page.getTotalElements(), + page.getNumberOfElements(), + page.getTotalPages(), + page.getNumber() + 1, + page.getContent() + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomCreateRequest.java b/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomCreateRequest.java index e4b75b9c1..114a257b1 100644 --- a/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomCreateRequest.java +++ b/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomCreateRequest.java @@ -2,13 +2,22 @@ import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; +import java.util.List; + import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; public record ChatRoomCreateRequest( @NotNull(message = "유저 ID는 필수입니다.") - @Schema(description = "채팅 대상 유저 ID", example = "10", requiredMode = REQUIRED) + @Schema(description = "채팅 대상 유저 ID (1:1 채팅 시)", example = "10", requiredMode = REQUIRED) Integer userId ) { + public record Group( + @NotEmpty(message = "초대할 유저 ID 목록은 필수입니다.") + @Schema(description = "초대할 유저 ID 목록", example = "[10, 11, 12]", requiredMode = REQUIRED) + List userIds + ) { + } } diff --git a/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomMatchesResponse.java b/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomMatchesResponse.java new file mode 100644 index 000000000..3d50631c2 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomMatchesResponse.java @@ -0,0 +1,37 @@ +package gg.agit.konect.domain.chat.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import org.springframework.data.domain.Page; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record ChatRoomMatchesResponse( + @Schema(description = "조건에 해당하는 채팅방 총 개수", example = "10", requiredMode = REQUIRED) + Long totalCount, + + @Schema(description = "현재 페이지에서 조회된 채팅방 개수", example = "5", requiredMode = REQUIRED) + Integer currentCount, + + @Schema(description = "최대 페이지", example = "2", requiredMode = REQUIRED) + Integer totalPage, + + @Schema(description = "현재 페이지", example = "1", requiredMode = REQUIRED) + Integer currentPage, + + @Schema(description = "채팅방 이름으로 매칭된 채팅방 목록", requiredMode = REQUIRED) + List rooms +) { + + public static ChatRoomMatchesResponse from(Page page) { + return new ChatRoomMatchesResponse( + page.getTotalElements(), + page.getNumberOfElements(), + page.getTotalPages(), + page.getNumber() + 1, + page.getContent() + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomNameUpdateRequest.java b/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomNameUpdateRequest.java new file mode 100644 index 000000000..8e86ff78d --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomNameUpdateRequest.java @@ -0,0 +1,17 @@ +package gg.agit.konect.domain.chat.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; + +public record ChatRoomNameUpdateRequest( + @Size(max = 30, message = "채팅방 이름은 30자 이내로 입력해주세요.") + @Schema( + description = "개인별 채팅방 이름. null 또는 공백이면 기본 이름으로 되돌립니다.", + example = "알바 이야기방", + requiredMode = NOT_REQUIRED + ) + String roomName +) { +} diff --git a/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomSummaryResponse.java b/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomSummaryResponse.java index 0688a20c7..ea4a7b5be 100644 --- a/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomSummaryResponse.java +++ b/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomSummaryResponse.java @@ -30,6 +30,10 @@ public record ChatRoomSummaryResponse( @JsonFormat(pattern = "yyyy.MM.dd HH:mm") LocalDateTime lastSentAt, + @Schema(description = "채팅방 생성 시간", example = "2025.12.19 23:20", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy.MM.dd HH:mm") + LocalDateTime createdAt, + @Schema(description = "읽지 않은 메시지 수", example = "12", requiredMode = REQUIRED) Integer unreadCount, diff --git a/src/main/java/gg/agit/konect/domain/chat/dto/ChatSearchResponse.java b/src/main/java/gg/agit/konect/domain/chat/dto/ChatSearchResponse.java new file mode 100644 index 000000000..cff92558d --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/dto/ChatSearchResponse.java @@ -0,0 +1,14 @@ +package gg.agit.konect.domain.chat.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record ChatSearchResponse( + @Schema(description = "채팅방 이름으로 매칭된 검색 결과", requiredMode = REQUIRED) + ChatRoomMatchesResponse roomMatches, + + @Schema(description = "메시지 내용으로 매칭된 검색 결과", requiredMode = REQUIRED) + ChatMessageMatchesResponse messageMatches +) { +} diff --git a/src/main/java/gg/agit/konect/domain/chat/enums/ChatInviteSortBy.java b/src/main/java/gg/agit/konect/domain/chat/enums/ChatInviteSortBy.java new file mode 100644 index 000000000..9f7f4e72f --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/enums/ChatInviteSortBy.java @@ -0,0 +1,6 @@ +package gg.agit.konect.domain.chat.enums; + +public enum ChatInviteSortBy { + NAME, + CLUB +} diff --git a/src/main/java/gg/agit/konect/domain/chat/enums/ChatType.java b/src/main/java/gg/agit/konect/domain/chat/enums/ChatType.java index 10b398775..24f08434b 100644 --- a/src/main/java/gg/agit/konect/domain/chat/enums/ChatType.java +++ b/src/main/java/gg/agit/konect/domain/chat/enums/ChatType.java @@ -2,5 +2,6 @@ public enum ChatType { DIRECT, + CLUB_GROUP, GROUP } diff --git a/src/main/java/gg/agit/konect/domain/chat/model/ChatRoom.java b/src/main/java/gg/agit/konect/domain/chat/model/ChatRoom.java index f437e57f4..6152f0f0a 100644 --- a/src/main/java/gg/agit/konect/domain/chat/model/ChatRoom.java +++ b/src/main/java/gg/agit/konect/domain/chat/model/ChatRoom.java @@ -7,12 +7,15 @@ import java.time.LocalDateTime; +import gg.agit.konect.domain.chat.enums.ChatType; import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.user.model.User; import gg.agit.konect.global.exception.CustomException; import gg.agit.konect.global.model.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; @@ -39,26 +42,40 @@ public class ChatRoom extends BaseEntity { @Column(name = "last_message_sent_at") private LocalDateTime lastMessageSentAt; + @Enumerated(EnumType.STRING) + @Column(name = "room_type", nullable = false, length = 20) + private ChatType roomType; + @ManyToOne(fetch = LAZY) @JoinColumn(name = "club_id") private Club club; @Builder - private ChatRoom(Integer id, Club club) { + private ChatRoom(Integer id, ChatType roomType, Club club) { this.id = id; + this.roomType = roomType; this.club = club; } public static ChatRoom directOf() { - return ChatRoom.builder().build(); + return ChatRoom.builder() + .roomType(ChatType.DIRECT) + .build(); } - public static ChatRoom groupOf(Club club) { + public static ChatRoom clubGroupOf(Club club) { return ChatRoom.builder() + .roomType(ChatType.CLUB_GROUP) .club(club) .build(); } + public static ChatRoom groupOf() { + return ChatRoom.builder() + .roomType(ChatType.GROUP) + .build(); + } + public static void validateIsNotSameParticipant(User sender, User receiver) { if (sender.getId().equals(receiver.getId())) { throw CustomException.of(CANNOT_CREATE_CHAT_ROOM_WITH_SELF); @@ -66,11 +83,15 @@ public static void validateIsNotSameParticipant(User sender, User receiver) { } public boolean isDirectRoom() { - return club == null; + return roomType == ChatType.DIRECT; } public boolean isGroupRoom() { - return club != null; + return roomType == ChatType.GROUP || roomType == ChatType.CLUB_GROUP; + } + + public boolean isClubGroupRoom() { + return roomType == ChatType.CLUB_GROUP; } public void updateLastMessage(String lastMessageContent, LocalDateTime lastMessageSentAt) { diff --git a/src/main/java/gg/agit/konect/domain/chat/model/ChatRoomMember.java b/src/main/java/gg/agit/konect/domain/chat/model/ChatRoomMember.java index de7706f24..7e9b39ffc 100644 --- a/src/main/java/gg/agit/konect/domain/chat/model/ChatRoomMember.java +++ b/src/main/java/gg/agit/konect/domain/chat/model/ChatRoomMember.java @@ -40,17 +40,37 @@ public class ChatRoomMember extends BaseEntity { @Column(name = "last_read_at", nullable = false) private LocalDateTime lastReadAt; + @Column(name = "visible_message_from") + private LocalDateTime visibleMessageFrom; + + @Column(name = "left_at") + private LocalDateTime leftAt; + + @Column(name = "custom_room_name", length = 30) + private String customRoomName; + + @Column(name = "is_owner", nullable = false) + private Boolean isOwner; + @Builder private ChatRoomMember( ChatRoomMemberId id, ChatRoom chatRoom, User user, - LocalDateTime lastReadAt + LocalDateTime lastReadAt, + LocalDateTime visibleMessageFrom, + LocalDateTime leftAt, + String customRoomName, + Boolean isOwner ) { this.id = id; this.chatRoom = chatRoom; this.user = user; this.lastReadAt = lastReadAt; + this.visibleMessageFrom = visibleMessageFrom; + this.leftAt = leftAt; + this.customRoomName = customRoomName; + this.isOwner = isOwner != null ? isOwner : false; } public static ChatRoomMember of(ChatRoom chatRoom, User user, LocalDateTime lastReadAt) { @@ -59,6 +79,17 @@ public static ChatRoomMember of(ChatRoom chatRoom, User user, LocalDateTime last .chatRoom(chatRoom) .user(user) .lastReadAt(lastReadAt) + .isOwner(false) + .build(); + } + + public static ChatRoomMember ofOwner(ChatRoom chatRoom, User user, LocalDateTime lastReadAt) { + return ChatRoomMember.builder() + .id(new ChatRoomMemberId(chatRoom.getId(), user.getId())) + .chatRoom(chatRoom) + .user(user) + .lastReadAt(lastReadAt) + .isOwner(true) .build(); } @@ -79,4 +110,56 @@ public void updateLastReadAt(LocalDateTime lastReadAt) { this.lastReadAt = lastReadAt; } } + + public void updateCustomRoomName(String customRoomName) { + this.customRoomName = customRoomName; + } + + public boolean hasLeft() { + return leftAt != null; + } + + public boolean isOwner() { + return isOwner != null && isOwner; + } + + public void leaveDirectRoom(LocalDateTime leftAt) { + this.leftAt = leftAt; + this.visibleMessageFrom = leftAt; + updateLastReadAt(leftAt); + } + + /** + * 나간 이후 새 메시지가 생겨 다시 볼 수 있을 때 사용한다. + *

+ * 나간 상태만 해제하고, 기존 {@code visibleMessageFrom}은 유지한다. + * 그래서 나간 이후 도착한 메시지부터 계속 보인다. + */ + public void restoreDirectRoom() { + this.leftAt = null; + } + + /** + * 사용자가 채팅방을 다시 열어 새 대화를 시작할 때 사용한다. + *

+ * 나간 상태를 해제하고 {@code visibleMessageFrom}도 새로 갱신한다. + * 그래서 전달한 시점 이후 메시지부터 새 대화처럼 보인다. + */ + public void reopenDirectRoom(LocalDateTime visibleMessageFrom) { + this.leftAt = null; + this.visibleMessageFrom = visibleMessageFrom; + updateLastReadAt(visibleMessageFrom); + } + + public boolean hasVisibleMessages(ChatRoom room) { + if (room.getLastMessageSentAt() == null) { + return false; + } + + if (visibleMessageFrom == null) { + return true; + } + + return room.getLastMessageSentAt().isAfter(visibleMessageFrom); + } } diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatInviteQueryRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatInviteQueryRepository.java new file mode 100644 index 000000000..01cd6dd91 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/repository/ChatInviteQueryRepository.java @@ -0,0 +1,229 @@ +package gg.agit.konect.domain.chat.repository; + +import static gg.agit.konect.domain.club.model.QClub.club; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; + +import com.querydsl.core.types.dsl.CaseBuilder; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.NumberExpression; +import com.querydsl.core.types.dsl.StringExpression; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import gg.agit.konect.domain.chat.model.QChatRoomMember; +import gg.agit.konect.domain.club.model.QClub; +import gg.agit.konect.domain.club.model.ClubMember; +import gg.agit.konect.domain.club.model.QClubMember; +import gg.agit.konect.domain.user.enums.UserRole; +import gg.agit.konect.domain.user.model.QUser; +import gg.agit.konect.domain.user.model.User; +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class ChatInviteQueryRepository { + + private final JPAQueryFactory jpaQueryFactory; + + public Page findInvitableUsers(Integer userId, String query, PageRequest pageRequest) { + QChatRoomMember requesterMember = new QChatRoomMember("requesterMember"); + QChatRoomMember candidateMember = new QChatRoomMember("candidateMember"); + QUser candidateUser = new QUser("candidateUser"); + + List content = createInvitableUsersQuery(userId, query, requesterMember, candidateMember, candidateUser) + .groupBy( + candidateUser.id, + candidateUser.name, + candidateUser.imageUrl, + candidateUser.studentNumber + ) + .orderBy( + candidateUser.name.asc(), + candidateUser.studentNumber.asc(), + candidateUser.id.asc() + ) + .offset(pageRequest.getOffset()) + .limit(pageRequest.getPageSize()) + .fetch(); + + Long total = createInvitableUsersCountQuery(userId, query, requesterMember, candidateMember, candidateUser) + .fetchOne(); + + return new PageImpl<>(content, pageRequest, total == null ? 0 : total); + } + + public Page findInvitableUserIdsGroupedByClub( + Integer userId, + String query, + PageRequest pageRequest + ) { + QChatRoomMember requesterMember = new QChatRoomMember("requesterMember"); + QChatRoomMember candidateMember = new QChatRoomMember("candidateMember"); + QUser candidateUser = new QUser("candidateUser"); + QClubMember requesterClubMember = new QClubMember("requesterClubMember"); + QClubMember candidateClubMember = new QClubMember("candidateClubMember"); + QClub sharedClub = new QClub("sharedClub"); + + StringExpression representativeClubName = new CaseBuilder() + .when(requesterClubMember.id.isNotNull()) + .then(sharedClub.name) + .otherwise((String)null); + NumberExpression clubPresenceOrder = new CaseBuilder() + .when(requesterClubMember.id.isNotNull()) + .then(0) + .otherwise(1); + + List content = createInvitableUsersQuery( + userId, + query, + requesterMember, + candidateMember, + candidateUser + ) + // 사용자가 여러 동아리에 속해도 대표 정렬 키는 + // 요청자와 실제로 공유하는 동아리만 기준으로 계산해야 한다. + .leftJoin(candidateClubMember) + .on(candidateClubMember.user.id.eq(candidateUser.id)) + .leftJoin(candidateClubMember.club, sharedClub) + .leftJoin(requesterClubMember) + .on( + requesterClubMember.club.id.eq(sharedClub.id) + .and(requesterClubMember.user.id.eq(userId)) + ) + .groupBy( + candidateUser.id, + candidateUser.name, + candidateUser.imageUrl, + candidateUser.studentNumber + ) + .orderBy( + // 공유 동아리가 있는 사용자를 먼저 두고, + // 그 안에서는 대표 동아리 이름 → 사용자 이름 순으로 페이지 경계를 고정한다. + clubPresenceOrder.min().asc(), + representativeClubName.min().asc(), + candidateUser.name.asc(), + candidateUser.studentNumber.asc(), + candidateUser.id.asc() + ) + .select(candidateUser.id) + .offset(pageRequest.getOffset()) + .limit(pageRequest.getPageSize()) + .fetch(); + + Long total = createInvitableUsersCountQuery(userId, query, requesterMember, candidateMember, candidateUser) + .fetchOne(); + + return new PageImpl<>(content, pageRequest, total == null ? 0 : total); + } + + private JPAQuery createInvitableUsersQuery( + Integer userId, + String query, + QChatRoomMember requesterMember, + QChatRoomMember candidateMember, + QUser candidateUser + ) { + return jpaQueryFactory.select(candidateUser) + .from(requesterMember) + .join(candidateMember) + .on(candidateMember.chatRoom.id.eq(requesterMember.chatRoom.id)) + .join(candidateMember.user, candidateUser) + .where( + requesterMember.user.id.eq(userId), + requesterMember.leftAt.isNull(), + candidateMember.user.id.ne(userId), + candidateMember.leftAt.isNull(), + candidateUser.deletedAt.isNull(), + candidateUser.role.ne(UserRole.ADMIN), + containsUserKeyword(candidateUser, query) + ); + } + + private JPAQuery createInvitableUsersCountQuery( + Integer userId, + String query, + QChatRoomMember requesterMember, + QChatRoomMember candidateMember, + QUser candidateUser + ) { + // offset/limit가 적용된 본문 쿼리만으로는 전체 개수를 알 수 없어 count 쿼리를 분리한다. + return jpaQueryFactory.select(candidateUser.id.countDistinct()) + .from(requesterMember) + .join(candidateMember) + .on(candidateMember.chatRoom.id.eq(requesterMember.chatRoom.id)) + .join(candidateMember.user, candidateUser) + .where( + requesterMember.user.id.eq(userId), + requesterMember.leftAt.isNull(), + candidateMember.user.id.ne(userId), + candidateMember.leftAt.isNull(), + candidateUser.deletedAt.isNull(), + candidateUser.role.ne(UserRole.ADMIN), + containsUserKeyword(candidateUser, query) + ); + } + + public List findRequesterClubMemberships(Integer userId) { + QClubMember requesterClubMember = new QClubMember("requesterClubMember"); + + // 서비스가 섹션 이름과 순서를 바로 쓸 수 있게 요청자 동아리를 fetch join으로 한 번에 읽는다. + return jpaQueryFactory.select(requesterClubMember) + .from(requesterClubMember) + .join(requesterClubMember.club, club).fetchJoin() + .join(requesterClubMember.user).fetchJoin() + .where(requesterClubMember.user.id.eq(userId)) + .orderBy(club.name.asc(), club.id.asc()) + .fetch(); + } + + public List findSharedClubMemberships(Integer userId, List candidateUserIds) { + if (candidateUserIds.isEmpty()) { + return List.of(); + } + + QClubMember requesterClubMember = new QClubMember("requesterClubMember"); + QClubMember candidateClubMember = new QClubMember("candidateClubMember"); + QUser candidateUser = new QUser("candidateUser"); + + // 대표 섹션 후보는 요청자와 실제로 공유하는 동아리만 남겨야 하므로 club_member를 다시 조인한다. + return jpaQueryFactory.select(candidateClubMember) + .from(candidateClubMember) + .join(candidateClubMember.club, club).fetchJoin() + .join(candidateClubMember.user, candidateUser).fetchJoin() + .join(requesterClubMember) + .on( + requesterClubMember.club.id.eq(candidateClubMember.club.id) + .and(requesterClubMember.user.id.eq(userId)) + ) + .where( + // 현재 페이지 후보 집합 안에서만 대표 동아리를 고르면 서비스 단계의 중복 배치를 막을 수 있다. + candidateClubMember.user.id.in(candidateUserIds), + candidateUser.deletedAt.isNull() + ) + // putIfAbsent로 첫 동아리를 대표값으로 고를 수 있도록 동아리명/이름순으로 정렬한다. + .orderBy( + club.name.asc(), + candidateUser.name.asc(), + candidateUser.studentNumber.asc(), + candidateUser.id.asc() + ) + .fetch(); + } + + private BooleanExpression containsUserKeyword(QUser candidateUser, String query) { + if (!StringUtils.hasText(query)) { + return null; + } + + String normalizedQuery = query.trim().toLowerCase(); + return candidateUser.name.lower().contains(normalizedQuery) + .or(candidateUser.studentNumber.contains(normalizedQuery)); + } +} diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java index dc7876acd..82551da83 100644 --- a/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java +++ b/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java @@ -1,5 +1,6 @@ package gg.agit.konect.domain.chat.repository; +import java.time.LocalDateTime; import java.util.List; import org.springframework.data.domain.Page; @@ -39,9 +40,25 @@ List countUnreadMessagesByChatRoomIdsAndUserId( FROM ChatMessage cm JOIN FETCH cm.sender WHERE cm.chatRoom.id = :chatRoomId + AND (:visibleMessageFrom IS NULL OR cm.createdAt > :visibleMessageFrom) ORDER BY cm.createdAt DESC """) - Page findByChatRoomId(@Param("chatRoomId") Integer chatRoomId, Pageable pageable); + Page findByChatRoomId( + @Param("chatRoomId") Integer chatRoomId, + @Param("visibleMessageFrom") LocalDateTime visibleMessageFrom, + Pageable pageable + ); + + @Query(""" + SELECT COUNT(m) + FROM ChatMessage m + WHERE m.chatRoom.id = :chatRoomId + AND (:visibleMessageFrom IS NULL OR m.createdAt > :visibleMessageFrom) + """) + long countByChatRoomId( + @Param("chatRoomId") Integer chatRoomId, + @Param("visibleMessageFrom") LocalDateTime visibleMessageFrom + ); @Query(""" SELECT new gg.agit.konect.domain.chat.dto.UnreadMessageCount( @@ -102,6 +119,27 @@ SELECT MAX(m2.id) """) List findLatestMessagesByRoomIds(@Param("roomIds") List roomIds); + @Query( + value = """ + SELECT cm + FROM ChatMessage cm + JOIN FETCH cm.chatRoom cr + WHERE cr.id IN :roomIds + AND LOCATE(LOWER(:keyword), LOWER(cm.content)) > 0 + AND cm.id = ( + SELECT MAX(innerCm.id) + FROM ChatMessage innerCm + WHERE innerCm.chatRoom.id = cr.id + AND LOCATE(LOWER(:keyword), LOWER(innerCm.content)) > 0 + ) + ORDER BY cm.createdAt DESC, cm.id DESC + """ + ) + List searchLatestMatchingMessagesByChatRoomIds( + @Param("roomIds") List roomIds, + @Param("keyword") String keyword + ); + @Query(""" SELECT COUNT(m) FROM ChatMessage m diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomMemberRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomMemberRepository.java index 255030852..1b7252648 100644 --- a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomMemberRepository.java +++ b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomMemberRepository.java @@ -79,13 +79,13 @@ List findByChatRoomIdsAndUserId( @Param("userId") Integer userId ); - @Modifying + @Modifying(clearAutomatically = true) @Query(""" UPDATE ChatRoomMember crm SET crm.lastReadAt = :lastReadAt WHERE crm.id.chatRoomId = :chatRoomId AND crm.id.userId = :userId - AND crm.lastReadAt < :lastReadAt + AND (crm.lastReadAt IS NULL OR crm.lastReadAt < :lastReadAt) """) int updateLastReadAtIfOlder( @Param("chatRoomId") Integer chatRoomId, @@ -119,4 +119,7 @@ List countUnreadByRoomIdsAndUserId( @Param("roomIds") List roomIds, @Param("userId") Integer userId ); + + List saveAll(Iterable chatRoomMembers); + } diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java index c1a308912..7d6ec6363 100644 --- a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java +++ b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java @@ -1,5 +1,7 @@ package gg.agit.konect.domain.chat.repository; +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CHAT_ROOM; + import java.util.List; import java.util.Optional; @@ -7,8 +9,11 @@ import org.springframework.data.repository.Repository; import org.springframework.data.repository.query.Param; +import gg.agit.konect.domain.chat.dto.AdminChatRoomProjection; import gg.agit.konect.domain.chat.model.ChatRoom; +import gg.agit.konect.domain.chat.enums.ChatType; import gg.agit.konect.domain.user.enums.UserRole; +import gg.agit.konect.global.exception.CustomException; public interface ChatRoomRepository extends Repository { @@ -20,10 +25,21 @@ public interface ChatRoomRepository extends Repository { JOIN ChatRoomMember crm ON crm.id.chatRoomId = cr.id LEFT JOIN FETCH cr.club WHERE crm.id.userId = :userId - AND cr.club IS NULL + AND cr.roomType = :roomType ORDER BY cr.lastMessageSentAt DESC NULLS LAST, cr.id """) - List findByUserId(@Param("userId") Integer userId); + List findByUserId(@Param("userId") Integer userId, @Param("roomType") ChatType roomType); + + @Query(""" + SELECT DISTINCT cr + FROM ChatRoom cr + JOIN ChatRoomMember crm ON crm.id.chatRoomId = cr.id + WHERE crm.id.userId = :userId + AND cr.roomType = gg.agit.konect.domain.chat.enums.ChatType.GROUP + AND crm.leftAt IS NULL + ORDER BY COALESCE(cr.lastMessageSentAt, cr.createdAt) DESC + """) + List findGroupRoomsByMemberUserId(@Param("userId") Integer userId); @Query(""" SELECT cr @@ -33,18 +49,27 @@ public interface ChatRoomRepository extends Repository { """) Optional findById(@Param("chatRoomId") Integer chatRoomId); + default ChatRoom getById(Integer chatRoomId) { + return findById(chatRoomId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + } + @Query(""" SELECT cr FROM ChatRoom cr JOIN ChatRoomMember crm ON crm.id.chatRoomId = cr.id - WHERE cr.club IS NULL + WHERE cr.roomType = :roomType GROUP BY cr HAVING COUNT(crm) = 2 AND SUM(CASE WHEN crm.id.userId = :userId1 THEN 1 ELSE 0 END) = 1 AND SUM(CASE WHEN crm.id.userId = :userId2 THEN 1 ELSE 0 END) = 1 """) - Optional findByTwoUsers(@Param("userId1") Integer userId1, @Param("userId2") Integer userId2); + Optional findByTwoUsers( + @Param("userId1") Integer userId1, + @Param("userId2") Integer userId2, + @Param("roomType") ChatType roomType + ); @Query(""" SELECT cr @@ -68,15 +93,16 @@ AND SUM(CASE WHEN crm.id.userId = :userId2 THEN 1 ELSE 0 END) = 1 JOIN ChatRoomMember crm ON crm.id.chatRoomId = cr.id LEFT JOIN FETCH cr.club WHERE crm.id.userId = :userId + AND cr.roomType = :roomType AND cr.club IS NOT NULL ORDER BY cr.lastMessageSentAt DESC NULLS LAST, cr.id """) - List findGroupRoomsByUserId(@Param("userId") Integer userId); + List findGroupRoomsByUserId(@Param("userId") Integer userId, @Param("roomType") ChatType roomType); @Query(""" SELECT DISTINCT cr FROM ChatRoom cr - WHERE cr.club IS NULL + WHERE cr.roomType = :roomType AND EXISTS ( SELECT 1 FROM ChatRoomMember adminMember JOIN adminMember.user adminUser @@ -91,12 +117,15 @@ AND EXISTS ( ) ORDER BY cr.lastMessageSentAt DESC NULLS LAST, cr.id """) - List findAllAdminUserDirectRooms(@Param("adminRole") UserRole adminRole); + List findAllAdminUserDirectRooms( + @Param("adminRole") UserRole adminRole, + @Param("roomType") ChatType roomType + ); @Query(""" SELECT DISTINCT cr FROM ChatRoom cr - WHERE cr.club IS NULL + WHERE cr.roomType = :roomType AND EXISTS ( SELECT 1 FROM ChatRoomMember systemAdminMember WHERE systemAdminMember.id.chatRoomId = cr.id @@ -112,6 +141,54 @@ AND EXISTS ( """) List findAllSystemAdminDirectRooms( @Param("systemAdminId") Integer systemAdminId, - @Param("adminRole") UserRole adminRole + @Param("adminRole") UserRole adminRole, + @Param("roomType") ChatType roomType + ); + + /** + * 관리자용 1:1 채팅방 목록을 Projection DTO로 최적화 조회 + *

+ * 사용자가 응답한 채팅방만 필터링하고, 필요한 필드만 한 번에 조회합니다. + * 이 메소드는 다음과 같은 최적화를 제공합니다: + *

    + *
  • ChatRoom 엔티티 전체 로딩 대신 필요한 필드만 Projection
  • + *
  • 읽지 않은 메시지 수를 DB에서 직접 계산 (COUNT 서브쿼리)
  • + *
  • 상대방 사용자 정보를 JOIN으로 한 번에 조회
  • + *
+ */ + @Query(""" + SELECT new gg.agit.konect.domain.chat.dto.AdminChatRoomProjection( + cr.id, + cr.lastMessageContent, + cr.lastMessageSentAt, + cr.createdAt, + u.id, + u.name, + u.imageUrl, + COUNT(cm) + ) + FROM ChatRoom cr + JOIN ChatRoomMember crm ON crm.id.chatRoomId = cr.id + JOIN User u ON u.id = crm.id.userId + JOIN ChatRoomMember adminCrm ON adminCrm.id.chatRoomId = cr.id + AND adminCrm.id.userId = :systemAdminId + LEFT JOIN ChatMessage cm ON cm.chatRoom.id = cr.id + AND cm.sender.id <> :systemAdminId + AND cm.createdAt > adminCrm.lastReadAt + WHERE cr.roomType = :roomType + AND u.role != :adminRole + AND EXISTS ( + SELECT 1 FROM ChatMessage userReply + JOIN userReply.sender userSender + WHERE userReply.chatRoom.id = cr.id + AND userSender.role != :adminRole + ) + GROUP BY cr.id, cr.lastMessageContent, cr.lastMessageSentAt, cr.createdAt, u.id, u.name, u.imageUrl + ORDER BY COALESCE(cr.lastMessageSentAt, cr.createdAt) DESC + """) + List findAdminChatRoomsOptimized( + @Param("systemAdminId") Integer systemAdminId, + @Param("adminRole") UserRole adminRole, + @Param("roomType") ChatType roomType ); } diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatPresenceService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatPresenceService.java index 956a4d852..9882f358f 100644 --- a/src/main/java/gg/agit/konect/domain/chat/service/ChatPresenceService.java +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatPresenceService.java @@ -1,6 +1,9 @@ package gg.agit.konect.domain.chat.service; import java.time.Duration; +import java.util.HashSet; +import java.util.List; +import java.util.Set; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; @@ -53,13 +56,29 @@ public boolean isUserInChatRoom(Integer roomId, Integer userId) { return redis.opsForValue().get(key) != null; } - /** - * Redis 키를 생성합니다. - * - * @param roomId 채팅방 ID - * @param userId 사용자 ID - * @return Redis 키 (형식: chat:presence:room:{roomId}:user:{userId}) - */ + public Set findUsersInChatRoom(Integer roomId, List userIds) { + if (roomId == null || userIds == null || userIds.isEmpty()) { + return Set.of(); + } + + List keys = userIds.stream() + .map(userId -> presenceKey(roomId, userId)) + .toList(); + + List values = redis.opsForValue().multiGet(keys); + if (values == null) { + return Set.of(); + } + + Set activeUsers = new HashSet<>(); + for (int i = 0; i < userIds.size(); i++) { + if (values.get(i) != null) { + activeUsers.add(userIds.get(i)); + } + } + return activeUsers; + } + private String presenceKey(Integer roomId, Integer userId) { return PRESENCE_PREFIX + roomId + USER_SUFFIX + userId; } diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java index 5e3d6bc30..3f285189e 100644 --- a/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java @@ -1,32 +1,53 @@ package gg.agit.konect.domain.chat.service; +import static gg.agit.konect.global.code.ApiResponseCode.FORBIDDEN_CHAT_ROOM_ACCESS; +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CHAT_ROOM; + import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.stream.Collectors; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import gg.agit.konect.domain.chat.model.ChatRoom; import gg.agit.konect.domain.chat.model.ChatRoomMember; import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; import gg.agit.konect.domain.chat.repository.ChatRoomRepository; +import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.ClubMember; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.user.enums.UserRole; import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.exception.CustomException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class ChatRoomMembershipService { + private static final int SYSTEM_ADMIN_ID = 1; + private final ChatRoomRepository chatRoomRepository; private final ChatRoomMemberRepository chatRoomMemberRepository; + private final ClubMemberRepository clubMemberRepository; + private final UserRepository userRepository; @Transactional public void addClubMember(ClubMember clubMember) { LocalDateTime baseline = Objects.requireNonNull(clubMember.getCreatedAt()); - ChatRoom room = chatRoomRepository.findByClubId(clubMember.getClub().getId()) - .orElseGet(() -> chatRoomRepository.save(ChatRoom.groupOf(clubMember.getClub()))); + ChatRoom room = findOrCreateClubRoom(clubMember.getClub()); ensureMember(room, clubMember.getUser(), baseline); } @@ -37,6 +58,147 @@ public void addDirectMembers(ChatRoom room, User firstUser, User secondUser, Loc ensureMember(room, secondUser, baseline); } + @Transactional + public void removeClubMember(Integer clubId, Integer userId) { + chatRoomRepository.findByClubId(clubId) + .ifPresent(room -> chatRoomMemberRepository.deleteByChatRoomIdAndUserId(room.getId(), userId)); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void ensureClubRoomMemberships(Integer userId) { + List memberships = clubMemberRepository.findAllByUserId(userId); + if (memberships.isEmpty()) { + return; + } + + Map membershipByClubId = memberships.stream() + .collect(Collectors.toMap(cm -> cm.getClub().getId(), cm -> cm, (a, b) -> a)); + + List rooms = resolveOrCreateClubRooms(memberships).stream() + .sorted(Comparator.comparing(ChatRoom::getId)) + .toList(); + List roomIds = rooms.stream().map(ChatRoom::getId).toList(); + if (roomIds.isEmpty()) { + return; + } + + Map memberByRoomId = chatRoomMemberRepository + .findByChatRoomIdsAndUserId(roomIds, userId) + .stream() + .collect(Collectors.toMap(ChatRoomMember::getChatRoomId, member -> member, (a, b) -> a)); + + for (ChatRoom room : rooms) { + ClubMember member = membershipByClubId.get(room.getClub().getId()); + if (member == null) { + continue; + } + + ChatRoomMember existingMember = memberByRoomId.get(room.getId()); + if (existingMember != null) { + LocalDateTime lastReadAt = existingMember.getLastReadAt(); + if (lastReadAt == null || lastReadAt.isBefore(member.getCreatedAt())) { + chatRoomMemberRepository.updateLastReadAtIfOlder( + room.getId(), userId, member.getCreatedAt() + ); + } + continue; + } + + saveRoomMemberIgnoringDuplicate(room, member.getUser(), member.getCreatedAt()); + } + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void updateLastReadAt(Integer roomId, Integer userId, LocalDateTime readAt) { + chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, userId, readAt); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void updateDirectRoomLastReadAt(Integer roomId, Integer userId, LocalDateTime readAt) { + User user = userRepository.getById(userId); + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + + ensureDirectRoomMemberExists(room, user, readAt); + + if (user.getRole() == UserRole.ADMIN) { + List members = chatRoomMemberRepository.findByChatRoomId(roomId); + boolean isSystemAdmin = members.stream() + .anyMatch(member -> Objects.equals(member.getUserId(), SYSTEM_ADMIN_ID)); + + if (isSystemAdmin) { + for (ChatRoomMember member : members) { + if (member.getUser().getRole() == UserRole.ADMIN) { + chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, member.getUserId(), readAt); + } + } + return; + } + } + + chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, userId, readAt); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void ensureClubRoomMember(Integer roomId, Integer userId) { + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + if (!room.isGroupRoom() || room.getClub() == null) { + throw CustomException.of(NOT_FOUND_CHAT_ROOM); + } + ClubMember member = clubMemberRepository.getByClubIdAndUserId(room.getClub().getId(), userId); + ensureMember(room, member.getUser(), member.getCreatedAt()); + } + + private ChatRoom findOrCreateClubRoom(Club club) { + return chatRoomRepository.findByClubId(club.getId()) + .orElseGet(() -> { + try { + return chatRoomRepository.save(ChatRoom.clubGroupOf(club)); + } catch (DataIntegrityViolationException e) { + if (!isDuplicateKeyException(e)) { + throw e; + } + log.debug("클럽 채팅방 동시 생성 감지, 재조회: clubId={}", club.getId()); + return chatRoomRepository.findByClubId(club.getId()) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + } + }); + } + + private List resolveOrCreateClubRooms(List memberships) { + Map clubById = memberships.stream() + .map(ClubMember::getClub) + .collect(Collectors.toMap(Club::getId, club -> club, (a, b) -> a)); + + Map roomByClubId = chatRoomRepository.findByClubIds(new ArrayList<>(clubById.keySet())) + .stream() + .filter(room -> room.getClub() != null) + .collect(Collectors.toMap(room -> room.getClub().getId(), room -> room, (a, b) -> a)); + + for (Map.Entry clubEntry : clubById.entrySet()) { + if (roomByClubId.containsKey(clubEntry.getKey())) { + continue; + } + try { + ChatRoom createdRoom = chatRoomRepository.save(ChatRoom.clubGroupOf(clubEntry.getValue())); + roomByClubId.put(clubEntry.getKey(), createdRoom); + } catch (DataIntegrityViolationException e) { + if (!isDuplicateKeyException(e)) { + throw e; + } + log.debug("클럽 채팅방 동시 생성 감지, 재조회: clubId={}", clubEntry.getKey()); + chatRoomRepository.findByClubId(clubEntry.getKey()) + .ifPresent(room -> roomByClubId.put(clubEntry.getKey(), room)); + } + } + + return memberships.stream() + .map(membership -> roomByClubId.get(membership.getClub().getId())) + .filter(Objects::nonNull) + .toList(); + } + private void ensureMember(ChatRoom room, User user, LocalDateTime baseline) { chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId()) .ifPresentOrElse(member -> { @@ -44,12 +206,50 @@ private void ensureMember(ChatRoom room, User user, LocalDateTime baseline) { if (lastReadAt == null || lastReadAt.isBefore(baseline)) { member.updateLastReadAt(baseline); } - }, () -> chatRoomMemberRepository.save(ChatRoomMember.of(room, user, baseline))); + }, () -> saveRoomMemberIgnoringDuplicate(room, user, baseline)); } - @Transactional - public void removeClubMember(Integer clubId, Integer userId) { - chatRoomRepository.findByClubId(clubId) - .ifPresent(room -> chatRoomMemberRepository.deleteByChatRoomIdAndUserId(room.getId(), userId)); + private void saveRoomMemberIgnoringDuplicate(ChatRoom room, User user, LocalDateTime baseline) { + try { + chatRoomMemberRepository.save(ChatRoomMember.of(room, user, baseline)); + } catch (DataIntegrityViolationException e) { + if (!isDuplicateKeyException(e)) { + throw e; + } + log.debug("채팅방 멤버 동시 생성 감지, 무시: roomId={}, userId={}", room.getId(), user.getId()); + } + } + + private void ensureDirectRoomMemberExists(ChatRoom room, User user, LocalDateTime readAt) { + boolean exists = chatRoomMemberRepository.existsByChatRoomIdAndUserId(room.getId(), user.getId()); + if (exists) { + return; + } + + if (user.getRole() == UserRole.ADMIN && isSystemAdminRoom(room.getId())) { + saveRoomMemberIgnoringDuplicate(room, user, readAt); + return; + } + + throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); + } + + private boolean isSystemAdminRoom(Integer roomId) { + List memberIds = chatRoomMemberRepository.findRoomMemberIdsByChatRoomIds(List.of(roomId)); + return memberIds.stream() + .map(row -> (Integer)row[1]) + .anyMatch(userId -> userId.equals(SYSTEM_ADMIN_ID)); + } + + private boolean isDuplicateKeyException(DataIntegrityViolationException e) { + if (e instanceof DuplicateKeyException) { + return true; + } + Throwable rootCause = e.getRootCause(); + if (rootCause == null) { + return false; + } + String message = rootCause.getMessage(); + return message != null && (message.contains("Duplicate") || message.contains("duplicate key")); } } diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java index 21f9b463b..9b04c825f 100644 --- a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java @@ -1,16 +1,14 @@ package gg.agit.konect.domain.chat.service; -import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_CREATE_CHAT_ROOM_WITH_SELF; -import static gg.agit.konect.global.code.ApiResponseCode.FORBIDDEN_CHAT_ROOM_ACCESS; -import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CHAT_ROOM; -import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_USER; +import static gg.agit.konect.global.code.ApiResponseCode.*; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; -import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Set; @@ -18,29 +16,40 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import gg.agit.konect.domain.chat.dto.AdminChatRoomProjection; +import gg.agit.konect.domain.chat.dto.ChatInvitableUsersResponse; import gg.agit.konect.domain.chat.dto.ChatMessageDetailResponse; +import gg.agit.konect.domain.chat.dto.ChatMessageMatchResult; +import gg.agit.konect.domain.chat.dto.ChatMessageMatchesResponse; import gg.agit.konect.domain.chat.dto.ChatMessagePageResponse; import gg.agit.konect.domain.chat.dto.ChatMessageSendRequest; import gg.agit.konect.domain.chat.dto.ChatMuteResponse; import gg.agit.konect.domain.chat.dto.ChatRoomCreateRequest; +import gg.agit.konect.domain.chat.dto.ChatRoomMatchesResponse; +import gg.agit.konect.domain.chat.dto.ChatRoomNameUpdateRequest; import gg.agit.konect.domain.chat.dto.ChatRoomResponse; import gg.agit.konect.domain.chat.dto.ChatRoomSummaryResponse; import gg.agit.konect.domain.chat.dto.ChatRoomsSummaryResponse; +import gg.agit.konect.domain.chat.dto.ChatSearchResponse; import gg.agit.konect.domain.chat.dto.UnreadMessageCount; +import gg.agit.konect.domain.chat.enums.ChatInviteSortBy; import gg.agit.konect.domain.chat.enums.ChatType; import gg.agit.konect.domain.chat.event.AdminChatReceivedEvent; import gg.agit.konect.domain.chat.model.ChatMessage; import gg.agit.konect.domain.chat.model.ChatRoom; import gg.agit.konect.domain.chat.model.ChatRoomMember; +import gg.agit.konect.domain.chat.repository.ChatInviteQueryRepository; import gg.agit.konect.domain.chat.repository.ChatMessageRepository; import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; import gg.agit.konect.domain.chat.repository.ChatRoomRepository; import gg.agit.konect.domain.chat.repository.RoomUnreadCountProjection; -import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.ClubMember; import gg.agit.konect.domain.club.repository.ClubMemberRepository; import gg.agit.konect.domain.notification.enums.NotificationTargetType; @@ -53,21 +62,27 @@ import gg.agit.konect.global.code.ApiResponseCode; import gg.agit.konect.global.exception.CustomException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class ChatService { private static final int SYSTEM_ADMIN_ID = 1; + private static final String ETC_SECTION_NAME = "기타"; + private static final String DEFAULT_GROUP_ROOM_NAME = "그룹 채팅"; private final ChatRoomRepository chatRoomRepository; private final ChatMessageRepository chatMessageRepository; private final ChatRoomMemberRepository chatRoomMemberRepository; private final NotificationMuteSettingRepository notificationMuteSettingRepository; private final ClubMemberRepository clubMemberRepository; + private final ChatInviteQueryRepository chatInviteQueryRepository; private final UserRepository userRepository; private final ChatPresenceService chatPresenceService; + private final ChatRoomMembershipService chatRoomMembershipService; private final NotificationService notificationService; private final ApplicationEventPublisher eventPublisher; @@ -84,18 +99,22 @@ public ChatRoomResponse createOrGetChatRoom(Integer currentUserId, ChatRoomCreat return getOrCreateSystemAdminChatRoomForUser(targetUser, currentUser); } - ChatRoom chatRoom = chatRoomRepository.findByTwoUsers(currentUser.getId(), targetUser.getId()) + ChatRoom chatRoom = chatRoomRepository.findByTwoUsers( + currentUser.getId(), + targetUser.getId(), + ChatType.DIRECT + ) .orElseGet(() -> chatRoomRepository.save(ChatRoom.directOf())); LocalDateTime joinedAt = Objects.requireNonNull(chatRoom.getCreatedAt(), "chatRoom.createdAt must not be null"); - ensureRoomMember(chatRoom, currentUser, joinedAt); + ensureDirectRoomRequester(chatRoom, currentUser, joinedAt); ensureRoomMember(chatRoom, targetUser, joinedAt); return ChatRoomResponse.from(chatRoom); } private ChatRoomResponse getOrCreateSystemAdminChatRoomForUser(User targetUser, User adminUser) { - ChatRoom chatRoom = chatRoomRepository.findByTwoUsers(SYSTEM_ADMIN_ID, targetUser.getId()) + ChatRoom chatRoom = chatRoomRepository.findByTwoUsers(SYSTEM_ADMIN_ID, targetUser.getId(), ChatType.DIRECT) .orElseGet(() -> { ChatRoom newRoom = chatRoomRepository.save(ChatRoom.directOf()); User systemAdmin = userRepository.getById(SYSTEM_ADMIN_ID); @@ -110,7 +129,7 @@ private ChatRoomResponse getOrCreateSystemAdminChatRoomForUser(User targetUser, LocalDateTime joinedAt = Objects.requireNonNull( chatRoom.getCreatedAt(), "chatRoom.createdAt must not be null" ); - ensureRoomMember(chatRoom, adminUser, joinedAt); + ensureDirectRoomRequester(chatRoom, adminUser, joinedAt); return ChatRoomResponse.from(chatRoom); } @@ -124,24 +143,95 @@ public ChatRoomResponse createOrGetAdminChatRoom(Integer currentUserId) { } @Transactional + public ChatRoomResponse createGroupChatRoom(Integer currentUserId, ChatRoomCreateRequest.Group request) { + User creator = userRepository.getById(currentUserId); + + List distinctUserIds = request.userIds().stream() + .distinct() + .filter(id -> !id.equals(currentUserId)) + .toList(); + + if (distinctUserIds.isEmpty()) { + throw CustomException.of(CANNOT_CREATE_CHAT_ROOM_WITH_SELF); + } + + List invitees = userRepository.findAllByIdIn(distinctUserIds); + if (invitees.size() != distinctUserIds.size()) { + throw CustomException.of(NOT_FOUND_USER); + } + + ChatRoom chatRoom = chatRoomRepository.save(ChatRoom.groupOf()); + LocalDateTime joinedAt = Objects.requireNonNull( + chatRoom.getCreatedAt(), "chatRoom.createdAt must not be null" + ); + + List members = new ArrayList<>(); + members.add(ChatRoomMember.ofOwner(chatRoom, creator, joinedAt)); + invitees.forEach(user -> members.add(ChatRoomMember.of(chatRoom, user, joinedAt))); + chatRoomMemberRepository.saveAll(members); + + return ChatRoomResponse.from(chatRoom); + } + + @Transactional + public void leaveChatRoom(Integer userId, Integer roomId) { + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + + if (room.isClubGroupRoom()) { + throw CustomException.of(CANNOT_LEAVE_GROUP_CHAT_ROOM); + } + + ChatRoomMember member = getRoomMember(roomId, userId); + if (room.isDirectRoom()) { + member.leaveDirectRoom(LocalDateTime.now()); + return; + } + + chatRoomMemberRepository.deleteByChatRoomIdAndUserId(roomId, userId); + } + + @Transactional + public void kickMember(Integer requesterId, Integer roomId, Integer targetUserId) { + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + + validateGroupRoomForKick(room); + validateNotSelfKick(requesterId, targetUserId); + + ChatRoomMember requester = getRoomMember(roomId, requesterId); + validateKickAuthority(requester); + + ChatRoomMember target = getRoomMember(roomId, targetUserId); + validateNotOwnerTarget(target); + + chatRoomMemberRepository.deleteByChatRoomIdAndUserId(roomId, targetUserId); + } + public ChatRoomsSummaryResponse getChatRooms(Integer userId) { + chatRoomMembershipService.ensureClubRoomMemberships(userId); + List directRooms = getDirectChatRooms(userId); List clubRooms = getClubChatRooms(userId); + List groupRooms = getGroupChatRooms(userId); List roomIds = new ArrayList<>(); roomIds.addAll(directRooms.stream().map(ChatRoomSummaryResponse::roomId).toList()); roomIds.addAll(clubRooms.stream().map(ChatRoomSummaryResponse::roomId).toList()); + roomIds.addAll(groupRooms.stream().map(ChatRoomSummaryResponse::roomId).toList()); Map muteMap = getMuteMap(roomIds, userId); + Map customRoomNameMap = getCustomRoomNameMap(roomIds, userId); List rooms = new ArrayList<>(); directRooms.forEach(room -> rooms.add(new ChatRoomSummaryResponse( room.roomId(), room.chatType(), - room.roomName(), + resolveRoomName(room.roomId(), room.roomName(), customRoomNameMap), room.roomImageUrl(), room.lastMessage(), room.lastSentAt(), + room.createdAt(), room.unreadCount(), muteMap.getOrDefault(room.roomId(), false) ))); @@ -149,32 +239,181 @@ public ChatRoomsSummaryResponse getChatRooms(Integer userId) { clubRooms.forEach(room -> rooms.add(new ChatRoomSummaryResponse( room.roomId(), room.chatType(), - room.roomName(), + resolveRoomName(room.roomId(), room.roomName(), customRoomNameMap), room.roomImageUrl(), room.lastMessage(), room.lastSentAt(), + room.createdAt(), + room.unreadCount(), + muteMap.getOrDefault(room.roomId(), false) + ))); + + groupRooms.forEach(room -> rooms.add(new ChatRoomSummaryResponse( + room.roomId(), + room.chatType(), + resolveRoomName(room.roomId(), room.roomName(), customRoomNameMap), + room.roomImageUrl(), + room.lastMessage(), + room.lastSentAt(), + room.createdAt(), room.unreadCount(), muteMap.getOrDefault(room.roomId(), false) ))); rooms.sort( - Comparator.comparing(ChatRoomSummaryResponse::lastSentAt, Comparator.nullsLast(Comparator.reverseOrder())) - .thenComparing(ChatRoomSummaryResponse::roomId) + Comparator.comparing( + (ChatRoomSummaryResponse room) -> + room.lastSentAt() != null ? room.lastSentAt() : room.createdAt(), + Comparator.reverseOrder() + ) ); - return new ChatRoomsSummaryResponse(rooms); + return new ChatRoomsSummaryResponse(getAccessibleChatRooms(userId).rooms()); } - @Transactional + public ChatSearchResponse searchChats(Integer userId, String keyword, Integer page, Integer limit) { + String normalizedKeyword = normalizeKeyword(keyword); + AccessibleChatRooms accessibleChatRooms = getAccessibleChatRooms(userId); + ChatRoomMatchesResponse roomMatches = searchRoomsByName(accessibleChatRooms, normalizedKeyword, page, limit); + ChatMessageMatchesResponse messageMatches = searchByMessageContent( + userId, + accessibleChatRooms.rooms(), + normalizedKeyword, + page, + limit + ); + + return new ChatSearchResponse(roomMatches, messageMatches); + } + + public ChatInvitableUsersResponse getInvitableUsers( + Integer userId, + String query, + ChatInviteSortBy sortBy, + Integer page, + Integer limit + ) { + userRepository.getById(userId); + PageRequest pageRequest = PageRequest.of(page - 1, limit); + + if (sortBy == ChatInviteSortBy.CLUB) { + return getInvitableUsersGroupedByClub(userId, query, pageRequest); + } + + Page filteredUserEntitiesPage = chatInviteQueryRepository.findInvitableUsers(userId, query, pageRequest); + + // 응답 DTO는 채팅 초대 화면에서 바로 쓰는 최소 필드만 유지한다. + List filteredUsers = filteredUserEntitiesPage.getContent().stream() + .map(ChatInvitableUsersResponse.InvitableUser::from) + .toList(); + + // 응답 메타(total/current page 정보)는 유지하면서 내용만 DTO로 치환한다. + Page filteredUsersPage = new PageImpl<>( + filteredUsers, + pageRequest, + filteredUserEntitiesPage.getTotalElements() + ); + + return ChatInvitableUsersResponse.forNameSort(filteredUsersPage); + } + + private ChatInvitableUsersResponse getInvitableUsersGroupedByClub( + Integer userId, + String query, + PageRequest pageRequest + ) { + // CLUB 정렬은 DB가 현재 페이지에 들어갈 userId까지 잘라 오고, + // 서비스는 그 결과를 섹션 응답으로만 복원한다. + Page pagedUserIds = chatInviteQueryRepository.findInvitableUserIdsGroupedByClub( + userId, + query, + pageRequest + ); + + if (pagedUserIds.isEmpty()) { + return ChatInvitableUsersResponse.forClubSort( + new PageImpl<>(List.of(), pageRequest, pagedUserIds.getTotalElements()), + List.of() + ); + } + + // IN 조회는 정렬 순서를 보장하지 않으므로, DB가 정한 userId 페이지 순서대로 다시 조립한다. + Map pagedUserMap = userRepository.findAllByIdIn(pagedUserIds.getContent()).stream() + .collect(Collectors.toMap(User::getId, user -> user)); + + List pagedUsers = pagedUserIds.getContent().stream() + .map(pagedUserMap::get) + .filter(Objects::nonNull) + .map(ChatInvitableUsersResponse.InvitableUser::from) + .toList(); + + Page pagedInvitableUsers = new PageImpl<>( + pagedUsers, + pageRequest, + pagedUserIds.getTotalElements() + ); + + record SectionKey(Integer clubId, String clubName) { + } + + Map representativeClubByUserId = new HashMap<>(); + Map representativeClubNames = new HashMap<>(); + // 현재 페이지 사용자에 대해서만 대표 동아리를 다시 구해도, + // userId 자체는 이미 대표 동아리 기준으로 정렬돼 있으므로 페이지 경계는 유지된다. + chatInviteQueryRepository.findSharedClubMemberships(userId, pagedUserIds.getContent()).stream() + .forEach(clubMember -> { + representativeClubNames.putIfAbsent(clubMember.getClub().getId(), clubMember.getClub().getName()); + representativeClubByUserId.putIfAbsent(clubMember.getUser().getId(), clubMember.getClub().getId()); + }); + + // 대표 동아리가 없는 사용자는 기타 섹션으로 떨어지고, + // 같은 대표 동아리를 가진 사용자끼리만 현재 페이지 sections[]로 묶는다. + Map> sectionMap = new LinkedHashMap<>(); + pagedUsers.forEach(user -> { + Integer representativeClubId = representativeClubByUserId.get(user.userId()); + String clubName = representativeClubId == null + ? ETC_SECTION_NAME + : representativeClubNames.get(representativeClubId); + SectionKey key = new SectionKey(representativeClubId, clubName); + sectionMap.computeIfAbsent(key, ignored -> new ArrayList<>()) + .add(user); + }); + + List sections = sectionMap.entrySet().stream() + .map(entry -> new ChatInvitableUsersResponse.InvitableSection( + entry.getKey().clubId(), + entry.getKey().clubName(), + entry.getValue() + )) + .toList(); + + return ChatInvitableUsersResponse.forClubSort(pagedInvitableUsers, sections); + } + + @Transactional(propagation = Propagation.NOT_SUPPORTED) public ChatMessagePageResponse getMessages(Integer userId, Integer roomId, Integer page, Integer limit) { ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + LocalDateTime readAt = LocalDateTime.now(); + if (room.isDirectRoom()) { - return getDirectChatRoomMessages(userId, roomId, page, limit); + chatRoomMembershipService.updateDirectRoomLastReadAt(roomId, userId, readAt); + recordPresenceSafely(roomId, userId); + return getDirectChatRoomMessages(userId, roomId, page, limit, readAt); + } + + if (room.isClubGroupRoom()) { + chatRoomMembershipService.ensureClubRoomMember(roomId, userId); + chatRoomMembershipService.updateLastReadAt(roomId, userId, readAt); + recordPresenceSafely(roomId, userId); + return getClubMessagesByRoomId(roomId, userId, page, limit); } - return getClubMessagesByRoomId(roomId, userId, page, limit); + getAccessibleRoomMember(room, userId); + chatRoomMembershipService.updateLastReadAt(roomId, userId, readAt); + recordPresenceSafely(roomId, userId); + return getGroupMessagesByRoomId(roomId, userId, page, limit); } @Transactional @@ -186,7 +425,11 @@ public ChatMessageDetailResponse sendMessage(Integer userId, Integer roomId, Cha return sendDirectMessage(userId, roomId, request); } - return sendClubMessageByRoomId(roomId, userId, request.content()); + if (room.isClubGroupRoom()) { + return sendClubMessageByRoomId(roomId, userId, request.content()); + } + + return sendGroupMessageByRoomId(roomId, userId, request.content()); } @Transactional @@ -195,11 +438,13 @@ public ChatMuteResponse toggleMute(Integer userId, Integer roomId) { .orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_CHAT_ROOM)); User user = userRepository.getById(userId); - if (room.isGroupRoom()) { + if (room.isClubGroupRoom()) { ClubMember member = clubMemberRepository.getByClubIdAndUserId(room.getClub().getId(), userId); ensureRoomMember(room, member.getUser(), member.getCreatedAt()); + } else if (room.isDirectRoom()) { + getAccessibleDirectRoomMember(room, user); } else { - getOrCreateDirectRoomMember(room, user); + getAccessibleRoomMember(room, userId); } Boolean isMuted = notificationMuteSettingRepository.findByTargetTypeAndTargetIdAndUserId( NotificationTargetType.CHAT_ROOM, @@ -224,6 +469,15 @@ public ChatMuteResponse toggleMute(Integer userId, Integer roomId) { return new ChatMuteResponse(isMuted); } + @Transactional + public void updateChatRoomName(Integer userId, Integer roomId, ChatRoomNameUpdateRequest request) { + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + + ChatRoomMember roomMember = getAccessibleRoomMember(room, userId); + roomMember.updateCustomRoomName(normalizeCustomRoomName(request.roomName())); + } + private List getDirectChatRooms(Integer userId) { User user = userRepository.getById(userId); @@ -232,24 +486,18 @@ private List getDirectChatRooms(Integer userId) { } List roomSummaries = new ArrayList<>(); - List personalChatRooms = chatRoomRepository.findByUserId(userId); - Map> roomMemberInfoMap = getRoomMemberInfoMap(personalChatRooms); + List personalChatRooms = chatRoomRepository.findByUserId(userId, ChatType.DIRECT); + Map> roomMembersMap = getRoomMembersMap(personalChatRooms); Map personalUnreadCountMap = getUnreadCountMap(extractChatRoomIds(personalChatRooms), userId); - List allUserIds = roomMemberInfoMap.values().stream() - .flatMap(List::stream) - .map(MemberInfo::userId) - .distinct() - .toList(); - - Map userMap = allUserIds.isEmpty() - ? Map.of() - : userRepository.findAllByIdIn(allUserIds).stream() - .collect(Collectors.toMap(User::getId, u -> u)); - for (ChatRoom chatRoom : personalChatRooms) { - List memberInfos = roomMemberInfoMap.getOrDefault(chatRoom.getId(), List.of()); - User chatPartner = resolveDirectChatPartner(memberInfos, user.getId(), userMap); + List members = roomMembersMap.getOrDefault(chatRoom.getId(), List.of()); + ChatRoomMember currentMember = findRoomMember(members, userId); + if (currentMember == null || !isDirectRoomVisibleToUser(chatRoom, currentMember)) { + continue; + } + + User chatPartner = resolveDirectChatPartner(members, user.getId()); if (chatPartner == null) { continue; } @@ -259,8 +507,9 @@ private List getDirectChatRooms(Integer userId) { ChatType.DIRECT, chatPartner.getName(), chatPartner.getImageUrl(), - chatRoom.getLastMessageContent(), - chatRoom.getLastMessageSentAt(), + getVisibleLastMessageContent(chatRoom, currentMember), + getVisibleLastMessageSentAt(chatRoom, currentMember), + chatRoom.getCreatedAt(), personalUnreadCountMap.getOrDefault(chatRoom.getId(), 0), false )); @@ -268,95 +517,117 @@ private List getDirectChatRooms(Integer userId) { roomSummaries.sort(Comparator .comparing( - ChatRoomSummaryResponse::lastSentAt, - Comparator.nullsLast(Comparator.reverseOrder()) - ) - .thenComparing(ChatRoomSummaryResponse::roomId)); + (ChatRoomSummaryResponse room) -> + room.lastSentAt() != null ? room.lastSentAt() : room.createdAt(), + Comparator.reverseOrder() + )); return roomSummaries; } private List getAdminDirectChatRooms() { - List roomSummaries = new ArrayList<>(); - - List adminUserRooms = chatRoomRepository.findAllSystemAdminDirectRooms( - SYSTEM_ADMIN_ID, UserRole.ADMIN + List projections = chatRoomRepository.findAdminChatRoomsOptimized( + SYSTEM_ADMIN_ID, UserRole.ADMIN, ChatType.DIRECT ); - List roomIds = extractChatRoomIds(adminUserRooms); - Map> roomMemberInfoMap = getRoomMemberInfoMap(adminUserRooms); - Map adminUnreadCountMap = getAdminUnreadCountMap(roomIds); - Set repliedRoomIds = roomIds.isEmpty() - ? Set.of() - : new HashSet<>(chatMessageRepository.findRoomIdsWithUserReplyByRoomIds(roomIds, UserRole.ADMIN)); - - List allUserIds = roomMemberInfoMap.values().stream() - .flatMap(List::stream) - .map(MemberInfo::userId) - .distinct() + + return projections.stream() + .map(projection -> new ChatRoomSummaryResponse( + projection.roomId(), + ChatType.DIRECT, + projection.nonAdminUserName(), + projection.nonAdminImageUrl(), + projection.lastMessage(), + projection.lastSentAt(), + projection.createdAt(), + projection.unreadCount().intValue(), + false + )) .toList(); + } - Map userMap = allUserIds.isEmpty() - ? Map.of() - : userRepository.findAllByIdIn(allUserIds).stream() - .collect(Collectors.toMap(User::getId, user -> user)); + private List getClubChatRooms(Integer userId) { + List memberships = clubMemberRepository.findAllByUserId(userId); + if (memberships.isEmpty()) { + return List.of(); + } - for (ChatRoom chatRoom : adminUserRooms) { - List memberInfos = roomMemberInfoMap.getOrDefault(chatRoom.getId(), List.of()); - User nonAdminUser = findNonAdminUserFromMemberInfo(memberInfos, userMap); - if (nonAdminUser == null) { - continue; - } - if (!repliedRoomIds.contains(chatRoom.getId())) { - continue; - } + List clubIds = memberships.stream() + .map(cm -> cm.getClub().getId()) + .toList(); - roomSummaries.add(new ChatRoomSummaryResponse( - chatRoom.getId(), - ChatType.DIRECT, - nonAdminUser.getName(), - nonAdminUser.getImageUrl(), - chatRoom.getLastMessageContent(), - chatRoom.getLastMessageSentAt(), - adminUnreadCountMap.getOrDefault(chatRoom.getId(), 0), - false - )); + List rooms = chatRoomRepository.findByClubIds(new ArrayList<>(clubIds)) + .stream() + .filter(room -> room.getClub() != null) + .toList(); + + List roomIds = rooms.stream().map(ChatRoom::getId).toList(); + Map lastMessageMap = getLastMessageMap(roomIds); + Map unreadCountMap = getRoomUnreadCountMap(roomIds, userId); + + return rooms.stream() + .map(room -> { + ChatMessage lastMessage = lastMessageMap.get(room.getId()); + return new ChatRoomSummaryResponse( + room.getId(), + ChatType.CLUB_GROUP, + room.getClub().getName(), + room.getClub().getImageUrl(), + lastMessage != null ? lastMessage.getContent() : null, + lastMessage != null ? lastMessage.getCreatedAt() : null, + room.getCreatedAt(), + unreadCountMap.getOrDefault(room.getId(), 0), + false + ); + }) + .toList(); + } + + private List getGroupChatRooms(Integer userId) { + List rooms = chatRoomRepository.findGroupRoomsByMemberUserId(userId); + if (rooms.isEmpty()) { + return List.of(); } - roomSummaries.sort(Comparator - .comparing( - ChatRoomSummaryResponse::lastSentAt, - Comparator.nullsLast(Comparator.reverseOrder()) - ) - .thenComparing(ChatRoomSummaryResponse::roomId)); + List roomIds = rooms.stream().map(ChatRoom::getId).toList(); + Map lastMessageMap = getLastMessageMap(roomIds); + Map unreadCountMap = getRoomUnreadCountMap(roomIds, userId); - return roomSummaries; + return rooms.stream() + .map(room -> { + ChatMessage lastMessage = lastMessageMap.get(room.getId()); + return new ChatRoomSummaryResponse( + room.getId(), + ChatType.GROUP, + DEFAULT_GROUP_ROOM_NAME, + null, + lastMessage != null ? lastMessage.getContent() : null, + lastMessage != null ? lastMessage.getCreatedAt() : null, + room.getCreatedAt(), + unreadCountMap.getOrDefault(room.getId(), 0), + false + ); + }) + .toList(); } private ChatMessagePageResponse getDirectChatRoomMessages( Integer userId, Integer roomId, Integer page, - Integer limit + Integer limit, + LocalDateTime readAt ) { ChatRoom chatRoom = getDirectRoom(roomId); User user = userRepository.getById(userId); ChatRoomMember member = getOrCreateDirectRoomMember(chatRoom, user); - - LocalDateTime readAt = LocalDateTime.now(); - chatPresenceService.recordPresence(roomId, userId); + LocalDateTime visibleMessageFrom = prepareDirectRoomAccess(member, chatRoom); boolean isAdminViewingSystemRoom = user.getRole() == UserRole.ADMIN && isSystemAdminRoom(chatRoom); PageRequest pageable = PageRequest.of(page - 1, limit); - Page messages = chatMessageRepository.findByChatRoomId(roomId, pageable); + Page messages = chatMessageRepository.findByChatRoomId(roomId, visibleMessageFrom, pageable); List members = chatRoomMemberRepository.findByChatRoomId(roomId); - if (isAdminViewingSystemRoom) { - updateAllAdminMembersLastReadAt(members, readAt); - } else { - member.updateLastReadAt(readAt); - } - List sortedReadBaselines = isAdminViewingSystemRoom ? toAdminChatReadBaselines(members) : toSortedReadBaselines(members); @@ -398,31 +669,26 @@ private ChatMessageDetailResponse sendDirectMessage( ) { ChatRoom chatRoom = getDirectRoom(roomId); User sender = userRepository.getById(userId); - getOrCreateDirectRoomMember(chatRoom, sender); - - List memberResults = chatRoomMemberRepository.findRoomMemberIdsByChatRoomIds(List.of(roomId)); - List memberInfos = memberResults.stream() - .map(row -> new MemberInfo((Integer)row[1], (LocalDateTime)row[2])) - .toList(); - - List memberUserIds = memberInfos.stream().map(MemberInfo::userId).toList(); - Map userMap = userRepository.findAllByIdIn(memberUserIds).stream() - .collect(Collectors.toMap(User::getId, u -> u)); - - User receiver = resolveMessageReceiverFromMemberInfo(sender, memberInfos, userMap); + ChatRoomMember senderMember = getAccessibleDirectRoomMember(chatRoom, sender); + boolean senderHadLeft = senderMember.hasLeft(); + List members = chatRoomMemberRepository.findByChatRoomId(roomId); + User receiver = resolveDirectChatPartner(members, userId); ChatMessage chatMessage = chatMessageRepository.save( ChatMessage.of(chatRoom, sender, request.content()) ); + if (senderHadLeft) { + senderMember.restoreDirectRoom(); + } chatRoom.updateLastMessage(chatMessage.getContent(), chatMessage.getCreatedAt()); updateMemberLastReadAt(roomId, userId, chatMessage.getCreatedAt()); - List members = chatRoomMemberRepository.findByChatRoomId(roomId); List sortedReadBaselines = toSortedReadBaselines(members); notificationService.sendChatNotification(receiver.getId(), roomId, sender.getName(), request.content()); - boolean isSystemAdminRoom = memberInfos.stream() - .anyMatch(info -> info.userId().equals(SYSTEM_ADMIN_ID)); + boolean isSystemAdminRoom = members.stream() + .map(ChatRoomMember::getUserId) + .anyMatch(memberUserId -> memberUserId.equals(SYSTEM_ADMIN_ID)); publishAdminChatEventIfNeeded(isSystemAdminRoom, sender, request.content()); return new ChatMessageDetailResponse( @@ -437,39 +703,6 @@ private ChatMessageDetailResponse sendDirectMessage( ); } - private List getClubChatRooms(Integer userId) { - List memberships = clubMemberRepository.findAllByUserId(userId); - if (memberships.isEmpty()) { - return List.of(); - } - - Map membershipByClubId = memberships.stream() - .collect(Collectors.toMap(cm -> cm.getClub().getId(), cm -> cm, (a, b) -> a)); - - List rooms = resolveOrCreateClubRooms(memberships); - ensureClubRoomMembers(rooms, membershipByClubId, userId); - - List roomIds = rooms.stream().map(ChatRoom::getId).toList(); - Map lastMessageMap = getLastMessageMap(roomIds); - Map unreadCountMap = getRoomUnreadCountMap(roomIds, userId); - - return rooms.stream() - .map(room -> { - ChatMessage lastMessage = lastMessageMap.get(room.getId()); - return new ChatRoomSummaryResponse( - room.getId(), - ChatType.GROUP, - room.getClub().getName(), - room.getClub().getImageUrl(), - lastMessage != null ? lastMessage.getContent() : null, - lastMessage != null ? lastMessage.getCreatedAt() : null, - unreadCountMap.getOrDefault(room.getId(), 0), - false - ); - }) - .toList(); - } - private ChatMessagePageResponse getClubMessagesByRoomId( Integer roomId, Integer userId, @@ -477,15 +710,10 @@ private ChatMessagePageResponse getClubMessagesByRoomId( Integer limit ) { ChatRoom room = getClubRoom(roomId); - ClubMember member = clubMemberRepository.getByClubIdAndUserId(room.getClub().getId(), userId); - ensureRoomMember(room, member.getUser(), member.getCreatedAt()); - - chatPresenceService.recordPresence(roomId, userId); - updateLastReadAt(roomId, userId, LocalDateTime.now()); PageRequest pageable = PageRequest.of(page - 1, limit); - long totalCount = chatMessageRepository.countByChatRoomId(roomId); - Page messagePage = chatMessageRepository.findByChatRoomId(roomId, pageable); + long totalCount = chatMessageRepository.countByChatRoomId(roomId, null); + Page messagePage = chatMessageRepository.findByChatRoomId(roomId, null, pageable); List messages = messagePage.getContent(); List members = chatRoomMemberRepository.findByChatRoomId(roomId); List sortedReadBaselines = toSortedReadBaselines(members); @@ -522,20 +750,104 @@ private ChatMessageDetailResponse sendClubMessageByRoomId(Integer roomId, Intege ClubMember member = clubMemberRepository.getByClubIdAndUserId(room.getClub().getId(), userId); User sender = member.getUser(); - ensureRoomMember(room, sender, member.getCreatedAt()); + ensureRoomMember(room, sender, member.getCreatedAt()); + + ChatMessage message = chatMessageRepository.save(ChatMessage.of(room, sender, content)); + room.updateLastMessage(message.getContent(), message.getCreatedAt()); + updateClubMessageLastReadAt(roomId, userId, message.getCreatedAt()); + + List members = chatRoomMemberRepository.findByChatRoomId(roomId); + List recipientUserIds = members.stream().map(ChatRoomMember::getUserId).toList(); + List sortedReadBaselines = toSortedReadBaselines(members); + + notificationService.sendGroupChatNotification( + roomId, + sender.getId(), + room.getClub().getName(), + sender.getName(), + message.getContent(), + recipientUserIds + ); + + return new ChatMessageDetailResponse( + message.getId(), + sender.getId(), + sender.getName(), + message.getContent(), + message.getCreatedAt(), + null, + countUnreadSince(message.getCreatedAt(), sortedReadBaselines), + true + ); + } + + private ChatMessagePageResponse getGroupMessagesByRoomId( + Integer roomId, + Integer userId, + Integer page, + Integer limit + ) { + chatRoomRepository.getById(roomId); + + PageRequest pageable = PageRequest.of(page - 1, limit); + long totalCount = chatMessageRepository.countByChatRoomId(roomId, null); + Page messagePage = chatMessageRepository.findByChatRoomId(roomId, null, pageable); + List messages = messagePage.getContent(); + List members = chatRoomMemberRepository.findByChatRoomId(roomId); + List sortedReadBaselines = toSortedReadBaselines(members); + + List responseMessages = messages.stream() + .map(message -> { + int unreadCount = countUnreadSince(message.getCreatedAt(), sortedReadBaselines); + return new ChatMessageDetailResponse( + message.getId(), + message.getSender().getId(), + message.getSender().getName(), + message.getContent(), + message.getCreatedAt(), + null, + unreadCount, + message.isSentBy(userId) + ); + }) + .toList(); + + int totalPage = limit > 0 ? (int)Math.ceil((double)totalCount / (double)limit) : 0; + return new ChatMessagePageResponse( + totalCount, + responseMessages.size(), + totalPage, + page, + null, + responseMessages + ); + } + + private ChatMessageDetailResponse sendGroupMessageByRoomId(Integer roomId, Integer userId, String content) { + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + User sender = userRepository.getById(userId); + + ChatRoomMember senderMember = getRoomMember(roomId, userId); + if (senderMember.hasLeft()) { + throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); + } ChatMessage message = chatMessageRepository.save(ChatMessage.of(room, sender, content)); room.updateLastMessage(message.getContent(), message.getCreatedAt()); updateLastReadAt(roomId, userId, message.getCreatedAt()); List members = chatRoomMemberRepository.findByChatRoomId(roomId); - List recipientUserIds = members.stream().map(ChatRoomMember::getUserId).toList(); + List recipientUserIds = members.stream() + .map(ChatRoomMember::getUserId) + .filter(id -> !id.equals(userId)) + .toList(); List sortedReadBaselines = toSortedReadBaselines(members); notificationService.sendGroupChatNotification( roomId, sender.getId(), - room.getClub().getName(), + DEFAULT_GROUP_ROOM_NAME, sender.getName(), message.getContent(), recipientUserIds @@ -553,6 +865,125 @@ private ChatMessageDetailResponse sendClubMessageByRoomId(Integer roomId, Intege ); } + private AccessibleChatRooms getAccessibleChatRooms(Integer userId) { + chatRoomMembershipService.ensureClubRoomMemberships(userId); + + List directRooms = getDirectChatRooms(userId); + List clubRooms = getClubChatRooms(userId); + + List roomIds = new ArrayList<>(); + roomIds.addAll(directRooms.stream().map(ChatRoomSummaryResponse::roomId).toList()); + roomIds.addAll(clubRooms.stream().map(ChatRoomSummaryResponse::roomId).toList()); + + Map muteMap = getMuteMap(roomIds, userId); + Map customRoomNameMap = getCustomRoomNameMap(roomIds, userId); + Map defaultRoomNameMap = getDefaultRoomNameMap(directRooms, clubRooms); + List rooms = new ArrayList<>(); + directRooms.forEach(room -> rooms.add(applyRoomSettings(room, muteMap, customRoomNameMap))); + clubRooms.forEach(room -> rooms.add(applyRoomSettings(room, muteMap, customRoomNameMap))); + + rooms.sort( + Comparator.comparing(ChatRoomSummaryResponse::lastSentAt, + Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(ChatRoomSummaryResponse::roomId) + ); + return new AccessibleChatRooms(rooms, defaultRoomNameMap); + } + + private ChatRoomSummaryResponse applyRoomSettings( + ChatRoomSummaryResponse room, + Map muteMap, + Map customRoomNameMap + ) { + return new ChatRoomSummaryResponse( + room.roomId(), + room.chatType(), + resolveRoomName(room.roomId(), room.roomName(), customRoomNameMap), + room.roomImageUrl(), + room.lastMessage(), + room.lastSentAt(), + room.createdAt(), + room.unreadCount(), + muteMap.getOrDefault(room.roomId(), false) + ); + } + + private ChatRoomMatchesResponse searchRoomsByName( + AccessibleChatRooms accessibleChatRooms, + String keyword, + Integer page, + Integer limit + ) { + List matchedRooms = accessibleChatRooms.rooms().stream() + .filter(room -> matchesRoomName(room, keyword, accessibleChatRooms.defaultRoomNameMap())) + .toList(); + + return ChatRoomMatchesResponse.from(toPage(matchedRooms, page, limit)); + } + + private ChatMessageMatchesResponse searchByMessageContent( + Integer userId, + List accessibleRooms, + String keyword, + Integer page, + Integer limit + ) { + if (accessibleRooms.isEmpty() || keyword.isBlank()) { + return ChatMessageMatchesResponse.from(emptyPage(page, limit)); + } + + Map roomMap = accessibleRooms.stream() + .collect(Collectors.toMap(ChatRoomSummaryResponse::roomId, room -> room)); + List roomIds = accessibleRooms.stream() + .map(ChatRoomSummaryResponse::roomId) + .toList(); + List directRoomIds = accessibleRooms.stream() + .filter(room -> room.chatType() == ChatType.DIRECT) + .map(ChatRoomSummaryResponse::roomId) + .toList(); + Map visibleMessageFromMap = getVisibleMessageFromMap(directRoomIds, userId); + + List matchedMessages = chatMessageRepository + .searchLatestMatchingMessagesByChatRoomIds(roomIds, keyword) + .stream() + .filter(message -> isVisibleMessageMatch(message, roomMap, visibleMessageFromMap)) + .map(message -> ChatMessageMatchResult.from(roomMap.get(message.getChatRoom().getId()), message)) + .toList(); + + return ChatMessageMatchesResponse.from(toPage(matchedMessages, page, limit)); + } + + private String normalizeKeyword(String keyword) { + if (keyword == null) { + return ""; + } + return keyword.trim(); + } + + private boolean containsKeyword(String text, String keyword) { + if (text == null || keyword.isBlank()) { + return false; + } + + return text.toLowerCase(Locale.ROOT).contains(keyword.toLowerCase(Locale.ROOT)); + } + + private Page toPage(List items, Integer page, Integer limit) { + PageRequest pageable = PageRequest.of(page - 1, limit); + long offset = (long)(page - 1) * limit; + if (offset >= items.size()) { + return new PageImpl<>(List.of(), pageable, items.size()); + } + + int fromIndex = (int)offset; + int toIndex = Math.min(fromIndex + limit, items.size()); + return new PageImpl<>(items.subList(fromIndex, toIndex), pageable, items.size()); + } + + private Page emptyPage(Integer page, Integer limit) { + return new PageImpl<>(List.of(), PageRequest.of(page - 1, limit), 0); + } + private Map getMuteMap(List roomIds, Integer userId) { if (roomIds.isEmpty()) { return Map.of(); @@ -572,81 +1003,87 @@ private Map getMuteMap(List roomIds, Integer userId) return muteMap; } - private ChatRoom getDirectRoom(Integer roomId) { - ChatRoom chatRoom = chatRoomRepository.findById(roomId) - .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); - - if (!chatRoom.isDirectRoom()) { - throw CustomException.of(NOT_FOUND_CHAT_ROOM); + private Map getVisibleMessageFromMap(List roomIds, Integer userId) { + if (roomIds.isEmpty()) { + return Map.of(); } - return chatRoom; + Map visibleMessageFromMap = new HashMap<>(); + for (ChatRoomMember roomMember : chatRoomMemberRepository.findByChatRoomIdsAndUserId(roomIds, userId)) { + visibleMessageFromMap.put(roomMember.getChatRoomId(), roomMember.getVisibleMessageFrom()); + } + return visibleMessageFromMap; } - private ChatRoom getClubRoom(Integer roomId) { - ChatRoom room = chatRoomRepository.findById(roomId) - .orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_CHAT_ROOM)); - if (!room.isGroupRoom() || room.getClub() == null) { - throw CustomException.of(ApiResponseCode.NOT_FOUND_GROUP_CHAT_ROOM); - } - return room; + private Map getDefaultRoomNameMap( + List directRooms, + List clubRooms + ) { + Map defaultRoomNameMap = new HashMap<>(); + directRooms.forEach(room -> defaultRoomNameMap.put(room.roomId(), room.roomName())); + clubRooms.forEach(room -> defaultRoomNameMap.put(room.roomId(), room.roomName())); + return defaultRoomNameMap; } - private List resolveOrCreateClubRooms(List memberships) { - Map clubById = memberships.stream() - .map(ClubMember::getClub) - .collect(Collectors.toMap(Club::getId, club -> club, (a, b) -> a)); + private Map getCustomRoomNameMap(List roomIds, Integer userId) { + if (roomIds.isEmpty()) { + return Map.of(); + } - Map roomByClubId = chatRoomRepository.findByClubIds(new ArrayList<>(clubById.keySet())) - .stream() - .filter(room -> room.getClub() != null) - .collect(Collectors.toMap(room -> room.getClub().getId(), room -> room, (a, b) -> a)); + return chatRoomMemberRepository.findByChatRoomIdsAndUserId(roomIds, userId).stream() + .filter(member -> StringUtils.hasText(member.getCustomRoomName())) + .collect(Collectors.toMap(ChatRoomMember::getChatRoomId, ChatRoomMember::getCustomRoomName)); + } - for (Map.Entry clubEntry : clubById.entrySet()) { - if (roomByClubId.containsKey(clubEntry.getKey())) { - continue; - } + private String resolveRoomName(Integer roomId, String + defaultRoomName, Map customRoomNameMap) { + return customRoomNameMap.getOrDefault(roomId, defaultRoomName); + } - ChatRoom createdRoom = chatRoomRepository.save(ChatRoom.groupOf(clubEntry.getValue())); - roomByClubId.put(clubEntry.getKey(), createdRoom); + private boolean matchesRoomName( + ChatRoomSummaryResponse room, + String keyword, + Map defaultRoomNameMap + ) { + if (containsKeyword(room.roomName(), keyword)) { + return true; } - return memberships.stream() - .map(membership -> roomByClubId.get(membership.getClub().getId())) - .toList(); + return containsKeyword(defaultRoomNameMap.get(room.roomId()), keyword); } - private void ensureClubRoomMembers( - List rooms, - Map membershipByClubId, - Integer userId + private boolean isVisibleMessageMatch( + ChatMessage message, + Map roomMap, + Map visibleMessageFromMap ) { - if (rooms.isEmpty()) { - return; + ChatRoomSummaryResponse room = roomMap.get(message.getChatRoom().getId()); + if (room == null || room.chatType() != ChatType.DIRECT) { + return true; } - Map memberByRoomId = chatRoomMemberRepository - .findByChatRoomIdsAndUserId(extractChatRoomIds(rooms), userId) - .stream() - .collect(Collectors.toMap(ChatRoomMember::getChatRoomId, member -> member, (a, b) -> a)); + LocalDateTime visibleMessageFrom = visibleMessageFromMap.get(room.roomId()); + return visibleMessageFrom == null || message.getCreatedAt().isAfter(visibleMessageFrom); + } - for (ChatRoom room : rooms) { - ClubMember member = membershipByClubId.get(room.getClub().getId()); - if (member == null) { - continue; - } + private ChatRoom getDirectRoom(Integer roomId) { + ChatRoom chatRoom = chatRoomRepository.findById(roomId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); - ChatRoomMember existingMember = memberByRoomId.get(room.getId()); - if (existingMember != null) { - LocalDateTime lastReadAt = existingMember.getLastReadAt(); - if (lastReadAt == null || lastReadAt.isBefore(member.getCreatedAt())) { - existingMember.updateLastReadAt(member.getCreatedAt()); - } - continue; - } + if (!chatRoom.isDirectRoom()) { + throw CustomException.of(NOT_FOUND_CHAT_ROOM); + } + + return chatRoom; + } - chatRoomMemberRepository.save(ChatRoomMember.of(room, member.getUser(), member.getCreatedAt())); + private ChatRoom getClubRoom(Integer roomId) { + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_CHAT_ROOM)); + if (!room.isClubGroupRoom()) { + throw CustomException.of(ApiResponseCode.NOT_FOUND_GROUP_CHAT_ROOM); } + return room; } private List extractChatRoomIds(List chatRooms) { @@ -672,23 +1109,6 @@ private Map getUnreadCountMap(List chatRoomIds, Integ )); } - private Map getAdminUnreadCountMap(List chatRoomIds) { - if (chatRoomIds.isEmpty()) { - return Map.of(); - } - - List unreadMessageCounts = chatMessageRepository.countUnreadMessagesForAdmin( - chatRoomIds, - UserRole.ADMIN - ); - - return unreadMessageCounts.stream() - .collect(Collectors.toMap( - UnreadMessageCount::chatRoomId, - unreadMessageCount -> unreadMessageCount.unreadCount().intValue() - )); - } - private Integer getMaskedAdminId(User user, ChatRoom chatRoom) { if (user.getRole() == UserRole.ADMIN) { return null; @@ -722,6 +1142,27 @@ private ChatRoomMember getRoomMember(Integer roomId, Integer userId) { .orElseThrow(() -> CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS)); } + private ChatRoomMember getAccessibleRoomMember(ChatRoom room, Integer userId) { + if (room.isClubGroupRoom()) { + ClubMember member = clubMemberRepository.getByClubIdAndUserId(room.getClub().getId(), userId); + ensureRoomMember(room, member.getUser(), member.getCreatedAt()); + return getRoomMember(room.getId(), userId); + } + + if (room.isDirectRoom()) { + User user = userRepository.getById(userId); + return getAccessibleDirectRoomMember(room, user); + } + + ChatRoomMember member = getRoomMember(room.getId(), userId); + + if (member.hasLeft()) { + throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); + } + + return member; + } + private void ensureRoomMember(ChatRoom room, User user, LocalDateTime joinedAt) { chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId()) .ifPresentOrElse(member -> { @@ -732,6 +1173,29 @@ private void ensureRoomMember(ChatRoom room, User user, LocalDateTime joinedAt) }, () -> chatRoomMemberRepository.save(ChatRoomMember.of(room, user, joinedAt))); } + private void ensureDirectRoomRequester(ChatRoom room, User user, LocalDateTime joinedAt) { + chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId()) + .ifPresentOrElse(member -> { + if (member.hasLeft()) { + member.reopenDirectRoom(LocalDateTime.now()); + return; + } + + LocalDateTime lastReadAt = member.getLastReadAt(); + if (lastReadAt == null || lastReadAt.isBefore(joinedAt)) { + member.updateLastReadAt(joinedAt); + } + }, () -> chatRoomMemberRepository.save(ChatRoomMember.of(room, user, joinedAt))); + } + + private String normalizeCustomRoomName(String roomName) { + if (!StringUtils.hasText(roomName)) { + return null; + } + + return roomName.trim(); + } + private void updateMemberLastReadAt(Integer roomId, Integer userId, LocalDateTime lastReadAt) { int updated = chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, userId, lastReadAt); if (updated == 0) { @@ -743,11 +1207,16 @@ private void updateMemberLastReadAt(Integer roomId, Integer userId, LocalDateTim } private void updateLastReadAt(Integer roomId, Integer userId, LocalDateTime lastReadAt) { + chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, userId, lastReadAt); + } + + private void updateClubMessageLastReadAt(Integer roomId, Integer userId, LocalDateTime lastReadAt) { int updated = chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, userId, lastReadAt); if (updated == 0) { - ChatRoom room = getClubRoom(roomId); - ClubMember member = clubMemberRepository.getByClubIdAndUserId(room.getClub().getId(), userId); - ensureRoomMember(room, member.getUser(), member.getCreatedAt()); + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + User user = userRepository.getById(userId); + ensureRoomMember(room, user, lastReadAt); } } @@ -784,14 +1253,6 @@ private List toAdminChatReadBaselines(List member return baselines; } - private void updateAllAdminMembersLastReadAt(List members, LocalDateTime readAt) { - for (ChatRoomMember member : members) { - if (member.getUser().getRole() == UserRole.ADMIN) { - member.updateLastReadAt(readAt); - } - } - } - private int countUnreadSince(LocalDateTime messageCreatedAt, List sortedReadBaselines) { int left = 0; int right = sortedReadBaselines.size(); @@ -854,6 +1315,34 @@ private ChatRoomMember getOrCreateDirectRoomMember(ChatRoom chatRoom, User user) }); } + private ChatRoomMember getAccessibleDirectRoomMember(ChatRoom chatRoom, User user) { + ChatRoomMember member = getOrCreateDirectRoomMember(chatRoom, user); + restoreDirectRoomIfVisible(member, chatRoom); + return member; + } + + private LocalDateTime prepareDirectRoomAccess(ChatRoomMember member, ChatRoom chatRoom) { + LocalDateTime visibleMessageFrom = member.getVisibleMessageFrom(); + restoreDirectRoomIfVisible(member, chatRoom); + return visibleMessageFrom; + } + + /** + * direct 채팅방에서 나간 사용자가 다시 볼 수 있는 상태인지 확인하고, + * 새 메시지가 이미 존재하면 나간 상태를 해제한다. + */ + private void restoreDirectRoomIfVisible(ChatRoomMember member, ChatRoom chatRoom) { + if (!member.hasLeft()) { + return; + } + + if (!member.hasVisibleMessages(chatRoom)) { + throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); + } + + member.restoreDirectRoom(); + } + private boolean isSystemAdminRoom(ChatRoom chatRoom) { List memberIds = chatRoomMemberRepository.findRoomMemberIdsByChatRoomIds( List.of(chatRoom.getId()) @@ -883,6 +1372,31 @@ private Integer resolveDirectSenderId(ChatMessage message, Integer maskedAdminId return message.getSender().getId(); } + private ChatRoomMember findRoomMember(List members, Integer userId) { + return members.stream() + .filter(member -> member.getUserId().equals(userId)) + .findFirst() + .orElse(null); + } + + private boolean isDirectRoomVisibleToUser(ChatRoom room, ChatRoomMember member) { + return !member.hasLeft() || member.hasVisibleMessages(room); + } + + private String getVisibleLastMessageContent(ChatRoom room, ChatRoomMember member) { + if (!member.hasVisibleMessages(room)) { + return null; + } + return room.getLastMessageContent(); + } + + private LocalDateTime getVisibleLastMessageSentAt(ChatRoom room, ChatRoomMember member) { + if (!member.hasVisibleMessages(room)) { + return null; + } + return room.getLastMessageSentAt(); + } + private Map> getRoomMembersMap(List rooms) { if (rooms.isEmpty()) { return Map.of(); @@ -893,9 +1407,6 @@ private Map> getRoomMembersMap(List room .collect(Collectors.groupingBy(ChatRoomMember::getChatRoomId)); } - private record MemberInfo(Integer userId, LocalDateTime createdAt) { - } - private Map> getRoomMemberInfoMap(List rooms) { if (rooms.isEmpty()) { return Map.of(); @@ -923,6 +1434,22 @@ private User findDirectPartner(List members, Integer userId) { .orElse(null); } + private User resolveDirectChatPartner(List members, Integer userId) { + boolean hasSystemAdmin = members.stream() + .map(ChatRoomMember::getUserId) + .anyMatch(memberUserId -> memberUserId.equals(SYSTEM_ADMIN_ID)); + + if (hasSystemAdmin) { + return members.stream() + .map(ChatRoomMember::getUser) + .filter(memberUser -> memberUser.getId().equals(SYSTEM_ADMIN_ID)) + .findFirst() + .orElse(null); + } + + return findDirectPartner(members, userId); + } + private User findDirectPartnerFromMemberInfo( List memberInfos, Integer userId, @@ -950,14 +1477,6 @@ private User resolveDirectChatPartner( return findDirectPartnerFromMemberInfo(memberInfos, userId, userMap); } - private User findNonAdminMember(List members) { - return members.stream() - .map(ChatRoomMember::getUser) - .filter(memberUser -> memberUser.getRole() != UserRole.ADMIN) - .findFirst() - .orElse(null); - } - private User findNonAdminUserFromMemberInfo(List memberInfos, Map userMap) { return memberInfos.stream() .sorted(Comparator.comparing(MemberInfo::createdAt)) @@ -968,21 +1487,6 @@ private User findNonAdminUserFromMemberInfo(List memberInfos, Map members) { - if (sender.getRole() == UserRole.ADMIN) { - User nonAdminUser = findNonAdminMember(members); - if (nonAdminUser != null) { - return nonAdminUser; - } - } - - User partner = findDirectPartner(members, sender.getId()); - if (partner == null) { - throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); - } - return partner; - } - private User resolveMessageReceiverFromMemberInfo( User sender, List memberInfos, @@ -1002,4 +1506,44 @@ private User resolveMessageReceiverFromMemberInfo( return partner; } + private void validateGroupRoomForKick(ChatRoom room) { + if (!room.isGroupRoom() || room.isClubGroupRoom()) { + throw CustomException.of(CANNOT_KICK_IN_NON_GROUP_ROOM); + } + } + + private void validateNotSelfKick(Integer requesterId, Integer targetUserId) { + if (requesterId.equals(targetUserId)) { + throw CustomException.of(CANNOT_KICK_SELF); + } + } + + private void validateKickAuthority(ChatRoomMember requester) { + if (!requester.isOwner()) { + throw CustomException.of(FORBIDDEN_CHAT_ROOM_KICK); + } + } + + private void validateNotOwnerTarget(ChatRoomMember target) { + if (target.isOwner()) { + throw CustomException.of(CANNOT_KICK_ROOM_OWNER); + } + } + + private void recordPresenceSafely(Integer roomId, Integer userId) { + try { + chatPresenceService.recordPresence(roomId, userId); + } catch (Exception e) { + log.warn("Redis presence record failed, continuing: roomId={}, userId={}", roomId, userId, e); + } + } + + private record AccessibleChatRooms( + List rooms, + Map defaultRoomNameMap + ) { + } + + private record MemberInfo(Integer userId, LocalDateTime createdAt) { + } } diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java new file mode 100644 index 000000000..47821587e --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java @@ -0,0 +1,51 @@ +package gg.agit.konect.domain.club.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; +import gg.agit.konect.domain.club.dto.ClubSheetIdUpdateRequest; +import gg.agit.konect.domain.club.enums.ClubSheetSortKey; +import gg.agit.konect.global.auth.annotation.UserId; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@Tag(name = "(Normal) Club - Sheet") +@RequestMapping("/clubs") +public interface ClubMemberSheetApi { + + @Operation( + summary = "구글 스프레드시트 ID 등록 / 수정", + description = "동아리에서 사용 중인 구글 스프레드시트 ID를 등록하거나 수정합니다. " + + "등록 시 AI(Claude Haiku)가 시트 상단 10행을 자동으로 분석하여 " + + "이름·학번·연락처 등 컬럼 위치를 파악하고, 이후 동기화 시 해당 컬럼에만 값을 채웁니다. " + + "시트 양식이 변경된 경우 이 API를 다시 호출하면 AI가 재분석합니다." + ) + @PutMapping("/{clubId}/sheet") + ResponseEntity updateSheetId( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody ClubSheetIdUpdateRequest request, + @UserId Integer requesterId + ); + + @Operation( + summary = "동아리 인명부 스프레드시트 동기화", + description = "등록된 구글 스프레드시트에 동아리 회원과 사전 회원 인명부를 동기화합니다. " + + "sortKey로 정렬 기준(NAME, STUDENT_ID, POSITION, JOINED_AT)을 지정할 수 있으며, " + + "ascending으로 오름차순/내림차순을 설정합니다. " + + "가입 승인·탈퇴 시에도 자동으로 동기화됩니다." + ) + @PostMapping("/{clubId}/members/sheet-sync") + ResponseEntity syncMembersToSheet( + @PathVariable(name = "clubId") Integer clubId, + @RequestParam(name = "sortKey", defaultValue = "POSITION") ClubSheetSortKey sortKey, + @RequestParam(name = "ascending", defaultValue = "true") boolean ascending, + @UserId Integer requesterId + ); +} diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetController.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetController.java new file mode 100644 index 000000000..ad374cc8b --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetController.java @@ -0,0 +1,46 @@ +package gg.agit.konect.domain.club.controller; + +import org.springframework.http.ResponseEntity; +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 gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; +import gg.agit.konect.domain.club.dto.ClubSheetIdUpdateRequest; +import gg.agit.konect.domain.club.enums.ClubSheetSortKey; +import gg.agit.konect.domain.club.service.ClubMemberSheetService; +import gg.agit.konect.global.auth.annotation.UserId; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/clubs") +public class ClubMemberSheetController implements ClubMemberSheetApi { + + private final ClubMemberSheetService clubMemberSheetService; + + @Override + public ResponseEntity updateSheetId( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody ClubSheetIdUpdateRequest request, + @UserId Integer requesterId + ) { + clubMemberSheetService.updateSheetId(clubId, requesterId, request); + return ResponseEntity.ok().build(); + } + + @Override + public ResponseEntity syncMembersToSheet( + @PathVariable(name = "clubId") Integer clubId, + @RequestParam(name = "sortKey", defaultValue = "POSITION") ClubSheetSortKey sortKey, + @RequestParam(name = "ascending", defaultValue = "true") boolean ascending, + @UserId Integer requesterId + ) { + ClubMemberSheetSyncResponse response = + clubMemberSheetService.syncMembersToSheet(clubId, requesterId, sortKey, ascending); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationApi.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationApi.java new file mode 100644 index 000000000..9c4bd1d5c --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationApi.java @@ -0,0 +1,61 @@ +package gg.agit.konect.domain.club.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; + +import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; +import gg.agit.konect.domain.club.dto.SheetImportRequest; +import gg.agit.konect.domain.club.dto.SheetImportResponse; +import gg.agit.konect.domain.club.dto.SheetMigrateRequest; +import gg.agit.konect.global.auth.annotation.UserId; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@Tag(name = "(Normal) Club - Sheet") +@RequestMapping("/clubs") +public interface ClubSheetMigrationApi { + + @Operation( + summary = "기존 스프레드시트 → 팀 양식으로 이관", + description = "동아리가 기존에 사용하던 스프레드시트 URL을 제출하면, " + + "AI가 데이터를 분석하여 KONECT 팀이 마련한 표준 양식 파일로 복사합니다. " + + "새 파일은 기존 URL과 동일한 Google Drive 폴더에 생성됩니다. " + + "이후 동기화는 새로 생성된 파일 기준으로 진행됩니다." + ) + @PostMapping("/{clubId}/sheet/migrate") + ResponseEntity migrateSheet( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody SheetMigrateRequest request, + @UserId Integer requesterId + ); + + @Operation( + summary = "기존 스프레드시트에서 사전 회원 가져오기", + description = "동아리가 기존에 관리하던 스프레드시트의 인명부를 읽어 " + + "DB에 사전 회원(ClubPreMember)으로 등록합니다. " + + "AI가 헤더를 자동 분석하며, 이미 등록된 회원(이름+학번 중복)은 건너뜁니다." + ) + @PostMapping("/{clubId}/sheet/import") + ResponseEntity importPreMembers( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody SheetImportRequest request, + @UserId Integer requesterId + ); + + @Operation( + summary = "스프레드시트 분석 후 사전 회원 가져오기", + description = "구글 스프레드시트 URL을 받아 먼저 시트를 분석 및 등록한 뒤, " + + "같은 스프레드시트에서 사전 회원을 읽어 DB에 등록합니다. " + + "기존 PUT /clubs/{clubId}/sheet 와 POST /clubs/{clubId}/sheet/import 를 순서대로 실행한 결과와 동일합니다." + ) + @PostMapping("/{clubId}/sheet/import/integrated") + ResponseEntity analyzeAndImportPreMembers( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody SheetImportRequest request, + @UserId Integer requesterId + ); +} diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java new file mode 100644 index 000000000..002d130bd --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java @@ -0,0 +1,64 @@ +package gg.agit.konect.domain.club.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; +import gg.agit.konect.domain.club.dto.SheetImportRequest; +import gg.agit.konect.domain.club.dto.SheetImportResponse; +import gg.agit.konect.domain.club.dto.SheetMigrateRequest; +import gg.agit.konect.domain.club.service.ClubSheetIntegratedService; +import gg.agit.konect.domain.club.service.SheetImportService; +import gg.agit.konect.domain.club.service.SheetMigrationService; +import gg.agit.konect.global.auth.annotation.UserId; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/clubs") +public class ClubSheetMigrationController implements ClubSheetMigrationApi { + + private final SheetMigrationService sheetMigrationService; + private final SheetImportService sheetImportService; + private final ClubSheetIntegratedService clubSheetIntegratedService; + + @Override + public ResponseEntity migrateSheet( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody SheetMigrateRequest request, + @UserId Integer requesterId + ) { + String newSpreadsheetId = sheetMigrationService.migrateToTemplate( + clubId, requesterId, request.sourceSpreadsheetUrl() + ); + return ResponseEntity.ok(ClubMemberSheetSyncResponse.of(0, newSpreadsheetId)); + } + + @Override + public ResponseEntity importPreMembers( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody SheetImportRequest request, + @UserId Integer requesterId + ) { + SheetImportResponse response = sheetImportService.importPreMembersFromSheet( + clubId, requesterId, request.spreadsheetUrl() + ); + return ResponseEntity.ok(response); + } + + @Override + public ResponseEntity analyzeAndImportPreMembers( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody SheetImportRequest request, + @UserId Integer requesterId + ) { + SheetImportResponse response = clubSheetIntegratedService.analyzeAndImportPreMembers( + clubId, requesterId, request.spreadsheetUrl() + ); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberSheetSyncRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberSheetSyncRequest.java new file mode 100644 index 000000000..c89419f5d --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberSheetSyncRequest.java @@ -0,0 +1,19 @@ +package gg.agit.konect.domain.club.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record ClubMemberSheetSyncRequest( + @NotBlank(message = "스프레드시트 ID는 필수 입력입니다.") + @Pattern( + regexp = "^[A-Za-z0-9_-]+$", + message = "스프레드시트 ID는 영문자, 숫자, 하이픈(-), 언더스코어(_)만 허용합니다." + ) + @Schema( + description = "동기화 대상 구글 스프레드시트 ID (URL의 /d/{spreadsheetId}/ 부분)", + example = "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms" + ) + String spreadsheetId +) { +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberSheetSyncResponse.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberSheetSyncResponse.java new file mode 100644 index 000000000..9daec1d03 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberSheetSyncResponse.java @@ -0,0 +1,19 @@ +package gg.agit.konect.domain.club.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record ClubMemberSheetSyncResponse( + @Schema(description = "동기화 요청된 회원 및 사전 회원 수", example = "42") + int syncedMemberCount, + + @Schema( + description = "동기화된 스프레드시트 URL", + example = "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms/edit" + ) + String sheetUrl +) { + public static ClubMemberSheetSyncResponse of(int syncedMemberCount, String spreadsheetId) { + String sheetUrl = "https://docs.google.com/spreadsheets/d/" + spreadsheetId + "/edit"; + return new ClubMemberSheetSyncResponse(syncedMemberCount, sheetUrl); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubSettingsResponse.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubSettingsResponse.java index f532403c3..10413e169 100644 --- a/src/main/java/gg/agit/konect/domain/club/dto/ClubSettingsResponse.java +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubSettingsResponse.java @@ -6,6 +6,8 @@ import java.time.LocalDateTime; import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; import io.swagger.v3.oas.annotations.media.Schema; @@ -30,6 +32,7 @@ public record ClubSettingsResponse( FeeSummary fee ) { @Schema(description = "모집공고 요약") + @JsonInclude(Include.NON_NULL) public record RecruitmentSummary( @Schema(description = "모집 시작일시", example = "2026.02.02 09:00", requiredMode = NOT_REQUIRED) @JsonFormat(pattern = "yyyy.MM.dd HH:mm") diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubSheetIdUpdateRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubSheetIdUpdateRequest.java new file mode 100644 index 000000000..9072b32cd --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubSheetIdUpdateRequest.java @@ -0,0 +1,19 @@ +package gg.agit.konect.domain.club.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record ClubSheetIdUpdateRequest( + @NotBlank(message = "스프레드시트 URL은 필수 입력입니다.") + @Pattern( + regexp = "^https://docs\\.google\\.com/spreadsheets/(?:u/\\d+/)?d/[A-Za-z0-9_-]+.*", + message = "유효한 구글 스프레드시트 URL을 입력해주세요." + ) + @Schema( + description = "등록할 구글 스프레드시트 URL", + example = "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms/edit" + ) + String spreadsheetUrl +) { +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/SheetImportRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/SheetImportRequest.java new file mode 100644 index 000000000..c7110b63a --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/SheetImportRequest.java @@ -0,0 +1,19 @@ +package gg.agit.konect.domain.club.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record SheetImportRequest( + @NotBlank + @Pattern( + regexp = "^https://docs\\.google\\.com/spreadsheets/(?:u/\\d+/)?d/[A-Za-z0-9_-]+.*", + message = "유효한 구글 스프레드시트 URL을 입력해주세요." + ) + @Schema( + description = "인명부가 담긴 구글 스프레드시트 URL", + example = "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms/edit" + ) + String spreadsheetUrl +) { +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/SheetImportResponse.java b/src/main/java/gg/agit/konect/domain/club/dto/SheetImportResponse.java new file mode 100644 index 000000000..4a11faf12 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/SheetImportResponse.java @@ -0,0 +1,26 @@ +package gg.agit.konect.domain.club.dto; + +import java.util.List; + +public record SheetImportResponse( + int importedCount, + int autoRegisteredCount, + List warnings +) { + + public static SheetImportResponse of(int importedCount) { + return new SheetImportResponse(importedCount, 0, List.of()); + } + + public static SheetImportResponse of( + int importedCount, + int autoRegisteredCount, + List warnings + ) { + return new SheetImportResponse( + importedCount, + autoRegisteredCount, + warnings != null ? warnings : List.of() + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/SheetMigrateRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/SheetMigrateRequest.java new file mode 100644 index 000000000..a38d99a90 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/SheetMigrateRequest.java @@ -0,0 +1,19 @@ +package gg.agit.konect.domain.club.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record SheetMigrateRequest( + @NotBlank + @Pattern( + regexp = "^https://docs\\.google\\.com/spreadsheets/.*", + message = "유효한 구글 스프레드시트 URL을 입력해주세요." + ) + @Schema( + description = "동아리가 기존에 사용하던 구글 스프레드시트 URL", + example = "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5.../edit" + ) + String sourceSpreadsheetUrl +) { +} diff --git a/src/main/java/gg/agit/konect/domain/club/enums/ClubSheetSortKey.java b/src/main/java/gg/agit/konect/domain/club/enums/ClubSheetSortKey.java new file mode 100644 index 000000000..06f9c43ed --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/enums/ClubSheetSortKey.java @@ -0,0 +1,8 @@ +package gg.agit.konect.domain.club.enums; + +public enum ClubSheetSortKey { + NAME, + STUDENT_ID, + POSITION, + JOINED_AT +} diff --git a/src/main/java/gg/agit/konect/domain/club/event/ClubApplicationSubmittedEvent.java b/src/main/java/gg/agit/konect/domain/club/event/ClubApplicationSubmittedEvent.java index 3313a8152..3eb051135 100644 --- a/src/main/java/gg/agit/konect/domain/club/event/ClubApplicationSubmittedEvent.java +++ b/src/main/java/gg/agit/konect/domain/club/event/ClubApplicationSubmittedEvent.java @@ -1,19 +1,23 @@ package gg.agit.konect.domain.club.event; +import java.util.List; + public record ClubApplicationSubmittedEvent( - Integer receiverId, + List receiverIds, Integer applicationId, Integer clubId, String clubName, String applicantName ) { public static ClubApplicationSubmittedEvent of( - Integer receiverId, + List receiverIds, Integer applicationId, Integer clubId, String clubName, String applicantName ) { - return new ClubApplicationSubmittedEvent(receiverId, applicationId, clubId, clubName, applicantName); + return new ClubApplicationSubmittedEvent( + List.copyOf(receiverIds), applicationId, clubId, clubName, applicantName + ); } } diff --git a/src/main/java/gg/agit/konect/domain/club/event/SheetSyncFailedEvent.java b/src/main/java/gg/agit/konect/domain/club/event/SheetSyncFailedEvent.java new file mode 100644 index 000000000..76749c328 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/event/SheetSyncFailedEvent.java @@ -0,0 +1,28 @@ +package gg.agit.konect.domain.club.event; + +import java.time.LocalDateTime; + +public record SheetSyncFailedEvent( + Integer clubId, + String spreadsheetId, + boolean accessDenied, + String reason, + LocalDateTime occurredAt +) { + + public static SheetSyncFailedEvent accessDenied( + Integer clubId, + String spreadsheetId, + String reason + ) { + return new SheetSyncFailedEvent(clubId, spreadsheetId, true, reason, LocalDateTime.now()); + } + + public static SheetSyncFailedEvent unexpected( + Integer clubId, + String spreadsheetId, + String reason + ) { + return new SheetSyncFailedEvent(clubId, spreadsheetId, false, reason, LocalDateTime.now()); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/model/Club.java b/src/main/java/gg/agit/konect/domain/club/model/Club.java index 3e9463924..114c1cc87 100644 --- a/src/main/java/gg/agit/konect/domain/club/model/Club.java +++ b/src/main/java/gg/agit/konect/domain/club/model/Club.java @@ -84,6 +84,18 @@ public class Club extends BaseEntity { @Column(name = "is_application_enabled") private Boolean isApplicationEnabled; + @Column(name = "google_sheet_id", length = 255) + private String googleSheetId; + + @Column(name = "sheet_column_mapping", columnDefinition = "JSON") + private String sheetColumnMapping; + + @Column(name = "drive_folder_id", length = 255) + private String driveFolderId; + + @Column(name = "template_spreadsheet_id", length = 255) + private String templateSpreadsheetId; + @OneToOne(mappedBy = "club", fetch = LAZY, cascade = ALL, orphanRemoval = true) private ClubRecruitment clubRecruitment; @@ -224,4 +236,16 @@ private void clearFeeInfo() { this.feeAccountNumber = null; this.feeAccountHolder = null; } + + public void updateGoogleSheetId(String googleSheetId) { + this.googleSheetId = googleSheetId; + } + + public void updateSheetColumnMapping(String sheetColumnMapping) { + this.sheetColumnMapping = sheetColumnMapping; + } + + public void updateDriveFolderId(String driveFolderId) { + this.driveFolderId = driveFolderId; + } } diff --git a/src/main/java/gg/agit/konect/domain/club/model/SheetColumnMapping.java b/src/main/java/gg/agit/konect/domain/club/model/SheetColumnMapping.java new file mode 100644 index 000000000..227a4de22 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/model/SheetColumnMapping.java @@ -0,0 +1,63 @@ +package gg.agit.konect.domain.club.model; + +import java.util.HashMap; +import java.util.Map; + +public class SheetColumnMapping { + + public static final String NAME = "name"; + public static final String STUDENT_ID = "studentId"; + public static final String EMAIL = "email"; + public static final String PHONE = "phone"; + public static final String POSITION = "position"; + public static final String JOINED_AT = "joinedAt"; + + private static final int COL_NAME = 0; + private static final int COL_STUDENT_ID = 1; + private static final int COL_EMAIL = 2; + private static final int COL_PHONE = 3; + private static final int COL_POSITION = 4; + private static final int COL_JOINED_AT = 5; + private static final int DEFAULT_DATA_START_ROW = 2; + + private final Map fieldToColumn; + private final int dataStartRow; + + public SheetColumnMapping(Map fieldToColumn, int dataStartRow) { + this.fieldToColumn = new HashMap<>(fieldToColumn); + this.dataStartRow = dataStartRow; + } + + public SheetColumnMapping(Map fieldToColumn) { + this(fieldToColumn, DEFAULT_DATA_START_ROW); + } + + public static SheetColumnMapping defaultMapping() { + Map mapping = new HashMap<>(); + mapping.put(NAME, COL_NAME); + mapping.put(STUDENT_ID, COL_STUDENT_ID); + mapping.put(EMAIL, COL_EMAIL); + mapping.put(PHONE, COL_PHONE); + mapping.put(POSITION, COL_POSITION); + mapping.put(JOINED_AT, COL_JOINED_AT); + return new SheetColumnMapping(mapping, DEFAULT_DATA_START_ROW); + } + + public boolean hasColumn(String field) { + return fieldToColumn.containsKey(field); + } + + public int getColumnIndex(String field) { + return fieldToColumn.getOrDefault(field, -1); + } + + public int getDataStartRow() { + return dataStartRow; + } + + public Map toMap() { + Map result = new HashMap<>(fieldToColumn); + result.put("dataStartRow", dataStartRow); + return result; + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java index 6a08d2a91..6a4454345 100644 --- a/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java @@ -66,6 +66,19 @@ List findAllByClubIdAndPosition( """) Optional findPresidentByClubId(@Param("clubId") Integer clubId); + @Query(""" + SELECT cm + FROM ClubMember cm + JOIN FETCH cm.user + WHERE cm.club.id = :clubId + AND cm.clubPosition IN :positions + AND cm.user.deletedAt IS NULL + """) + List findAllByClubIdAndPositionIn( + @Param("clubId") Integer clubId, + @Param("positions") Set positions + ); + @Query(""" SELECT cm FROM ClubMember cm @@ -189,8 +202,18 @@ long countByClubIdAndPosition( ClubMember save(ClubMember clubMember); + List saveAll(Iterable clubMembers); + void deleteByUserId(Integer userId); @Query("SELECT COUNT(cm) FROM ClubMember cm") long countAll(); + + @Query(""" + SELECT cm.user.studentNumber + FROM ClubMember cm + WHERE cm.club.id = :clubId + AND cm.user.deletedAt IS NULL + """) + Set findStudentNumbersByClubId(@Param("clubId") Integer clubId); } diff --git a/src/main/java/gg/agit/konect/domain/club/repository/ClubPreMemberRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubPreMemberRepository.java index b8ace676c..55d7a6d95 100644 --- a/src/main/java/gg/agit/konect/domain/club/repository/ClubPreMemberRepository.java +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubPreMemberRepository.java @@ -2,6 +2,7 @@ import java.util.List; import java.util.Optional; +import java.util.Set; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; @@ -63,9 +64,37 @@ List findAllByUniversityIdAndStudentNumberAndName( boolean existsByClubIdAndStudentNumberAndName(Integer clubId, String studentNumber, String name); + @Query(""" + SELECT cpm.studentNumber as studentNumber, cpm.name as name + FROM ClubPreMember cpm + WHERE cpm.club.id = :clubId + """) + List findStudentNumberAndNameByClubId(@Param("clubId") Integer clubId); + + interface PreMemberKey { + String getStudentNumber(); + + String getName(); + } + void deleteByClubIdAndStudentNumber(Integer clubId, String studentNumber); + @Query(""" + DELETE FROM ClubPreMember cpm + WHERE cpm.club.id = :clubId + AND cpm.studentNumber IN :studentNumbers + """) + @org.springframework.data.jpa.repository.Modifying(clearAutomatically = true) + void deleteByClubIdAndStudentNumberIn( + @Param("clubId") Integer clubId, + @Param("studentNumbers") Set studentNumbers + ); + void delete(ClubPreMember preMember); ClubPreMember save(ClubPreMember preMember); + + List saveAll(Iterable preMembers); + + long countByClubId(Integer clubId); } diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java index 076d7c699..d282d17ac 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java @@ -1,6 +1,8 @@ package gg.agit.konect.domain.club.service; import static gg.agit.konect.domain.club.enums.ClubPosition.MEMBER; + +import gg.agit.konect.domain.club.enums.ClubPosition; import static gg.agit.konect.global.code.ApiResponseCode.*; import java.time.LocalDateTime; @@ -299,14 +301,21 @@ public ClubFeeInfoResponse applyClub(Integer clubId, Integer userId, ClubApplyRe clubApplyAnswerRepository.saveAll(applyAnswers); } - clubMemberRepository.findPresidentByClubId(clubId) - .ifPresent(president -> applicationEventPublisher.publishEvent(ClubApplicationSubmittedEvent.of( - president.getUser().getId(), + List managerIds = clubMemberRepository + .findAllByClubIdAndPositionIn(clubId, ClubPosition.MANAGERS) + .stream() + .map(manager -> manager.getUser().getId()) + .toList(); + + if (!managerIds.isEmpty()) { + applicationEventPublisher.publishEvent(ClubApplicationSubmittedEvent.of( + managerIds, apply.getId(), clubId, club.getName(), user.getName() - ))); + )); + } Integer bankId = resolveBankId(club.getFeeBank()); return ClubFeeInfoResponse.of(club, bankId, club.getFeeBank()); diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java new file mode 100644 index 000000000..f92c9b22d --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java @@ -0,0 +1,102 @@ +package gg.agit.konect.domain.club.service; + +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CLUB_SHEET_ID; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; +import gg.agit.konect.domain.club.dto.ClubSheetIdUpdateRequest; +import gg.agit.konect.domain.club.enums.ClubSheetSortKey; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.global.exception.CustomException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ClubMemberSheetService { + + private final ClubRepository clubRepository; + private final ClubMemberRepository clubMemberRepository; + private final ClubPreMemberRepository clubPreMemberRepository; + private final ClubPermissionValidator clubPermissionValidator; + private final SheetSyncExecutor sheetSyncExecutor; + private final SheetHeaderMapper sheetHeaderMapper; + private final ObjectMapper objectMapper; + + @Transactional + public void updateSheetId( + Integer clubId, + Integer requesterId, + ClubSheetIdUpdateRequest request + ) { + Club club = clubRepository.getById(clubId); + clubPermissionValidator.validateManagerAccess(clubId, requesterId); + + String spreadsheetId = SpreadsheetUrlParser.extractId(request.spreadsheetUrl()); + + SheetHeaderMapper.SheetAnalysisResult result = + sheetHeaderMapper.analyzeAllSheets(spreadsheetId); + applySheetRegistration(club, spreadsheetId, result); + } + + @Transactional + void updateSheetId( + Integer clubId, + Integer requesterId, + String spreadsheetId, + SheetHeaderMapper.SheetAnalysisResult result + ) { + Club club = clubRepository.getById(clubId); + clubPermissionValidator.validateManagerAccess(clubId, requesterId); + applySheetRegistration(club, spreadsheetId, result); + } + + private void applySheetRegistration( + Club club, + String spreadsheetId, + SheetHeaderMapper.SheetAnalysisResult result + ) { + String mappingJson = null; + try { + mappingJson = objectMapper.writeValueAsString(result.memberListMapping().toMap()); + } catch (JsonProcessingException e) { + log.warn("Failed to serialize mapping, skipping. cause={}", e.getMessage()); + } + + club.updateGoogleSheetId(spreadsheetId); + if (mappingJson != null) { + club.updateSheetColumnMapping(mappingJson); + } + } + + @Transactional(readOnly = true) + public ClubMemberSheetSyncResponse syncMembersToSheet( + Integer clubId, + Integer requesterId, + ClubSheetSortKey sortKey, + boolean ascending + ) { + Club club = clubRepository.getById(clubId); + clubPermissionValidator.validateManagerAccess(clubId, requesterId); + + String spreadsheetId = club.getGoogleSheetId(); + if (spreadsheetId == null || spreadsheetId.isBlank()) { + throw CustomException.of(NOT_FOUND_CLUB_SHEET_ID); + } + + long memberCount = clubMemberRepository.countByClubId(clubId); + long preMemberCount = clubPreMemberRepository.countByClubId(clubId); + sheetSyncExecutor.executeWithSort(clubId, sortKey, ascending); + + return ClubMemberSheetSyncResponse.of(Math.toIntExact(memberCount + preMemberCount), spreadsheetId); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubService.java index a6a1fa782..08c4dccc6 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubService.java @@ -118,7 +118,7 @@ public ClubDetailResponse createClub(Integer userId, ClubCreateRequest request) Club savedClub = clubRepository.save(club); - chatRoomRepository.save(ChatRoom.groupOf(savedClub)); + chatRoomRepository.save(ChatRoom.clubGroupOf(savedClub)); ClubMember president = ClubMember.builder() .club(savedClub) diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedService.java new file mode 100644 index 000000000..8959dc03e --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedService.java @@ -0,0 +1,45 @@ +package gg.agit.konect.domain.club.service; + +import org.springframework.stereotype.Service; + +import gg.agit.konect.domain.club.dto.SheetImportResponse; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ClubSheetIntegratedService { + + private final ClubPermissionValidator clubPermissionValidator; + private final GoogleSheetPermissionService googleSheetPermissionService; + private final SheetHeaderMapper sheetHeaderMapper; + private final ClubMemberSheetService clubMemberSheetService; + private final SheetImportService sheetImportService; + + public SheetImportResponse analyzeAndImportPreMembers( + Integer clubId, + Integer requesterId, + String spreadsheetUrl + ) { + clubPermissionValidator.validateManagerAccess(clubId, requesterId); + + String spreadsheetId = SpreadsheetUrlParser.extractId(spreadsheetUrl); + // OAuth 미연결이면 건너뛰고 계속 진행한다. Drive 초기화/인증 오류는 예외로 전파한다. + googleSheetPermissionService.tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId); + + SheetHeaderMapper.SheetAnalysisResult analysis = + sheetHeaderMapper.analyzeAllSheets(spreadsheetId); + + clubMemberSheetService.updateSheetId( + clubId, + requesterId, + spreadsheetId, + analysis + ); + return sheetImportService.importPreMembersFromSheet( + clubId, + requesterId, + spreadsheetId, + analysis.memberListMapping() + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/GoogleDrivePermissionHelper.java b/src/main/java/gg/agit/konect/domain/club/service/GoogleDrivePermissionHelper.java new file mode 100644 index 000000000..918fb12df --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/GoogleDrivePermissionHelper.java @@ -0,0 +1,236 @@ +package gg.agit.konect.domain.club.service; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import com.google.api.services.drive.Drive; +import com.google.api.services.drive.model.Permission; +import com.google.api.services.drive.model.PermissionList; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +final class GoogleDrivePermissionHelper { + + private static final int PERMISSION_APPLY_MAX_ATTEMPTS = 2; + private static final int ROLE_RANK_NONE = 0; + private static final int ROLE_RANK_READER = 1; + private static final int ROLE_RANK_COMMENTER = 2; + private static final int ROLE_RANK_WRITER = 3; + private static final String PERMISSION_FIELDS = + "nextPageToken,permissions(id,type,emailAddress,role)"; + private static final Set SUPPORTED_TARGET_ROLES = Set.of( + "reader", + "commenter", + "writer" + ); + + private GoogleDrivePermissionHelper() {} + + enum PermissionApplyStatus { + CREATED, + UPGRADED, + UNCHANGED + } + + static boolean hasRequiredRole(String currentRole, String targetRole) { + return roleRank(currentRole) >= validateTargetRole(targetRole); + } + + static PermissionApplyStatus ensureServiceAccountPermission( + Drive userDriveService, + String fileId, + String targetRole, + String serviceAccountEmail + ) throws IOException { + validateTargetRole(targetRole); + Permission initialPermission = findServiceAccountPermission( + userDriveService, + fileId, + serviceAccountEmail + ); + int attempt = 1; + while (true) { + try { + return applyServiceAccountPermission( + userDriveService, + fileId, + targetRole, + serviceAccountEmail + ); + } catch (IOException e) { + PermissionApplyStatus recoveredStatus = recoverPermissionApplyStatus( + userDriveService, + fileId, + serviceAccountEmail, + targetRole, + initialPermission, + attempt + ); + if (recoveredStatus != null) { + log.info( + "Service account permission reached target role after attempt {}. " + + "fileId={}, role={}, email={}, status={}", + attempt, + fileId, + targetRole, + serviceAccountEmail, + recoveredStatus + ); + return recoveredStatus; + } + + if (attempt++ >= PERMISSION_APPLY_MAX_ATTEMPTS) { + throw e; + } + } + } + } + + static List listAllPermissions(Drive driveService, String fileId) throws IOException { + List permissions = new ArrayList<>(); + String nextPageToken = null; + + do { + Drive.Permissions.List request = driveService.permissions().list(fileId) + .setFields(PERMISSION_FIELDS); + if (nextPageToken != null) { + request.setPageToken(nextPageToken); + } + + PermissionList response = request.execute(); + if (response.getPermissions() != null) { + permissions.addAll(response.getPermissions()); + } + nextPageToken = response.getNextPageToken(); + } while (nextPageToken != null && !nextPageToken.isBlank()); + + return permissions; + } + + static Permission findServiceAccountPermission( + Drive userDriveService, + String fileId, + String serviceAccountEmail + ) throws IOException { + return listAllPermissions(userDriveService, fileId).stream() + .filter(permission -> "user".equals(permission.getType())) + .filter(permission -> serviceAccountEmail.equals(permission.getEmailAddress())) + .findFirst() + .orElse(null); + } + + private static PermissionApplyStatus applyServiceAccountPermission( + Drive userDriveService, + String fileId, + String targetRole, + String serviceAccountEmail + ) throws IOException { + validateTargetRole(targetRole); + Permission existingPermission = findServiceAccountPermission( + userDriveService, + fileId, + serviceAccountEmail + ); + + if (existingPermission == null) { + Permission permission = new Permission() + .setType("user") + .setRole(targetRole) + .setEmailAddress(serviceAccountEmail); + + userDriveService.permissions().create(fileId, permission) + .setSendNotificationEmail(false) + .execute(); + log.info( + "Service account {} access granted. fileId={}, email={}", + targetRole, + fileId, + serviceAccountEmail + ); + return PermissionApplyStatus.CREATED; + } + + String currentRole = existingPermission.getRole(); + if (hasRequiredRole(currentRole, targetRole)) { + log.info( + "Service account permission already satisfies requested role. fileId={}, role={}, email={}", + fileId, + currentRole, + serviceAccountEmail + ); + return PermissionApplyStatus.UNCHANGED; + } + + Permission updatedPermission = new Permission().setRole(targetRole); + userDriveService.permissions().update(fileId, existingPermission.getId(), updatedPermission) + .execute(); + log.info( + "Service account permission upgraded. fileId={}, fromRole={}, toRole={}, email={}", + fileId, + currentRole, + targetRole, + serviceAccountEmail + ); + return PermissionApplyStatus.UPGRADED; + } + + private static PermissionApplyStatus recoverPermissionApplyStatus( + Drive userDriveService, + String fileId, + String serviceAccountEmail, + String targetRole, + Permission initialPermission, + int attempt + ) { + try { + Permission currentPermission = findServiceAccountPermission( + userDriveService, + fileId, + serviceAccountEmail + ); + if (currentPermission == null + || !hasRequiredRole(currentPermission.getRole(), targetRole)) { + return null; + } + + if (initialPermission == null) { + return PermissionApplyStatus.CREATED; + } + + return hasRequiredRole(initialPermission.getRole(), targetRole) + ? PermissionApplyStatus.UNCHANGED + : PermissionApplyStatus.UPGRADED; + } catch (IOException e) { + log.debug( + "Failed to re-check service account permission after attempt {}. fileId={}, email={}, cause={}", + attempt, + fileId, + serviceAccountEmail, + e.getMessage() + ); + return null; + } + } + + private static int roleRank(String role) { + if (role == null) { + return ROLE_RANK_NONE; + } + + return switch (role) { + case "reader" -> ROLE_RANK_READER; + case "commenter" -> ROLE_RANK_COMMENTER; + case "writer", "fileOrganizer", "organizer", "owner" -> ROLE_RANK_WRITER; + default -> ROLE_RANK_NONE; + }; + } + + private static int validateTargetRole(String targetRole) { + if (!SUPPORTED_TARGET_ROLES.contains(targetRole)) { + throw new IllegalArgumentException("Unsupported targetRole: " + targetRole); + } + return roleRank(targetRole); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/GoogleSheetApiExceptionHelper.java b/src/main/java/gg/agit/konect/domain/club/service/GoogleSheetApiExceptionHelper.java new file mode 100644 index 000000000..d289429db --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/GoogleSheetApiExceptionHelper.java @@ -0,0 +1,226 @@ +package gg.agit.konect.domain.club.service; + +import java.io.IOException; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import com.google.api.client.googleapis.json.GoogleJsonError; +import com.google.api.client.googleapis.json.GoogleJsonResponseException; +import com.google.api.client.http.HttpResponseException; + +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; + +public final class GoogleSheetApiExceptionHelper { + + private static final Set ACCESS_DENIED_REASONS = Set.of( + "accessDenied", + "forbidden", + "insufficientFilePermissions", + "insufficientPermissions", + "notAuthorized", + "required" + ); + private static final Set AUTH_FAILURE_REASONS = Set.of( + "authError", + "invalidCredentials", + "unauthorized" + ); + private static final String INVALID_GRANT_ERROR = "invalid_grant"; + private static final Pattern ERROR_FIELD_PATTERN = + Pattern.compile("\"error\"\\s*:\\s*\"([^\"]+)\""); + private static final Pattern ERROR_DESCRIPTION_PATTERN = + Pattern.compile("\"error_description\"\\s*:\\s*\"([^\"]+)\""); + private static final int HTTP_STATUS_BAD_REQUEST = 400; + private static final int HTTP_STATUS_FORBIDDEN = 403; + private static final int HTTP_STATUS_UNAUTHORIZED = 401; + private static final int HTTP_STATUS_NOT_FOUND = 404; + + private GoogleSheetApiExceptionHelper() {} + + public static boolean isAccessDenied(IOException exception) { + if (exception instanceof GoogleJsonResponseException responseException) { + if (responseException.getStatusCode() != HTTP_STATUS_FORBIDDEN) { + return false; + } + return hasReason(responseException, ACCESS_DENIED_REASONS); + } + return getStatusCode(exception) == HTTP_STATUS_FORBIDDEN; + } + + public static boolean isAuthFailure(IOException exception) { + if (exception instanceof GoogleJsonResponseException responseException) { + if (responseException.getStatusCode() != HTTP_STATUS_UNAUTHORIZED) { + return false; + } + return hasReason(responseException, AUTH_FAILURE_REASONS) + || !hasKnownReasons(responseException); + } + return getStatusCode(exception) == HTTP_STATUS_UNAUTHORIZED; + } + + public static boolean isNotFound(IOException exception) { + return getStatusCode(exception) == HTTP_STATUS_NOT_FOUND; + } + + public static boolean isInvalidGrant(IOException exception) { + if (getStatusCode(exception) != HTTP_STATUS_BAD_REQUEST) { + return false; + } + + String content = getResponseContent(exception); + if (content == null) { + return false; + } + + return content.toLowerCase().contains(INVALID_GRANT_ERROR); + } + + public static CustomException accessDenied() { + return CustomException.of(ApiResponseCode.FORBIDDEN_GOOGLE_SHEET_ACCESS); + } + + public static CustomException invalidGoogleDriveAuth(IOException exception) { + return CustomException.of( + ApiResponseCode.INVALID_GOOGLE_DRIVE_AUTH, + extractClientDetail(exception) + ); + } + + public static String extractDetail(IOException exception) { + HttpResponseException responseException = findResponseException(exception); + if (responseException != null) { + String content = responseException.getContent(); + if (content != null && !content.isBlank()) { + return "%d %s%n%s".formatted( + responseException.getStatusCode(), + defaultStatusText(responseException.getStatusCode()), + content + ); + } + + String message = responseException.getMessage(); + if (message != null && !message.isBlank()) { + return message; + } + } + return exception.getMessage(); + } + + private static String extractClientDetail(IOException exception) { + if (isInvalidGrant(exception)) { + return sanitizeInvalidGrantDetail(exception); + } + return extractDetail(exception); + } + + private static boolean hasReason( + GoogleJsonResponseException exception, + Set expectedReasons + ) { + return getReasons(exception).stream() + .anyMatch(expectedReasons::contains); + } + + private static boolean hasKnownReasons(GoogleJsonResponseException exception) { + return !getReasons(exception).isEmpty(); + } + + private static List getReasons(GoogleJsonResponseException exception) { + if (exception.getDetails() == null || exception.getDetails().getErrors() == null) { + return List.of(); + } + return exception.getDetails().getErrors().stream() + .map(GoogleJsonError.ErrorInfo::getReason) + .filter(reason -> reason != null && !reason.isBlank()) + .toList(); + } + + private static int getStatusCode(IOException exception) { + HttpResponseException responseException = findResponseException(exception); + if (responseException != null) { + return responseException.getStatusCode(); + } + return -1; + } + + private static String getResponseContent(IOException exception) { + HttpResponseException responseException = findResponseException(exception); + if (responseException == null) { + return null; + } + + String content = responseException.getContent(); + if (content != null && !content.isBlank()) { + return content; + } + + return responseException.getMessage(); + } + + private static HttpResponseException findResponseException(Throwable throwable) { + Throwable current = throwable; + while (current != null) { + if (current instanceof GoogleJsonResponseException responseException) { + return responseException; + } + if (current instanceof HttpResponseException responseException) { + return responseException; + } + current = current.getCause(); + } + return null; + } + + private static String sanitizeInvalidGrantDetail(IOException exception) { + String content = getResponseContent(exception); + if (content == null) { + return "%d %s%nerror=%s".formatted( + HTTP_STATUS_BAD_REQUEST, + defaultStatusText(HTTP_STATUS_BAD_REQUEST), + INVALID_GRANT_ERROR + ); + } + + String error = extractJsonField(content, ERROR_FIELD_PATTERN); + String errorDescription = extractJsonField(content, ERROR_DESCRIPTION_PATTERN); + + StringBuilder detail = new StringBuilder() + .append(HTTP_STATUS_BAD_REQUEST) + .append(' ') + .append(defaultStatusText(HTTP_STATUS_BAD_REQUEST)); + if (error != null) { + detail.append(System.lineSeparator()).append("error=").append(error); + } + if (errorDescription != null) { + detail.append(System.lineSeparator()) + .append("error_description=") + .append(errorDescription); + } + + if (error == null && errorDescription == null) { + detail.append(System.lineSeparator()).append("error=").append(INVALID_GRANT_ERROR); + } + return detail.toString(); + } + + private static String extractJsonField(String content, Pattern pattern) { + Matcher matcher = pattern.matcher(content); + if (!matcher.find()) { + return null; + } + return matcher.group(1).trim(); + } + + private static String defaultStatusText(int statusCode) { + return switch (statusCode) { + case HTTP_STATUS_BAD_REQUEST -> "Bad Request"; + case HTTP_STATUS_UNAUTHORIZED -> "Unauthorized"; + case HTTP_STATUS_FORBIDDEN -> "Forbidden"; + case HTTP_STATUS_NOT_FOUND -> "Not Found"; + default -> "HTTP Error"; + }; + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionService.java b/src/main/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionService.java new file mode 100644 index 000000000..c01c588a7 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionService.java @@ -0,0 +1,97 @@ +package gg.agit.konect.domain.club.service; + +import java.io.IOException; +import java.security.GeneralSecurityException; + +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import com.google.api.services.drive.Drive; +import com.google.auth.oauth2.ServiceAccountCredentials; + +import gg.agit.konect.domain.user.enums.Provider; +import gg.agit.konect.domain.user.repository.UserOAuthAccountRepository; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.infrastructure.googlesheets.GoogleSheetsConfig; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class GoogleSheetPermissionService { + + private final ServiceAccountCredentials serviceAccountCredentials; + private final GoogleSheetsConfig googleSheetsConfig; + private final UserOAuthAccountRepository userOAuthAccountRepository; + + public boolean tryGrantServiceAccountWriterAccess(Integer requesterId, String spreadsheetId) { + String refreshToken = userOAuthAccountRepository + .findByUserIdAndProvider(requesterId, Provider.GOOGLE) + .map(account -> account.getGoogleDriveRefreshToken()) + .filter(StringUtils::hasText) + .orElse(null); + + if (refreshToken == null) { + log.warn( + "Skipping service account auto-share because Google Drive OAuth is not connected. requesterId={}", + requesterId + ); + return false; + } + + Drive userDriveService; + try { + userDriveService = googleSheetsConfig.buildUserDriveService(refreshToken); + } catch (IOException | GeneralSecurityException e) { + log.error("Failed to build user Drive service. requesterId={}", requesterId, e); + throw CustomException.of(ApiResponseCode.FAILED_INIT_GOOGLE_DRIVE); + } + + try { + GoogleDrivePermissionHelper.ensureServiceAccountPermission( + userDriveService, + spreadsheetId, + "writer", + getServiceAccountEmail() + ); + return true; + } catch (IOException e) { + if (GoogleSheetApiExceptionHelper.isInvalidGrant(e)) { + log.warn( + "Google Drive OAuth token is invalid while auto-sharing spreadsheet. requesterId={}, " + + "spreadsheetId={}, cause={}", + requesterId, + spreadsheetId, + GoogleSheetApiExceptionHelper.extractDetail(e) + ); + throw GoogleSheetApiExceptionHelper.invalidGoogleDriveAuth(e); + } + + if (GoogleSheetApiExceptionHelper.isAccessDenied(e) + || GoogleSheetApiExceptionHelper.isAuthFailure(e) + || GoogleSheetApiExceptionHelper.isNotFound(e)) { + log.warn( + "Failed to auto-share spreadsheet with service account. requesterId={}, spreadsheetId={}, cause={}", + requesterId, + spreadsheetId, + e.getMessage() + ); + return false; + } + + log.error( + "Unexpected error while auto-sharing spreadsheet. requesterId={}, spreadsheetId={}", + requesterId, + spreadsheetId, + e + ); + throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); + } + } + + private String getServiceAccountEmail() { + return serviceAccountCredentials.getClientEmail(); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java b/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java new file mode 100644 index 000000000..44c33c309 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java @@ -0,0 +1,296 @@ +package gg.agit.konect.domain.club.service; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.api.services.sheets.v4.Sheets; +import com.google.api.services.sheets.v4.model.Sheet; +import com.google.api.services.sheets.v4.model.Spreadsheet; +import com.google.api.services.sheets.v4.model.ValueRange; + +import gg.agit.konect.domain.club.model.SheetColumnMapping; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.infrastructure.claude.config.ClaudeProperties; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class SheetHeaderMapper { + + private static final String API_URL = "https://api.anthropic.com/v1/messages"; + private static final String ANTHROPIC_VERSION = "2023-06-01"; + // 헤더 분석용으로 haiku 모델을 고정 사용 (ClaudeProperties.model()과 의도적으로 분리) + // 운영 모델과 다르게 비용/속도 최적화를 위해 저비용 모델을 선택 + private static final String MAPPING_MODEL = "claude-haiku-4-5-20251001"; + private static final int MAX_TOKENS = 1024; + private static final int SCAN_ROWS = 10; + + private final Sheets googleSheetsService; + private final ClaudeProperties claudeProperties; + private final ObjectMapper objectMapper; + private final RestClient restClient; + + public SheetHeaderMapper( + Sheets googleSheetsService, + ClaudeProperties claudeProperties, + ObjectMapper objectMapper, + RestClient.Builder restClientBuilder + ) { + this.googleSheetsService = googleSheetsService; + this.claudeProperties = claudeProperties; + this.objectMapper = objectMapper; + this.restClient = restClientBuilder.build(); + } + + public record SheetAnalysisResult( + SheetColumnMapping memberListMapping, + Integer feeSheetId, + SheetColumnMapping feeLedgerMapping + ) {} + + public SheetAnalysisResult analyzeAllSheets(String spreadsheetId) { + List sheets = readAllSheets(spreadsheetId); + if (sheets.isEmpty()) { + log.warn("No sheets found. Using default mapping."); + return new SheetAnalysisResult(SheetColumnMapping.defaultMapping(), null, null); + } + + try { + return inferAllMappings(spreadsheetId, sheets); + } catch (CustomException e) { + throw e; + } catch (Exception e) { + log.warn("Sheet analysis failed, using default. cause={}", e.getMessage()); + return new SheetAnalysisResult(SheetColumnMapping.defaultMapping(), null, null); + } + } + + private record SheetInfo(Integer sheetId, String title) {} + + private List readAllSheets(String spreadsheetId) { + try { + Spreadsheet spreadsheet = googleSheetsService.spreadsheets() + .get(spreadsheetId) + .execute(); + + List result = new ArrayList<>(); + for (Sheet sheet : spreadsheet.getSheets()) { + result.add(new SheetInfo( + sheet.getProperties().getSheetId(), + sheet.getProperties().getTitle() + )); + } + return result; + + } catch (IOException e) { + if (GoogleSheetApiExceptionHelper.isAccessDenied(e)) { + log.warn( + "Google Sheets access denied while reading spreadsheet info. spreadsheetId={}, cause={}", + spreadsheetId, + e.getMessage() + ); + throw GoogleSheetApiExceptionHelper.accessDenied(); + } + log.error("Failed to read spreadsheet info. spreadsheetId={}", spreadsheetId, e); + return List.of(); + } + } + + private List> readSheetRows(String spreadsheetId, String sheetTitle) { + try { + String range = "'" + sheetTitle + "'!A1:Z10"; + ValueRange response = googleSheetsService.spreadsheets().values() + .get(spreadsheetId, range) + .execute(); + + List> values = response.getValues(); + if (values == null || values.isEmpty()) { + return List.of(); + } + + List> rows = new ArrayList<>(); + int limit = Math.min(values.size(), SCAN_ROWS); + for (int i = 0; i < limit; i++) { + List row = values.get(i).stream() + .map(Object::toString) + .toList(); + rows.add(row); + } + return rows; + + } catch (IOException e) { + if (GoogleSheetApiExceptionHelper.isAccessDenied(e)) { + log.warn( + "Google Sheets access denied while reading sheet rows. spreadsheetId={}, sheetTitle={}, cause={}", + spreadsheetId, + sheetTitle, + e.getMessage() + ); + throw GoogleSheetApiExceptionHelper.accessDenied(); + } + log.warn("Failed to read rows from sheet '{}'. cause={}", sheetTitle, e.getMessage()); + return List.of(); + } + } + + private SheetAnalysisResult inferAllMappings( + String spreadsheetId, + List sheets + ) throws Exception { + StringBuilder sheetsDescription = new StringBuilder(); + Map>> sheetRowsMap = new HashMap<>(); + + for (SheetInfo sheet : sheets) { + List> rows = readSheetRows(spreadsheetId, sheet.title()); + sheetRowsMap.put(sheet.title(), rows); + + sheetsDescription.append(String.format("=== Sheet: \"%s\" (sheetId: %d) ===%n", + sheet.title(), sheet.sheetId())); + if (rows.isEmpty()) { + sheetsDescription.append("(empty)\n"); + } else { + for (int i = 0; i < rows.size(); i++) { + sheetsDescription.append(String.format("Row %d: %s%n", i + 1, rows.get(i))); + } + } + sheetsDescription.append("\n"); + } + + String prompt = buildPrompt(sheetsDescription.toString(), sheets); + String rawJson = callClaude(prompt); + return parseAllMappings(rawJson, sheets); + } + + private String buildPrompt(String sheetsDescription, List sheets) { + List sheetNames = sheets.stream().map(SheetInfo::title).toList(); + return String.format(""" + A Korean university club uses a Google Spreadsheet with these sheets: + %s + + %s + + Analyze the sheets and respond ONLY with a JSON object in this format: + { + "memberList": { + "sheetTitle": "sheet name containing member list", + "headerRow": 1, + "mapping": {"name": 0, "studentId": 1, "email": 2} + } + } + + Field definitions: + - memberList fields: name(이름/성명), studentId(학번), email(이메일), + phone(전화번호/연락처), position(직책), joinedAt(가입일) + + Rules: + - "memberList.sheetTitle" must be one of: %s + - "headerRow" is 1-indexed + - "mapping" uses 0-indexed column positions + - Only include fields you are confident about + - Do not include explanation + """, + sheetNames, sheetsDescription, sheetNames + ); + } + + private String callClaude(String prompt) { + Map request = Map.of( + "model", MAPPING_MODEL, + "max_tokens", MAX_TOKENS, + "messages", List.of(Map.of("role", "user", "content", prompt)) + ); + + try { + String response = restClient.post() + .uri(API_URL) + .header("x-api-key", claudeProperties.apiKey()) + .header("anthropic-version", ANTHROPIC_VERSION) + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .retrieve() + .body(String.class); + + JsonNode root = objectMapper.readTree(response); + JsonNode content = root.path("content"); + if (!content.isArray() || content.isEmpty()) { + throw new RuntimeException("Claude API returned empty content. response=" + response); + } + return content.get(0).path("text").asText(); + + } catch (RestClientException | IOException e) { + throw new RuntimeException("Claude API call failed", e); + } + } + + private SheetAnalysisResult parseAllMappings( + String rawJson, + List sheets + ) { + try { + String cleaned = rawJson.trim(); + int start = cleaned.indexOf('{'); + int end = cleaned.lastIndexOf('}'); + if (start < 0 || end < 0) { + throw new IllegalArgumentException("No JSON object found"); + } + cleaned = cleaned.substring(start, end + 1); + + JsonNode root = objectMapper.readTree(cleaned); + + SheetColumnMapping memberListMapping = parseSingleMapping(root.path("memberList")); + SheetColumnMapping feeLedgerMapping = null; + Integer feeSheetId = null; + + JsonNode feeLedgerNode = root.path("feeLedger"); + if (!feeLedgerNode.isMissingNode() && !feeLedgerNode.isNull()) { + String feeLedgerTitle = feeLedgerNode.path("sheetTitle").asText(null); + if (feeLedgerTitle != null && !"null".equals(feeLedgerTitle)) { + feeLedgerMapping = parseSingleMapping(feeLedgerNode); + feeSheetId = sheets.stream() + .filter(s -> s.title().equals(feeLedgerTitle)) + .map(SheetInfo::sheetId) + .findFirst() + .orElse(null); + } + } + + log.info( + "Sheet analysis done. memberList={}, feeSheetId={}, feeLedger={}", + memberListMapping.toMap(), feeSheetId, + feeLedgerMapping != null ? feeLedgerMapping.toMap() : "none" + ); + + return new SheetAnalysisResult(memberListMapping, feeSheetId, feeLedgerMapping); + + } catch (Exception e) { + log.warn("Failed to parse all mappings: {}. Using default.", rawJson); + return new SheetAnalysisResult(SheetColumnMapping.defaultMapping(), null, null); + } + } + + private SheetColumnMapping parseSingleMapping(JsonNode node) { + int headerRow = Math.max(1, node.path("headerRow").asInt(1)); + int dataStartRow = headerRow + 1; + + JsonNode mappingNode = node.path("mapping"); + Map mapping = new HashMap<>(); + mappingNode.fields().forEachRemaining(entry -> { + int colIndex = entry.getValue().asInt(-1); + if (colIndex >= 0) { + mapping.put(entry.getKey(), colIndex); + } + }); + + return new SheetColumnMapping(mapping, dataStartRow); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java b/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java new file mode 100644 index 000000000..45e10e63d --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java @@ -0,0 +1,291 @@ +package gg.agit.konect.domain.club.service; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.google.api.services.sheets.v4.Sheets; +import com.google.api.services.sheets.v4.model.ValueRange; + +import gg.agit.konect.domain.chat.service.ChatRoomMembershipService; +import gg.agit.konect.domain.club.dto.SheetImportResponse; +import gg.agit.konect.domain.club.enums.ClubPosition; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.ClubMember; +import gg.agit.konect.domain.club.model.ClubPreMember; +import gg.agit.konect.domain.club.model.SheetColumnMapping; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.global.util.PhoneNumberNormalizer; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SheetImportService { + + private final Sheets googleSheetsService; + private final SheetHeaderMapper sheetHeaderMapper; + private final ClubRepository clubRepository; + private final ClubPreMemberRepository clubPreMemberRepository; + private final ClubMemberRepository clubMemberRepository; + private final UserRepository userRepository; + private final ChatRoomMembershipService chatRoomMembershipService; + private final ClubPermissionValidator clubPermissionValidator; + + @Transactional + public SheetImportResponse importPreMembersFromSheet( + Integer clubId, + Integer requesterId, + String spreadsheetUrl + ) { + clubPermissionValidator.validateManagerAccess(clubId, requesterId); + String spreadsheetId = SpreadsheetUrlParser.extractId(spreadsheetUrl); + + SheetHeaderMapper.SheetAnalysisResult analysis = + sheetHeaderMapper.analyzeAllSheets(spreadsheetId); + return importPreMembersFromSheet( + clubId, + requesterId, + spreadsheetId, + analysis.memberListMapping() + ); + } + + @Transactional + SheetImportResponse importPreMembersFromSheet( + Integer clubId, + Integer requesterId, + String spreadsheetId, + SheetColumnMapping mapping + ) { + clubPermissionValidator.validateManagerAccess(clubId, requesterId); + Club club = clubRepository.getById(clubId); + return importPreMembersFromSheet(clubId, club, spreadsheetId, mapping); + } + + private SheetImportResponse importPreMembersFromSheet( + Integer clubId, + Club club, + String spreadsheetId, + SheetColumnMapping mapping + ) { + Integer universityId = club.getUniversity().getId(); + List> rows = readDataRows(spreadsheetId, mapping); + + // N+1 방지: 루프 전 기존 부원 학번 Set / 사전 회원 key Set / 부원 userId Set 일괄 조회 + Set existingMemberStudentNumbers = + new HashSet<>(clubMemberRepository.findStudentNumbersByClubId(clubId)); + Set existingPreMemberKeys = buildPreMemberKeySet(clubId); + Set existingMemberUserIds = + new HashSet<>(clubMemberRepository.findUserIdsByClubId(clubId)); + + // 시트에 등장하는 모든 학번 수집 → users 일괄 조회 + Set allStudentNumbers = rows.stream() + .map(row -> getCell(row, mapping, SheetColumnMapping.STUDENT_ID)) + .filter(s -> !s.isBlank()) + .collect(Collectors.toSet()); + + Map> usersByStudentNumber = new HashMap<>(); + if (!allStudentNumbers.isEmpty()) { + userRepository.findAllByUniversityIdAndStudentNumberIn(universityId, allStudentNumbers) + .forEach(u -> usersByStudentNumber + .computeIfAbsent(u.getStudentNumber(), k -> new ArrayList<>()) + .add(u)); + } + + // 루프에서 수집할 배치 작업 대상 + List clubMembersToSave = new ArrayList<>(); + Set studentNumbersToCleanFromPre = new HashSet<>(); + List preMembersToSave = new ArrayList<>(); + + List warnings = new ArrayList<>(); + int presidentCount = 0; + + for (List row : rows) { + String name = getCell(row, mapping, SheetColumnMapping.NAME); + String studentNumber = getCell(row, mapping, SheetColumnMapping.STUDENT_ID); + + if (name.isBlank() || studentNumber.isBlank()) { + continue; + } + + // 전화번호 형식 유효성 경고 + String phone = getCell(row, mapping, SheetColumnMapping.PHONE); + if (!phone.isBlank() && !PhoneNumberNormalizer.looksLikePhoneNumber(phone)) { + warnings.add(String.format( + "전화번호 형식이 올바르지 않습니다 - 학번: %s, 이름: %s, 입력값: '%s'", + studentNumber, name, phone + )); + } + + String positionStr = getCell(row, mapping, SheetColumnMapping.POSITION); + ClubPosition position = resolvePosition(positionStr); + + // 회장 중복 감지 + if (position == ClubPosition.PRESIDENT) { + presidentCount++; + if (presidentCount > 1) { + warnings.add(String.format( + "회장이 2명 이상 등록되어 있습니다 - 중복 회장: 학번 %s, 이름 %s", + studentNumber, name + )); + } + } + + // 이미 club_member에 있는 학번은 스킵 + if (existingMemberStudentNumbers.contains(studentNumber)) { + continue; + } + + // users 테이블에서 동일 대학 + 학번으로 매칭, 이름까지 일치하는 유저 탐색 + // trim() / equalsIgnoreCase로 공백·대소문자 차이 허용 + // 주의: existingPreMemberKeys 체크보다 먼저 수행하여 + // 이미 pre_member로 등록된 행도 User 생성 후 재-import 시 club_member로 승격 가능하게 함 + List candidates = usersByStudentNumber.getOrDefault(studentNumber, List.of()); + List matched = candidates.stream() + .filter(u -> name != null && u.getName() != null + && name.trim().equalsIgnoreCase(u.getName().trim())) + .toList(); + + if (matched.size() == 1) { + User matchedUser = matched.get(0); + // userId Set으로 중복 체크 (N+1 없음) + if (!existingMemberUserIds.contains(matchedUser.getId())) { + // 기존 pre_member 행도 함께 정리 (중복 방지) + studentNumbersToCleanFromPre.add(matchedUser.getStudentNumber()); + clubMembersToSave.add(ClubMember.builder() + .club(club) + .user(matchedUser) + .clubPosition(position) + .build()); + existingMemberStudentNumbers.add(studentNumber); + existingMemberUserIds.add(matchedUser.getId()); + existingPreMemberKeys.remove(preMemberKey(studentNumber, name)); + } + continue; + } + + if (matched.size() > 1) { + warnings.add(String.format( + "동명이인이 여러 명 존재하여 자동 매칭할 수 없습니다 - 학번: %s, 이름: %s", + studentNumber, name + )); + } + + // users 미매칭 또는 동명이인 → 이미 pre_member에 있으면 스킵, 없으면 등록 + if (existingPreMemberKeys.contains(preMemberKey(studentNumber, name))) { + continue; + } + + preMembersToSave.add(ClubPreMember.builder() + .club(club) + .studentNumber(studentNumber) + .name(name) + .clubPosition(position) + .build()); + existingPreMemberKeys.add(preMemberKey(studentNumber, name)); + } + + // 배치 처리: pre_member 정리 → club_member 일괄 저장 → 채팅방 등록 + if (!studentNumbersToCleanFromPre.isEmpty()) { + clubPreMemberRepository.deleteByClubIdAndStudentNumberIn( + clubId, studentNumbersToCleanFromPre + ); + } + List savedMembers = clubMembersToSave.isEmpty() + ? List.of() + : clubMemberRepository.saveAll(clubMembersToSave); + + for (ClubMember saved : savedMembers) { + chatRoomMembershipService.addClubMember(saved); + } + + if (!preMembersToSave.isEmpty()) { + clubPreMemberRepository.saveAll(preMembersToSave); + } + + int autoRegistered = savedMembers.size(); + int imported = preMembersToSave.size(); + + log.info( + "Sheet import done. clubId={}, spreadsheetId={}, imported={}, autoRegistered={}, " + + "warnings={}", + clubId, spreadsheetId, imported, autoRegistered, warnings.size() + ); + return SheetImportResponse.of(imported, autoRegistered, warnings); + } + + private List> readDataRows(String spreadsheetId, SheetColumnMapping mapping) { + try { + int dataStartRow = mapping.getDataStartRow(); + String range = "A" + dataStartRow + ":Z"; + ValueRange response = googleSheetsService.spreadsheets().values() + .get(spreadsheetId, range) + .setValueRenderOption("FORMATTED_VALUE") + .execute(); + + List> values = response.getValues(); + return values != null ? values : List.of(); + + } catch (IOException e) { + if (GoogleSheetApiExceptionHelper.isAccessDenied(e)) { + log.warn( + "Google Sheets access denied while reading sheet data. spreadsheetId={}, cause={}", + spreadsheetId, + e.getMessage() + ); + throw GoogleSheetApiExceptionHelper.accessDenied(); + } + log.error("Failed to read sheet data. spreadsheetId={}", spreadsheetId, e); + throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); + } + } + + private String getCell(List row, SheetColumnMapping mapping, String field) { + int col = mapping.getColumnIndex(field); + if (col < 0 || col >= row.size()) { + return ""; + } + String value = row.get(col).toString().trim(); + if (value.startsWith("'")) { + return value.substring(1); + } + return value; + } + + private ClubPosition resolvePosition(String positionStr) { + for (ClubPosition pos : ClubPosition.values()) { + if (pos.getDescription().equals(positionStr) + || pos.name().equalsIgnoreCase(positionStr)) { + return pos; + } + } + return ClubPosition.MEMBER; + } + + private Set buildPreMemberKeySet(Integer clubId) { + Set keys = new HashSet<>(); + clubPreMemberRepository.findStudentNumberAndNameByClubId(clubId) + .forEach(k -> keys.add(preMemberKey(k.getStudentNumber(), k.getName()))); + return keys; + } + + private String preMemberKey(String studentNumber, String name) { + return studentNumber + "\u0000" + name; + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java b/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java new file mode 100644 index 000000000..1eeb66c2d --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java @@ -0,0 +1,514 @@ +package gg.agit.konect.domain.club.service; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import com.google.api.services.drive.Drive; +import com.google.api.services.drive.model.File; +import com.google.api.services.drive.model.Permission; +import com.google.api.services.sheets.v4.Sheets; +import com.google.api.services.sheets.v4.model.ValueRange; +import com.google.auth.oauth2.ServiceAccountCredentials; + +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.SheetColumnMapping; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.domain.user.enums.Provider; +import gg.agit.konect.domain.user.model.UserOAuthAccount; +import gg.agit.konect.domain.user.repository.UserOAuthAccountRepository; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.global.util.PhoneNumberNormalizer; +import gg.agit.konect.infrastructure.googlesheets.GoogleSheetsConfig; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SheetMigrationService { + + private static final Pattern FOLDER_ID_PATTERN = + Pattern.compile("(?:folders/|id=)([a-zA-Z0-9_-]{20,})"); + private static final String MIME_TYPE_SPREADSHEET = + "application/vnd.google-apps.spreadsheet"; + private static final String NEW_SHEET_TITLE_PREFIX = "KONECT_인명부_"; + + @Value("${google.sheets.template-spreadsheet-id:}") + private String defaultTemplateSpreadsheetId; + + private final Sheets googleSheetsService; + private final ServiceAccountCredentials serviceAccountCredentials; + private final SheetHeaderMapper sheetHeaderMapper; + private final ClubRepository clubRepository; + private final UserOAuthAccountRepository userOAuthAccountRepository; + private final ClubPermissionValidator clubPermissionValidator; + private final GoogleSheetsConfig googleSheetsConfig; + private final ObjectMapper objectMapper; + + @Transactional + public String migrateToTemplate( + Integer clubId, + Integer requesterId, + String sourceSpreadsheetUrl + ) { + Club club = clubRepository.getById(clubId); + clubPermissionValidator.validateManagerAccess(clubId, requesterId); + + String templateId = defaultTemplateSpreadsheetId; + if (templateId == null || templateId.isBlank()) { + throw CustomException.of(ApiResponseCode.NOT_FOUND_CLUB_SHEET_ID); + } + + UserOAuthAccount oauthAccount = userOAuthAccountRepository + .findByUserIdAndProvider(requesterId, Provider.GOOGLE) + .orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_GOOGLE_DRIVE_AUTH)); + + String driveRefreshToken = oauthAccount.getGoogleDriveRefreshToken(); + if (driveRefreshToken == null || driveRefreshToken.isBlank()) { + throw CustomException.of(ApiResponseCode.NOT_FOUND_GOOGLE_DRIVE_AUTH); + } + + Drive userDriveService; + try { + userDriveService = googleSheetsConfig.buildUserDriveService(driveRefreshToken); + } catch (IOException | GeneralSecurityException e) { + log.error("Failed to build user Drive service. requesterId={}", requesterId, e); + throw CustomException.of(ApiResponseCode.FAILED_INIT_GOOGLE_DRIVE); + } + + String sourceSpreadsheetId = SpreadsheetUrlParser.extractId(sourceSpreadsheetUrl); + String folderId = resolveFolderId(userDriveService, sourceSpreadsheetUrl, sourceSpreadsheetId); + + // 소스 파일에 서비스 계정 reader 권한을 먼저 부여해야 readAllData()가 성공함 + GoogleDrivePermissionHelper.PermissionApplyStatus sourcePermissionStatus = + grantServiceAccountReadAccess(userDriveService, sourceSpreadsheetId); + // 트랜잭션 실패 / 완료 후 이번 요청에서 추가한 소스 파일 권한만 정리한다. + if (sourcePermissionStatus == GoogleDrivePermissionHelper.PermissionApplyStatus.CREATED) { + registerSourceFilePermissionCleanup(userDriveService, sourceSpreadsheetId); + } + + String newSpreadsheetId = copyTemplate(userDriveService, templateId, club.getName(), folderId); + registerDriveRollback(userDriveService, newSpreadsheetId); + grantServiceAccountAccess(userDriveService, newSpreadsheetId); + + SheetHeaderMapper.SheetAnalysisResult sourceAnalysis = + sheetHeaderMapper.analyzeAllSheets(sourceSpreadsheetId); + + List> sourceData = readAllData( + sourceSpreadsheetId, + sourceAnalysis.memberListMapping() + ); + + writeToTemplate(newSpreadsheetId, sourceData, sourceAnalysis.memberListMapping()); + + club.updateGoogleSheetId(newSpreadsheetId); + if (folderId != null) { + club.updateDriveFolderId(folderId); + } + + SheetHeaderMapper.SheetAnalysisResult newAnalysis = + sheetHeaderMapper.analyzeAllSheets(newSpreadsheetId); + try { + club.updateSheetColumnMapping( + objectMapper.writeValueAsString(newAnalysis.memberListMapping().toMap()) + ); + } catch (Exception e) { + log.warn("Failed to serialize new mapping. cause={}", e.getMessage()); + } + + log.info( + "Sheet migration done. clubId={}, sourceId={}, newId={}, folderId={}", + clubId, sourceSpreadsheetId, newSpreadsheetId, folderId + ); + + return newSpreadsheetId; + } + + /** + * 소스 파일에 서비스 계정 reader 권한을 부여합니다. + * migrate 시 서비스 계정 Sheets API로 소스 데이터를 읽어야 하므로 필요합니다. + */ + private GoogleDrivePermissionHelper.PermissionApplyStatus grantServiceAccountReadAccess( + Drive userDriveService, + String fileId + ) { + return grantServiceAccountPermission(userDriveService, fileId, "reader"); + } + + /** + * 트랜잭션 완료(성공/실패 모두) 후 소스 파일에서 서비스 계정 권한을 제거합니다. + * 서비스 계정의 파일 접근을 최소화하기 위한 보상 처리입니다. + */ + private void registerSourceFilePermissionCleanup(Drive driveService, String fileId) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCompletion(int status) { + removeServiceAccountPermission(driveService, fileId); + } + }); + } + + private void removeServiceAccountPermission(Drive driveService, String fileId) { + String serviceAccountEmail = serviceAccountCredentials.getClientEmail(); + try { + Permission permission = GoogleDrivePermissionHelper.findServiceAccountPermission( + driveService, + fileId, + serviceAccountEmail + ); + if (permission == null) { + return; + } + + try { + driveService.permissions().delete(fileId, permission.getId()).execute(); + log.info( + "Service account permission removed from source file. fileId={}", + fileId + ); + } catch (IOException ex) { + log.warn( + "Failed to remove service account permission. fileId={}, cause={}", + fileId, ex.getMessage() + ); + } + } catch (IOException e) { + log.warn( + "Failed to list permissions for source file cleanup. fileId={}, cause={}", + fileId, e.getMessage() + ); + } + } + + private void grantServiceAccountAccess(Drive userDriveService, String fileId) { + grantServiceAccountPermission(userDriveService, fileId, "writer"); + } + + /** + * 서비스 계정에 지정된 role로 Drive 접근 권한을 부여하는 공통 메서드입니다. + */ + private GoogleDrivePermissionHelper.PermissionApplyStatus grantServiceAccountPermission( + Drive userDriveService, + String fileId, + String role + ) { + try { + return GoogleDrivePermissionHelper.ensureServiceAccountPermission( + userDriveService, + fileId, + role, + serviceAccountCredentials.getClientEmail() + ); + } catch (IOException e) { + if (GoogleSheetApiExceptionHelper.isInvalidGrant(e)) { + log.warn( + "Google Drive OAuth token is invalid while granting service account permission. " + + "fileId={}, role={}, cause={}", + fileId, + role, + GoogleSheetApiExceptionHelper.extractDetail(e) + ); + throw GoogleSheetApiExceptionHelper.invalidGoogleDriveAuth(e); + } + if (GoogleSheetApiExceptionHelper.isAuthFailure(e)) { + log.warn( + "Google Drive auth failed while granting service account permission. " + + "fileId={}, role={}, cause={}", + fileId, + role, + e.getMessage() + ); + throw CustomException.of(ApiResponseCode.FAILED_INIT_GOOGLE_DRIVE); + } + if (GoogleSheetApiExceptionHelper.isAccessDenied(e)) { + log.warn( + "Google Sheets access denied while granting service account permission. " + + "fileId={}, role={}, cause={}", + fileId, + role, + e.getMessage() + ); + throw GoogleSheetApiExceptionHelper.accessDenied(); + } + log.error( + "Failed to grant service account {} access. fileId={}, cause={}", + role, fileId, e.getMessage(), e + ); + throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); + } + } + + private void registerDriveRollback(Drive driveService, String fileId) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCompletion(int status) { + if (status == TransactionSynchronization.STATUS_ROLLED_BACK) { + deleteFile(driveService, fileId); + } + } + }); + } + + private void deleteFile(Drive driveService, String fileId) { + try { + driveService.files().delete(fileId).execute(); + log.info("Orphaned file deleted. fileId={}", fileId); + } catch (IOException ex) { + log.warn("Failed to delete orphaned file. fileId={}, cause={}", fileId, ex.getMessage()); + } + } + + private String resolveFolderId(Drive driveService, String url, String spreadsheetId) { + Matcher m = FOLDER_ID_PATTERN.matcher(url); + if (m.find()) { + return m.group(1); + } + try { + File file = driveService.files().get(spreadsheetId) + .setFields("parents") + .execute(); + List parents = file.getParents(); + if (parents != null && !parents.isEmpty()) { + return parents.get(0); + } + } catch (IOException e) { + log.warn("Failed to get parent folder of spreadsheet. cause={}", e.getMessage()); + } + return null; + } + + private String copyTemplate(Drive driveService, String templateId, String clubName, String targetFolderId) { + // newFileId를 try 바깥에서 선언하여 예외 경로에서도 고아 파일 정리가 가능하도록 함 + String newFileId = null; + try { + String title = NEW_SHEET_TITLE_PREFIX + clubName; + File copyMetadata = new File().setName(title); + + // Drive API v3에서 files().copy() 바디의 parents 필드는 무시됨. + // 복사 후 files().update()로 addParents/removeParents를 명시적으로 호출해야 폴더 이동이 적용됨. + File copied = driveService.files().copy(templateId, copyMetadata) + .setFields("id, parents") + .execute(); + + newFileId = copied.getId(); + + if (targetFolderId != null) { + List currentParents = copied.getParents(); + // copy() 응답에서 parents가 null로 오는 경우 별도 GET 으로 재조회 + if (currentParents == null || currentParents.isEmpty()) { + try { + File fileInfo = driveService.files().get(newFileId) + .setFields("parents") + .execute(); + currentParents = fileInfo.getParents(); + log.debug( + "Re-fetched parents for copied file. fileId={}, parents={}", + newFileId, currentParents + ); + } catch (IOException ex) { + log.error( + "Failed to re-fetch parents for copied file. fileId={}, cause={}", + newFileId, ex.getMessage() + ); + deleteFile(driveService, newFileId); + throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); + } + } + // parents를 끝내 확보하지 못한 경우, 폴더 이동이 보장되지 않으므로 예외로 처리해 롤백 + if (currentParents == null || currentParents.isEmpty()) { + log.error("Cannot determine parents for copied file. fileId={}", newFileId); + deleteFile(driveService, newFileId); + throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); + } + String removeParents = String.join(",", currentParents); + driveService.files().update(newFileId, new File()) + .setAddParents(targetFolderId) + .setRemoveParents(removeParents) + .setFields("id, parents") + .execute(); + } + + log.info("Template copied by user. newId={}, folderId={}", newFileId, targetFolderId); + return newFileId; + + } catch (IOException e) { + if (newFileId != null) { + deleteFile(driveService, newFileId); + } + if (GoogleSheetApiExceptionHelper.isInvalidGrant(e)) { + log.warn( + "Google Drive OAuth token is invalid while copying template. templateId={}, " + + "targetFolderId={}, cause={}", + templateId, + targetFolderId, + GoogleSheetApiExceptionHelper.extractDetail(e) + ); + throw GoogleSheetApiExceptionHelper.invalidGoogleDriveAuth(e); + } + if (GoogleSheetApiExceptionHelper.isAuthFailure(e)) { + log.warn( + "Google Drive auth failed while copying template. templateId={}, targetFolderId={}, cause={}", + templateId, + targetFolderId, + e.getMessage() + ); + throw CustomException.of(ApiResponseCode.FAILED_INIT_GOOGLE_DRIVE); + } + if (GoogleSheetApiExceptionHelper.isAccessDenied(e)) { + log.warn( + "Google Sheets access denied while copying template. templateId={}, targetFolderId={}, cause={}", + templateId, + targetFolderId, + e.getMessage() + ); + throw GoogleSheetApiExceptionHelper.accessDenied(); + } + log.error("Failed to copy template. cause={}", e.getMessage(), e); + throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); + } + } + + private List> readAllData( + String spreadsheetId, + SheetColumnMapping mapping + ) { + try { + // 이 호출은 서비스 계정 Sheets API를 사용하므로 user OAuth refresh token 기반 invalid_grant는 발생하지 않는다. + int dataStartRow = mapping.getDataStartRow(); + String range = "A" + dataStartRow + ":Z"; + ValueRange response = googleSheetsService.spreadsheets().values() + .get(spreadsheetId, range) + .setValueRenderOption("FORMATTED_VALUE") + .execute(); + + List> values = response.getValues(); + return values != null ? values : List.of(); + + } catch (IOException e) { + if (GoogleSheetApiExceptionHelper.isAccessDenied(e)) { + log.warn( + "Google Sheets access denied while reading source data. spreadsheetId={}, cause={}", + spreadsheetId, + e.getMessage() + ); + throw GoogleSheetApiExceptionHelper.accessDenied(); + } + log.error("Failed to read source data. cause={}", e.getMessage(), e); + throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); + } + } + + private void writeToTemplate( + String newSpreadsheetId, + List> sourceData, + SheetColumnMapping sourceMapping + ) { + if (sourceData.isEmpty()) { + return; + } + + try { + SheetHeaderMapper.SheetAnalysisResult templateAnalysis = + sheetHeaderMapper.analyzeAllSheets(newSpreadsheetId); + SheetColumnMapping targetMapping = templateAnalysis.memberListMapping(); + int targetDataStartRow = targetMapping.getDataStartRow(); + + Map sourceFieldToCol = buildReverseMapping(sourceMapping); + List> targetRows = new ArrayList<>(); + + for (List sourceRow : sourceData) { + List targetRow = buildTargetRow( + sourceRow, sourceFieldToCol, targetMapping + ); + targetRows.add(targetRow); + } + + String range = "A" + targetDataStartRow; + ValueRange body = new ValueRange().setValues(targetRows); + googleSheetsService.spreadsheets().values() + .update(newSpreadsheetId, range, body) + .setValueInputOption("USER_ENTERED") + .execute(); + + log.info( + "Data written to template. rows={}, targetStartRow={}", + targetRows.size(), targetDataStartRow + ); + + } catch (IOException e) { + if (GoogleSheetApiExceptionHelper.isAccessDenied(e)) { + log.warn( + "Google Sheets access denied while writing template data. spreadsheetId={}, cause={}", + newSpreadsheetId, + e.getMessage() + ); + throw GoogleSheetApiExceptionHelper.accessDenied(); + } + log.error("Failed to write data to template. cause={}", e.getMessage(), e); + throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); + } + } + + private Map buildReverseMapping(SheetColumnMapping mapping) { + Map result = new java.util.HashMap<>(); + for (String field : List.of( + SheetColumnMapping.NAME, SheetColumnMapping.STUDENT_ID, + SheetColumnMapping.EMAIL, SheetColumnMapping.PHONE, + SheetColumnMapping.POSITION, SheetColumnMapping.JOINED_AT + )) { + int colIndex = mapping.getColumnIndex(field); + if (colIndex >= 0) { + result.put(field, colIndex); + } + } + return result; + } + + private List buildTargetRow( + List sourceRow, + Map sourceFieldToCol, + SheetColumnMapping targetMapping + ) { + int maxCol = targetMapping.toMap().values().stream() + .filter(v -> v instanceof Integer) + .mapToInt(v -> (Integer)v) + .max() + .orElse(0); + + List row = new ArrayList<>( + Collections.nCopies(maxCol + 1, "") + ); + + for (Map.Entry entry : sourceFieldToCol.entrySet()) { + String field = entry.getKey(); + int sourceCol = entry.getValue(); + int targetCol = targetMapping.getColumnIndex(field); + + if (targetCol >= 0 && sourceCol < sourceRow.size()) { + Object cellValue = sourceRow.get(sourceCol); + // 전화번호 컬럼은 010-xxxx-xxxx 형식으로 포맷팅 (0 잘림 복구 포함) + if (SheetColumnMapping.PHONE.equals(field) && cellValue != null) { + cellValue = PhoneNumberNormalizer.format(cellValue.toString()); + } + row.set(targetCol, cellValue); + } + } + + return row; + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java new file mode 100644 index 000000000..31309e4e9 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java @@ -0,0 +1,365 @@ +package gg.agit.konect.domain.club.service; + +import java.io.IOException; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.api.services.sheets.v4.Sheets; +import com.google.api.services.sheets.v4.model.BasicFilter; +import com.google.api.services.sheets.v4.model.BatchClearValuesRequest; +import com.google.api.services.sheets.v4.model.BatchUpdateSpreadsheetRequest; +import com.google.api.services.sheets.v4.model.BatchUpdateValuesRequest; +import com.google.api.services.sheets.v4.model.ClearValuesRequest; +import com.google.api.services.sheets.v4.model.GridProperties; +import com.google.api.services.sheets.v4.model.GridRange; +import com.google.api.services.sheets.v4.model.Request; +import com.google.api.services.sheets.v4.model.SetBasicFilterRequest; +import com.google.api.services.sheets.v4.model.SheetProperties; +import com.google.api.services.sheets.v4.model.UpdateSheetPropertiesRequest; +import com.google.api.services.sheets.v4.model.ValueRange; + +import gg.agit.konect.domain.club.event.SheetSyncFailedEvent; +import gg.agit.konect.domain.club.enums.ClubSheetSortKey; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.ClubMember; +import gg.agit.konect.domain.club.model.ClubPreMember; +import gg.agit.konect.domain.club.model.SheetColumnMapping; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.global.util.PhoneNumberNormalizer; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SheetSyncExecutor { + + private static final String SHEET_RANGE = "A1"; + private static final int ALPHABET_SIZE = 26; + private static final DateTimeFormatter DATE_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + private static final List HEADER_ROW = List.of( + "Name", "StudentId", "Email", "Phone", "Position", "JoinedAt" + ); + + private final Sheets googleSheetsService; + private final ClubRepository clubRepository; + private final ClubMemberRepository clubMemberRepository; + private final ClubPreMemberRepository clubPreMemberRepository; + private final ObjectMapper objectMapper; + private final ApplicationEventPublisher applicationEventPublisher; + + @Async("sheetSyncTaskExecutor") + @Transactional(readOnly = true) + public void executeWithSort(Integer clubId, ClubSheetSortKey sortKey, boolean ascending) { + Club club = clubRepository.getById(clubId); + String spreadsheetId = club.getGoogleSheetId(); + if (spreadsheetId == null || spreadsheetId.isBlank()) { + return; + } + + SheetColumnMapping mapping = resolveMapping(club); + List members = clubMemberRepository.findAllByClubId(clubId); + List preMembers = clubPreMemberRepository.findAllByClubId(clubId); + List sorted = sort(toSheetSyncRows(members, preMembers), sortKey, ascending); + + try { + if (club.getSheetColumnMapping() != null) { + updateMappedColumns(spreadsheetId, sorted, mapping); + } else { + clearAndWriteAll(spreadsheetId, sorted); + applyFormat(spreadsheetId); + } + log.info( + "Sheet sync done. clubId={}, members={}, preMembers={}", + clubId, + members.size(), + preMembers.size() + ); + } catch (IOException e) { + if (GoogleSheetApiExceptionHelper.isAccessDenied(e)) { + log.warn( + "Google Sheets access denied during sheet sync. clubId={}, spreadsheetId={}, cause={}", + clubId, + spreadsheetId, + e.getMessage() + ); + applicationEventPublisher.publishEvent( + SheetSyncFailedEvent.accessDenied(clubId, spreadsheetId, e.getMessage()) + ); + return; + } + log.error( + "Sheet sync failed. clubId={}, spreadsheetId={}, cause={}", + clubId, spreadsheetId, e.getMessage(), e + ); + applicationEventPublisher.publishEvent( + SheetSyncFailedEvent.unexpected(clubId, spreadsheetId, e.getMessage()) + ); + } + } + + private SheetColumnMapping resolveRawMapping(String mappingJson) { + try { + Map raw = objectMapper.readValue( + mappingJson, new TypeReference<>() {} + ); + int dataStartRow = raw.containsKey("dataStartRow") + ? ((Number)raw.get("dataStartRow")).intValue() : 2; + Map fieldMap = new HashMap<>(); + raw.forEach((key, value) -> { + if (!"dataStartRow".equals(key) && value instanceof Number num) { + fieldMap.put(key, num.intValue()); + } + }); + return new SheetColumnMapping(fieldMap, dataStartRow); + } catch (Exception e) { + log.warn("Failed to parse raw mapping, using default. cause={}", e.getMessage()); + return SheetColumnMapping.defaultMapping(); + } + } + + private SheetColumnMapping resolveMapping(Club club) { + String mappingJson = club.getSheetColumnMapping(); + if (mappingJson == null || mappingJson.isBlank()) { + return SheetColumnMapping.defaultMapping(); + } + return resolveRawMapping(mappingJson); + } + + private void updateMappedColumns( + String spreadsheetId, + List members, + SheetColumnMapping mapping + ) throws IOException { + int dataStartRow = mapping.getDataStartRow(); + clearMappedColumns(spreadsheetId, mapping, dataStartRow); + Map> columnData = buildColumnData(members, mapping); + + List data = new ArrayList<>(); + for (Map.Entry> entry : columnData.entrySet()) { + int colIndex = entry.getKey(); + String colLetter = columnLetter(colIndex); + String range = colLetter + dataStartRow + ":" + colLetter; + List> wrapped = + entry.getValue().stream().map(v -> List.of((Object)v)).toList(); + data.add(new ValueRange().setRange(range).setValues(wrapped)); + } + + if (!data.isEmpty()) { + googleSheetsService.spreadsheets().values() + .batchUpdate(spreadsheetId, + new BatchUpdateValuesRequest() + .setValueInputOption("USER_ENTERED") + .setData(data)) + .execute(); + } + } + + private void clearMappedColumns( + String spreadsheetId, + SheetColumnMapping mapping, + int dataStartRow + ) throws IOException { + List clearRanges = new ArrayList<>(); + for (String field : List.of( + SheetColumnMapping.NAME, SheetColumnMapping.STUDENT_ID, SheetColumnMapping.EMAIL, + SheetColumnMapping.PHONE, SheetColumnMapping.POSITION, SheetColumnMapping.JOINED_AT + )) { + int colIndex = mapping.getColumnIndex(field); + if (colIndex >= 0) { + String colLetter = columnLetter(colIndex); + clearRanges.add(colLetter + dataStartRow + ":" + colLetter); + } + } + if (!clearRanges.isEmpty()) { + googleSheetsService.spreadsheets().values() + .batchClear(spreadsheetId, new BatchClearValuesRequest().setRanges(clearRanges)) + .execute(); + } + } + + private Map> buildColumnData( + List members, + SheetColumnMapping mapping + ) { + Map> columns = new HashMap<>(); + + for (SheetSyncRow member : members) { + putValue(columns, mapping, SheetColumnMapping.NAME, + member.name()); + putValue(columns, mapping, SheetColumnMapping.STUDENT_ID, + member.studentNumber()); + putValue(columns, mapping, SheetColumnMapping.EMAIL, + member.email()); + putValue(columns, mapping, SheetColumnMapping.PHONE, + member.phone()); + putValue(columns, mapping, SheetColumnMapping.POSITION, + member.positionDescription()); + putValue(columns, mapping, SheetColumnMapping.JOINED_AT, + member.joinedAt()); + } + + return columns; + } + + private void putValue( + Map> columns, + SheetColumnMapping mapping, + String field, + Object value + ) { + int colIndex = mapping.getColumnIndex(field); + if (colIndex >= 0) { + columns.computeIfAbsent(colIndex, k -> new ArrayList<>()).add(value != null ? value : ""); + } + } + + private void clearAndWriteAll( + String spreadsheetId, + List members + ) throws IOException { + String clearRange = "A:F"; + googleSheetsService.spreadsheets().values() + .clear(spreadsheetId, clearRange, new ClearValuesRequest()) + .execute(); + + List> rows = new ArrayList<>(); + rows.add(HEADER_ROW); + + for (SheetSyncRow member : members) { + rows.add(List.of( + member.name(), + member.studentNumber(), + member.email(), + member.phone(), + member.positionDescription(), + member.joinedAt() + )); + } + + ValueRange body = new ValueRange().setValues(rows); + googleSheetsService.spreadsheets().values() + .update(spreadsheetId, SHEET_RANGE, body) + .setValueInputOption("USER_ENTERED") + .execute(); + } + + private void applyFormat(String spreadsheetId) throws IOException { + List requests = new ArrayList<>(); + + requests.add(new Request().setUpdateSheetProperties( + new UpdateSheetPropertiesRequest() + .setProperties(new SheetProperties() + .setGridProperties(new GridProperties().setFrozenRowCount(1))) + .setFields("gridProperties.frozenRowCount") + )); + + requests.add(new Request().setSetBasicFilter( + new SetBasicFilterRequest() + .setFilter(new BasicFilter() + .setRange(new GridRange().setSheetId(0))) + )); + + googleSheetsService.spreadsheets() + .batchUpdate(spreadsheetId, new BatchUpdateSpreadsheetRequest().setRequests(requests)) + .execute(); + } + + private List sort( + List members, + ClubSheetSortKey sortKey, + boolean ascending + ) { + Comparator comparator = switch (sortKey) { + case NAME -> Comparator.comparing(SheetSyncRow::name); + case STUDENT_ID -> Comparator.comparing(SheetSyncRow::studentNumber); + case POSITION -> Comparator.comparingInt(SheetSyncRow::positionPriority); + case JOINED_AT -> Comparator.comparing(SheetSyncRow::joinedAtRaw); + + }; + + if (!ascending) { + comparator = comparator.reversed(); + } + + return members.stream().sorted(comparator).toList(); + } + + private List toSheetSyncRows( + List members, + List preMembers + ) { + List rows = new ArrayList<>(members.size() + preMembers.size()); + for (ClubMember member : members) { + rows.add(SheetSyncRow.from(member)); + } + for (ClubPreMember preMember : preMembers) { + rows.add(SheetSyncRow.from(preMember)); + } + return rows; + } + + private String columnLetter(int index) { + StringBuilder sb = new StringBuilder(); + index++; + while (index > 0) { + index--; + sb.insert(0, (char)('A' + index % ALPHABET_SIZE)); + index /= ALPHABET_SIZE; + } + return sb.toString(); + } + + private record SheetSyncRow( + String name, + String studentNumber, + String email, + String phone, + String positionDescription, + int positionPriority, + String joinedAt, + java.time.LocalDateTime joinedAtRaw + ) { + private static SheetSyncRow from(ClubMember member) { + String phone = PhoneNumberNormalizer.format(member.getUser().getPhoneNumber()); + return new SheetSyncRow( + member.getUser().getName(), + member.getUser().getStudentNumber(), + member.getUser().getEmail(), + phone != null ? phone : "", + member.getClubPosition().getDescription(), + member.getClubPosition().getPriority(), + member.getCreatedAt().format(DATE_FORMATTER), + member.getCreatedAt() + ); + } + + private static SheetSyncRow from(ClubPreMember preMember) { + return new SheetSyncRow( + preMember.getName(), + preMember.getStudentNumber(), + "", + "", + preMember.getClubPosition().getDescription(), + preMember.getClubPosition().getPriority(), + preMember.getCreatedAt().format(DATE_FORMATTER), + preMember.getCreatedAt() + ); + } + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/SpreadsheetUrlParser.java b/src/main/java/gg/agit/konect/domain/club/service/SpreadsheetUrlParser.java new file mode 100644 index 000000000..b86a4c339 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/SpreadsheetUrlParser.java @@ -0,0 +1,24 @@ +package gg.agit.konect.domain.club.service; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; + +public final class SpreadsheetUrlParser { + + private static final Pattern SPREADSHEET_ID_PATTERN = + Pattern.compile("/spreadsheets/(?:u/\\d+/)?d/([a-zA-Z0-9_-]+)"); + + private SpreadsheetUrlParser() { + } + + public static String extractId(String url) { + Matcher m = SPREADSHEET_ID_PATTERN.matcher(url); + if (m.find()) { + return m.group(1); + } + throw CustomException.of(ApiResponseCode.INVALID_REQUEST_BODY); + } +} diff --git a/src/main/java/gg/agit/konect/domain/notification/controller/NotificationInboxApi.java b/src/main/java/gg/agit/konect/domain/notification/controller/NotificationInboxApi.java new file mode 100644 index 000000000..3d782842f --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/notification/controller/NotificationInboxApi.java @@ -0,0 +1,48 @@ +package gg.agit.konect.domain.notification.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import gg.agit.konect.domain.notification.dto.NotificationInboxesResponse; +import gg.agit.konect.domain.notification.dto.NotificationInboxUnreadCountResponse; +import gg.agit.konect.global.auth.annotation.UserId; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Min; + +@Validated +@Tag(name = "(Normal) Notification: 알림", description = "알림 API") +@RequestMapping("/notifications/inbox") +public interface NotificationInboxApi { + + @Operation(summary = "인앱 알림 목록을 조회한다.") + @GetMapping + ResponseEntity getMyInboxes( + @UserId Integer userId, + @RequestParam(defaultValue = "1") @Min(1) int page + ); + + @Operation(summary = "인앱 알림 미읽음 개수를 조회한다.") + @GetMapping("/unread-count") + ResponseEntity getUnreadCount( + @UserId Integer userId + ); + + @Operation(summary = "인앱 알림을 읽음 처리한다.") + @PatchMapping("/{notificationId}/read") + ResponseEntity markAsRead( + @UserId Integer userId, + @PathVariable Integer notificationId + ); + + @Operation(summary = "인앱 알림 전체를 읽음 처리한다.") + @PatchMapping("/read-all") + ResponseEntity markAllAsRead( + @UserId Integer userId + ); +} diff --git a/src/main/java/gg/agit/konect/domain/notification/controller/NotificationInboxController.java b/src/main/java/gg/agit/konect/domain/notification/controller/NotificationInboxController.java new file mode 100644 index 000000000..83b0e5f08 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/notification/controller/NotificationInboxController.java @@ -0,0 +1,42 @@ +package gg.agit.konect.domain.notification.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import gg.agit.konect.domain.notification.dto.NotificationInboxesResponse; +import gg.agit.konect.domain.notification.dto.NotificationInboxUnreadCountResponse; +import gg.agit.konect.domain.notification.service.NotificationInboxService; +import lombok.RequiredArgsConstructor; + +@Validated +@RestController +@RequiredArgsConstructor +@RequestMapping("/notifications/inbox") +public class NotificationInboxController implements NotificationInboxApi { + + private final NotificationInboxService notificationInboxService; + + @Override + public ResponseEntity getMyInboxes(Integer userId, int page) { + return ResponseEntity.ok(notificationInboxService.getMyInboxes(userId, page)); + } + + @Override + public ResponseEntity getUnreadCount(Integer userId) { + return ResponseEntity.ok(notificationInboxService.getUnreadCount(userId)); + } + + @Override + public ResponseEntity markAsRead(Integer userId, Integer notificationId) { + notificationInboxService.markAsRead(userId, notificationId); + return ResponseEntity.ok().build(); + } + + @Override + public ResponseEntity markAllAsRead(Integer userId) { + notificationInboxService.markAllAsRead(userId); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/gg/agit/konect/domain/notification/controller/NotificationInboxSseApi.java b/src/main/java/gg/agit/konect/domain/notification/controller/NotificationInboxSseApi.java new file mode 100644 index 000000000..f783f374b --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/notification/controller/NotificationInboxSseApi.java @@ -0,0 +1,19 @@ +package gg.agit.konect.domain.notification.controller; + +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import gg.agit.konect.global.auth.annotation.UserId; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "(Normal) Notification: 알림", description = "알림 API") +@RequestMapping("/notifications/inbox") +public interface NotificationInboxSseApi { + + @Operation(summary = "인앱 알림 SSE 구독을 시작한다.") + @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + SseEmitter subscribe(@UserId Integer userId); +} diff --git a/src/main/java/gg/agit/konect/domain/notification/controller/NotificationInboxSseController.java b/src/main/java/gg/agit/konect/domain/notification/controller/NotificationInboxSseController.java new file mode 100644 index 000000000..28ede1147 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/notification/controller/NotificationInboxSseController.java @@ -0,0 +1,21 @@ +package gg.agit.konect.domain.notification.controller; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import gg.agit.konect.domain.notification.service.NotificationInboxSseService; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/notifications/inbox") +public class NotificationInboxSseController implements NotificationInboxSseApi { + + private final NotificationInboxSseService notificationInboxSseService; + + @Override + public SseEmitter subscribe(Integer userId) { + return notificationInboxSseService.subscribe(userId); + } +} diff --git a/src/main/java/gg/agit/konect/domain/notification/dto/NotificationInboxResponse.java b/src/main/java/gg/agit/konect/domain/notification/dto/NotificationInboxResponse.java new file mode 100644 index 000000000..a28c4ed7e --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/notification/dto/NotificationInboxResponse.java @@ -0,0 +1,42 @@ +package gg.agit.konect.domain.notification.dto; + +import java.time.LocalDateTime; + +import gg.agit.konect.domain.notification.enums.NotificationInboxType; +import gg.agit.konect.domain.notification.model.NotificationInbox; +import io.swagger.v3.oas.annotations.media.Schema; + +public record NotificationInboxResponse( + @Schema(description = "알림 ID", example = "1") + Integer id, + + @Schema(description = "알림 타입", example = "CLUB_APPLICATION_SUBMITTED") + NotificationInboxType type, + + @Schema(description = "알림 제목", example = "동아리 가입 신청이 접수되었습니다") + String title, + + @Schema(description = "알림 내용", example = "신청하신 동아리의 가입 신청이 접수되었습니다") + String body, + + @Schema(description = "알림 클릭 시 이동할 경로", example = "/clubs/1") + String path, + + @Schema(description = "읽음 여부", example = "false") + Boolean isRead, + + @Schema(description = "알림 생성 시간", example = "2024-01-15T10:30:00") + LocalDateTime createdAt +) { + public static NotificationInboxResponse from(NotificationInbox inbox) { + return new NotificationInboxResponse( + inbox.getId(), + inbox.getType(), + inbox.getTitle(), + inbox.getBody(), + inbox.getPath(), + inbox.getIsRead(), + inbox.getCreatedAt() + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/notification/dto/NotificationInboxUnreadCountResponse.java b/src/main/java/gg/agit/konect/domain/notification/dto/NotificationInboxUnreadCountResponse.java new file mode 100644 index 000000000..7aa7e12c5 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/notification/dto/NotificationInboxUnreadCountResponse.java @@ -0,0 +1,12 @@ +package gg.agit.konect.domain.notification.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record NotificationInboxUnreadCountResponse( + @Schema(description = "미읽은 알림 개수", example = "5") + long unreadCount +) { + public static NotificationInboxUnreadCountResponse of(long count) { + return new NotificationInboxUnreadCountResponse(count); + } +} diff --git a/src/main/java/gg/agit/konect/domain/notification/dto/NotificationInboxesResponse.java b/src/main/java/gg/agit/konect/domain/notification/dto/NotificationInboxesResponse.java new file mode 100644 index 000000000..667ab5d42 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/notification/dto/NotificationInboxesResponse.java @@ -0,0 +1,35 @@ +package gg.agit.konect.domain.notification.dto; + +import java.util.List; + +import org.springframework.data.domain.Page; + +import gg.agit.konect.domain.notification.model.NotificationInbox; +import io.swagger.v3.oas.annotations.media.Schema; + +public record NotificationInboxesResponse( + @Schema(description = "알림 목록") + List notifications, + + @Schema(description = "현재 페이지 번호 (1부터 시작)", example = "1") + int currentPage, + + @Schema(description = "총 페이지 수", example = "10") + int totalPages, + + @Schema(description = "총 알림 개수", example = "100") + long totalElements, + + @Schema(description = "다음 페이지 존재 여부", example = "true") + boolean hasNext +) { + public static NotificationInboxesResponse from(Page page) { + return new NotificationInboxesResponse( + page.getContent().stream().map(NotificationInboxResponse::from).toList(), + page.getNumber() + 1, + page.getTotalPages(), + page.getTotalElements(), + page.hasNext() + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/notification/enums/NotificationInboxType.java b/src/main/java/gg/agit/konect/domain/notification/enums/NotificationInboxType.java new file mode 100644 index 000000000..563020a46 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/notification/enums/NotificationInboxType.java @@ -0,0 +1,16 @@ +package gg.agit.konect.domain.notification.enums; + +public enum NotificationInboxType { + CLUB_APPLICATION_SUBMITTED, + CLUB_APPLICATION_APPROVED, + CLUB_APPLICATION_REJECTED, + CHAT_MESSAGE, + GROUP_CHAT_MESSAGE, + UNREAD_CHAT_COUNT; + + public boolean isChatRelated() { + return this == CHAT_MESSAGE + || this == GROUP_CHAT_MESSAGE + || this == UNREAD_CHAT_COUNT; + } +} diff --git a/src/main/java/gg/agit/konect/domain/notification/listener/ClubApplicationNotificationListener.java b/src/main/java/gg/agit/konect/domain/notification/listener/ClubApplicationNotificationListener.java index 6c3d2ea76..43143483d 100644 --- a/src/main/java/gg/agit/konect/domain/notification/listener/ClubApplicationNotificationListener.java +++ b/src/main/java/gg/agit/konect/domain/notification/listener/ClubApplicationNotificationListener.java @@ -27,12 +27,14 @@ public void handleClubApplicationApproved(ClubApplicationApprovedEvent event) { @TransactionalEventListener(phase = AFTER_COMMIT) public void handleClubApplicationSubmitted(ClubApplicationSubmittedEvent event) { - notificationService.sendClubApplicationSubmittedNotification( - event.receiverId(), - event.applicationId(), - event.clubId(), - event.clubName(), - event.applicantName() + event.receiverIds().forEach(receiverId -> + notificationService.sendClubApplicationSubmittedNotification( + receiverId, + event.applicationId(), + event.clubId(), + event.clubName(), + event.applicantName() + ) ); } } diff --git a/src/main/java/gg/agit/konect/domain/notification/model/NotificationInbox.java b/src/main/java/gg/agit/konect/domain/notification/model/NotificationInbox.java new file mode 100644 index 000000000..eea61b21b --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/notification/model/NotificationInbox.java @@ -0,0 +1,83 @@ +package gg.agit.konect.domain.notification.model; + +import static jakarta.persistence.EnumType.STRING; +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import gg.agit.konect.domain.notification.enums.NotificationInboxType; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.global.model.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "notification_inbox") +@NoArgsConstructor(access = PROTECTED) +public class NotificationInbox extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + @Column(name = "id", nullable = false, updatable = false, unique = true) + private Integer id; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(STRING) + @Column(name = "type", nullable = false, length = 50) + private NotificationInboxType type; + + @Column(name = "title", nullable = false, length = 100) + private String title; + + @Column(name = "body", nullable = false, length = 300) + private String body; + + @Column(name = "path", length = 200) + private String path; + + @Column(name = "is_read", nullable = false) + private Boolean isRead; + + @Builder + private NotificationInbox(User user, NotificationInboxType type, String title, String body, String path) { + this.user = user; + this.type = type; + this.title = title; + this.body = body; + this.path = path; + this.isRead = false; + } + + public static NotificationInbox of( + User user, + NotificationInboxType type, + String title, + String body, + String path + ) { + return NotificationInbox.builder() + .user(user) + .type(type) + .title(title) + .body(body) + .path(path) + .build(); + } + + public void markAsRead() { + this.isRead = true; + } +} diff --git a/src/main/java/gg/agit/konect/domain/notification/repository/NotificationDeviceTokenRepository.java b/src/main/java/gg/agit/konect/domain/notification/repository/NotificationDeviceTokenRepository.java index df221ab8d..bd5f89094 100644 --- a/src/main/java/gg/agit/konect/domain/notification/repository/NotificationDeviceTokenRepository.java +++ b/src/main/java/gg/agit/konect/domain/notification/repository/NotificationDeviceTokenRepository.java @@ -35,9 +35,18 @@ Optional findByUserIdAndToken( SELECT ndt.token FROM NotificationDeviceToken ndt WHERE ndt.user.id = :userId + AND ndt.user.deletedAt IS NULL """) List findTokensByUserId(@Param("userId") Integer userId); + @Query(""" + SELECT ndt.token + FROM NotificationDeviceToken ndt + WHERE ndt.user.id IN :userIds + AND ndt.user.deletedAt IS NULL + """) + List findTokensByUserIds(@Param("userIds") List userIds); + void save(NotificationDeviceToken notificationDeviceToken); void delete(NotificationDeviceToken notificationDeviceToken); diff --git a/src/main/java/gg/agit/konect/domain/notification/repository/NotificationInboxRepository.java b/src/main/java/gg/agit/konect/domain/notification/repository/NotificationInboxRepository.java new file mode 100644 index 000000000..e00c6eb87 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/notification/repository/NotificationInboxRepository.java @@ -0,0 +1,55 @@ +package gg.agit.konect.domain.notification.repository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; + +import gg.agit.konect.domain.notification.enums.NotificationInboxType; +import gg.agit.konect.domain.notification.model.NotificationInbox; +import gg.agit.konect.global.exception.CustomException; + +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_NOTIFICATION_INBOX; + +public interface NotificationInboxRepository extends Repository { + + NotificationInbox save(NotificationInbox notificationInbox); + + List saveAll(Iterable notificationInboxes); + + Page findAllByUserIdAndTypeNotInOrderByCreatedAtDescIdDesc( + Integer userId, + Collection excludedTypes, + Pageable pageable + ); + + long countByUserIdAndIsReadFalse(Integer userId); + + long countByUserIdAndIsReadFalseAndTypeNotIn(Integer userId, Collection excludedTypes); + + Optional findByIdAndUserId(Integer id, Integer userId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE NotificationInbox n + SET n.isRead = true + WHERE n.user.id = :userId + AND n.isRead = false + AND n.type NOT IN :excludedTypes + """) + void markAllAsReadByUserIdAndTypeNotIn( + @Param("userId") Integer userId, + @Param("excludedTypes") Collection excludedTypes + ); + + default NotificationInbox getByIdAndUserId(Integer id, Integer userId) { + return findByIdAndUserId(id, userId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_NOTIFICATION_INBOX)); + } +} diff --git a/src/main/java/gg/agit/konect/domain/notification/repository/NotificationMuteSettingRepository.java b/src/main/java/gg/agit/konect/domain/notification/repository/NotificationMuteSettingRepository.java index f85e5fd7e..01bd1f529 100644 --- a/src/main/java/gg/agit/konect/domain/notification/repository/NotificationMuteSettingRepository.java +++ b/src/main/java/gg/agit/konect/domain/notification/repository/NotificationMuteSettingRepository.java @@ -2,6 +2,7 @@ import java.util.List; import java.util.Optional; +import java.util.Set; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; @@ -51,4 +52,18 @@ List findByTargetTypeAndTargetIdAndIsMutedTrue( @Param("targetType") NotificationTargetType targetType, @Param("targetId") Integer targetId ); + + @Query(""" + SELECT s.user.id + FROM NotificationMuteSetting s + WHERE s.targetType = :targetType + AND s.targetId = :targetId + AND s.user.id IN :userIds + AND s.isMuted = true + """) + Set findMutedUserIdsByTargetTypeAndTargetIdAndUserIds( + @Param("targetType") NotificationTargetType targetType, + @Param("targetId") Integer targetId, + @Param("userIds") List userIds + ); } diff --git a/src/main/java/gg/agit/konect/domain/notification/service/ExpoPushClient.java b/src/main/java/gg/agit/konect/domain/notification/service/ExpoPushClient.java index 50e280177..82b5d6904 100644 --- a/src/main/java/gg/agit/konect/domain/notification/service/ExpoPushClient.java +++ b/src/main/java/gg/agit/konect/domain/notification/service/ExpoPushClient.java @@ -1,5 +1,6 @@ package gg.agit.konect.domain.notification.service; +import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -25,6 +26,7 @@ public class ExpoPushClient { private static final String EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send"; private static final String DEFAULT_NOTIFICATION_CHANNEL_ID = "default_notifications"; + private static final int BATCH_SIZE = 100; private final RestTemplate expoRestTemplate; @@ -84,6 +86,73 @@ public void sendNotification(Integer receiverId, List tokens, String tit log.debug("알림 발송 완료: receiverId={}, tokenCount={}", receiverId, tokens.size()); } + public void sendBatchNotifications(List messages) { + if (messages == null || messages.isEmpty()) { + return; + } + + List> batches = partition(messages, BATCH_SIZE); + + for (List batch : batches) { + sendSingleBatch(batch); + } + + log.debug("배치 알림 발송 완료: messageCount={}", messages.size()); + } + + @Retryable(maxAttempts = 2) + public void sendSingleBatch(List batch) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setAccept(List.of(MediaType.APPLICATION_JSON)); + + HttpEntity> entity = new HttpEntity<>(batch, headers); + ResponseEntity response = expoRestTemplate.exchange( + EXPO_PUSH_URL, + HttpMethod.POST, + entity, + ExpoPushResponse.class + ); + + if (!response.getStatusCode().is2xxSuccessful()) { + throw new IllegalStateException( + "Expo push batch response not successful: status=%s" + .formatted(response.getStatusCode()) + ); + } + + ExpoPushResponse responseBody = response.getBody(); + if (responseBody == null || responseBody.data() == null) { + throw new IllegalStateException("Expo push batch response body missing"); + } + + for (int i = 0; i < responseBody.data().size(); i += 1) { + ExpoPushTicket ticket = responseBody.data().get(i); + if (ticket == null || "ok".equalsIgnoreCase(ticket.status())) { + continue; + } + String token = i < batch.size() ? batch.get(i).to() : "unknown"; + log.error( + "Expo 푸시 배치 발송 실패: token={}, status={}, message={}, details={}", + token, + ticket.status(), + ticket.message(), + ticket.details() + ); + } + } + + private List> partition(List list, int size) { + List> partitions = new ArrayList<>(); + for (int i = 0; i < list.size(); i += size) { + partitions.add(list.subList(i, Math.min(i + size, list.size()))); + } + return partitions; + } + + public record ExpoPushMessage(String to, String title, String body, Map data, String channelId) { + } + @Recover public void sendNotificationRecover(HttpStatusCodeException e, Integer receiverId, List tokens, String title, @@ -142,6 +211,61 @@ public void sendNotificationRecover(RestClientException e, Integer receiverId, L ); } + @Recover + public void sendSingleBatchRecover(HttpStatusCodeException e, List batch) { + log.error( + "배치 알림 재시도 후에도 HTTP 오류로 발송에 실패했습니다: batchSize={}, statusCode={}, responseBody={}", + batch.size(), + e.getStatusCode(), + e.getResponseBodyAsString(), + e + ); + } + + @Recover + public void sendSingleBatchRecover(ResourceAccessException e, List batch) { + Throwable rootCause = e.getMostSpecificCause(); + log.error( + "배치 알림 재시도 후에도 연결 문제로 발송에 실패했습니다: batchSize={}, rootCauseType={}, rootCauseMessage={}", + batch.size(), + rootCause.getClass().getSimpleName(), + rootCause.getMessage(), + e + ); + } + + @Recover + public void sendSingleBatchRecover(IllegalStateException e, List batch) { + log.error( + "배치 알림 재시도 후에도 Expo 응답이 비정상이라 발송에 실패했습니다: batchSize={}, message={}", + batch.size(), + e.getMessage(), + e + ); + } + + @Recover + public void sendSingleBatchRecover(RestClientException e, List batch) { + log.error( + "배치 알림 재시도 후에도 Rest 클라이언트 오류로 발송에 실패했습니다: batchSize={}, exceptionType={}, message={}", + batch.size(), + e.getClass().getSimpleName(), + e.getMessage(), + e + ); + } + + @Recover + public void sendSingleBatchRecover(Exception e, List batch) { + log.error( + "배치 알림 재시도 후에도 예기치 못한 오류로 발송에 실패했습니다: batchSize={}, exceptionType={}, message={}", + batch.size(), + e.getClass().getSimpleName(), + e.getMessage(), + e + ); + } + @Recover public void sendNotificationRecover(Exception e, Integer receiverId, List tokens, String title, String body, Map data) { @@ -155,9 +279,6 @@ public void sendNotificationRecover(Exception e, Integer receiverId, List data, String channelId) { - } - private record ExpoPushResponse(List data) { } diff --git a/src/main/java/gg/agit/konect/domain/notification/service/NotificationInboxService.java b/src/main/java/gg/agit/konect/domain/notification/service/NotificationInboxService.java new file mode 100644 index 000000000..e617d65e2 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/notification/service/NotificationInboxService.java @@ -0,0 +1,105 @@ +package gg.agit.konect.domain.notification.service; + +import java.util.Arrays; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import gg.agit.konect.domain.notification.dto.NotificationInboxResponse; +import gg.agit.konect.domain.notification.dto.NotificationInboxUnreadCountResponse; +import gg.agit.konect.domain.notification.dto.NotificationInboxesResponse; +import gg.agit.konect.domain.notification.enums.NotificationInboxType; +import gg.agit.konect.domain.notification.model.NotificationInbox; +import gg.agit.konect.domain.notification.repository.NotificationInboxRepository; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class NotificationInboxService { + + private static final int DEFAULT_PAGE_SIZE = 20; + private static final Set CHAT_NOTIFICATION_TYPES = + Arrays.stream(NotificationInboxType.values()) + .filter(NotificationInboxType::isChatRelated) + .collect(() -> EnumSet.noneOf(NotificationInboxType.class), Set::add, Set::addAll); + + private final NotificationInboxRepository notificationInboxRepository; + private final UserRepository userRepository; + private final NotificationInboxSseService notificationInboxSseService; + + @Transactional + public NotificationInbox save(Integer userId, NotificationInboxType type, String title, String body, String path) { + User user = userRepository.getById(userId); + return notificationInboxRepository.save(NotificationInbox.of(user, type, title, body, path)); + } + + @Transactional + public List saveAll( + List userIds, + NotificationInboxType type, + String title, + String body, + String path + ) { + if (userIds == null || userIds.isEmpty()) { + return List.of(); + } + + List users = userRepository.findAllByIdIn(userIds); + List inboxes = users.stream() + .map(user -> NotificationInbox.of(user, type, title, body, path)) + .toList(); + + return notificationInboxRepository.saveAll(inboxes); + } + + public void sendSse(Integer userId, NotificationInboxResponse response) { + try { + notificationInboxSseService.send(userId, response); + } catch (Exception e) { + log.warn("Failed to send SSE notification: userId={}", userId, e); + } + } + + public void sendSseBatch(List inboxes) { + for (NotificationInbox inbox : inboxes) { + sendSse(inbox.getUser().getId(), NotificationInboxResponse.from(inbox)); + } + } + + public NotificationInboxesResponse getMyInboxes(Integer userId, int page) { + PageRequest pageable = PageRequest.of(page - 1, DEFAULT_PAGE_SIZE); + Page result = notificationInboxRepository + .findAllByUserIdAndTypeNotInOrderByCreatedAtDescIdDesc(userId, CHAT_NOTIFICATION_TYPES, pageable); + return NotificationInboxesResponse.from(result); + } + + public NotificationInboxUnreadCountResponse getUnreadCount(Integer userId) { + long count = notificationInboxRepository.countByUserIdAndIsReadFalseAndTypeNotIn( + userId, + CHAT_NOTIFICATION_TYPES + ); + return NotificationInboxUnreadCountResponse.of(count); + } + + @Transactional + public void markAsRead(Integer userId, Integer notificationId) { + NotificationInbox inbox = notificationInboxRepository.getByIdAndUserId(notificationId, userId); + inbox.markAsRead(); + } + + @Transactional + public void markAllAsRead(Integer userId) { + notificationInboxRepository.markAllAsReadByUserIdAndTypeNotIn(userId, CHAT_NOTIFICATION_TYPES); + } +} diff --git a/src/main/java/gg/agit/konect/domain/notification/service/NotificationInboxSseService.java b/src/main/java/gg/agit/konect/domain/notification/service/NotificationInboxSseService.java new file mode 100644 index 000000000..4f357d06c --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/notification/service/NotificationInboxSseService.java @@ -0,0 +1,56 @@ +package gg.agit.konect.domain.notification.service; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import gg.agit.konect.domain.notification.dto.NotificationInboxResponse; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +public class NotificationInboxSseService { + + private static final long SSE_TIMEOUT_MS = 30 * 60 * 1000L; + + private final Map emitters = new ConcurrentHashMap<>(); + + public SseEmitter subscribe(Integer userId) { + SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS); + + emitter.onCompletion(() -> emitters.remove(userId, emitter)); + emitter.onTimeout(() -> emitters.remove(userId, emitter)); + emitter.onError(e -> emitters.remove(userId, emitter)); + + SseEmitter previous = emitters.put(userId, emitter); + if (previous != null) { + previous.complete(); + } + + try { + emitter.send(SseEmitter.event().name("connect").data("connected")); + } catch (IOException e) { + emitters.remove(userId, emitter); + emitter.completeWithError(e); + } + + return emitter; + } + + public void send(Integer userId, NotificationInboxResponse notification) { + SseEmitter emitter = emitters.get(userId); + if (emitter == null) { + return; + } + try { + emitter.send(SseEmitter.event().name("notification").data(notification)); + } catch (IOException e) { + log.warn("SSE send failed: userId={}", userId, e); + emitters.remove(userId, emitter); + emitter.completeWithError(e); + } + } +} diff --git a/src/main/java/gg/agit/konect/domain/notification/service/NotificationService.java b/src/main/java/gg/agit/konect/domain/notification/service/NotificationService.java index c144733bb..15bd7ae82 100644 --- a/src/main/java/gg/agit/konect/domain/notification/service/NotificationService.java +++ b/src/main/java/gg/agit/konect/domain/notification/service/NotificationService.java @@ -5,26 +5,24 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.regex.Pattern; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.client.RestTemplate; import gg.agit.konect.domain.chat.service.ChatPresenceService; +import gg.agit.konect.domain.notification.dto.NotificationInboxResponse; import gg.agit.konect.domain.notification.dto.NotificationTokenDeleteRequest; import gg.agit.konect.domain.notification.dto.NotificationTokenRegisterRequest; import gg.agit.konect.domain.notification.dto.NotificationTokenResponse; +import gg.agit.konect.domain.notification.enums.NotificationInboxType; import gg.agit.konect.domain.notification.enums.NotificationTargetType; import gg.agit.konect.domain.notification.model.NotificationDeviceToken; -import gg.agit.konect.domain.notification.repository.NotificationMuteSettingRepository; +import gg.agit.konect.domain.notification.model.NotificationInbox; import gg.agit.konect.domain.notification.repository.NotificationDeviceTokenRepository; +import gg.agit.konect.domain.notification.repository.NotificationMuteSettingRepository; import gg.agit.konect.domain.user.model.User; import gg.agit.konect.domain.user.repository.UserRepository; import gg.agit.konect.global.exception.CustomException; @@ -37,7 +35,6 @@ @Transactional(readOnly = true) public class NotificationService { - private static final String EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send"; private static final Pattern EXPO_PUSH_TOKEN_PATTERN = Pattern.compile("^(ExponentPushToken|ExpoPushToken)\\[[^\\]]+\\]$"); private static final String DEFAULT_NOTIFICATION_CHANNEL_ID = "default_notifications"; @@ -47,9 +44,9 @@ public class NotificationService { private final UserRepository userRepository; private final NotificationDeviceTokenRepository notificationDeviceTokenRepository; private final NotificationMuteSettingRepository notificationMuteSettingRepository; - private final RestTemplate restTemplate; private final ChatPresenceService chatPresenceService; private final ExpoPushClient expoPushClient; + private final NotificationInboxService notificationInboxService; public NotificationTokenResponse getMyToken(Integer userId) { NotificationDeviceToken token = notificationDeviceTokenRepository.getByUserId(userId); @@ -76,7 +73,8 @@ public void deleteToken(Integer userId, NotificationTokenDeleteRequest request) .ifPresent(notificationDeviceTokenRepository::delete); } - @Async + @Async("notificationTaskExecutor") + @Transactional public void sendChatNotification(Integer receiverId, Integer roomId, String senderName, String messageContent) { try { if (chatPresenceService.isUserInChatRoom(roomId, receiverId)) { @@ -97,78 +95,26 @@ public void sendChatNotification(Integer receiverId, Integer roomId, String send return; } + String truncatedBody = buildPreview(messageContent); + String path = "chats/" + roomId; + List tokens = notificationDeviceTokenRepository.findTokensByUserId(receiverId); if (tokens.isEmpty()) { log.debug("No device tokens found for user: receiverId={}", receiverId); return; } - String truncatedBody = buildPreview(messageContent); Map data = new HashMap<>(); - data.put("path", "chats/" + roomId); - - List messages = tokens.stream() - .map(token -> new ExpoPushMessage( - token, senderName, truncatedBody, data, DEFAULT_NOTIFICATION_CHANNEL_ID)) - .toList(); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.setAccept(List.of(MediaType.APPLICATION_JSON)); - - HttpEntity> entity = new HttpEntity<>(messages, headers); - ResponseEntity response = restTemplate.exchange( - EXPO_PUSH_URL, - HttpMethod.POST, - entity, - ExpoPushResponse.class - ); + data.put("path", path); - if (!response.getStatusCode().is2xxSuccessful()) { - log.error( - "Expo push response not successful: roomId={}, receiverId={}, status={}", - roomId, - receiverId, - response.getStatusCode() - ); - return; - } - - ExpoPushResponse body = response.getBody(); - if (body == null || body.data == null) { - log.error("Expo push response body missing: roomId={}, receiverId={}", roomId, receiverId); - return; - } - - for (int i = 0; i < body.data.size(); i += 1) { - ExpoPushTicket ticket = body.data.get(i); - if (ticket == null || "ok".equalsIgnoreCase(ticket.status())) { - continue; - } - String token = i < tokens.size() ? tokens.get(i) : "unknown"; - log.error( - "Expo push failed: roomId={}, receiverId={}, token={}, status={}, message={}, details={}", - roomId, - receiverId, - token, - ticket.status(), - ticket.message(), - ticket.details() - ); - } - - log.debug( - "Chat notification sent: roomId={}, receiverId={}, tokenCount={}", - roomId, - receiverId, - tokens.size() - ); + expoPushClient.sendNotification(receiverId, tokens, senderName, truncatedBody, data); } catch (Exception e) { log.error("Failed to send chat notification: roomId={}, receiverId={}", roomId, receiverId, e); } } - @Async + @Async("notificationTaskExecutor") + @Transactional public void sendGroupChatNotification( Integer roomId, Integer senderId, @@ -188,121 +134,64 @@ public void sendGroupChatNotification( return; } + // 채팅방에 접속하고 있는 유저 목록 + Set activeUsers = chatPresenceService.findUsersInChatRoom(roomId, filteredRecipients); + + // 채팅방 알림을 뮤트 처리한 유저 목록 + Set mutedUsers = notificationMuteSettingRepository + .findMutedUserIdsByTargetTypeAndTargetIdAndUserIds( + NotificationTargetType.CHAT_ROOM, roomId, filteredRecipients); + + List targetRecipients = filteredRecipients.stream() + .filter(id -> !activeUsers.contains(id)) + .filter(id -> !mutedUsers.contains(id)) + .toList(); + + if (targetRecipients.isEmpty()) { + log.info( + "Group chat notification completed: roomId={}, totalRecipients={}, active={}, muted={}, target=0", + roomId, + filteredRecipients.size(), + activeUsers.size(), + mutedUsers.size() + ); + return; + } + String truncatedBody = buildPreview(messageContent); String previewBody = senderName + ": " + truncatedBody; + String path = "chats/" + roomId; + + List tokens = notificationDeviceTokenRepository.findTokensByUserIds(targetRecipients); + Map data = new HashMap<>(); - data.put("path", "chats/" + roomId); - - for (Integer recipientId : filteredRecipients) { - try { - // 사용자가 현재 채팅방에 접속 중인 경우 알림 전송 생략 - if (chatPresenceService.isUserInChatRoom(roomId, recipientId)) { - log.debug( - "User in group chat room, skipping notification: roomId={}, recipientId={}", - roomId, - recipientId - ); - continue; - } - - boolean isMuted = notificationMuteSettingRepository.findByTargetTypeAndTargetIdAndUserId( - NotificationTargetType.CHAT_ROOM, - roomId, - recipientId - ) - .map(setting -> Boolean.TRUE.equals(setting.getIsMuted())) - .orElse(false); - - if (isMuted) { - log.debug( - "Group chat muted, skipping notification: roomId={}, recipientId={}", - roomId, - recipientId - ); - continue; - } - - List tokens = notificationDeviceTokenRepository.findTokensByUserId(recipientId); - if (tokens.isEmpty()) { - log.debug("No device tokens found for user: recipientId={}", recipientId); - continue; - } - - List messages = tokens.stream() - .map(token -> new ExpoPushMessage( - token, clubName, previewBody, data, DEFAULT_NOTIFICATION_CHANNEL_ID)) - .toList(); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.setAccept(List.of(MediaType.APPLICATION_JSON)); - - HttpEntity> entity = new HttpEntity<>(messages, headers); - ResponseEntity response = restTemplate.exchange( - EXPO_PUSH_URL, - HttpMethod.POST, - entity, - ExpoPushResponse.class - ); - - if (!response.getStatusCode().is2xxSuccessful()) { - log.error( - "Expo push response not successful: roomId={}, recipientId={}, status={}", - roomId, - recipientId, - response.getStatusCode() - ); - continue; - } - - ExpoPushResponse body = response.getBody(); - if (body == null || body.data == null) { - log.error( - "Expo push response body missing: roomId={}, recipientId={}", - roomId, - recipientId - ); - continue; - } - - for (int i = 0; i < body.data.size(); i += 1) { - ExpoPushTicket ticket = body.data.get(i); - if (ticket == null || "ok".equalsIgnoreCase(ticket.status())) { - continue; - } - String token = i < tokens.size() ? tokens.get(i) : "unknown"; - log.error( - "Expo push failed: roomId={}, recipientId={}, token={}, status={}, message={}, details={}", - roomId, - recipientId, - token, - ticket.status(), - ticket.message(), - ticket.details() - ); - } - - log.debug( - "Group chat notification sent: roomId={}, recipientId={}, tokenCount={}", - roomId, - recipientId, - tokens.size() - ); - } catch (Exception e) { - log.error( - "Failed to send group chat notification to recipient: roomId={}, recipientId={}", - roomId, - recipientId, - e - ); - } + data.put("path", path); + + List messages = tokens.stream() + .map(token -> new ExpoPushClient.ExpoPushMessage( + token, clubName, previewBody, data, DEFAULT_NOTIFICATION_CHANNEL_ID)) + .toList(); + + if (!messages.isEmpty()) { + expoPushClient.sendBatchNotifications(messages); } + + log.info( + "Group chat notification completed: roomId={}, total={}, active={}, muted={}, target={}, tokens={}", + roomId, + filteredRecipients.size(), + activeUsers.size(), + mutedUsers.size(), + targetRecipients.size(), + messages.size() + ); } catch (Exception e) { log.error("Failed to send group chat notification: roomId={}, senderId={}", roomId, senderId, e); } } - @Async + @Async("notificationTaskExecutor") + @Transactional public void sendClubApplicationSubmittedNotification( Integer receiverId, Integer applicationId, @@ -312,17 +201,32 @@ public void sendClubApplicationSubmittedNotification( ) { String body = applicantName + "님이 동아리 가입을 신청했어요."; String path = "mypage/manager/" + clubId + "/applications/" + applicationId; + NotificationInbox saved = notificationInboxService.save( + receiverId, NotificationInboxType.CLUB_APPLICATION_SUBMITTED, clubName, body, path); + notificationInboxService.sendSse(receiverId, NotificationInboxResponse.from(saved)); sendNotification(receiverId, clubName, body, path); } - @Async + @Async("notificationTaskExecutor") + @Transactional public void sendClubApplicationApprovedNotification(Integer receiverId, Integer clubId, String clubName) { - sendNotification(receiverId, clubName, "동아리 지원이 승인되었어요.", "clubs/" + clubId); + String body = "동아리 지원이 승인되었어요."; + String path = "clubs/" + clubId; + NotificationInbox saved = notificationInboxService.save( + receiverId, NotificationInboxType.CLUB_APPLICATION_APPROVED, clubName, body, path); + notificationInboxService.sendSse(receiverId, NotificationInboxResponse.from(saved)); + sendNotification(receiverId, clubName, body, path); } - @Async + @Async("notificationTaskExecutor") + @Transactional public void sendClubApplicationRejectedNotification(Integer receiverId, Integer clubId, String clubName) { - sendNotification(receiverId, clubName, "동아리 지원이 거절되었어요.", "clubs/" + clubId); + String body = "동아리 지원이 거절되었어요."; + String path = "clubs/" + clubId; + NotificationInbox saved = notificationInboxService.save( + receiverId, NotificationInboxType.CLUB_APPLICATION_REJECTED, clubName, body, path); + notificationInboxService.sendSse(receiverId, NotificationInboxResponse.from(saved)); + sendNotification(receiverId, clubName, body, path); } private void sendNotification(Integer receiverId, String title, String body, String path) { @@ -361,18 +265,9 @@ private String buildPreview(String messageContent) { return messageContent.substring(0, endIndex) + CHAT_MESSAGE_PREVIEW_SUFFIX; } - private record ExpoPushMessage(String to, String title, String body, Map data, String channelId) { - } - - private record ExpoPushResponse(List data) { - } - private void validateExpoToken(String token) { if (!EXPO_PUSH_TOKEN_PATTERN.matcher(token).matches()) { throw CustomException.of(INVALID_NOTIFICATION_TOKEN); } } - - private record ExpoPushTicket(String status, String message, Map details) { - } } diff --git a/src/main/java/gg/agit/konect/domain/upload/controller/UploadApi.java b/src/main/java/gg/agit/konect/domain/upload/controller/UploadApi.java index 036448aaf..f070652aa 100644 --- a/src/main/java/gg/agit/konect/domain/upload/controller/UploadApi.java +++ b/src/main/java/gg/agit/konect/domain/upload/controller/UploadApi.java @@ -20,15 +20,15 @@ public interface UploadApi { @Operation(summary = "이미지 파일을 업로드한다.", description = """ 서버가 multipart 파일을 받아 S3에 업로드합니다. - + - target 쿼리파라미터로 이미지 저장 대상 도메인을 지정합니다. (CLUB, BANK, COUNCIL, USER) - 응답의 fileUrl을 기존 도메인 API의 imageUrl로 사용합니다. - + ## 에러 - MISSING_ACCESS_TOKEN (401): 액세스 토큰이 필요합니다. - INVALID_REQUEST_BODY (400): 파일이 비어있거나 요청 형식이 올바르지 않은 경우 - INVALID_FILE_CONTENT_TYPE (400): 지원하지 않는 Content-Type 인 경우 - - INVALID_FILE_SIZE (400): 파일 크기가 제한을 초과한 경우 + - PAYLOAD_TOO_LARGE (413): 파일 크기가 제한을 초과한 경우 - FAILED_UPLOAD_FILE (500): S3 업로드에 실패한 경우 """) @PostMapping(value = "/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) diff --git a/src/main/java/gg/agit/konect/domain/upload/service/ImageConversionService.java b/src/main/java/gg/agit/konect/domain/upload/service/ImageConversionService.java deleted file mode 100644 index d42182b24..000000000 --- a/src/main/java/gg/agit/konect/domain/upload/service/ImageConversionService.java +++ /dev/null @@ -1,266 +0,0 @@ -package gg.agit.konect.domain.upload.service; - -import java.awt.Graphics2D; -import java.awt.image.BufferedImage; -import java.io.IOException; -import java.io.InputStream; -import java.util.Iterator; -import java.util.Set; - -import javax.imageio.ImageIO; -import javax.imageio.ImageReadParam; -import javax.imageio.ImageReader; -import javax.imageio.metadata.IIOMetadata; -import javax.imageio.metadata.IIOMetadataNode; -import javax.imageio.stream.ImageInputStream; - -import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; - -import com.sksamuel.scrimage.AwtImage; -import com.sksamuel.scrimage.webp.WebpWriter; - -import gg.agit.konect.global.code.ApiResponseCode; -import gg.agit.konect.global.exception.CustomException; -import lombok.extern.slf4j.Slf4j; - -@Service -@Slf4j -public class ImageConversionService { - - private static final Set SKIP_CONVERSION_TYPES = Set.of("image/webp"); - - private static final float DEFAULT_WEBP_QUALITY = 0.8f; - private static final int WEBP_QUALITY_PERCENT_SCALE = 100; - - private static final int MAX_IMAGE_DIMENSION = 8000; - - private static final int ORIENTATION_NORMAL = 1; - private static final int ORIENTATION_FLIP_HORIZONTAL = 2; - private static final int ORIENTATION_ROTATE_180 = 3; - private static final int ORIENTATION_FLIP_VERTICAL = 4; - private static final int ORIENTATION_ROTATE_90_FLIP = 5; - private static final int ORIENTATION_ROTATE_90 = 6; - private static final int ORIENTATION_ROTATE_270_FLIP = 7; - private static final int ORIENTATION_ROTATE_270 = 8; - - public ConversionResult convertToWebP(MultipartFile file) throws IOException { - String contentType = file.getContentType(); - - if (contentType != null && SKIP_CONVERSION_TYPES.contains(contentType.toLowerCase())) { - log.debug("WEBP 이미지는 변환을 건너뜁니다: contentType={}", contentType); - return new ConversionResult(file.getBytes(), contentType, getExtension(contentType)); - } - - try (InputStream input = file.getInputStream(); - ImageInputStream iis = ImageIO.createImageInputStream(input)) { - - Iterator readers = ImageIO.getImageReaders(iis); - if (!readers.hasNext()) { - throw CustomException.of(ApiResponseCode.INVALID_FILE_CONTENT_TYPE); - } - - ImageReader reader = readers.next(); - try { - validateImageDimensions(reader, iis); - - ImageReadParam readParam = reader.getDefaultReadParam(); - BufferedImage image = reader.read(0, readParam); - - if (image == null) { - throw CustomException.of(ApiResponseCode.INVALID_FILE_CONTENT_TYPE); - } - - image = applyExifOrientation(reader, image); - - byte[] webpBytes = convertImageToWebP(image, DEFAULT_WEBP_QUALITY); - log.info("이미지 WEBP 변환 완료: 원본 {} bytes → WEBP {} bytes", file.getSize(), webpBytes.length); - - return new ConversionResult(webpBytes, "image/webp", "webp"); - } finally { - reader.dispose(); - } - } - } - - private void validateImageDimensions(ImageReader reader, ImageInputStream iis) throws IOException { - reader.setInput(iis); - int width = reader.getWidth(0); - int height = reader.getHeight(0); - - if (width > MAX_IMAGE_DIMENSION || height > MAX_IMAGE_DIMENSION) { - log.warn("이미지 해상도 초과: {}x{} (최대 {}px)", width, height, MAX_IMAGE_DIMENSION); - throw CustomException.of(ApiResponseCode.INVALID_FILE_CONTENT_TYPE); - } - } - - private BufferedImage applyExifOrientation(ImageReader reader, BufferedImage image) { - try { - IIOMetadata metadata = reader.getImageMetadata(0); - int orientation = readExifOrientation(metadata); - if (orientation > ORIENTATION_NORMAL) { - log.debug("EXIF Orientation 적용: {}", orientation); - return rotateImage(image, orientation); - } - } catch (Exception e) { - log.debug("EXIF Orientation 읽기 실패, 원본 유지: {}", e.getMessage()); - } - return image; - } - - private int readExifOrientation(IIOMetadata metadata) { - for (String formatName : metadata.getMetadataFormatNames()) { - IIOMetadataNode root = (IIOMetadataNode)metadata.getAsTree(formatName); - Integer orientation = findOrientationInNode(root); - if (orientation != null) { - return orientation; - } - } - return 1; - } - - private Integer findOrientationInNode(IIOMetadataNode node) { - if ("exif".equalsIgnoreCase(node.getNodeName()) || "Orientation".equalsIgnoreCase(node.getNodeName())) { - String attr = node.getAttribute("value"); - if (attr.isEmpty()) { - attr = node.getAttribute("Orientation"); - } - if (!attr.isEmpty()) { - try { - return Integer.parseInt(attr); - } catch (NumberFormatException ignored) { - } - } - } - - String tagName = node.getAttribute("TagName"); - if ("Orientation".equals(tagName)) { - String attr = node.getAttribute("TagValue"); - if (attr != null && !attr.isEmpty()) { - try { - return Integer.parseInt(attr); - } catch (NumberFormatException ignored) { - } - } - } - - for (IIOMetadataNode child = (IIOMetadataNode)node.getFirstChild(); - child != null; - child = (IIOMetadataNode)child.getNextSibling()) { - Integer result = findOrientationInNode(child); - if (result != null) { - return result; - } - } - return null; - } - - private BufferedImage rotateImage(BufferedImage image, int orientation) { - return switch (orientation) { - case ORIENTATION_FLIP_HORIZONTAL -> flipHorizontal(image); - case ORIENTATION_ROTATE_180 -> rotate180(image); - case ORIENTATION_FLIP_VERTICAL -> flipVertical(image); - case ORIENTATION_ROTATE_90_FLIP -> flipHorizontal(rotate90(image)); - case ORIENTATION_ROTATE_90 -> rotate90(image); - case ORIENTATION_ROTATE_270_FLIP -> flipHorizontal(rotate270(image)); - case ORIENTATION_ROTATE_270 -> rotate270(image); - default -> image; - }; - } - - private BufferedImage rotate90(BufferedImage image) { - int w = image.getWidth(); - int h = image.getHeight(); - BufferedImage rotated = new BufferedImage(h, - w, - image.getType() == 0 ? BufferedImage.TYPE_INT_RGB : image.getType()); - Graphics2D g = rotated.createGraphics(); - g.translate((h - w) / 2, (h - w) / 2); - g.rotate(Math.PI / 2, h / 2.0, w / 2.0); - g.drawRenderedImage(image, null); - g.dispose(); - return rotated; - } - - private BufferedImage rotate180(BufferedImage image) { - int w = image.getWidth(); - int h = image.getHeight(); - BufferedImage rotated = new BufferedImage(w, - h, - image.getType() == 0 ? BufferedImage.TYPE_INT_RGB : image.getType()); - Graphics2D g = rotated.createGraphics(); - g.rotate(Math.PI, w / 2.0, h / 2.0); - g.drawRenderedImage(image, null); - g.dispose(); - return rotated; - } - - private BufferedImage rotate270(BufferedImage image) { - int w = image.getWidth(); - int h = image.getHeight(); - BufferedImage rotated = new BufferedImage(h, - w, - image.getType() == 0 ? BufferedImage.TYPE_INT_RGB : image.getType()); - Graphics2D g = rotated.createGraphics(); - g.translate((h - w) / 2, (h - w) / 2); - g.rotate(-Math.PI / 2, h / 2.0, w / 2.0); - g.drawRenderedImage(image, null); - g.dispose(); - return rotated; - } - - private BufferedImage flipHorizontal(BufferedImage image) { - int w = image.getWidth(); - int h = image.getHeight(); - BufferedImage flipped = new BufferedImage(w, - h, - image.getType() == 0 ? BufferedImage.TYPE_INT_RGB : image.getType()); - Graphics2D g = flipped.createGraphics(); - g.drawImage(image, w, 0, -w, h, null); - g.dispose(); - return flipped; - } - - private BufferedImage flipVertical(BufferedImage image) { - int w = image.getWidth(); - int h = image.getHeight(); - BufferedImage flipped = new BufferedImage(w, - h, - image.getType() == 0 ? BufferedImage.TYPE_INT_RGB : image.getType()); - Graphics2D g = flipped.createGraphics(); - g.drawImage(image, 0, h, w, -h, null); - g.dispose(); - return flipped; - } - - private byte[] convertImageToWebP(BufferedImage image, float quality) throws IOException { - try { - return new AwtImage(image) - .bytes(WebpWriter.DEFAULT.withQ(toWebpQualityPercent(quality))); - } catch (RuntimeException e) { - throw new IOException("WEBP 이미지 변환에 실패했습니다.", e); - } - } - - private int toWebpQualityPercent(float quality) { - if (quality <= 0) { - return 0; - } - if (quality >= 1) { - return WEBP_QUALITY_PERCENT_SCALE; - } - return Math.round(quality * WEBP_QUALITY_PERCENT_SCALE); - } - - private String getExtension(String contentType) { - return switch (contentType.toLowerCase()) { - case "image/png" -> "png"; - case "image/jpg", "image/jpeg" -> "jpg"; - case "image/webp" -> "webp"; - default -> "bin"; - }; - } - - public record ConversionResult(byte[] bytes, String contentType, String extension) { - } -} diff --git a/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java b/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java index e34acb3a1..a7445a37e 100644 --- a/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java +++ b/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java @@ -1,6 +1,7 @@ package gg.agit.konect.domain.upload.service; import java.io.IOException; +import java.io.InputStream; import java.time.LocalDate; import java.util.Set; import java.util.UUID; @@ -10,7 +11,6 @@ import gg.agit.konect.domain.upload.dto.ImageUploadResponse; import gg.agit.konect.domain.upload.enums.UploadTarget; -import gg.agit.konect.domain.upload.service.ImageConversionService.ConversionResult; import gg.agit.konect.global.code.ApiResponseCode; import gg.agit.konect.global.exception.CustomException; import gg.agit.konect.infrastructure.storage.cdn.StorageCdnProperties; @@ -38,30 +38,23 @@ public class UploadService { private final S3Client s3Client; private final S3StorageProperties s3StorageProperties; private final StorageCdnProperties storageCdnProperties; - private final ImageConversionService imageConversionService; public ImageUploadResponse uploadImage(MultipartFile file, UploadTarget target) { validateS3Configuration(); validateFile(file); - ConversionResult conversionResult; - try { - conversionResult = imageConversionService.convertToWebP(file); - } catch (IOException e) { - log.error("이미지 WEBP 변환 실패. fileName: {}", file.getOriginalFilename(), e); - throw CustomException.of(ApiResponseCode.FAILED_UPLOAD_FILE); - } - - String key = buildKey(conversionResult.extension(), target); + String contentType = file.getContentType(); + String extension = getExtension(contentType); + String key = buildKey(extension, target); PutObjectRequest putObjectRequest = PutObjectRequest.builder() .bucket(s3StorageProperties.bucket()) .key(key) - .contentType(conversionResult.contentType()) + .contentType(contentType) .build(); - try { - s3Client.putObject(putObjectRequest, RequestBody.fromBytes(conversionResult.bytes())); + try (InputStream inputStream = file.getInputStream()) { + s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(inputStream, file.getSize())); } catch (S3Exception e) { String awsErrorCode = e.awsErrorDetails() != null ? e.awsErrorDetails().errorCode() : null; String awsErrorMessage = e.awsErrorDetails() != null ? e.awsErrorDetails().errorMessage() : e.getMessage(); @@ -77,7 +70,7 @@ public ImageUploadResponse uploadImage(MultipartFile file, UploadTarget target) e ); throw CustomException.of(ApiResponseCode.FAILED_UPLOAD_FILE); - } catch (SdkClientException e) { + } catch (SdkClientException | IOException e) { log.error( "S3 업로드 클라이언트 오류(네트워크/자격증명/설정). bucket: {}, key: {}, message: {}", s3StorageProperties.bucket(), @@ -102,10 +95,6 @@ private void validateFile(MultipartFile file) { throw CustomException.of(ApiResponseCode.INVALID_FILE_CONTENT_TYPE); } - Long maxUploadBytes = s3StorageProperties.maxUploadBytes(); - if (maxUploadBytes != null && file.getSize() > maxUploadBytes) { - throw CustomException.of(ApiResponseCode.INVALID_FILE_SIZE); - } } private String buildKey(String extension, UploadTarget target) { @@ -142,6 +131,15 @@ private String normalizePrefix(String keyPrefix) { return normalized; } + private String getExtension(String contentType) { + return switch (contentType.toLowerCase()) { + case "image/png" -> "png"; + case "image/jpg", "image/jpeg" -> "jpg"; + case "image/webp" -> "webp"; + default -> "bin"; + }; + } + private String trimTrailingSlash(String baseUrl) { if (baseUrl == null || baseUrl.isBlank()) { throw CustomException.of(ApiResponseCode.ILLEGAL_STATE, "storage.cdn.base-url 설정이 필요합니다."); diff --git a/src/main/java/gg/agit/konect/domain/user/model/UserOAuthAccount.java b/src/main/java/gg/agit/konect/domain/user/model/UserOAuthAccount.java index fc521b312..43dd32d4a 100644 --- a/src/main/java/gg/agit/konect/domain/user/model/UserOAuthAccount.java +++ b/src/main/java/gg/agit/konect/domain/user/model/UserOAuthAccount.java @@ -64,6 +64,9 @@ public class UserOAuthAccount extends BaseEntity { @Column(name = "apple_refresh_token", length = 1024) private String appleRefreshToken; + @Column(name = "google_drive_refresh_token", length = 1024) + private String googleDriveRefreshToken; + @Builder private UserOAuthAccount(User user, Provider provider, String providerId, String oauthEmail, String appleRefreshToken) { @@ -96,4 +99,8 @@ public void updateProviderId(String providerId) { public void updateAppleRefreshToken(String appleRefreshToken) { this.appleRefreshToken = appleRefreshToken; } + + public void updateGoogleDriveRefreshToken(String googleDriveRefreshToken) { + this.googleDriveRefreshToken = googleDriveRefreshToken; + } } diff --git a/src/main/java/gg/agit/konect/domain/user/repository/UserRepository.java b/src/main/java/gg/agit/konect/domain/user/repository/UserRepository.java index 8669e09b6..86dc810f9 100644 --- a/src/main/java/gg/agit/konect/domain/user/repository/UserRepository.java +++ b/src/main/java/gg/agit/konect/domain/user/repository/UserRepository.java @@ -2,6 +2,7 @@ import java.util.List; import java.util.Optional; +import java.util.Set; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; @@ -42,6 +43,18 @@ List findAllByUniversityIdAndStudentNumber( @Param("studentNumber") String studentNumber ); + @Query(""" + SELECT u + FROM User u + WHERE u.university.id = :universityId + AND u.studentNumber IN :studentNumbers + AND u.deletedAt IS NULL + """) + List findAllByUniversityIdAndStudentNumberIn( + @Param("universityId") Integer universityId, + @Param("studentNumbers") Set studentNumbers + ); + User save(User user); @Query(""" diff --git a/src/main/java/gg/agit/konect/domain/user/service/UserService.java b/src/main/java/gg/agit/konect/domain/user/service/UserService.java index baa48c07d..aa3edc2e2 100644 --- a/src/main/java/gg/agit/konect/domain/user/service/UserService.java +++ b/src/main/java/gg/agit/konect/domain/user/service/UserService.java @@ -13,6 +13,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import gg.agit.konect.domain.chat.enums.ChatType; import gg.agit.konect.domain.chat.model.ChatMessage; import gg.agit.konect.domain.chat.model.ChatRoom; import gg.agit.konect.domain.chat.repository.ChatMessageRepository; @@ -116,7 +117,11 @@ private void sendWelcomeMessage(User newUser) { return; } ChatRoom.validateIsNotSameParticipant(operator, newUser); - ChatRoom chatRoom = chatRoomRepository.findByTwoUsers(operator.getId(), newUser.getId()) + ChatRoom chatRoom = chatRoomRepository.findByTwoUsers( + operator.getId(), + newUser.getId(), + ChatType.DIRECT + ) .orElseGet(() -> chatRoomRepository.save(ChatRoom.directOf())); LocalDateTime joinedAt = Objects.requireNonNull( chatRoom.getCreatedAt(), diff --git a/src/main/java/gg/agit/konect/global/auth/web/AuthorizationInterceptor.java b/src/main/java/gg/agit/konect/global/auth/web/AuthorizationInterceptor.java index 389c2fb44..91cc4b1fb 100644 --- a/src/main/java/gg/agit/konect/global/auth/web/AuthorizationInterceptor.java +++ b/src/main/java/gg/agit/konect/global/auth/web/AuthorizationInterceptor.java @@ -1,9 +1,11 @@ package gg.agit.konect.global.auth.web; +import org.springframework.context.annotation.Lazy; import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.http.HttpMethod; import org.springframework.stereotype.Component; import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerExceptionResolver; import org.springframework.web.servlet.HandlerInterceptor; import gg.agit.konect.domain.user.model.User; @@ -13,20 +15,29 @@ import gg.agit.konect.global.exception.CustomException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; /** * 권한 체크 인터셉터. - * @Auth 어노테이션이 있는 경우 사용자의 역할(Role)을 검증 + * @Auth 어노테이션이 있는 경우 사용자의 역할(Role)을 검증합니다. + * 예외 발생 시 HandlerExceptionResolver를 통해 GlobalExceptionHandler로 위임합니다. */ @Component -@RequiredArgsConstructor public class AuthorizationInterceptor implements HandlerInterceptor { private final UserRepository userRepository; + private final HandlerExceptionResolver handlerExceptionResolver; + + public AuthorizationInterceptor( + UserRepository userRepository, + @Lazy HandlerExceptionResolver handlerExceptionResolver + ) { + this.userRepository = userRepository; + this.handlerExceptionResolver = handlerExceptionResolver; + } @Override - public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws + Exception { if (HttpMethod.OPTIONS.matches(request.getMethod())) { return true; } @@ -45,15 +56,19 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons return true; } - Object userId = request.getAttribute(LoginCheckInterceptor.AUTHENTICATED_USER_ID_ATTRIBUTE); - - if (!(userId instanceof Integer id)) { - throw CustomException.of(ApiResponseCode.MISSING_ACCESS_TOKEN); - } + try { + Object userId = request.getAttribute(LoginCheckInterceptor.AUTHENTICATED_USER_ID_ATTRIBUTE); - validateRole(id, auth); + if (!(userId instanceof Integer id)) { + throw CustomException.of(ApiResponseCode.MISSING_ACCESS_TOKEN); + } - return true; + validateRole(id, auth); + return true; + } catch (CustomException e) { + handlerExceptionResolver.resolveException(request, response, handler, e); + return false; + } } private Auth findAuthAnnotation(HandlerMethod handlerMethod) { diff --git a/src/main/java/gg/agit/konect/global/auth/web/LoginCheckInterceptor.java b/src/main/java/gg/agit/konect/global/auth/web/LoginCheckInterceptor.java index 543ca29c6..46b0fb7ad 100644 --- a/src/main/java/gg/agit/konect/global/auth/web/LoginCheckInterceptor.java +++ b/src/main/java/gg/agit/konect/global/auth/web/LoginCheckInterceptor.java @@ -1,24 +1,32 @@ package gg.agit.konect.global.auth.web; +import org.springframework.context.annotation.Lazy; import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.http.MediaType; import org.springframework.http.HttpMethod; import org.springframework.stereotype.Component; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerExceptionResolver; import org.springframework.web.servlet.HandlerInterceptor; import gg.agit.konect.global.auth.jwt.JwtProvider; import gg.agit.konect.global.auth.annotation.PublicApi; +import gg.agit.konect.global.exception.CustomException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; /** * 로그인 체크 인터셉터. * JWT 액세스 토큰을 검증하고 인증된 사용자 ID를 request attribute에 설정합니다. * @PublicApi 어노테이션이 있는 경우 인증을 건너뜁니다. + * + * 예외 발생 시 HandlerExceptionResolver를 통해 GlobalExceptionHandler로 위임합니다. */ +@Slf4j @Component -@RequiredArgsConstructor public class LoginCheckInterceptor implements HandlerInterceptor { public static final String AUTHENTICATED_USER_ID_ATTRIBUTE = "authenticatedUserId"; @@ -27,9 +35,19 @@ public class LoginCheckInterceptor implements HandlerInterceptor { private static final String BEARER_PREFIX = "Bearer "; private final JwtProvider jwtProvider; + private final HandlerExceptionResolver handlerExceptionResolver; + + public LoginCheckInterceptor( + JwtProvider jwtProvider, + @Lazy HandlerExceptionResolver handlerExceptionResolver + ) { + this.jwtProvider = jwtProvider; + this.handlerExceptionResolver = handlerExceptionResolver; + } @Override - public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws + Exception { if (HttpMethod.OPTIONS.matches(request.getMethod())) { return true; } @@ -43,11 +61,58 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons return true; } - String accessToken = resolveBearerToken(request); - Integer userId = jwtProvider.getUserId(accessToken); - request.setAttribute(AUTHENTICATED_USER_ID_ATTRIBUTE, userId); + try { + String accessToken = resolveBearerToken(request); + Integer userId = jwtProvider.getUserId(accessToken); + request.setAttribute(AUTHENTICATED_USER_ID_ATTRIBUTE, userId); + return true; + } catch (CustomException e) { + if (isSseRequest(request, handlerMethod)) { + log.warn( + "SSE authentication failed: method={} uri={} status={} code={}", + request.getMethod(), + request.getRequestURI(), + e.getErrorCode().getHttpStatus().value(), + e.getErrorCode().getCode() + ); + response.setStatus(e.getErrorCode().getHttpStatus().value()); + return false; + } + + // GlobalExceptionHandler가 처리하도록 위임 + handlerExceptionResolver.resolveException(request, response, handler, e); + return false; + } + } + + private boolean isSseRequest(HttpServletRequest request, HandlerMethod handlerMethod) { + String accept = request.getHeader("Accept"); + if (accept != null && accept.contains(MediaType.TEXT_EVENT_STREAM_VALUE)) { + return true; + } + + GetMapping getMapping = AnnotatedElementUtils.findMergedAnnotation(handlerMethod.getMethod(), GetMapping.class); + if (getMapping != null) { + for (String producedType : getMapping.produces()) { + if (MediaType.TEXT_EVENT_STREAM_VALUE.equals(producedType)) { + return true; + } + } + } + + RequestMapping requestMapping = AnnotatedElementUtils.findMergedAnnotation( + handlerMethod.getMethod(), RequestMapping.class); + if (requestMapping == null) { + return false; + } + + for (String producedType : requestMapping.produces()) { + if (MediaType.TEXT_EVENT_STREAM_VALUE.equals(producedType)) { + return true; + } + } - return true; + return false; } private boolean isPublicEndpoint(HandlerMethod handlerMethod) { diff --git a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java index ed6b7ab46..d62a0fdfa 100644 --- a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java +++ b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java @@ -18,7 +18,13 @@ public enum ApiResponseCode { MISSING_REQUIRED_PARAMETER(HttpStatus.BAD_REQUEST, "필수 요청 파라미터가 누락되었습니다."), FAILED_EXTRACT_EMAIL(HttpStatus.BAD_REQUEST, "OAuth 로그인 과정에서 이메일 정보를 가져올 수 없습니다."), FAILED_EXTRACT_PROVIDER_ID(HttpStatus.BAD_REQUEST, "OAuth 로그인 과정에서 제공자 식별자를 가져올 수 없습니다."), + INVALID_GOOGLE_DRIVE_AUTH(HttpStatus.BAD_REQUEST, + "Google Drive 인증이 만료되었거나 올바르지 않습니다. Drive 권한을 다시 연결해 주세요."), CANNOT_CREATE_CHAT_ROOM_WITH_SELF(HttpStatus.BAD_REQUEST, "자기 자신과는 채팅방을 만들 수 없습니다."), + CANNOT_LEAVE_GROUP_CHAT_ROOM(HttpStatus.BAD_REQUEST, "동아리 채팅방은 나갈 수 없습니다."), + CANNOT_KICK_SELF(HttpStatus.BAD_REQUEST, "자기 자신을 강퇴할 수 없습니다."), + CANNOT_KICK_ROOM_OWNER(HttpStatus.BAD_REQUEST, "방장은 강퇴할 수 없습니다."), + CANNOT_KICK_IN_NON_GROUP_ROOM(HttpStatus.BAD_REQUEST, "그룹 채팅방에서만 강퇴할 수 있습니다."), INVALID_CHAT_ROOM_CREATE_REQUEST(HttpStatus.BAD_REQUEST, "clubId 또는 targetUserId 중 하나만 전달해야 합니다."), CANNOT_CHANGE_OWN_POSITION(HttpStatus.BAD_REQUEST, "자기 자신의 직책은 변경할 수 없습니다."), CANNOT_DELETE_CLUB_PRESIDENT(HttpStatus.BAD_REQUEST, "동아리 회장인 경우 회장을 양도하고 탈퇴해야 합니다."), @@ -67,7 +73,10 @@ public enum ApiResponseCode { FORBIDDEN_MEMBER_POSITION_CHANGE(HttpStatus.FORBIDDEN, "회원 직책 변경 권한이 없습니다."), FORBIDDEN_POSITION_NAME_CHANGE(HttpStatus.FORBIDDEN, "해당 직책의 이름은 변경할 수 없습니다."), FORBIDDEN_ROLE_ACCESS(HttpStatus.FORBIDDEN, "접근 권한이 없습니다."), + FORBIDDEN_GOOGLE_SHEET_ACCESS(HttpStatus.FORBIDDEN, + "구글 스프레드시트 접근 권한이 없습니다. 서비스 계정을 공유하거나 Google Drive 권한을 연결한 뒤 다시 시도해 주세요."), FORBIDDEN_ORIGIN_ACCESS(HttpStatus.FORBIDDEN, "허용되지 않은 Origin 입니다."), + FORBIDDEN_CHAT_ROOM_KICK(HttpStatus.FORBIDDEN, "채팅방 방장만 멤버를 강퇴할 수 있습니다."), // 404 Not Found (리소스를 찾을 수 없음) NO_HANDLER_FOUND(HttpStatus.NOT_FOUND, "유효하지 않은 API 경로입니다."), @@ -91,6 +100,10 @@ public enum ApiResponseCode { NOT_FOUND_BANK(HttpStatus.NOT_FOUND, "해당하는 은행을 찾을 수 없습니다."), NOT_FOUND_VERSION(HttpStatus.NOT_FOUND, "버전을 찾을 수 없습니다."), NOT_FOUND_NOTIFICATION_TOKEN(HttpStatus.NOT_FOUND, "알림 토큰을 찾을 수 없습니다."), + NOT_FOUND_NOTIFICATION_INBOX(HttpStatus.NOT_FOUND, "알림을 찾을 수 없습니다."), + NOT_FOUND_ADVERTISEMENT(HttpStatus.NOT_FOUND, "광고를 찾을 수 없습니다."), + NOT_FOUND_CLUB_SHEET_ID(HttpStatus.NOT_FOUND, "등록된 스프레드시트 ID가 없습니다. 먼저 스프레드시트 ID를 등록해 주세요."), + NOT_FOUND_GOOGLE_DRIVE_AUTH(HttpStatus.NOT_FOUND, "Google Drive 권한이 연결되지 않았습니다. 먼저 Drive 권한을 연결해 주세요."), // 405 Method Not Allowed (지원하지 않는 HTTP 메소드) METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "지원하지 않는 HTTP 메소드 입니다."), @@ -112,9 +125,15 @@ public enum ApiResponseCode { OAUTH_ACCOUNT_ALREADY_LINKED(HttpStatus.CONFLICT, "해당 OAuth 계정은 이미 다른 사용자에게 연동되어 있습니다."), OAUTH_PROVIDER_ALREADY_LINKED(HttpStatus.CONFLICT, "이 계정에는 해당 OAuth 제공자가 이미 연동되어 있습니다."), + // 413 Payload Too Large (요청 본문 크기 초과) + PAYLOAD_TOO_LARGE(HttpStatus.PAYLOAD_TOO_LARGE, "파일 크기가 제한을 초과했습니다."), + // 500 Internal Server Error (서버 오류) CLIENT_ABORTED(HttpStatus.INTERNAL_SERVER_ERROR, "클라이언트에 의해 연결이 중단되었습니다."), FAILED_UPLOAD_FILE(HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드에 실패했습니다."), + FAILED_SYNC_GOOGLE_SHEET(HttpStatus.INTERNAL_SERVER_ERROR, "구글 스프레드시트 동기화에 실패했습니다."), + FAILED_INIT_GOOGLE_DRIVE(HttpStatus.INTERNAL_SERVER_ERROR, "Google Drive 서비스 초기화에 실패했습니다."), + FAILED_GOOGLE_DRIVE_AUTH(HttpStatus.INTERNAL_SERVER_ERROR, "Google Drive 인증 코드 교환에 실패했습니다."), FAILED_SEND_NOTIFICATION(HttpStatus.INTERNAL_SERVER_ERROR, "알림 전송에 실패했습니다."), UNEXPECTED_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버에 예기치 못한 에러가 발생했습니다."); diff --git a/src/main/java/gg/agit/konect/global/config/AsyncConfig.java b/src/main/java/gg/agit/konect/global/config/AsyncConfig.java new file mode 100644 index 000000000..f88deb3ef --- /dev/null +++ b/src/main/java/gg/agit/konect/global/config/AsyncConfig.java @@ -0,0 +1,108 @@ +package gg.agit.konect.global.config; + +import java.util.concurrent.Executor; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ThreadPoolExecutor; + +import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler; +import org.springframework.aop.interceptor.SimpleAsyncUncaughtExceptionHandler; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.AsyncConfigurer; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Configuration +public class AsyncConfig implements AsyncConfigurer { + + private static final int DEFAULT_CORE_POOL_SIZE = 2; + private static final int DEFAULT_MAX_POOL_SIZE = 5; + private static final int DEFAULT_QUEUE_CAPACITY = 50; + private static final int DEFAULT_AWAIT_TERMINATION_SECONDS = 30; + + private static final int SHEET_SYNC_CORE_POOL_SIZE = 2; + private static final int SHEET_SYNC_MAX_POOL_SIZE = 4; + private static final int SHEET_SYNC_QUEUE_CAPACITY = 50; + private static final int SHEET_SYNC_AWAIT_TERMINATION_SECONDS = 30; + + private static final int NOTIFICATION_CORE_POOL_SIZE = 2; + private static final int NOTIFICATION_MAX_POOL_SIZE = 5; + private static final int NOTIFICATION_QUEUE_CAPACITY = 100; + private static final int NOTIFICATION_AWAIT_TERMINATION_SECONDS = 30; + + private static final int SLACK_CORE_POOL_SIZE = 1; + private static final int SLACK_MAX_POOL_SIZE = 3; + private static final int SLACK_QUEUE_CAPACITY = 50; + private static final int SLACK_AWAIT_TERMINATION_SECONDS = 30; + + @Override + public Executor getAsyncExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(DEFAULT_CORE_POOL_SIZE); + executor.setMaxPoolSize(DEFAULT_MAX_POOL_SIZE); + executor.setQueueCapacity(DEFAULT_QUEUE_CAPACITY); + executor.setThreadNamePrefix("async-default-"); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(DEFAULT_AWAIT_TERMINATION_SECONDS); + executor.initialize(); + return executor; + } + + @Override + public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { + return new SimpleAsyncUncaughtExceptionHandler(); + } + + @Bean(name = "sheetSyncTaskExecutor") + public Executor sheetSyncTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(SHEET_SYNC_CORE_POOL_SIZE); + executor.setMaxPoolSize(SHEET_SYNC_MAX_POOL_SIZE); + executor.setQueueCapacity(SHEET_SYNC_QUEUE_CAPACITY); + executor.setThreadNamePrefix("sheet-sync-"); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(SHEET_SYNC_AWAIT_TERMINATION_SECONDS); + executor.initialize(); + return executor; + } + + @Bean(name = "notificationTaskExecutor") + public Executor notificationTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(NOTIFICATION_CORE_POOL_SIZE); + executor.setMaxPoolSize(NOTIFICATION_MAX_POOL_SIZE); + executor.setQueueCapacity(NOTIFICATION_QUEUE_CAPACITY); + executor.setThreadNamePrefix("notification-"); + executor.setRejectedExecutionHandler((runnable, pool) -> { + log.warn("알림 스레드풀 포화로 작업이 거절되었습니다. poolSize={}, activeCount={}, queueSize={}", + pool.getPoolSize(), pool.getActiveCount(), pool.getQueue().size()); + throw new RejectedExecutionException("notificationTaskExecutor saturated"); + }); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(NOTIFICATION_AWAIT_TERMINATION_SECONDS); + executor.initialize(); + return executor; + } + + @Bean(name = "slackTaskExecutor") + public Executor slackTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(SLACK_CORE_POOL_SIZE); + executor.setMaxPoolSize(SLACK_MAX_POOL_SIZE); + executor.setQueueCapacity(SLACK_QUEUE_CAPACITY); + executor.setThreadNamePrefix("slack-"); + executor.setRejectedExecutionHandler((runnable, pool) -> { + log.warn("Slack 스레드풀 포화로 작업이 거절되었습니다. poolSize={}, activeCount={}, queueSize={}", + pool.getPoolSize(), pool.getActiveCount(), pool.getQueue().size()); + throw new RejectedExecutionException("slackTaskExecutor saturated"); + }); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(SLACK_AWAIT_TERMINATION_SECONDS); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/gg/agit/konect/global/config/SecurityPaths.java b/src/main/java/gg/agit/konect/global/config/SecurityPaths.java index 74dee66f1..814b02cfe 100644 --- a/src/main/java/gg/agit/konect/global/config/SecurityPaths.java +++ b/src/main/java/gg/agit/konect/global/config/SecurityPaths.java @@ -10,7 +10,8 @@ public final class SecurityPaths { "/v3/api-docs/**", "/swagger-resources/**", "/error", - "/slack/events" + "/slack/events", + "/auth/oauth/google/drive/callback" }; public static final String[] DENY_PATHS = {}; diff --git a/src/main/java/gg/agit/konect/global/config/WebConfig.java b/src/main/java/gg/agit/konect/global/config/WebConfig.java index c7058bf3a..42d6ba717 100644 --- a/src/main/java/gg/agit/konect/global/config/WebConfig.java +++ b/src/main/java/gg/agit/konect/global/config/WebConfig.java @@ -56,5 +56,4 @@ public void addInterceptors(InterceptorRegistry registry) { public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController("/login").setViewName("forward:/login.html"); } - } diff --git a/src/main/java/gg/agit/konect/global/exception/CustomException.java b/src/main/java/gg/agit/konect/global/exception/CustomException.java index 777c17211..ee19d0a19 100644 --- a/src/main/java/gg/agit/konect/global/exception/CustomException.java +++ b/src/main/java/gg/agit/konect/global/exception/CustomException.java @@ -27,7 +27,7 @@ public static CustomException of(ApiResponseCode errorCode, String detail) { } public String getFullMessage() { - if (StringUtils.hasText(detail)) { + if (!StringUtils.hasText(detail)) { return super.getMessage(); } return String.format("%s: %s", getMessage(), detail); diff --git a/src/main/java/gg/agit/konect/global/exception/ErrorResponse.java b/src/main/java/gg/agit/konect/global/exception/ErrorResponse.java index f0a72d2b6..df5af21c8 100644 --- a/src/main/java/gg/agit/konect/global/exception/ErrorResponse.java +++ b/src/main/java/gg/agit/konect/global/exception/ErrorResponse.java @@ -17,13 +17,30 @@ public record ErrorResponse( @Schema(description = "에러 추적용 UUID") String errorTraceId, + @Schema(description = "에러 상세 사유") + @JsonInclude(JsonInclude.Include.NON_EMPTY) + String detail, + @Schema(description = "필드별 검증 오류 목록") @JsonInclude(JsonInclude.Include.NON_EMPTY) List fieldErrors ) { public ErrorResponse(String code, String message, String errorTraceId) { - this(code, message, errorTraceId, List.of()); + this(code, message, errorTraceId, null, List.of()); + } + + public ErrorResponse( + String code, + String message, + String errorTraceId, + List fieldErrors + ) { + this(code, message, errorTraceId, null, fieldErrors); + } + + public ErrorResponse(String code, String message, String errorTraceId, String detail) { + this(code, message, errorTraceId, detail, List.of()); } @Schema(description = "필드별 검증 오류 목록 아이템") @@ -44,4 +61,3 @@ public FieldError(String field, String message, String constraint) { } } } - diff --git a/src/main/java/gg/agit/konect/global/exception/GlobalExceptionHandler.java b/src/main/java/gg/agit/konect/global/exception/GlobalExceptionHandler.java index fb8b002cf..f0df6e537 100644 --- a/src/main/java/gg/agit/konect/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/gg/agit/konect/global/exception/GlobalExceptionHandler.java @@ -10,6 +10,7 @@ import org.apache.catalina.connector.ClientAbortException; import org.slf4j.Logger; +import org.springframework.web.multipart.MaxUploadSizeExceededException; import org.slf4j.LoggerFactory; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatusCode; @@ -46,7 +47,12 @@ public ResponseEntity handleCustomException( HttpServletRequest request, CustomException e ) { - return buildErrorResponse(request, e.getErrorCode(), e.getFullMessage()); + return buildErrorResponse( + request, + e.getErrorCode(), + e.getFullMessage(), + e.getDetail() + ); } @ExceptionHandler(IllegalArgumentException.class) @@ -75,6 +81,16 @@ public ResponseEntity handleClientAbortException() { return buildErrorResponse(ApiResponseCode.CLIENT_ABORTED); } + @Override + protected ResponseEntity handleMaxUploadSizeExceededException( + MaxUploadSizeExceededException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request + ) { + return buildErrorResponse(ApiResponseCode.PAYLOAD_TOO_LARGE); + } + @ExceptionHandler(MethodArgumentTypeMismatchException.class) public ResponseEntity handleMethodArgumentTypeMismatchException() { return buildErrorResponse(ApiResponseCode.INVALID_TYPE_VALUE); @@ -207,6 +223,15 @@ private ResponseEntity buildErrorResponse( HttpServletRequest request, ApiResponseCode errorCode, String errorMessage + ) { + return buildErrorResponse(request, errorCode, errorMessage, null); + } + + private ResponseEntity buildErrorResponse( + HttpServletRequest request, + ApiResponseCode errorCode, + String errorMessage, + String detail ) { String errorTraceId = UUID.randomUUID().toString(); requestLogging(request, errorCode.getHttpStatus().value(), errorMessage, errorTraceId); @@ -214,7 +239,8 @@ private ResponseEntity buildErrorResponse( ErrorResponse response = new ErrorResponse( errorCode.getCode(), errorCode.getMessage(), - errorTraceId + errorTraceId, + detail ); return ResponseEntity.status(errorCode.getHttpStatus().value()).body(response); diff --git a/src/main/java/gg/agit/konect/global/util/PhoneNumberNormalizer.java b/src/main/java/gg/agit/konect/global/util/PhoneNumberNormalizer.java new file mode 100644 index 000000000..5e9cd5cfa --- /dev/null +++ b/src/main/java/gg/agit/konect/global/util/PhoneNumberNormalizer.java @@ -0,0 +1,116 @@ +package gg.agit.konect.global.util; + +public final class PhoneNumberNormalizer { + + private static final int PHONE_NUMBER_MIN_DIGITS = 9; + private static final int PHONE_NUMBER_MAX_DIGITS = 11; + + private static final int DIGITS_9 = PHONE_NUMBER_MIN_DIGITS; + private static final int DIGITS_10 = PHONE_NUMBER_MIN_DIGITS + 1; + private static final int DIGITS_11 = PHONE_NUMBER_MAX_DIGITS; + + private static final int IDX_2 = 2; + private static final int IDX_3 = 3; + private static final int IDX_5 = 5; + private static final int IDX_6 = 6; + private static final int IDX_7 = 7; + + private PhoneNumberNormalizer() { + } + + /** + * 다양한 형식의 전화번호 문자열을 숫자만 남긴 형태로 정규화합니다. + * 예) 010-1234-5678 -> 01012345678 + * (010) 1234-5678 -> 01012345678 + * 010 1234 5678 -> 01012345678 + */ + public static String normalize(String phone) { + if (phone == null) { + return null; + } + String trimmed = phone.trim(); + if (trimmed.isEmpty()) { + return ""; + } + return trimmed.replaceAll("[^0-9]", ""); + } + + /** + * 전화번호에서 숫자만 추출한 뒤 길이와 패턴에 따라 하이픈(-)을 삽입하여 포맷팅합니다. + * 구글 시트에서 앞자리 0이 잘린 경우(10자리, 10으로 시작)도 복구합니다. + * + *

포맷 규칙은 다음과 같습니다. + *

    + *
  • 구글 시트에서 앞자리 0이 잘린 10자리 번호(10으로 시작): 앞에 0을 붙여 11자리로 복구
  • + *
  • 11자리: XXX-XXXX-XXXX
  • + *
  • 서울 지역번호 02, 10자리: 02-XXXX-XXXX
  • + *
  • 서울 지역번호 02, 9자리: 02-XXX-XXXX
  • + *
  • 그 외 10자리: XXX-XXX-XXXX
  • + *
  • 위 조건에 모두 해당하지 않는 경우: 하이픈 없이 숫자만 그대로 반환
  • + *
+ * + *

예) + * 1012345678 -> 010-1234-5678 (구글 시트에서 0이 잘린 경우 복구) + * 01012345678 -> 010-1234-5678 + * 0212345678 -> 02-1234-5678 + * 021234567 -> 02-123-4567 + * 0311234567 -> 031-123-4567 + */ + public static String format(String phone) { + if (phone == null) { + return null; + } + String digits = phone.trim().replaceAll("[^0-9]", ""); + if (digits.isEmpty()) { + return ""; + } + + // 구글 시트에서 앞자리 0이 잘린 경우 복구: 10자리이고 10으로 시작 (010 -> 10) + if (digits.length() == DIGITS_10 && digits.startsWith("10")) { + digits = "0" + digits; + } + + // 11자리: XXX-XXXX-XXXX + if (digits.length() == DIGITS_11) { + return digits.substring(0, IDX_3) + + "-" + digits.substring(IDX_3, IDX_7) + + "-" + digits.substring(IDX_7); + } + + // 서울 지역번호 02: 02-XXXX-XXXX(10자리) or 02-XXX-XXXX(9자리) + if (digits.startsWith("02")) { + if (digits.length() == DIGITS_10) { + return digits.substring(0, IDX_2) + + "-" + digits.substring(IDX_2, IDX_6) + + "-" + digits.substring(IDX_6); + } + if (digits.length() == DIGITS_9) { + return digits.substring(0, IDX_2) + + "-" + digits.substring(IDX_2, IDX_5) + + "-" + digits.substring(IDX_5); + } + } + + // 기타 10자리: XXX-XXX-XXXX + if (digits.length() == DIGITS_10) { + return digits.substring(0, IDX_3) + + "-" + digits.substring(IDX_3, IDX_6) + + "-" + digits.substring(IDX_6); + } + + return digits; + } + + /** + * 전화번호처럼 보이는지 간단히 검증합니다. + * 숫자만 남겼을 때 9~11자리면 유효한 것으로 판단합니다. + */ + public static boolean looksLikePhoneNumber(String phone) { + if (phone == null || phone.isBlank()) { + return false; + } + String digits = phone.replaceAll("[^0-9]", ""); + return digits.length() >= PHONE_NUMBER_MIN_DIGITS + && digits.length() <= PHONE_NUMBER_MAX_DIGITS; + } +} diff --git a/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java b/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java new file mode 100644 index 000000000..52cee7d50 --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java @@ -0,0 +1,121 @@ +package gg.agit.konect.infrastructure.googlesheets; + +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.security.GeneralSecurityException; +import java.util.Arrays; +import java.util.Collections; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; + +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; +import com.google.api.client.json.gson.GsonFactory; +import com.google.api.services.drive.Drive; +import com.google.api.services.drive.DriveScopes; +import com.google.api.services.sheets.v4.Sheets; +import com.google.api.services.sheets.v4.SheetsScopes; +import com.google.auth.http.HttpCredentialsAdapter; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.auth.oauth2.ServiceAccountCredentials; +import com.google.auth.oauth2.UserCredentials; + +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class GoogleSheetsConfig { + + private final GoogleSheetsProperties googleSheetsProperties; + private final ResourceLoader resourceLoader; + + @Bean + public ServiceAccountCredentials serviceAccountCredentials() throws IOException { + try (InputStream in = openCredentialsStream()) { + GoogleCredentials credentials = GoogleCredentials.fromStream(in); + if (!(credentials instanceof ServiceAccountCredentials serviceAccountCredentials)) { + throw new IllegalStateException( + "Google credentials must be ServiceAccountCredentials. actual type=" + + credentials.getClass().getName() + ); + } + return serviceAccountCredentials; + } + } + + @Bean + @Primary + public GoogleCredentials googleCredentials( + ServiceAccountCredentials serviceAccountCredentials + ) { + return serviceAccountCredentials.createScoped(Arrays.asList( + SheetsScopes.SPREADSHEETS, + DriveScopes.DRIVE + )); + } + + @Bean + public Sheets googleSheetsService( + GoogleCredentials googleCredentials + ) throws IOException, GeneralSecurityException { + return new Sheets.Builder( + GoogleNetHttpTransport.newTrustedTransport(), + GsonFactory.getDefaultInstance(), + new HttpCredentialsAdapter(googleCredentials)) + .setApplicationName(googleSheetsProperties.applicationName()) + .build(); + } + + @Bean + public Drive googleDriveService( + GoogleCredentials googleCredentials + ) throws IOException, GeneralSecurityException { + return new Drive.Builder( + GoogleNetHttpTransport.newTrustedTransport(), + GsonFactory.getDefaultInstance(), + new HttpCredentialsAdapter(googleCredentials)) + .setApplicationName(googleSheetsProperties.applicationName()) + .build(); + } + + public Drive buildUserDriveService(String refreshToken) throws IOException, GeneralSecurityException { + UserCredentials credentials = UserCredentials.newBuilder() + .setClientId(googleSheetsProperties.oauthClientId()) + .setClientSecret(googleSheetsProperties.oauthClientSecret()) + .setRefreshToken(refreshToken) + .build(); + + GoogleCredentials scoped = credentials.createScoped( + Collections.singletonList(DriveScopes.DRIVE) + ); + + return new Drive.Builder( + GoogleNetHttpTransport.newTrustedTransport(), + GsonFactory.getDefaultInstance(), + new HttpCredentialsAdapter(scoped)) + .setApplicationName(googleSheetsProperties.applicationName()) + .build(); + } + + private InputStream openCredentialsStream() throws IOException { + String credentialsPath = googleSheetsProperties.credentialsPath(); + if (credentialsPath == null || credentialsPath.isBlank()) { + throw new IOException("Google Sheets credentials path is not configured."); + } + + if (credentialsPath.startsWith("classpath:")) { + Resource resource = resourceLoader.getResource(credentialsPath); + if (!resource.exists()) { + throw new FileNotFoundException(credentialsPath); + } + return resource.getInputStream(); + } + + return new FileInputStream(credentialsPath); + } +} diff --git a/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsProperties.java b/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsProperties.java new file mode 100644 index 000000000..3abce5b2e --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsProperties.java @@ -0,0 +1,16 @@ +package gg.agit.konect.infrastructure.googlesheets; + +import jakarta.validation.constraints.NotBlank; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@Validated +@ConfigurationProperties(prefix = "google.sheets") +public record GoogleSheetsProperties( + String credentialsPath, + String applicationName, + @NotBlank String oauthClientId, + @NotBlank String oauthClientSecret, + @NotBlank String oauthCallbackBaseUrl +) { +} diff --git a/src/main/java/gg/agit/konect/infrastructure/oauth/GoogleDriveOAuthController.java b/src/main/java/gg/agit/konect/infrastructure/oauth/GoogleDriveOAuthController.java new file mode 100644 index 000000000..3912c616c --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/oauth/GoogleDriveOAuthController.java @@ -0,0 +1,51 @@ +package gg.agit.konect.infrastructure.oauth; + +import java.net.URI; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import gg.agit.konect.global.auth.annotation.PublicApi; +import gg.agit.konect.global.auth.annotation.UserId; +import gg.agit.konect.infrastructure.oauth.dto.GoogleDriveAuthorizationUrlResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/auth/oauth/google/drive") +@Tag(name = "(Normal) OAuth - Google Drive") +public class GoogleDriveOAuthController { + + private final GoogleDriveOAuthService googleDriveOAuthService; + + @Operation(summary = "Google Drive 권한 연결 URL 조회") + @GetMapping("/authorize-url") + public ResponseEntity getAuthorizationUrl(@UserId Integer userId) { + String authUrl = googleDriveOAuthService.buildAuthorizationUrl(userId); + return ResponseEntity.ok(new GoogleDriveAuthorizationUrlResponse(authUrl)); + } + + @Operation(summary = "Google Drive 권한 연결 페이지로 리다이렉트") + @GetMapping("/authorize") + public ResponseEntity authorize(@UserId Integer userId) { + String authUrl = googleDriveOAuthService.buildAuthorizationUrl(userId); + return ResponseEntity.status(HttpStatus.FOUND).location(URI.create(authUrl)).build(); + } + + @PublicApi + @Operation(summary = "Google Drive OAuth callback 처리") + @GetMapping("/callback") + public ResponseEntity callback( + @RequestParam("code") String code, + @RequestParam("state") String state + ) { + googleDriveOAuthService.exchangeAndSaveToken(code, state); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/gg/agit/konect/infrastructure/oauth/GoogleDriveOAuthService.java b/src/main/java/gg/agit/konect/infrastructure/oauth/GoogleDriveOAuthService.java new file mode 100644 index 000000000..2e5d6133f --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/oauth/GoogleDriveOAuthService.java @@ -0,0 +1,172 @@ +package gg.agit.konect.infrastructure.oauth; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import gg.agit.konect.domain.user.enums.Provider; +import gg.agit.konect.domain.user.model.UserOAuthAccount; +import gg.agit.konect.domain.user.repository.UserOAuthAccountRepository; +import gg.agit.konect.global.auth.util.SecureTokenGenerator; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.infrastructure.googlesheets.GoogleSheetsProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class GoogleDriveOAuthService { + + private static final String GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; + private static final String GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"; + private static final String DRIVE_SCOPE = "https://www.googleapis.com/auth/drive"; + private static final String STATE_KEY_PREFIX = "drive:oauth:state:"; + private static final Duration STATE_TTL = Duration.ofMinutes(10); + private static final String CALLBACK_PATH = "/auth/oauth/google/drive/callback"; + + private static final DefaultRedisScript GET_DEL_SCRIPT = new DefaultRedisScript<>( + "local v = redis.call('GET', KEYS[1]); if v then redis.call('DEL', KEYS[1]); end; return v;", + String.class + ); + + private final GoogleSheetsProperties googleSheetsProperties; + private final UserOAuthAccountRepository userOAuthAccountRepository; + private final RestTemplate restTemplate; + private final StringRedisTemplate redis; + private final SecureTokenGenerator secureTokenGenerator; + + public String buildAuthorizationUrl(Integer userId) { + String state = secureTokenGenerator.generate(); + redis.opsForValue().set(STATE_KEY_PREFIX + state, userId.toString(), STATE_TTL); + + String callbackUri = buildCallbackUri(); + + return UriComponentsBuilder.fromHttpUrl(GOOGLE_AUTH_URL) + .queryParam("client_id", googleSheetsProperties.oauthClientId()) + .queryParam("redirect_uri", callbackUri) + .queryParam("response_type", "code") + .queryParam("scope", DRIVE_SCOPE) + .queryParam("access_type", "offline") + .queryParam("prompt", "consent") + .queryParam("state", state) + .build() + .toUriString(); + } + + @Transactional + public void exchangeAndSaveToken(String code, String state) { + if (!StringUtils.hasText(code) || !StringUtils.hasText(state)) { + log.warn("Drive OAuth callback received empty code or state."); + throw CustomException.of(ApiResponseCode.INVALID_SESSION); + } + + String stateKey = STATE_KEY_PREFIX + state; + String userIdStr = redis.execute(GET_DEL_SCRIPT, List.of(stateKey)); + + if (userIdStr == null || userIdStr.isBlank()) { + log.warn("Invalid or expired Drive OAuth state. state={}", state); + throw CustomException.of(ApiResponseCode.INVALID_SESSION); + } + + Integer userId; + try { + userId = Integer.parseInt(userIdStr); + } catch (NumberFormatException e) { + throw CustomException.of(ApiResponseCode.INVALID_SESSION); + } + + String refreshToken = requestRefreshToken(code); + + if (refreshToken == null) { + handleMissingRefreshToken(userId); + return; + } + + persistGoogleRefreshToken(userId, refreshToken); + } + + public boolean isDriveConnected(Integer userId) { + return userOAuthAccountRepository + .findByUserIdAndProvider(userId, Provider.GOOGLE) + .map(account -> StringUtils.hasText(account.getGoogleDriveRefreshToken())) + .orElse(false); + } + + private void persistGoogleRefreshToken(Integer userId, String refreshToken) { + UserOAuthAccount account = userOAuthAccountRepository + .findByUserIdAndProvider(userId, Provider.GOOGLE) + .orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_GOOGLE_DRIVE_AUTH)); + + account.updateGoogleDriveRefreshToken(refreshToken); + log.info("Google Drive refresh token saved. userId={}", userId); + } + + private void handleMissingRefreshToken(Integer userId) { + UserOAuthAccount existing = userOAuthAccountRepository + .findByUserIdAndProvider(userId, Provider.GOOGLE) + .orElse(null); + + if (existing != null && StringUtils.hasText(existing.getGoogleDriveRefreshToken())) { + log.info("Re-authorization detected, keeping existing refresh token. userId={}", userId); + return; + } + + log.error("No refresh_token received and no existing token. userId={}", userId); + throw CustomException.of(ApiResponseCode.FAILED_GOOGLE_DRIVE_AUTH); + } + + private String requestRefreshToken(String code) { + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("code", code); + params.add("client_id", googleSheetsProperties.oauthClientId()); + params.add("client_secret", googleSheetsProperties.oauthClientSecret()); + params.add("redirect_uri", buildCallbackUri()); + params.add("grant_type", "authorization_code"); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + HttpEntity> request = new HttpEntity<>(params, headers); + + try { + ResponseEntity response = + restTemplate.postForEntity(GOOGLE_TOKEN_URL, request, Map.class); + + if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { + log.error("Failed to exchange Drive OAuth code for tokens. status={}", + response.getStatusCode()); + throw CustomException.of(ApiResponseCode.FAILED_GOOGLE_DRIVE_AUTH); + } + + return (String)response.getBody().get("refresh_token"); + + } catch (RestClientException e) { + log.error("RestClient error while exchanging Drive OAuth code. cause={}", e.getMessage(), e); + throw CustomException.of(ApiResponseCode.FAILED_GOOGLE_DRIVE_AUTH); + } + } + + private String buildCallbackUri() { + String base = googleSheetsProperties.oauthCallbackBaseUrl(); + if (base.endsWith("/")) { + base = base.substring(0, base.length() - 1); + } + return base + CALLBACK_PATH; + } +} diff --git a/src/main/java/gg/agit/konect/infrastructure/oauth/dto/GoogleDriveAuthorizationUrlResponse.java b/src/main/java/gg/agit/konect/infrastructure/oauth/dto/GoogleDriveAuthorizationUrlResponse.java new file mode 100644 index 000000000..cea6906f1 --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/oauth/dto/GoogleDriveAuthorizationUrlResponse.java @@ -0,0 +1,15 @@ +package gg.agit.konect.infrastructure.oauth.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record GoogleDriveAuthorizationUrlResponse( + @Schema( + description = "Google Drive 권한 연결을 위해 브라우저를 이동시킬 authorize URL", + example = "https://accounts.google.com/o/oauth2/v2/auth?client_id=example&redirect_uri=https://api.stage.agit.gg/auth/oauth/google/drive/callback&response_type=code&scope=https://www.googleapis.com/auth/drive&access_type=offline&prompt=consent&state=example-state", + requiredMode = REQUIRED + ) + String authorizationUrl +) { +} diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackAIService.java b/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackAIService.java index c984ef59f..d050a21e9 100644 --- a/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackAIService.java +++ b/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackAIService.java @@ -81,7 +81,7 @@ public List> fetchAIThreadReplies(String channelId, String t return new ArrayList<>(); } - @Async + @Async("slackTaskExecutor") public void processAIQuery(String text, String channelId, String threadTs, List> cachedReplies) { try { diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/enums/SlackMessageTemplate.java b/src/main/java/gg/agit/konect/infrastructure/slack/enums/SlackMessageTemplate.java index c329dbf88..76711b27a 100644 --- a/src/main/java/gg/agit/konect/infrastructure/slack/enums/SlackMessageTemplate.java +++ b/src/main/java/gg/agit/konect/infrastructure/slack/enums/SlackMessageTemplate.java @@ -28,6 +28,16 @@ public enum SlackMessageTemplate { > %s """ ), + SHEET_SYNC_FAILED( + """ + *:warning: 시트 동기화 실패* + 동아리 ID: %s + 스프레드시트 ID: `%s` + 유형: %s + 발생 시각: %s + > %s + """ + ), ; private final String template; diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/listener/ChatSlackListener.java b/src/main/java/gg/agit/konect/infrastructure/slack/listener/ChatSlackListener.java index c2b7d9aef..44b6774ce 100644 --- a/src/main/java/gg/agit/konect/infrastructure/slack/listener/ChatSlackListener.java +++ b/src/main/java/gg/agit/konect/infrastructure/slack/listener/ChatSlackListener.java @@ -16,7 +16,7 @@ public class ChatSlackListener { private final SlackNotificationService slackNotificationService; - @Async + @Async("slackTaskExecutor") @TransactionalEventListener(phase = AFTER_COMMIT) public void handleAdminChatReceived(AdminChatReceivedEvent event) { slackNotificationService.notifyAdminChatReceived(event.senderName(), event.content()); diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/listener/InquirySlackListener.java b/src/main/java/gg/agit/konect/infrastructure/slack/listener/InquirySlackListener.java index 578c2b22e..32f98195e 100644 --- a/src/main/java/gg/agit/konect/infrastructure/slack/listener/InquirySlackListener.java +++ b/src/main/java/gg/agit/konect/infrastructure/slack/listener/InquirySlackListener.java @@ -16,7 +16,7 @@ public class InquirySlackListener { private final SlackNotificationService slackNotificationService; - @Async + @Async("slackTaskExecutor") @TransactionalEventListener(phase = AFTER_COMMIT) public void handleInquirySubmitted(InquirySubmittedEvent event) { slackNotificationService.notifyInquiry(event.content()); diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/listener/SheetSyncSlackListener.java b/src/main/java/gg/agit/konect/infrastructure/slack/listener/SheetSyncSlackListener.java new file mode 100644 index 000000000..f63e40085 --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/slack/listener/SheetSyncSlackListener.java @@ -0,0 +1,42 @@ +package gg.agit.konect.infrastructure.slack.listener; + +import static org.springframework.transaction.event.TransactionPhase.AFTER_COMMIT; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; + +import gg.agit.konect.domain.club.event.SheetSyncFailedEvent; +import gg.agit.konect.infrastructure.slack.service.SlackNotificationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SheetSyncSlackListener { + + private final SlackNotificationService slackNotificationService; + + @TransactionalEventListener(phase = AFTER_COMMIT, fallbackExecution = true) + public void handleSheetSyncFailed(SheetSyncFailedEvent event) { + try { + log.warn( + "Handling sheet sync failure event. occurredAt={}, clubId={}, spreadsheetId={}, " + + "accessDenied={}, reason={}", + event.occurredAt(), + event.clubId(), + event.spreadsheetId(), + event.accessDenied(), + event.reason() + ); + slackNotificationService.notifySheetSyncFailed(event); + } catch (Exception e) { + log.error( + "Failed to handle sheet sync failure event. clubId={}, spreadsheetId={}", + event.clubId(), + event.spreadsheetId(), + e + ); + } + } +} diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/listener/UserSlackListener.java b/src/main/java/gg/agit/konect/infrastructure/slack/listener/UserSlackListener.java index eea11dc88..8b5caa244 100644 --- a/src/main/java/gg/agit/konect/infrastructure/slack/listener/UserSlackListener.java +++ b/src/main/java/gg/agit/konect/infrastructure/slack/listener/UserSlackListener.java @@ -17,13 +17,13 @@ public class UserSlackListener { private final SlackNotificationService slackNotificationService; - @Async + @Async("slackTaskExecutor") @TransactionalEventListener(phase = AFTER_COMMIT) public void handleUserWithdrawn(UserWithdrawnEvent event) { slackNotificationService.notifyUserWithdraw(event.email(), event.provider()); } - @Async + @Async("slackTaskExecutor") @TransactionalEventListener(phase = AFTER_COMMIT) public void handleUserRegistered(UserRegisteredEvent event) { slackNotificationService.notifyUserRegister(event.email(), event.provider()); diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/service/SlackNotificationService.java b/src/main/java/gg/agit/konect/infrastructure/slack/service/SlackNotificationService.java index 87703a201..86db2f7d1 100644 --- a/src/main/java/gg/agit/konect/infrastructure/slack/service/SlackNotificationService.java +++ b/src/main/java/gg/agit/konect/infrastructure/slack/service/SlackNotificationService.java @@ -2,11 +2,13 @@ import static gg.agit.konect.infrastructure.slack.enums.SlackMessageTemplate.ADMIN_CHAT_RECEIVED; import static gg.agit.konect.infrastructure.slack.enums.SlackMessageTemplate.INQUIRY; +import static gg.agit.konect.infrastructure.slack.enums.SlackMessageTemplate.SHEET_SYNC_FAILED; import static gg.agit.konect.infrastructure.slack.enums.SlackMessageTemplate.USER_REGISTER; import static gg.agit.konect.infrastructure.slack.enums.SlackMessageTemplate.USER_WITHDRAWAL; import org.springframework.stereotype.Service; +import gg.agit.konect.domain.club.event.SheetSyncFailedEvent; import gg.agit.konect.infrastructure.slack.client.SlackClient; import gg.agit.konect.infrastructure.slack.config.SlackProperties; import lombok.RequiredArgsConstructor; @@ -37,4 +39,15 @@ public void notifyAdminChatReceived(String senderName, String content) { String message = ADMIN_CHAT_RECEIVED.format(senderName, content); slackClient.sendMessage(message, slackProperties.webhooks().event()); } + + public void notifySheetSyncFailed(SheetSyncFailedEvent event) { + String message = SHEET_SYNC_FAILED.format( + event.clubId(), + event.spreadsheetId(), + event.accessDenied() ? "ACCESS_DENIED" : "UNEXPECTED", + event.occurredAt(), + event.reason() + ); + slackClient.sendMessage(message, slackProperties.webhooks().error()); + } } diff --git a/src/main/resources/application-db.yml b/src/main/resources/application-db.yml index 4bc347d1d..de2bb656e 100644 --- a/src/main/resources/application-db.yml +++ b/src/main/resources/application-db.yml @@ -9,6 +9,11 @@ spring: url: ${MYSQL_URL} username: ${MYSQL_USERNAME} password: ${MYSQL_PASSWORD} + hikari: + maximum-pool-size: 20 + minimum-idle: 10 + connection-timeout: 5000 + leak-detection-threshold: 30000 jpa: properties: @@ -17,8 +22,12 @@ spring: format_sql: true jdbc: time_zone: Asia/Seoul + batch_size: 100 + order_inserts: true + order_updates: true hibernate: ddl-auto: validate + open-in-view: false data: redis: diff --git a/src/main/resources/application-infrastructure.yml b/src/main/resources/application-infrastructure.yml index b44991e10..e006a42b4 100644 --- a/src/main/resources/application-infrastructure.yml +++ b/src/main/resources/application-infrastructure.yml @@ -20,3 +20,12 @@ claude: mcp: url: ${MCP_BRIDGE_URL:http://localhost:3100} + +google: + sheets: + credentials-path: ${GOOGLE_SHEETS_CREDENTIALS_PATH} + application-name: ${GOOGLE_SHEETS_APP_NAME:KONECT} + template-spreadsheet-id: ${GOOGLE_SHEETS_TEMPLATE_ID:} + oauth-client-id: ${OAUTH_GOOGLE_CLIENT_ID} + oauth-client-secret: ${OAUTH_GOOGLE_CLIENT_SECRET} + oauth-callback-base-url: ${APP_BACKEND_BASE_URL} diff --git a/src/main/resources/application-monitoring.yml b/src/main/resources/application-monitoring.yml index 7215d9a9f..4ec70381f 100644 --- a/src/main/resources/application-monitoring.yml +++ b/src/main/resources/application-monitoring.yml @@ -2,9 +2,10 @@ logging: ignored-url-patterns: - /**/api-docs/** - /**/swagger-ui/** - - /error, - - /favicon.ico, + - /error + - /favicon.ico - /actuator/** + - /notifications/inbox/stream management: endpoints: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 415cce7ea..c81f1d98e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,6 +1,8 @@ spring: application: name: konect-backend + lifecycle: + timeout-per-shutdown-phase: 30s config: import: - classpath:application-db.yml @@ -44,6 +46,7 @@ cors: allowedOrigins: ${ALLOWED_ORIGINS} server: + shutdown: graceful forward-headers-strategy: framework tomcat: diff --git a/src/main/resources/db/migration/V50__add_advertisement_table.sql b/src/main/resources/db/migration/V50__add_advertisement_table.sql new file mode 100644 index 000000000..9b2c47a33 --- /dev/null +++ b/src/main/resources/db/migration/V50__add_advertisement_table.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS advertisement +( + id INT AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(100) NOT NULL, + description VARCHAR(255) NOT NULL, + image_url VARCHAR(255) NOT NULL, + link_url VARCHAR(255) NOT NULL, + is_visible TINYINT(1) NOT NULL DEFAULT 1, + click_count INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); diff --git a/src/main/resources/db/migration/V51__add_google_sheet_id_to_club.sql b/src/main/resources/db/migration/V51__add_google_sheet_id_to_club.sql new file mode 100644 index 000000000..722c71786 --- /dev/null +++ b/src/main/resources/db/migration/V51__add_google_sheet_id_to_club.sql @@ -0,0 +1,2 @@ +ALTER TABLE club + ADD COLUMN google_sheet_id VARCHAR(255) NULL; diff --git a/src/main/resources/db/migration/V52__add_club_fee_payment_table.sql b/src/main/resources/db/migration/V52__add_club_fee_payment_table.sql new file mode 100644 index 000000000..08420496d --- /dev/null +++ b/src/main/resources/db/migration/V52__add_club_fee_payment_table.sql @@ -0,0 +1,16 @@ +CREATE TABLE club_fee_payment ( + id INT NOT NULL AUTO_INCREMENT, + club_id INT NOT NULL, + user_id INT NOT NULL, + is_paid TINYINT(1) NOT NULL DEFAULT 0, + payment_image_url VARCHAR(255), + approved_at TIMESTAMP NULL, + approved_by INT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + CONSTRAINT fk_fee_payment_club FOREIGN KEY (club_id) REFERENCES club (id), + CONSTRAINT fk_fee_payment_user FOREIGN KEY (user_id) REFERENCES users (id), + CONSTRAINT fk_fee_payment_approved_by FOREIGN KEY (approved_by) REFERENCES users (id), + UNIQUE KEY uq_fee_payment_club_user (club_id, user_id) +); diff --git a/src/main/resources/db/migration/V53__add_sheet_column_mapping_to_club.sql b/src/main/resources/db/migration/V53__add_sheet_column_mapping_to_club.sql new file mode 100644 index 000000000..7e9a1be35 --- /dev/null +++ b/src/main/resources/db/migration/V53__add_sheet_column_mapping_to_club.sql @@ -0,0 +1,2 @@ +ALTER TABLE club + ADD COLUMN sheet_column_mapping JSON NULL; diff --git a/src/main/resources/db/migration/V54__add_fee_sheet_columns_to_club.sql b/src/main/resources/db/migration/V54__add_fee_sheet_columns_to_club.sql new file mode 100644 index 000000000..8ff2b6e04 --- /dev/null +++ b/src/main/resources/db/migration/V54__add_fee_sheet_columns_to_club.sql @@ -0,0 +1,3 @@ +ALTER TABLE club + ADD COLUMN fee_sheet_id INT NULL, + ADD COLUMN fee_sheet_column_mapping JSON NULL; diff --git a/src/main/resources/db/migration/V55__add_drive_and_template_columns_to_club.sql b/src/main/resources/db/migration/V55__add_drive_and_template_columns_to_club.sql new file mode 100644 index 000000000..4bdaf225b --- /dev/null +++ b/src/main/resources/db/migration/V55__add_drive_and_template_columns_to_club.sql @@ -0,0 +1,3 @@ +ALTER TABLE club + ADD COLUMN drive_folder_id VARCHAR(255) NULL, + ADD COLUMN template_spreadsheet_id VARCHAR(255) NULL; diff --git a/src/main/resources/db/migration/V56__rollback_fee_payment_feature.sql b/src/main/resources/db/migration/V56__rollback_fee_payment_feature.sql new file mode 100644 index 000000000..d2d64de56 --- /dev/null +++ b/src/main/resources/db/migration/V56__rollback_fee_payment_feature.sql @@ -0,0 +1,9 @@ +-- V52 rollback: drop club_fee_payment table +DROP TABLE IF EXISTS club_fee_payment; + +-- V54 rollback: drop fee_sheet columns from club +ALTER TABLE club + DROP COLUMN fee_sheet_id; + +ALTER TABLE club + DROP COLUMN fee_sheet_column_mapping; diff --git a/src/main/resources/db/migration/V57__add_chat_admin_query_indexes.sql b/src/main/resources/db/migration/V57__add_chat_admin_query_indexes.sql new file mode 100644 index 000000000..ae235662c --- /dev/null +++ b/src/main/resources/db/migration/V57__add_chat_admin_query_indexes.sql @@ -0,0 +1,10 @@ +-- 관리자 1:1 채팅방 조회 쿼리 최적화를 위한 인덱스 추가 +-- findAdminChatRoomsOptimized() 메소드 성능 개선 + +-- 1. 관리자 응답 여부 확인 및 unread count 계산용 +CREATE INDEX idx_chat_message_room_sender + ON chat_message (chat_room_id, sender_id); + +-- 2. last_message_sent_at 정렬 최적화 +CREATE INDEX idx_chat_room_last_message + ON chat_room (club_id, last_message_sent_at DESC); diff --git a/src/main/resources/db/migration/V58__add_google_drive_refresh_token_to_user_oauth_account.sql b/src/main/resources/db/migration/V58__add_google_drive_refresh_token_to_user_oauth_account.sql new file mode 100644 index 000000000..f8521510f --- /dev/null +++ b/src/main/resources/db/migration/V58__add_google_drive_refresh_token_to_user_oauth_account.sql @@ -0,0 +1,2 @@ +ALTER TABLE user_oauth_account + ADD COLUMN google_drive_refresh_token VARCHAR(1024) NULL; diff --git a/src/main/resources/db/migration/V59__add_notification_inbox_table.sql b/src/main/resources/db/migration/V59__add_notification_inbox_table.sql new file mode 100644 index 000000000..c7adb6048 --- /dev/null +++ b/src/main/resources/db/migration/V59__add_notification_inbox_table.sql @@ -0,0 +1,16 @@ +CREATE TABLE notification_inbox +( + id INT NOT NULL AUTO_INCREMENT, + user_id INT NOT NULL, + type VARCHAR(50) NOT NULL, + title VARCHAR(100) NOT NULL, + body VARCHAR(300) NOT NULL, + path VARCHAR(200), + is_read TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + INDEX idx_notification_inbox_user_id (user_id), + INDEX idx_notification_inbox_user_id_is_read (user_id, is_read), + CONSTRAINT fk_notification_inbox_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); diff --git a/src/main/resources/db/migration/V60__fix_notification_inbox_table.sql b/src/main/resources/db/migration/V60__fix_notification_inbox_table.sql new file mode 100644 index 000000000..f46dba330 --- /dev/null +++ b/src/main/resources/db/migration/V60__fix_notification_inbox_table.sql @@ -0,0 +1,9 @@ +ALTER TABLE notification_inbox + MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + MODIFY COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP; + +ALTER TABLE notification_inbox + ADD INDEX idx_notification_inbox_user_id_created_at (user_id, created_at DESC); + +ALTER TABLE notification_inbox + DROP INDEX idx_notification_inbox_user_id; diff --git a/src/main/resources/db/migration/V61__update_notification_inbox_index.sql b/src/main/resources/db/migration/V61__update_notification_inbox_index.sql new file mode 100644 index 000000000..050058975 --- /dev/null +++ b/src/main/resources/db/migration/V61__update_notification_inbox_index.sql @@ -0,0 +1,5 @@ +ALTER TABLE notification_inbox + DROP INDEX idx_notification_inbox_user_id_created_at; + +ALTER TABLE notification_inbox + ADD INDEX idx_notification_inbox_user_id_created_at_id (user_id, created_at DESC, id DESC); diff --git a/src/main/resources/db/migration/V62__add_performance_indexes.sql b/src/main/resources/db/migration/V62__add_performance_indexes.sql new file mode 100644 index 000000000..452800263 --- /dev/null +++ b/src/main/resources/db/migration/V62__add_performance_indexes.sql @@ -0,0 +1,8 @@ +-- 성능 최적화 인덱스 추가 +-- 목적: 자주 사용되는 조회 조건에 대한 인덱스 추가 + +-- chat_message 테이블: 채팅방별 시간순 조회 (findByChatRoomId ORDER BY created_at) +CREATE INDEX idx_chat_message_room_created_at ON chat_message (chat_room_id, created_at DESC); + +-- notification_inbox 테이블: 읽지 않은 알림 카운트 (countByUserIdAndIsReadFalse) +CREATE INDEX idx_notification_inbox_user_read ON notification_inbox (user_id, is_read); diff --git a/src/main/resources/db/migration/V63__add_custom_room_name_to_chat_room_member.sql b/src/main/resources/db/migration/V63__add_custom_room_name_to_chat_room_member.sql new file mode 100644 index 000000000..d396cf151 --- /dev/null +++ b/src/main/resources/db/migration/V63__add_custom_room_name_to_chat_room_member.sql @@ -0,0 +1,2 @@ +ALTER TABLE chat_room_member + ADD COLUMN custom_room_name VARCHAR(30) NULL AFTER last_read_at; diff --git a/src/main/resources/db/migration/V64__add_chat_room_type_and_leave_columns.sql b/src/main/resources/db/migration/V64__add_chat_room_type_and_leave_columns.sql new file mode 100644 index 000000000..0c62d38eb --- /dev/null +++ b/src/main/resources/db/migration/V64__add_chat_room_type_and_leave_columns.sql @@ -0,0 +1,18 @@ +ALTER TABLE chat_room + ADD COLUMN room_type VARCHAR(20) NULL AFTER last_message_sent_at; + +UPDATE chat_room +SET room_type = CASE + WHEN club_id IS NULL THEN 'DIRECT' + ELSE 'GROUP' +END; + +ALTER TABLE chat_room + MODIFY COLUMN room_type VARCHAR(20) NOT NULL; + +ALTER TABLE chat_room_member + ADD COLUMN visible_message_from TIMESTAMP NULL AFTER last_read_at, + ADD COLUMN left_at TIMESTAMP NULL AFTER visible_message_from; + +CREATE INDEX idx_chat_room_member_user_left_at + ON chat_room_member (user_id, left_at); diff --git a/src/main/resources/db/migration/V65__add_is_owner_to_chat_room_member.sql b/src/main/resources/db/migration/V65__add_is_owner_to_chat_room_member.sql new file mode 100644 index 000000000..1005d0d6b --- /dev/null +++ b/src/main/resources/db/migration/V65__add_is_owner_to_chat_room_member.sql @@ -0,0 +1,2 @@ +ALTER TABLE chat_room_member + ADD COLUMN is_owner BOOLEAN NOT NULL DEFAULT FALSE AFTER custom_room_name; diff --git a/src/main/resources/db/migration/V66__split_group_chat_type.sql b/src/main/resources/db/migration/V66__split_group_chat_type.sql new file mode 100644 index 000000000..1d17037e1 --- /dev/null +++ b/src/main/resources/db/migration/V66__split_group_chat_type.sql @@ -0,0 +1,5 @@ +-- 동아리가 연결된 기존 GROUP 채팅방을 CLUB_GROUP으로 마이그레이션 +UPDATE chat_room +SET room_type = 'CLUB_GROUP' +WHERE room_type = 'GROUP' + AND club_id IS NOT NULL; diff --git a/src/test/java/gg/agit/konect/domain/club/service/ClubMemberSheetServiceTest.java b/src/test/java/gg/agit/konect/domain/club/service/ClubMemberSheetServiceTest.java new file mode 100644 index 000000000..7db589d73 --- /dev/null +++ b/src/test/java/gg/agit/konect/domain/club/service/ClubMemberSheetServiceTest.java @@ -0,0 +1,79 @@ +package gg.agit.konect.domain.club.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; +import gg.agit.konect.domain.club.enums.ClubSheetSortKey; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.ClubFixture; +import gg.agit.konect.support.fixture.UniversityFixture; + +class ClubMemberSheetServiceTest extends ServiceTestSupport { + + @Mock + private ClubRepository clubRepository; + + @Mock + private ClubMemberRepository clubMemberRepository; + + @Mock + private ClubPreMemberRepository clubPreMemberRepository; + + @Mock + private ClubPermissionValidator clubPermissionValidator; + + @Mock + private SheetSyncExecutor sheetSyncExecutor; + + @Mock + private SheetHeaderMapper sheetHeaderMapper; + + @Mock + private ObjectMapper objectMapper; + + @InjectMocks + private ClubMemberSheetService clubMemberSheetService; + + @Test + @DisplayName("시트 동기화 수에 사전 회원도 포함한다") + void syncMembersToSheetIncludesPreMembersInCount() { + // given + Integer clubId = 1; + Integer requesterId = 2; + String spreadsheetId = "spreadsheet-id"; + Club club = ClubFixture.create(UniversityFixture.create()); + club.updateGoogleSheetId(spreadsheetId); + + given(clubRepository.getById(clubId)).willReturn(club); + given(clubMemberRepository.countByClubId(clubId)).willReturn(2L); + given(clubPreMemberRepository.countByClubId(clubId)).willReturn(3L); + + // when + ClubMemberSheetSyncResponse response = clubMemberSheetService.syncMembersToSheet( + clubId, + requesterId, + ClubSheetSortKey.POSITION, + true + ); + + // then + verify(clubPermissionValidator).validateManagerAccess(clubId, requesterId); + verify(sheetSyncExecutor).executeWithSort(clubId, ClubSheetSortKey.POSITION, true); + assertThat(response.syncedMemberCount()).isEqualTo(5); + assertThat(response.sheetUrl()) + .isEqualTo("https://docs.google.com/spreadsheets/d/" + spreadsheetId + "/edit"); + } +} diff --git a/src/test/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedServiceTest.java b/src/test/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedServiceTest.java new file mode 100644 index 000000000..700737c68 --- /dev/null +++ b/src/test/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedServiceTest.java @@ -0,0 +1,182 @@ +package gg.agit.konect.domain.club.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.verifyNoInteractions; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import gg.agit.konect.domain.club.dto.SheetImportResponse; +import gg.agit.konect.domain.club.model.SheetColumnMapping; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.ServiceTestSupport; + +class ClubSheetIntegratedServiceTest extends ServiceTestSupport { + + @Mock + private ClubPermissionValidator clubPermissionValidator; + + @Mock + private SheetHeaderMapper sheetHeaderMapper; + + @Mock + private GoogleSheetPermissionService googleSheetPermissionService; + + @Mock + private ClubMemberSheetService clubMemberSheetService; + + @Mock + private SheetImportService sheetImportService; + + @InjectMocks + private ClubSheetIntegratedService clubSheetIntegratedService; + + @Test + @DisplayName("시트 분석 등록 후 사전 회원 가져오기를 순서대로 실행한다") + void analyzeAndImportPreMembersSuccess() { + // given + Integer clubId = 1; + Integer requesterId = 2; + String spreadsheetUrl = + "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms/edit"; + String spreadsheetId = "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms"; + SheetHeaderMapper.SheetAnalysisResult analysis = + new SheetHeaderMapper.SheetAnalysisResult(SheetColumnMapping.defaultMapping(), null, null); + SheetImportResponse expected = SheetImportResponse.of(3, 1, List.of("warn")); + + given(googleSheetPermissionService.tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId)) + .willReturn(true); + given(sheetHeaderMapper.analyzeAllSheets(spreadsheetId)).willReturn(analysis); + given(sheetImportService.importPreMembersFromSheet( + clubId, + requesterId, + spreadsheetId, + analysis.memberListMapping() + )) + .willReturn(expected); + + // when + SheetImportResponse actual = clubSheetIntegratedService.analyzeAndImportPreMembers( + clubId, + requesterId, + spreadsheetUrl + ); + + // then + InOrder inOrder = inOrder( + clubPermissionValidator, + googleSheetPermissionService, + sheetHeaderMapper, + clubMemberSheetService, + sheetImportService + ); + inOrder.verify(clubPermissionValidator).validateManagerAccess(clubId, requesterId); + inOrder.verify(googleSheetPermissionService) + .tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId); + inOrder.verify(sheetHeaderMapper).analyzeAllSheets(spreadsheetId); + inOrder.verify(clubMemberSheetService).updateSheetId( + clubId, + requesterId, + spreadsheetId, + analysis + ); + inOrder.verify(sheetImportService).importPreMembersFromSheet( + clubId, + requesterId, + spreadsheetId, + analysis.memberListMapping() + ); + assertThat(actual).isEqualTo(expected); + } + + @Test + @DisplayName("자동 권한 부여가 실패해도 기존 공유 권한으로 가져오기를 계속 시도한다") + void analyzeAndImportPreMembersContinuesWhenAutoGrantFails() { + // given + Integer clubId = 1; + Integer requesterId = 2; + String spreadsheetUrl = + "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms/edit"; + String spreadsheetId = "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms"; + SheetHeaderMapper.SheetAnalysisResult analysis = + new SheetHeaderMapper.SheetAnalysisResult(SheetColumnMapping.defaultMapping(), null, null); + SheetImportResponse expected = SheetImportResponse.of(1, 0, List.of()); + + given(googleSheetPermissionService.tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId)) + .willReturn(false); + given(sheetHeaderMapper.analyzeAllSheets(spreadsheetId)).willReturn(analysis); + given(sheetImportService.importPreMembersFromSheet( + clubId, + requesterId, + spreadsheetId, + analysis.memberListMapping() + )) + .willReturn(expected); + + // when + SheetImportResponse actual = clubSheetIntegratedService.analyzeAndImportPreMembers( + clubId, + requesterId, + spreadsheetUrl + ); + + // then + InOrder inOrder = inOrder( + clubPermissionValidator, + googleSheetPermissionService, + sheetHeaderMapper, + clubMemberSheetService, + sheetImportService + ); + inOrder.verify(clubPermissionValidator).validateManagerAccess(clubId, requesterId); + inOrder.verify(googleSheetPermissionService) + .tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId); + inOrder.verify(sheetHeaderMapper).analyzeAllSheets(spreadsheetId); + inOrder.verify(clubMemberSheetService).updateSheetId( + clubId, + requesterId, + spreadsheetId, + analysis + ); + inOrder.verify(sheetImportService).importPreMembersFromSheet( + clubId, + requesterId, + spreadsheetId, + analysis.memberListMapping() + ); + assertThat(actual).isEqualTo(expected); + } + + @Test + @DisplayName("자동 권한 부여 중 예외가 발생하면 후속 시트 작업을 진행하지 않는다") + void analyzeAndImportPreMembersStopsWhenAutoGrantThrowsException() { + // given + Integer clubId = 1; + Integer requesterId = 2; + String spreadsheetUrl = + "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms/edit"; + String spreadsheetId = "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms"; + CustomException expected = CustomException.of(ApiResponseCode.FAILED_INIT_GOOGLE_DRIVE); + + given(googleSheetPermissionService.tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId)) + .willThrow(expected); + + // when & then + assertThatThrownBy(() -> clubSheetIntegratedService.analyzeAndImportPreMembers( + clubId, + requesterId, + spreadsheetUrl + )) + .isSameAs(expected); + verifyNoInteractions(sheetHeaderMapper, clubMemberSheetService, sheetImportService); + } +} diff --git a/src/test/java/gg/agit/konect/domain/club/service/GoogleApiTestUtils.java b/src/test/java/gg/agit/konect/domain/club/service/GoogleApiTestUtils.java new file mode 100644 index 000000000..2c2603cb7 --- /dev/null +++ b/src/test/java/gg/agit/konect/domain/club/service/GoogleApiTestUtils.java @@ -0,0 +1,42 @@ +package gg.agit.konect.domain.club.service; + +import java.util.List; + +import com.google.api.client.googleapis.json.GoogleJsonError; +import com.google.api.client.googleapis.json.GoogleJsonResponseException; +import com.google.api.client.http.HttpHeaders; +import com.google.api.client.http.HttpResponseException; + +final class GoogleApiTestUtils { + + private GoogleApiTestUtils() {} + + static GoogleJsonResponseException googleException(int statusCode, String reason) { + GoogleJsonError.ErrorInfo errorInfo = new GoogleJsonError.ErrorInfo(); + errorInfo.setReason(reason); + + GoogleJsonError error = new GoogleJsonError(); + error.setCode(statusCode); + error.setErrors(List.of(errorInfo)); + + HttpResponseException.Builder builder = new HttpResponseException.Builder( + statusCode, + null, + new HttpHeaders() + ); + + return new GoogleJsonResponseException(builder, error); + } + + static HttpResponseException httpResponseException(int statusCode) { + return httpResponseException(statusCode, null); + } + + static HttpResponseException httpResponseException(int statusCode, String content) { + return new HttpResponseException.Builder( + statusCode, + null, + new HttpHeaders() + ).setContent(content).build(); + } +} diff --git a/src/test/java/gg/agit/konect/domain/club/service/GoogleDrivePermissionHelperTest.java b/src/test/java/gg/agit/konect/domain/club/service/GoogleDrivePermissionHelperTest.java new file mode 100644 index 000000000..cd5337d36 --- /dev/null +++ b/src/test/java/gg/agit/konect/domain/club/service/GoogleDrivePermissionHelperTest.java @@ -0,0 +1,182 @@ +package gg.agit.konect.domain.club.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.mock; + +import java.io.IOException; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +import com.google.api.services.drive.Drive; +import com.google.api.services.drive.model.Permission; +import com.google.api.services.drive.model.PermissionList; + +import gg.agit.konect.support.ServiceTestSupport; + +class GoogleDrivePermissionHelperTest extends ServiceTestSupport { + + private static final String FILE_ID = "spreadsheet-id"; + + @Mock + private Drive driveService; + + @Mock + private Drive.Permissions permissions; + + @Mock + private Drive.Permissions.List firstListRequest; + + @Mock + private Drive.Permissions.List secondListRequest; + + @Mock + private Drive.Permissions.Create createRequest; + + @Mock + private Drive.Permissions.Update updateRequest; + + @Test + @DisplayName("returns true only when the current role satisfies the target role") + void hasRequiredRoleReturnsExpectedResult() { + assertThat(GoogleDrivePermissionHelper.hasRequiredRole("reader", "reader")).isTrue(); + assertThat(GoogleDrivePermissionHelper.hasRequiredRole("commenter", "reader")).isTrue(); + assertThat(GoogleDrivePermissionHelper.hasRequiredRole("writer", "commenter")).isTrue(); + assertThat(GoogleDrivePermissionHelper.hasRequiredRole("reader", "commenter")).isFalse(); + assertThat(GoogleDrivePermissionHelper.hasRequiredRole("commenter", "writer")).isFalse(); + } + + @Test + @DisplayName("throws when target role is unsupported") + void hasRequiredRoleThrowsWhenTargetRoleIsUnsupported() { + assertThatThrownBy(() -> GoogleDrivePermissionHelper.hasRequiredRole("reader", "invalid-role")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unsupported targetRole"); + } + + @Test + @DisplayName("lists all permissions across paged Drive responses") + void listAllPermissionsReturnsPermissionsAcrossPages() throws IOException { + Permission firstPermission = new Permission().setId("perm-1"); + Permission secondPermission = new Permission().setId("perm-2"); + + given(driveService.permissions()).willReturn(permissions); + given(permissions.list(FILE_ID)).willReturn(firstListRequest, secondListRequest); + given(firstListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(firstListRequest); + given(firstListRequest.execute()).willReturn( + new PermissionList() + .setPermissions(List.of(firstPermission)) + .setNextPageToken("next-page") + ); + given(secondListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(secondListRequest); + given(secondListRequest.setPageToken("next-page")).willReturn(secondListRequest); + given(secondListRequest.execute()).willReturn( + new PermissionList().setPermissions(List.of(secondPermission)) + ); + + assertThat(GoogleDrivePermissionHelper.listAllPermissions(driveService, FILE_ID)) + .containsExactly(firstPermission, secondPermission); + } + + @Test + @DisplayName("returns created when create succeeds after a retry check") + void ensureServiceAccountPermissionReturnsCreatedWhenPermissionAppearsAfterRetry() throws IOException { + Drive.Permissions.List initialListRequest = mock(Drive.Permissions.List.class); + Drive.Permissions.List applyListRequest = mock(Drive.Permissions.List.class); + Drive.Permissions.List recheckListRequest = mock(Drive.Permissions.List.class); + + given(driveService.permissions()).willReturn(permissions); + given(permissions.list(FILE_ID)).willReturn(initialListRequest, applyListRequest, recheckListRequest); + given(initialListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(initialListRequest); + given(applyListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(applyListRequest); + given(recheckListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(recheckListRequest); + given(initialListRequest.execute()).willReturn(new PermissionList().setPermissions(List.of())); + given(applyListRequest.execute()).willReturn(new PermissionList().setPermissions(List.of())); + given(recheckListRequest.execute()).willReturn( + new PermissionList().setPermissions(List.of(serviceAccountPermission("perm-1", "writer"))) + ); + given(permissions.create(eq(FILE_ID), org.mockito.ArgumentMatchers.any(Permission.class))) + .willReturn(createRequest); + given(createRequest.setSendNotificationEmail(false)).willReturn(createRequest); + given(createRequest.execute()).willThrow(new IOException("create failed after applying")); + + assertThat( + GoogleDrivePermissionHelper.ensureServiceAccountPermission( + driveService, + FILE_ID, + "writer", + "service-account@project.iam.gserviceaccount.com" + ) + ).isEqualTo(GoogleDrivePermissionHelper.PermissionApplyStatus.CREATED); + } + + @Test + @DisplayName("returns upgraded when update succeeds after a retry check") + void ensureServiceAccountPermissionReturnsUpgradedWhenPermissionImprovesAfterRetry() throws IOException { + Drive.Permissions.List initialListRequest = mock(Drive.Permissions.List.class); + Drive.Permissions.List applyListRequest = mock(Drive.Permissions.List.class); + Drive.Permissions.List recheckListRequest = mock(Drive.Permissions.List.class); + + given(driveService.permissions()).willReturn(permissions); + given(permissions.list(FILE_ID)).willReturn(initialListRequest, applyListRequest, recheckListRequest); + given(initialListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(initialListRequest); + given(applyListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(applyListRequest); + given(recheckListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(recheckListRequest); + given(initialListRequest.execute()).willReturn( + new PermissionList().setPermissions(List.of(serviceAccountPermission("perm-1", "reader"))) + ); + given(applyListRequest.execute()).willReturn( + new PermissionList().setPermissions(List.of(serviceAccountPermission("perm-1", "reader"))) + ); + given(recheckListRequest.execute()).willReturn( + new PermissionList().setPermissions(List.of(serviceAccountPermission("perm-1", "writer"))) + ); + given(permissions.update(eq(FILE_ID), eq("perm-1"), org.mockito.ArgumentMatchers.any(Permission.class))) + .willReturn(updateRequest); + given(updateRequest.execute()).willThrow(new IOException("update failed after applying")); + + assertThat( + GoogleDrivePermissionHelper.ensureServiceAccountPermission( + driveService, + FILE_ID, + "writer", + "service-account@project.iam.gserviceaccount.com" + ) + ).isEqualTo(GoogleDrivePermissionHelper.PermissionApplyStatus.UPGRADED); + } + + @Test + @DisplayName("throws when ensure is called with unsupported target role") + void ensureServiceAccountPermissionThrowsWhenTargetRoleIsUnsupported() { + assertThatThrownBy( + () -> GoogleDrivePermissionHelper.ensureServiceAccountPermission( + driveService, + FILE_ID, + "invalid-role", + "service-account@project.iam.gserviceaccount.com" + ) + ).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unsupported targetRole"); + } + + private Permission serviceAccountPermission(String permissionId, String role) { + return new Permission() + .setId(permissionId) + .setType("user") + .setEmailAddress("service-account@project.iam.gserviceaccount.com") + .setRole(role); + } +} diff --git a/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetApiExceptionHelperTest.java b/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetApiExceptionHelperTest.java new file mode 100644 index 000000000..75ec30bea --- /dev/null +++ b/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetApiExceptionHelperTest.java @@ -0,0 +1,103 @@ +package gg.agit.konect.domain.club.service; + +import static gg.agit.konect.domain.club.service.GoogleApiTestUtils.googleException; +import static gg.agit.konect.domain.club.service.GoogleApiTestUtils.httpResponseException; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.google.api.client.googleapis.json.GoogleJsonError; +import com.google.api.client.googleapis.json.GoogleJsonResponseException; +import com.google.api.client.http.HttpHeaders; +import com.google.api.client.http.HttpResponseException; + +class GoogleSheetApiExceptionHelperTest { + + @Test + @DisplayName("classifies only permission-related 403 reasons as access denied") + void isAccessDeniedReturnsTrueOnlyForPermissionReasons() { + GoogleJsonResponseException permissionDenied = + googleException(403, "insufficientPermissions"); + GoogleJsonResponseException accessDenied = + googleException(403, "accessDenied"); + GoogleJsonResponseException forbidden = + googleException(403, "forbidden"); + GoogleJsonResponseException quotaExceeded = + googleException(403, "quotaExceeded"); + + assertThat(GoogleSheetApiExceptionHelper.isAccessDenied(permissionDenied)).isTrue(); + assertThat(GoogleSheetApiExceptionHelper.isAccessDenied(accessDenied)).isTrue(); + assertThat(GoogleSheetApiExceptionHelper.isAccessDenied(forbidden)).isTrue(); + assertThat(GoogleSheetApiExceptionHelper.isAccessDenied(quotaExceeded)).isFalse(); + } + + @Test + @DisplayName("returns false for 403 responses without a reason") + void isAccessDeniedReturnsFalseWhenReasonIsMissing() { + GoogleJsonError error = new GoogleJsonError(); + error.setCode(403); + error.setErrors(List.of()); + + HttpResponseException.Builder builder = new HttpResponseException.Builder( + 403, + null, + new HttpHeaders() + ); + + GoogleJsonResponseException exception = new GoogleJsonResponseException(builder, error); + + assertThat(GoogleSheetApiExceptionHelper.isAccessDenied(exception)).isFalse(); + } + + @Test + @DisplayName("classifies auth-related 401 as auth failure") + void isAuthFailureReturnsTrueForAuthReasons() { + GoogleJsonResponseException authFailure = + googleException(401, "authError"); + + assertThat(GoogleSheetApiExceptionHelper.isAuthFailure(authFailure)).isTrue(); + } + + @Test + @DisplayName("classifies 404 as not found") + void isNotFoundReturnsTrueFor404() { + GoogleJsonResponseException notFound = + googleException(404, "notFound"); + + assertThat(GoogleSheetApiExceptionHelper.isNotFound(notFound)).isTrue(); + } + + @Test + @DisplayName("classifies nested 400 invalid_grant responses as invalid Google Drive auth") + void isInvalidGrantReturnsTrueForNestedHttpResponseException() { + IOException wrapped = new IOException( + "token refresh failed", + httpResponseException(400, "{\"error\":\"invalid_grant\",\"error_description\":\"Bad Request\"}") + ); + + assertThat(GoogleSheetApiExceptionHelper.isInvalidGrant(wrapped)).isTrue(); + assertThat(GoogleSheetApiExceptionHelper.extractDetail(wrapped)) + .contains("400 Bad Request") + .contains("invalid_grant"); + assertThat(GoogleSheetApiExceptionHelper.invalidGoogleDriveAuth(wrapped).getDetail()) + .contains("400 Bad Request") + .contains("error=invalid_grant") + .contains("error_description=Bad Request"); + } + + @Test + @DisplayName("classifies non-json http response exceptions with their status code") + void classifiesNonJsonHttpResponseExceptionByStatusCode() { + HttpResponseException forbidden = httpResponseException(403); + HttpResponseException unauthorized = httpResponseException(401); + HttpResponseException notFound = httpResponseException(404); + + assertThat(GoogleSheetApiExceptionHelper.isAccessDenied(forbidden)).isTrue(); + assertThat(GoogleSheetApiExceptionHelper.isAuthFailure(unauthorized)).isTrue(); + assertThat(GoogleSheetApiExceptionHelper.isNotFound(notFound)).isTrue(); + } +} diff --git a/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionServiceTest.java b/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionServiceTest.java new file mode 100644 index 000000000..0db70d9ea --- /dev/null +++ b/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionServiceTest.java @@ -0,0 +1,262 @@ +package gg.agit.konect.domain.club.service; + +import static gg.agit.konect.domain.club.service.GoogleApiTestUtils.googleException; +import static gg.agit.konect.domain.club.service.GoogleApiTestUtils.httpResponseException; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import com.google.api.services.drive.Drive; +import com.google.api.services.drive.model.Permission; +import com.google.api.services.drive.model.PermissionList; +import com.google.auth.oauth2.ServiceAccountCredentials; + +import gg.agit.konect.domain.user.enums.Provider; +import gg.agit.konect.domain.user.model.UserOAuthAccount; +import gg.agit.konect.domain.user.repository.UserOAuthAccountRepository; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.infrastructure.googlesheets.GoogleSheetsConfig; +import gg.agit.konect.support.ServiceTestSupport; + +class GoogleSheetPermissionServiceTest extends ServiceTestSupport { + + private static final Integer REQUESTER_ID = 1; + private static final String FILE_ID = "spreadsheet-id"; + private static final String REFRESH_TOKEN = "refresh-token"; + private static final String SERVICE_ACCOUNT_EMAIL = "service-account@konect.iam.gserviceaccount.com"; + + @Mock + private ServiceAccountCredentials serviceAccountCredentials; + + @Mock + private GoogleSheetsConfig googleSheetsConfig; + + @Mock + private UserOAuthAccountRepository userOAuthAccountRepository; + + @Mock + private UserOAuthAccount userOAuthAccount; + + @Mock + private Drive userDriveService; + + @Mock + private Drive.Permissions permissions; + + @Mock + private Drive.Permissions.List listRequest; + + @Mock + private Drive.Permissions.List nextPageListRequest; + + @Mock + private Drive.Permissions.Create createRequest; + + @Mock + private Drive.Permissions.Update updateRequest; + + @InjectMocks + private GoogleSheetPermissionService googleSheetPermissionService; + + @Test + @DisplayName("returns false when the requester has no Google Drive OAuth account") + void tryGrantServiceAccountWriterAccessReturnsFalseWhenOAuthAccountIsMissing() { + given(userOAuthAccountRepository.findByUserIdAndProvider(REQUESTER_ID, Provider.GOOGLE)) + .willReturn(Optional.empty()); + + boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ); + + assertThat(granted).isFalse(); + } + + @Test + @DisplayName("returns true without creating when the service account already has writer access") + void tryGrantServiceAccountWriterAccessReturnsTrueWhenPermissionAlreadyExists() + throws IOException, GeneralSecurityException { + mockConnectedDriveAccount(); + given(permissions.list(FILE_ID)).willReturn(listRequest); + given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(listRequest); + given(listRequest.execute()).willReturn(permissionList(permission("perm-1", "writer"))); + + boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ); + + assertThat(granted).isTrue(); + verify(permissions, never()).create(eq(FILE_ID), any(Permission.class)); + verify(permissions, never()).update(eq(FILE_ID), eq("perm-1"), any(Permission.class)); + } + + @Test + @DisplayName("finds existing permission across paged Drive permission results") + void tryGrantServiceAccountWriterAccessFindsPermissionAcrossPages() + throws IOException, GeneralSecurityException { + mockConnectedDriveAccount(); + given(permissions.list(FILE_ID)).willReturn(listRequest, nextPageListRequest); + given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(listRequest); + given(listRequest.execute()).willReturn( + new PermissionList().setPermissions(List.of()).setNextPageToken("next-page") + ); + given(nextPageListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(nextPageListRequest); + given(nextPageListRequest.setPageToken("next-page")).willReturn(nextPageListRequest); + given(nextPageListRequest.execute()).willReturn(permissionList(permission("perm-1", "writer"))); + + boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ); + + assertThat(granted).isTrue(); + verify(permissions, never()).create(eq(FILE_ID), any(Permission.class)); + } + + @Test + @DisplayName("returns true when create fails but the permission is visible on re-check") + void tryGrantServiceAccountWriterAccessReturnsTrueAfterConcurrentGrant() + throws IOException, GeneralSecurityException { + mockConnectedDriveAccount(); + given(permissions.list(FILE_ID)).willReturn(listRequest); + given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(listRequest); + given(listRequest.execute()).willReturn( + permissionList(), + permissionList(permission("perm-1", "writer")) + ); + given(permissions.create(eq(FILE_ID), any(Permission.class))).willReturn(createRequest); + given(createRequest.setSendNotificationEmail(false)).willReturn(createRequest); + given(createRequest.execute()).willThrow(new IOException("already granted")); + + boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ); + + assertThat(granted).isTrue(); + verify(permissions).create(eq(FILE_ID), any(Permission.class)); + } + + @Test + @DisplayName("returns true when an existing permission needs to be upgraded to writer") + void tryGrantServiceAccountWriterAccessUpgradesExistingPermission() + throws IOException, GeneralSecurityException { + mockConnectedDriveAccount(); + given(permissions.list(FILE_ID)).willReturn(listRequest); + given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(listRequest); + given(listRequest.execute()).willReturn(permissionList(permission("perm-x", "reader"))); + given(permissions.update(eq(FILE_ID), eq("perm-x"), any(Permission.class))).willReturn(updateRequest); + given(updateRequest.execute()).willReturn(permission("perm-x", "writer")); + + boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ); + + assertThat(granted).isTrue(); + verify(permissions).update(eq(FILE_ID), eq("perm-x"), any(Permission.class)); + } + + @Test + @DisplayName("returns false when Google Drive auth fails during permission lookup") + void tryGrantServiceAccountWriterAccessReturnsFalseWhenAuthFails() + throws IOException, GeneralSecurityException { + mockConnectedDriveAccount(); + given(permissions.list(FILE_ID)).willReturn(listRequest); + given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(listRequest); + given(listRequest.execute()).willThrow(googleException(401, "authError")); + + boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ); + + assertThat(granted).isFalse(); + } + + @Test + @DisplayName("returns false when Google Drive reports access denied while listing permissions") + void tryGrantServiceAccountWriterAccessReturnsFalseWhenAccessIsDenied() + throws IOException, GeneralSecurityException { + mockConnectedDriveAccount(); + given(permissions.list(FILE_ID)).willReturn(listRequest); + given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(listRequest); + given(listRequest.execute()).willThrow(googleException(403, "forbidden")); + + boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ); + + assertThat(granted).isFalse(); + } + + @Test + @DisplayName("throws a bad request custom exception when Google returns invalid_grant") + void tryGrantServiceAccountWriterAccessThrowsWhenInvalidGrantOccurs() + throws IOException, GeneralSecurityException { + mockConnectedDriveAccount(); + given(permissions.list(FILE_ID)).willReturn(listRequest); + given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(listRequest); + given(listRequest.execute()).willThrow(new IOException( + "token refresh failed", + httpResponseException( + 400, + "{\"error\":\"invalid_grant\",\"error_description\":\"Bad Request\"}" + ) + )); + + assertThatThrownBy(() -> googleSheetPermissionService.tryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + )) + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ApiResponseCode.INVALID_GOOGLE_DRIVE_AUTH); + } + + private void mockConnectedDriveAccount() throws IOException, GeneralSecurityException { + given(userOAuthAccountRepository.findByUserIdAndProvider(REQUESTER_ID, Provider.GOOGLE)) + .willReturn(Optional.of(userOAuthAccount)); + given(userOAuthAccount.getGoogleDriveRefreshToken()).willReturn(REFRESH_TOKEN); + given(googleSheetsConfig.buildUserDriveService(REFRESH_TOKEN)).willReturn(userDriveService); + given(serviceAccountCredentials.getClientEmail()).willReturn(SERVICE_ACCOUNT_EMAIL); + given(userDriveService.permissions()).willReturn(permissions); + } + + private Permission permission(String id, String role) { + return new Permission() + .setId(id) + .setType("user") + .setEmailAddress(SERVICE_ACCOUNT_EMAIL) + .setRole(role); + } + + private PermissionList permissionList(Permission... permissions) { + return new PermissionList().setPermissions(List.of(permissions)); + } +} diff --git a/src/test/java/gg/agit/konect/domain/club/service/SheetSyncExecutorTest.java b/src/test/java/gg/agit/konect/domain/club/service/SheetSyncExecutorTest.java new file mode 100644 index 000000000..7aa5115f6 --- /dev/null +++ b/src/test/java/gg/agit/konect/domain/club/service/SheetSyncExecutorTest.java @@ -0,0 +1,180 @@ +package gg.agit.konect.domain.club.service; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static gg.agit.konect.domain.club.service.GoogleApiTestUtils.googleException; + +import java.lang.reflect.Field; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.context.ApplicationEventPublisher; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.api.services.sheets.v4.Sheets; +import com.google.api.services.sheets.v4.model.BatchUpdateSpreadsheetResponse; +import com.google.api.services.sheets.v4.model.ClearValuesRequest; +import com.google.api.services.sheets.v4.model.UpdateValuesResponse; +import com.google.api.services.sheets.v4.model.ValueRange; + +import gg.agit.konect.domain.club.enums.ClubSheetSortKey; +import gg.agit.konect.domain.club.enums.ClubPosition; +import gg.agit.konect.domain.club.event.SheetSyncFailedEvent; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.ClubMember; +import gg.agit.konect.domain.club.model.ClubPreMember; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.ClubFixture; +import gg.agit.konect.support.fixture.ClubMemberFixture; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +class SheetSyncExecutorTest extends ServiceTestSupport { + + @Mock + private Sheets googleSheetsService; + + @Mock + private Sheets.Spreadsheets spreadsheets; + + @Mock + private Sheets.Spreadsheets.Values values; + + @Mock + private Sheets.Spreadsheets.Values.Clear clearRequest; + + @Mock + private Sheets.Spreadsheets.Values.Update updateRequest; + + @Mock + private Sheets.Spreadsheets.BatchUpdate batchUpdateRequest; + + @Mock + private ClubRepository clubRepository; + + @Mock + private ClubMemberRepository clubMemberRepository; + + @Mock + private ClubPreMemberRepository clubPreMemberRepository; + + @Mock + private ObjectMapper objectMapper; + + @Mock + private ApplicationEventPublisher applicationEventPublisher; + + @InjectMocks + private SheetSyncExecutor sheetSyncExecutor; + + @Test + @DisplayName("시트 동기화 권한 오류는 실패 이벤트로 발행한다") + void executeWithSortPublishesFailureEventWhenAccessDenied() throws Exception { + // given + Integer clubId = 1; + String spreadsheetId = "spreadsheet-id"; + Club club = ClubFixture.create(UniversityFixture.create()); + club.updateGoogleSheetId(spreadsheetId); + + given(clubRepository.getById(clubId)).willReturn(club); + given(clubMemberRepository.findAllByClubId(clubId)).willReturn(List.of()); + given(clubPreMemberRepository.findAllByClubId(clubId)).willReturn(List.of()); + given(googleSheetsService.spreadsheets()).willReturn(spreadsheets); + given(spreadsheets.values()).willReturn(values); + given(values.clear(eq(spreadsheetId), eq("A:F"), any(ClearValuesRequest.class))) + .willReturn(clearRequest); + given(clearRequest.execute()).willThrow(googleException(403, "accessDenied")); + + // when + sheetSyncExecutor.executeWithSort(clubId, ClubSheetSortKey.NAME, true); + + // then + verify(applicationEventPublisher).publishEvent(argThat((Object event) -> + event instanceof SheetSyncFailedEvent sheetSyncFailedEvent + && sheetSyncFailedEvent.clubId().equals(clubId) + && sheetSyncFailedEvent.spreadsheetId().equals(spreadsheetId) + && sheetSyncFailedEvent.accessDenied() + )); + } + + @Test + @DisplayName("시트 동기화 시 사전 회원도 함께 덮어쓴다") + void executeWithSortWritesClubMembersAndPreMembers() throws Exception { + // given + Integer clubId = 1; + String spreadsheetId = "spreadsheet-id"; + Club club = ClubFixture.create(UniversityFixture.create()); + club.updateGoogleSheetId(spreadsheetId); + + User memberUser = UserFixture.createUser(club.getUniversity(), "김회원", "2021000001"); + ClubMember member = ClubMemberFixture.createMember(club, memberUser); + setCreatedAt(member, LocalDateTime.of(2024, 3, 1, 10, 0)); + + ClubPreMember preMember = ClubPreMember.builder() + .club(club) + .studentNumber("2024000001") + .name("박사전") + .clubPosition(ClubPosition.MEMBER) + .build(); + setCreatedAt(preMember, LocalDateTime.of(2024, 3, 2, 10, 0)); + + given(clubRepository.getById(clubId)).willReturn(club); + given(clubMemberRepository.findAllByClubId(clubId)).willReturn(List.of(member)); + given(clubPreMemberRepository.findAllByClubId(clubId)).willReturn(List.of(preMember)); + given(googleSheetsService.spreadsheets()).willReturn(spreadsheets); + given(spreadsheets.values()).willReturn(values); + given(values.clear(eq(spreadsheetId), eq("A:F"), any(ClearValuesRequest.class))) + .willReturn(clearRequest); + given(values.update(eq(spreadsheetId), eq("A1"), any(ValueRange.class))) + .willReturn(updateRequest); + given(updateRequest.setValueInputOption("USER_ENTERED")).willReturn(updateRequest); + given(updateRequest.execute()).willReturn(new UpdateValuesResponse()); + given(spreadsheets.batchUpdate(eq(spreadsheetId), any())).willReturn(batchUpdateRequest); + given(batchUpdateRequest.execute()).willReturn(new BatchUpdateSpreadsheetResponse()); + + // when + sheetSyncExecutor.executeWithSort(clubId, ClubSheetSortKey.NAME, true); + + // then + verify(values).update(eq(spreadsheetId), eq("A1"), argThat((ValueRange body) -> + body.getValues().equals(List.of( + List.of("Name", "StudentId", "Email", "Phone", "Position", "JoinedAt"), + List.of("김회원", "2021000001", "2021000001@koreatech.ac.kr", "", "일반회원", "2024-03-01"), + List.of("박사전", "2024000001", "", "", "일반회원", "2024-03-02") + )) + )); + } + + private void setCreatedAt(Object target, LocalDateTime createdAt) throws Exception { + Field createdAtField = findField(target.getClass(), "createdAt"); + createdAtField.setAccessible(true); + createdAtField.set(target, createdAt); + } + + private Field findField(Class type, String fieldName) throws NoSuchFieldException { + Class current = type; + + while (current != null) { + try { + return current.getDeclaredField(fieldName); + } catch (NoSuchFieldException e) { + current = current.getSuperclass(); + } + } + + throw new NoSuchFieldException( + "Field '%s' not found in class hierarchy of %s".formatted(fieldName, type.getName()) + ); + } +} diff --git a/src/test/java/gg/agit/konect/infrastructure/slack/listener/SheetSyncSlackListenerTest.java b/src/test/java/gg/agit/konect/infrastructure/slack/listener/SheetSyncSlackListenerTest.java new file mode 100644 index 000000000..4da3a2168 --- /dev/null +++ b/src/test/java/gg/agit/konect/infrastructure/slack/listener/SheetSyncSlackListenerTest.java @@ -0,0 +1,45 @@ +package gg.agit.konect.infrastructure.slack.listener; + +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import gg.agit.konect.domain.club.event.SheetSyncFailedEvent; +import gg.agit.konect.infrastructure.slack.service.SlackNotificationService; +import gg.agit.konect.support.ServiceTestSupport; + +class SheetSyncSlackListenerTest extends ServiceTestSupport { + + @Mock + private SlackNotificationService slackNotificationService; + + @InjectMocks + private SheetSyncSlackListener sheetSyncSlackListener; + + @Test + @DisplayName("delegates sheet sync failure events to Slack notification service") + void handleSheetSyncFailedDelegatesToSlackService() { + SheetSyncFailedEvent event = SheetSyncFailedEvent.accessDenied(1, "spreadsheet-id", "access denied"); + + sheetSyncSlackListener.handleSheetSyncFailed(event); + + verify(slackNotificationService).notifySheetSyncFailed(event); + } + + @Test + @DisplayName("swallows listener exceptions so event publishing flow is not broken") + void handleSheetSyncFailedSwallowsExceptions() { + SheetSyncFailedEvent event = SheetSyncFailedEvent.unexpected(1, "spreadsheet-id", "boom"); + doThrow(new RuntimeException("slack error")) + .when(slackNotificationService) + .notifySheetSyncFailed(event); + + sheetSyncSlackListener.handleSheetSyncFailed(event); + + verify(slackNotificationService).notifySheetSyncFailed(event); + } +} diff --git a/src/test/java/gg/agit/konect/integration/KonectApplicationTests.java b/src/test/java/gg/agit/konect/integration/KonectApplicationTests.java index f1a93e0e2..ac1367eec 100644 --- a/src/test/java/gg/agit/konect/integration/KonectApplicationTests.java +++ b/src/test/java/gg/agit/konect/integration/KonectApplicationTests.java @@ -5,19 +5,33 @@ import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import com.google.api.services.drive.Drive; +import com.google.api.services.sheets.v4.Sheets; +import com.google.auth.oauth2.GoogleCredentials; import gg.agit.konect.support.TestClaudeConfig; +import gg.agit.konect.support.TestMcpConfig; import gg.agit.konect.support.TestSecurityConfig; @SpringBootTest @ActiveProfiles("test") -@Import({TestClaudeConfig.class, TestSecurityConfig.class}) +@Import({TestClaudeConfig.class, TestMcpConfig.class, TestSecurityConfig.class}) @TestPropertySource(locations = "classpath:.env.test.properties") class KonectApplicationTests { + @MockitoBean + private GoogleCredentials googleCredentials; + + @MockitoBean + private Sheets googleSheetsService; + + @MockitoBean + private Drive googleDriveService; + @Test void contextLoads() { } } - diff --git a/src/test/java/gg/agit/konect/integration/admin/schedule/AdminScheduleApiTest.java b/src/test/java/gg/agit/konect/integration/admin/schedule/AdminScheduleApiTest.java new file mode 100644 index 000000000..6ba3b40a0 --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/admin/schedule/AdminScheduleApiTest.java @@ -0,0 +1,757 @@ +package gg.agit.konect.integration.admin.schedule; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.IntStream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import gg.agit.konect.admin.schedule.dto.AdminScheduleCreateRequest; +import gg.agit.konect.admin.schedule.dto.AdminScheduleUpsertItemRequest; +import gg.agit.konect.admin.schedule.dto.AdminScheduleUpsertRequest; +import gg.agit.konect.domain.schedule.model.Schedule; +import gg.agit.konect.domain.schedule.model.ScheduleType; +import gg.agit.konect.domain.schedule.model.UniversitySchedule; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.IntegrationTestSupport; +import gg.agit.konect.support.fixture.ScheduleFixture; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +class AdminScheduleApiTest extends IntegrationTestSupport { + + private static final String BASE_URL = "/admin/schedules"; + + private University university; + private User admin; + + @BeforeEach + void setUp() { + university = persist(UniversityFixture.create()); + admin = persist(UserFixture.createAdmin(university)); + } + + @Nested + @DisplayName("POST /admin/schedules - 일정 생성") + class CreateSchedule { + + @Test + @DisplayName("관리자가 일정 생성에 성공한다") + void createScheduleSuccess() throws Exception { + // given + LocalDateTime startedAt = LocalDateTime.now().plusDays(1); + LocalDateTime endedAt = startedAt.plusDays(7); + + var request = new AdminScheduleCreateRequest( + "동계방학", + startedAt, + endedAt, + ScheduleType.UNIVERSITY + ); + + mockLoginUser(admin.getId()); + clearPersistenceContext(); + + // when & then + performPost(BASE_URL, request) + .andExpect(status().isOk()); + + clearPersistenceContext(); + + // 데이터 저장 검증 + List saved = entityManager.createQuery( + "SELECT us FROM UniversitySchedule us WHERE us.university.id = :universityId", + UniversitySchedule.class) + .setParameter("universityId", university.getId()) + .getResultList(); + + assertThat(saved).hasSize(1); + assertThat(saved.get(0).getSchedule().getTitle()).isEqualTo("동계방학"); + assertThat(saved.get(0).getSchedule().getScheduleType()).isEqualTo(ScheduleType.UNIVERSITY); + } + + @Test + @DisplayName("제목이 없으면 400 에러를 반환한다") + void createScheduleFailWithoutTitle() throws Exception { + // given + LocalDateTime startedAt = LocalDateTime.now().plusDays(1); + LocalDateTime endedAt = startedAt.plusDays(7); + + var request = new AdminScheduleCreateRequest( + null, + startedAt, + endedAt, + ScheduleType.UNIVERSITY + ); + + mockLoginUser(admin.getId()); + + // when & then + performPost(BASE_URL, request) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_REQUEST_BODY.getCode())); + } + + @Test + @DisplayName("시작일시가 종료일시보다 늦으면 400 에러를 반환한다") + void createScheduleFailWithInvalidDateRange() throws Exception { + // given + LocalDateTime startedAt = LocalDateTime.now().plusDays(10); + LocalDateTime endedAt = LocalDateTime.now().plusDays(1); + + var request = new AdminScheduleCreateRequest( + "동계방학", + startedAt, + endedAt, + ScheduleType.UNIVERSITY + ); + + mockLoginUser(admin.getId()); + + // when & then + performPost(BASE_URL, request) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_DATE_TIME.getCode())); + } + + @Test + @DisplayName("일정 종류가 없으면 400 에러를 반환한다") + void createScheduleFailWithoutScheduleType() throws Exception { + // given + LocalDateTime startedAt = LocalDateTime.now().plusDays(1); + LocalDateTime endedAt = startedAt.plusDays(7); + + var request = new AdminScheduleCreateRequest( + "동계방학", + startedAt, + endedAt, + null + ); + + mockLoginUser(admin.getId()); + + // when & then + performPost(BASE_URL, request) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_REQUEST_BODY.getCode())); + } + + @Test + @DisplayName("시작일시가 null이면 400 에러를 반환한다") + void createScheduleFailWithNullStartedAt() throws Exception { + // given + LocalDateTime endedAt = LocalDateTime.now().plusDays(7); + + var request = new AdminScheduleCreateRequest( + "동계방학", + null, + endedAt, + ScheduleType.UNIVERSITY + ); + + mockLoginUser(admin.getId()); + + // when & then + performPost(BASE_URL, request) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_REQUEST_BODY.getCode())); + } + + @Test + @DisplayName("종료일시가 null이면 400 에러를 반환한다") + void createScheduleFailWithNullEndedAt() throws Exception { + // given + LocalDateTime startedAt = LocalDateTime.now().plusDays(1); + + var request = new AdminScheduleCreateRequest( + "동계방학", + startedAt, + null, + ScheduleType.UNIVERSITY + ); + + mockLoginUser(admin.getId()); + + // when & then + performPost(BASE_URL, request) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_REQUEST_BODY.getCode())); + } + + @Test + @DisplayName("빈 문자열 제목으로는 일정을 생성할 수 없다") + void createScheduleFailWithBlankTitle() throws Exception { + // given + LocalDateTime startedAt = LocalDateTime.now().plusDays(1); + LocalDateTime endedAt = startedAt.plusDays(7); + + var request = new AdminScheduleCreateRequest( + " ", + startedAt, + endedAt, + ScheduleType.UNIVERSITY + ); + + mockLoginUser(admin.getId()); + + // when & then + performPost(BASE_URL, request) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_REQUEST_BODY.getCode())); + } + + @Test + @DisplayName("시작일시와 종료일시가 같아도 일정을 생성할 수 있다") + void createScheduleSuccessWithSameStartAndEnd() throws Exception { + // given + LocalDateTime sameDateTime = LocalDateTime.now().plusDays(1); + + var request = new AdminScheduleCreateRequest( + "단일 일정", + sameDateTime, + sameDateTime, + ScheduleType.UNIVERSITY + ); + + mockLoginUser(admin.getId()); + clearPersistenceContext(); + + // when & then + performPost(BASE_URL, request) + .andExpect(status().isOk()); + + clearPersistenceContext(); + + List saved = entityManager.createQuery( + "SELECT us FROM UniversitySchedule us WHERE us.university.id = :universityId", + UniversitySchedule.class) + .setParameter("universityId", university.getId()) + .getResultList(); + + assertThat(saved).hasSize(1); + } + } + + @Nested + @DisplayName("PUT /admin/schedules/batch - 일정 일괄 생성/수정") + class UpsertSchedules { + + @Test + @DisplayName("새로운 일정을 일괄 생성한다") + void upsertSchedulesCreateSuccess() throws Exception { + // given + LocalDateTime startedAt = LocalDateTime.now().plusDays(1); + LocalDateTime endedAt = startedAt.plusDays(7); + + var item1 = new AdminScheduleUpsertItemRequest( + null, + "동계방학", + startedAt, + endedAt, + ScheduleType.UNIVERSITY + ); + + var item2 = new AdminScheduleUpsertItemRequest( + null, + "하계방학", + startedAt.plusMonths(6), + startedAt.plusMonths(6).plusDays(7), + ScheduleType.UNIVERSITY + ); + + var request = new AdminScheduleUpsertRequest(List.of(item1, item2)); + + mockLoginUser(admin.getId()); + clearPersistenceContext(); + + // when & then + performPut(BASE_URL + "/batch", request) + .andExpect(status().isOk()); + + clearPersistenceContext(); + + List saved = entityManager.createQuery( + "SELECT us FROM UniversitySchedule us WHERE us.university.id = :universityId", + UniversitySchedule.class) + .setParameter("universityId", university.getId()) + .getResultList(); + + assertThat(saved).hasSize(2); + } + + @Test + @DisplayName("기존 일정을 수정한다") + void upsertSchedulesUpdateSuccess() throws Exception { + // given + Schedule schedule = persist(ScheduleFixture.createUniversity( + "기존 일정", + LocalDateTime.now().plusDays(1), + LocalDateTime.now().plusDays(5) + )); + UniversitySchedule universitySchedule = persist( + ScheduleFixture.createUniversitySchedule(schedule, university) + ); + clearPersistenceContext(); + + String newTitle = "수정된 일정"; + LocalDateTime newStartedAt = LocalDateTime.now().plusDays(2); + LocalDateTime newEndedAt = newStartedAt.plusDays(3); + + var item = new AdminScheduleUpsertItemRequest( + universitySchedule.getId(), + newTitle, + newStartedAt, + newEndedAt, + ScheduleType.CLUB + ); + + var request = new AdminScheduleUpsertRequest(List.of(item)); + + mockLoginUser(admin.getId()); + + // when & then + performPut(BASE_URL + "/batch", request) + .andExpect(status().isOk()); + + clearPersistenceContext(); + + UniversitySchedule updated = entityManager.find(UniversitySchedule.class, universitySchedule.getId()); + assertThat(updated.getSchedule().getTitle()).isEqualTo(newTitle); + assertThat(updated.getSchedule().getScheduleType()).isEqualTo(ScheduleType.CLUB); + } + + @Test + @DisplayName("생성과 수정을 동시에 수행한다") + void upsertSchedulesMixedSuccess() throws Exception { + // given + Schedule existingSchedule = persist(ScheduleFixture.createUniversity( + "기존 일정", + LocalDateTime.now().plusDays(1), + LocalDateTime.now().plusDays(5) + )); + UniversitySchedule existingUniversitySchedule = persist( + ScheduleFixture.createUniversitySchedule(existingSchedule, university) + ); + clearPersistenceContext(); + + var updateItem = new AdminScheduleUpsertItemRequest( + existingUniversitySchedule.getId(), + "수정된 일정", + LocalDateTime.now().plusDays(2), + LocalDateTime.now().plusDays(6), + ScheduleType.UNIVERSITY + ); + + var createItem = new AdminScheduleUpsertItemRequest( + null, + "새 일정", + LocalDateTime.now().plusDays(10), + LocalDateTime.now().plusDays(15), + ScheduleType.DORM + ); + + var request = new AdminScheduleUpsertRequest(List.of(updateItem, createItem)); + + mockLoginUser(admin.getId()); + + // when & then + performPut(BASE_URL + "/batch", request) + .andExpect(status().isOk()); + + clearPersistenceContext(); + + List allSchedules = entityManager.createQuery( + "SELECT us FROM UniversitySchedule us WHERE us.university.id = :universityId", + UniversitySchedule.class) + .setParameter("universityId", university.getId()) + .getResultList(); + + assertThat(allSchedules).hasSize(2); + } + + @Test + @DisplayName("빈 목록이면 400 에러를 반환한다") + void upsertSchedulesFailWithEmptyList() throws Exception { + // given + var request = new AdminScheduleUpsertRequest(List.of()); + + mockLoginUser(admin.getId()); + + // when & then + performPut(BASE_URL + "/batch", request) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_REQUEST_BODY.getCode())); + } + + @Test + @DisplayName("수정 시 존재하지 않는 일정 ID면 404 에러를 반환한다") + void upsertSchedulesFailWithNonExistentId() throws Exception { + // given + int nonExistentId = 99999; + + var item = new AdminScheduleUpsertItemRequest( + nonExistentId, + "수정된 일정", + LocalDateTime.now().plusDays(1), + LocalDateTime.now().plusDays(7), + ScheduleType.UNIVERSITY + ); + + var request = new AdminScheduleUpsertRequest(List.of(item)); + + mockLoginUser(admin.getId()); + + // when & then + performPut(BASE_URL + "/batch", request) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_SCHEDULE.getCode())); + } + + @Test + @DisplayName("다른 대학의 일정은 수정할 수 없다") + void upsertSchedulesFailOtherUniversity() throws Exception { + // given + University otherUniversity = persist(UniversityFixture.createWithName("다른대학교")); + Schedule otherSchedule = persist(ScheduleFixture.createUniversity( + "다른 대학 일정", + LocalDateTime.now().plusDays(1), + LocalDateTime.now().plusDays(5) + )); + UniversitySchedule otherUniversitySchedule = persist( + ScheduleFixture.createUniversitySchedule(otherSchedule, otherUniversity) + ); + clearPersistenceContext(); + + var item = new AdminScheduleUpsertItemRequest( + otherUniversitySchedule.getId(), + "수정 시도", + LocalDateTime.now().plusDays(2), + LocalDateTime.now().plusDays(6), + ScheduleType.UNIVERSITY + ); + + var request = new AdminScheduleUpsertRequest(List.of(item)); + + mockLoginUser(admin.getId()); + + // when & then + performPut(BASE_URL + "/batch", request) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_SCHEDULE.getCode())); + } + + @Test + @DisplayName("수정 시 잘못된 날짜 범위면 400 에러를 반환한다") + void upsertSchedulesFailWithInvalidDateRange() throws Exception { + // given + Schedule schedule = persist(ScheduleFixture.createUniversity( + "기존 일정", + LocalDateTime.now().plusDays(1), + LocalDateTime.now().plusDays(5) + )); + UniversitySchedule universitySchedule = persist( + ScheduleFixture.createUniversitySchedule(schedule, university) + ); + clearPersistenceContext(); + + var item = new AdminScheduleUpsertItemRequest( + universitySchedule.getId(), + "수정된 일정", + LocalDateTime.now().plusDays(10), + LocalDateTime.now().plusDays(1), + ScheduleType.UNIVERSITY + ); + + var request = new AdminScheduleUpsertRequest(List.of(item)); + + mockLoginUser(admin.getId()); + + // when & then + performPut(BASE_URL + "/batch", request) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_DATE_TIME.getCode())); + } + + @Test + @DisplayName("항목 중 하나라도 검증 실패하면 전체 요청이 실패한다") + void upsertSchedulesFailWithOneInvalidItem() throws Exception { + // given + var validItem = new AdminScheduleUpsertItemRequest( + null, + "유효한 일정", + LocalDateTime.now().plusDays(1), + LocalDateTime.now().plusDays(7), + ScheduleType.UNIVERSITY + ); + + var invalidItem = new AdminScheduleUpsertItemRequest( + null, + "잘못된 일정", + LocalDateTime.now().plusDays(10), + LocalDateTime.now().plusDays(1), + ScheduleType.UNIVERSITY + ); + + var request = new AdminScheduleUpsertRequest(List.of(validItem, invalidItem)); + + mockLoginUser(admin.getId()); + + // when & then + performPut(BASE_URL + "/batch", request) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_DATE_TIME.getCode())); + + clearPersistenceContext(); + + List saved = entityManager.createQuery( + "SELECT us FROM UniversitySchedule us WHERE us.university.id = :universityId", + UniversitySchedule.class) + .setParameter("universityId", university.getId()) + .getResultList(); + assertThat(saved).isEmpty(); + } + + @Test + @DisplayName("대량 일정을 일괄 처리할 수 있다") + void upsertSchedulesLargeBatch() throws Exception { + // given + LocalDateTime baseDate = LocalDateTime.now().plusDays(1); + + var items = IntStream.range(0, 50) + .mapToObj(i -> new AdminScheduleUpsertItemRequest( + null, + "일정 " + i, + baseDate.plusDays(i), + baseDate.plusDays(i + 1), + ScheduleType.UNIVERSITY + )) + .toList(); + + var request = new AdminScheduleUpsertRequest(items); + + mockLoginUser(admin.getId()); + clearPersistenceContext(); + + // when & then + performPut(BASE_URL + "/batch", request) + .andExpect(status().isOk()); + + clearPersistenceContext(); + + List saved = entityManager.createQuery( + "SELECT us FROM UniversitySchedule us WHERE us.university.id = :universityId", + UniversitySchedule.class) + .setParameter("universityId", university.getId()) + .getResultList(); + + assertThat(saved).hasSize(50); + } + } + + @Nested + @DisplayName("DELETE /admin/schedules/{scheduleId} - 일정 삭제") + class DeleteSchedule { + + @Test + @DisplayName("일정 삭제에 성공한다") + void deleteScheduleSuccess() throws Exception { + // given + Schedule schedule = persist(ScheduleFixture.createUniversity( + "삭제될 일정", + LocalDateTime.now().plusDays(1), + LocalDateTime.now().plusDays(5) + )); + UniversitySchedule universitySchedule = persist( + ScheduleFixture.createUniversitySchedule(schedule, university) + ); + clearPersistenceContext(); + + mockLoginUser(admin.getId()); + + // when & then + performDelete(BASE_URL + "/" + universitySchedule.getId()) + .andExpect(status().isOk()); + + clearPersistenceContext(); + + UniversitySchedule deleted = entityManager.find(UniversitySchedule.class, universitySchedule.getId()); + Schedule deletedSchedule = entityManager.find(Schedule.class, schedule.getId()); + + assertThat(deleted).isNull(); + assertThat(deletedSchedule).isNull(); + } + + @Test + @DisplayName("존재하지 않는 일정이면 404 에러를 반환한다") + void deleteScheduleFailNotFound() throws Exception { + // given + int nonExistentId = 99999; + + mockLoginUser(admin.getId()); + + // when & then + performDelete(BASE_URL + "/" + nonExistentId) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_SCHEDULE.getCode())); + } + + @Test + @DisplayName("다른 대학의 일정은 삭제할 수 없다") + void deleteScheduleFailOtherUniversity() throws Exception { + // given + University otherUniversity = persist(UniversityFixture.createWithName("다른대학교")); + Schedule otherSchedule = persist(ScheduleFixture.createUniversity( + "다른 대학 일정", + LocalDateTime.now().plusDays(1), + LocalDateTime.now().plusDays(5) + )); + UniversitySchedule otherUniversitySchedule = persist( + ScheduleFixture.createUniversitySchedule(otherSchedule, otherUniversity) + ); + clearPersistenceContext(); + + mockLoginUser(admin.getId()); + + // when & then + performDelete(BASE_URL + "/" + otherUniversitySchedule.getId()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_SCHEDULE.getCode())); + } + + @Test + @DisplayName("이미 삭제된 일정을 다시 삭제하면 404 에러를 반환한다") + void deleteScheduleFailAlreadyDeleted() throws Exception { + // given + Schedule schedule = persist(ScheduleFixture.createUniversity( + "삭제될 일정", + LocalDateTime.now().plusDays(1), + LocalDateTime.now().plusDays(5) + )); + UniversitySchedule universitySchedule = persist( + ScheduleFixture.createUniversitySchedule(schedule, university) + ); + clearPersistenceContext(); + + mockLoginUser(admin.getId()); + + // when - 첫 삭제 성공 + performDelete(BASE_URL + "/" + universitySchedule.getId()) + .andExpect(status().isOk()); + + clearPersistenceContext(); + + // then - 재삭제 시 404 + performDelete(BASE_URL + "/" + universitySchedule.getId()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_SCHEDULE.getCode())); + } + + @Test + @DisplayName("음수 ID로 삭제 요청하면 404 에러를 반환한다") + void deleteScheduleFailWithNegativeId() throws Exception { + // given + mockLoginUser(admin.getId()); + + // when & then + performDelete(BASE_URL + "/-1") + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_SCHEDULE.getCode())); + } + } + + @Nested + @DisplayName("관리자 권한 검증") + class AdminAuthorization { + + @Test + @DisplayName("일반 사용자는 일정 생성 권한이 없다") + void nonAdminCannotCreateSchedule() throws Exception { + // given + User normalUser = persist(UserFixture.createUser(university, "일반유저", "2021136002")); + clearPersistenceContext(); + + LocalDateTime startedAt = LocalDateTime.now().plusDays(1); + LocalDateTime endedAt = startedAt.plusDays(7); + + var request = new AdminScheduleCreateRequest( + "동계방학", + startedAt, + endedAt, + ScheduleType.UNIVERSITY + ); + + mockLoginUser(normalUser.getId()); + given(authorizationInterceptor.preHandle(any(), any(), any())) + .willThrow(CustomException.of(ApiResponseCode.FORBIDDEN_ROLE_ACCESS)); + + // when & then + performPost(BASE_URL, request) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.FORBIDDEN_ROLE_ACCESS.getCode())); + } + + @Test + @DisplayName("일반 사용자는 일정 삭제 권한이 없다") + void nonAdminCannotDeleteSchedule() throws Exception { + // given + User normalUser = persist(UserFixture.createUser(university, "일반유저", "2021136003")); + Schedule schedule = persist(ScheduleFixture.createUniversity( + "일정", + LocalDateTime.now().plusDays(1), + LocalDateTime.now().plusDays(5) + )); + UniversitySchedule universitySchedule = persist( + ScheduleFixture.createUniversitySchedule(schedule, university) + ); + clearPersistenceContext(); + + mockLoginUser(normalUser.getId()); + given(authorizationInterceptor.preHandle(any(), any(), any())) + .willThrow(CustomException.of(ApiResponseCode.FORBIDDEN_ROLE_ACCESS)); + + // when & then + performDelete(BASE_URL + "/" + universitySchedule.getId()) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.FORBIDDEN_ROLE_ACCESS.getCode())); + } + + @Test + @DisplayName("일반 사용자는 배치 수정 권한이 없다") + void nonAdminCannotUpsertSchedules() throws Exception { + // given + User normalUser = persist(UserFixture.createUser(university, "일반유저", "2021136004")); + clearPersistenceContext(); + + var item = new AdminScheduleUpsertItemRequest( + null, + "동계방학", + LocalDateTime.now().plusDays(1), + LocalDateTime.now().plusDays(7), + ScheduleType.UNIVERSITY + ); + + var request = new AdminScheduleUpsertRequest(List.of(item)); + + mockLoginUser(normalUser.getId()); + given(authorizationInterceptor.preHandle(any(), any(), any())) + .willThrow(CustomException.of(ApiResponseCode.FORBIDDEN_ROLE_ACCESS)); + + // when & then + performPut(BASE_URL + "/batch", request) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.FORBIDDEN_ROLE_ACCESS.getCode())); + } + } +} diff --git a/src/test/java/gg/agit/konect/integration/domain/advertisement/AdvertisementApiTest.java b/src/test/java/gg/agit/konect/integration/domain/advertisement/AdvertisementApiTest.java new file mode 100644 index 000000000..735a15ad2 --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/domain/advertisement/AdvertisementApiTest.java @@ -0,0 +1,235 @@ +package gg.agit.konect.integration.domain.advertisement; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementCreateRequest; +import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementUpdateRequest; +import gg.agit.konect.domain.advertisement.dto.AdvertisementResponse; +import gg.agit.konect.domain.advertisement.dto.AdvertisementsResponse; +import gg.agit.konect.domain.advertisement.model.Advertisement; +import gg.agit.konect.support.IntegrationTestSupport; +import gg.agit.konect.support.fixture.AdvertisementFixture; + +class AdvertisementApiTest extends IntegrationTestSupport { + + @Nested + @DisplayName("GET /advertisements - 랜덤 광고 목록 조회") + class GetAdvertisements { + + @Test + @DisplayName("노출 가능한 광고를 랜덤으로 count개 조회한다 - 비노출 광고는 제외") + void getRandomAdvertisements() throws Exception { + // given + persist(AdvertisementFixture.create("광고1", true)); + persist(AdvertisementFixture.create("광고2", true)); + persist(AdvertisementFixture.create("광고3", true)); + persist(AdvertisementFixture.create("비노출 광고", false)); + clearPersistenceContext(); + + // when & then - 반환된 광고는 모두 노출 광고만 포함 + performGet("/advertisements?count=2") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.advertisements", hasSize(2))) + .andExpect(jsonPath("$.advertisements[*].title").value(org.hamcrest.Matchers.not("비노출 광고"))); + } + + @Test + @DisplayName("count가 visible 광고 수 이하면 중복 없이 반환한다") + void getRandomAdvertisementsNoDuplicate() throws Exception { + // given + persist(AdvertisementFixture.create("광고1", true)); + persist(AdvertisementFixture.create("광고2", true)); + persist(AdvertisementFixture.create("광고3", true)); + clearPersistenceContext(); + + // when + String responseJson = performGet("/advertisements?count=3") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.advertisements", hasSize(3))) + .andReturn() + .getResponse() + .getContentAsString(); + + AdvertisementsResponse response = objectMapper.readValue( + responseJson, AdvertisementsResponse.class); + + // then - 반환된 ID는 모두 distinct + List ids = response.advertisements().stream() + .map(AdvertisementResponse::id) + .toList(); + Set uniqueIds = Set.copyOf(ids); + assertThat(uniqueIds).hasSize(3); + } + + @Test + @DisplayName("count 기본값은 1이다") + void getRandomAdvertisementsDefaultCount() throws Exception { + // given + persist(AdvertisementFixture.create("광고1", true)); + clearPersistenceContext(); + + // when & then + performGet("/advertisements") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.advertisements", hasSize(1))); + } + + @Test + @DisplayName("count가 등록된 광고 수보다 많으면 중복을 허용하여 반환한다") + void getRandomAdvertisementsWithDuplication() throws Exception { + // given - 노출 광고 2개만 등록 + persist(AdvertisementFixture.create("광고1", true)); + persist(AdvertisementFixture.create("광고2", true)); + clearPersistenceContext(); + + // when & then - 5개 요청 시 중복 허용하여 5개 반환 + performGet("/advertisements?count=5") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.advertisements", hasSize(5))); + } + + @Test + @DisplayName("count가 1 미만이면 400을 반환한다") + void getRandomAdvertisementsInvalidMinCount() throws Exception { + performGet("/advertisements?count=0") + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("count가 10을 초과하면 400을 반환한다") + void getRandomAdvertisementsInvalidMaxCount() throws Exception { + performGet("/advertisements?count=11") + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("노출 가능한 광고가 없으면 빈 목록을 반환한다") + void getRandomAdvertisementsEmpty() throws Exception { + // given + persist(AdvertisementFixture.create("비노출 광고", false)); + clearPersistenceContext(); + + // when & then + performGet("/advertisements?count=3") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.advertisements", hasSize(0))); + } + } + + @Nested + @DisplayName("POST /advertisements/{id}/clicks - 광고 클릭 수 증가") + class IncreaseClickCount { + + @Test + @DisplayName("광고 클릭 수를 1 증가시킨다") + void increaseClickCount() throws Exception { + // given + Advertisement advertisement = persist(AdvertisementFixture.create("클릭 광고", true)); + clearPersistenceContext(); + + // when & then + performPost("/advertisements/" + advertisement.getId() + "/clicks") + .andExpect(status().isNoContent()); + + clearPersistenceContext(); + Advertisement foundAdvertisement = entityManager.find(Advertisement.class, advertisement.getId()); + assertThat(foundAdvertisement.getClickCount()).isEqualTo(1); + } + } + + @Nested + @DisplayName("Admin Advertisement API") + class AdminAdvertisementCrud { + + @Test + @DisplayName("어드민이 광고를 생성한다") + void createAdvertisement() throws Exception { + // given + AdminAdvertisementCreateRequest request = new AdminAdvertisementCreateRequest( + "생성 광고", + "생성 설명", + "https://example.com/create.png", + "https://example.com/create", + true + ); + + // when & then + performPost("/admin/advertisements", request) + .andExpect(status().isOk()); + + clearPersistenceContext(); + Long count = entityManager.createQuery("select count(a) from Advertisement a", Long.class) + .getSingleResult(); + assertThat(count).isEqualTo(1L); + } + + @Test + @DisplayName("어드민이 광고 목록과 단건을 조회한다") + void getAdvertisementsAndDetail() throws Exception { + // given + Advertisement advertisement = persist(AdvertisementFixture.create("관리 광고", true)); + clearPersistenceContext(); + + // when & then + performGet("/admin/advertisements") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.advertisements", hasSize(1))) + .andExpect(jsonPath("$.advertisements[0].title").value("관리 광고")); + + performGet("/admin/advertisements/" + advertisement.getId()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("관리 광고")); + } + + @Test + @DisplayName("어드민이 광고를 수정한다") + void updateAdvertisement() throws Exception { + // given + Advertisement advertisement = persist(AdvertisementFixture.create("수정 전 광고", true)); + clearPersistenceContext(); + + AdminAdvertisementUpdateRequest request = new AdminAdvertisementUpdateRequest( + "수정 후 광고", + "수정 설명", + "https://example.com/update.png", + "https://example.com/update", + false + ); + + // when & then + performPut("/admin/advertisements/" + advertisement.getId(), request) + .andExpect(status().isOk()); + + clearPersistenceContext(); + Advertisement foundAdvertisement = entityManager.find(Advertisement.class, advertisement.getId()); + assertThat(foundAdvertisement.getTitle()).isEqualTo("수정 후 광고"); + assertThat(foundAdvertisement.getIsVisible()).isFalse(); + } + + @Test + @DisplayName("어드민이 광고를 삭제한다") + void deleteAdvertisement() throws Exception { + // given + Advertisement advertisement = persist(AdvertisementFixture.create("삭제 광고", true)); + clearPersistenceContext(); + + // when & then + performDelete("/admin/advertisements/" + advertisement.getId()) + .andExpect(status().isOk()); + + clearPersistenceContext(); + Advertisement foundAdvertisement = entityManager.find(Advertisement.class, advertisement.getId()); + assertThat(foundAdvertisement).isNull(); + } + } +} diff --git a/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java b/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java new file mode 100644 index 000000000..6b988082b --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java @@ -0,0 +1,1047 @@ +package gg.agit.konect.integration.domain.chat; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import gg.agit.konect.domain.chat.dto.ChatMessageSendRequest; +import gg.agit.konect.domain.chat.dto.ChatRoomCreateRequest; +import gg.agit.konect.domain.chat.dto.ChatRoomNameUpdateRequest; +import gg.agit.konect.domain.chat.enums.ChatType; +import gg.agit.konect.domain.chat.model.ChatMessage; +import gg.agit.konect.domain.chat.model.ChatRoom; +import gg.agit.konect.domain.chat.model.ChatRoomMember; +import gg.agit.konect.domain.chat.repository.ChatMessageRepository; +import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; +import gg.agit.konect.domain.chat.repository.ChatRoomRepository; +import gg.agit.konect.domain.chat.service.ChatPresenceService; +import gg.agit.konect.domain.club.enums.ClubPosition; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.ClubMember; +import gg.agit.konect.domain.notification.enums.NotificationTargetType; +import gg.agit.konect.domain.notification.repository.NotificationMuteSettingRepository; +import gg.agit.konect.domain.notification.service.NotificationService; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.support.IntegrationTestSupport; +import gg.agit.konect.support.fixture.ClubFixture; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +import org.springframework.util.LinkedMultiValueMap; + +class ChatApiTest extends IntegrationTestSupport { + + @Autowired + private ChatRoomRepository chatRoomRepository; + + @Autowired + private ChatMessageRepository chatMessageRepository; + + @Autowired + private ChatRoomMemberRepository chatRoomMemberRepository; + + @Autowired + private NotificationMuteSettingRepository notificationMuteSettingRepository; + + @MockitoBean + private ChatPresenceService chatPresenceService; + + @MockitoBean + private NotificationService notificationService; + + private User adminUser; + private User normalUser; + private User targetUser; + private User outsiderUser; + private University university; + + @BeforeEach + void setUp() { + university = persist(UniversityFixture.create()); + normalUser = persist(UserFixture.createUser(university, "일반유저", "2021136001")); + clearPersistenceContext(); + } + + @Nested + @DisplayName("POST /chats/rooms - 일반 채팅방 생성") + class CreateDirectChatRoom { + + @BeforeEach + void setUpDirectChatFixture() { + targetUser = createUser("상대유저", "2021136002"); + clearPersistenceContext(); + } + + @Test + @DisplayName("일반 채팅방을 생성한다") + void createDirectChatRoomSuccess() throws Exception { + // given + long beforeCount = countDirectRoomsBetween(normalUser, targetUser); + mockLoginUser(normalUser.getId()); + + // when & then + performPost("/chats/rooms", new ChatRoomCreateRequest(targetUser.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.chatRoomId").isNumber()); + + clearPersistenceContext(); + assertThat(chatRoomRepository.findByTwoUsers(normalUser.getId(), targetUser.getId(), ChatType.DIRECT)) + .isPresent(); + assertThat(countDirectRoomsBetween(normalUser, targetUser)).isEqualTo(beforeCount + 1); + } + + @Test + @DisplayName("자기 자신과 채팅방을 만들면 400을 반환한다") + void createDirectChatRoomWithSelfFails() throws Exception { + // given + mockLoginUser(normalUser.getId()); + + // when & then + performPost("/chats/rooms", new ChatRoomCreateRequest(normalUser.getId())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("CANNOT_CREATE_CHAT_ROOM_WITH_SELF")); + } + + @Test + @DisplayName("존재하지 않는 대상 유저면 404를 반환한다") + void createDirectChatRoomWithMissingUserFails() throws Exception { + // given + mockLoginUser(normalUser.getId()); + + // when & then + performPost("/chats/rooms", new ChatRoomCreateRequest(99999)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("NOT_FOUND_USER")); + } + + @Test + @DisplayName("이미 같은 상대와 채팅방이 있으면 기존 방을 반환한다") + void createDirectChatRoomReturnsExistingRoom() throws Exception { + // given + ChatRoom existingRoom = createDirectChatRoom(normalUser, targetUser); + long beforeCount = countDirectRoomsBetween(normalUser, targetUser); + mockLoginUser(normalUser.getId()); + + // when & then + performPost("/chats/rooms", new ChatRoomCreateRequest(targetUser.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.chatRoomId").value(existingRoom.getId())); + + clearPersistenceContext(); + assertThat(chatRoomRepository.findByTwoUsers(normalUser.getId(), targetUser.getId(), ChatType.DIRECT)) + .isPresent() + .get() + .extracting(ChatRoom::getId) + .isEqualTo(existingRoom.getId()); + assertThat(countDirectRoomsBetween(normalUser, targetUser)).isEqualTo(beforeCount); + assertThat(chatRoomMemberRepository.findByChatRoomId(existingRoom.getId())).hasSize(2); + } + } + + @Nested + @DisplayName("POST /chats/rooms/admin, GET /chats/rooms - 관리자 전용 방 생성 및 조회") + class AdminChatRoom { + + @BeforeEach + void setUpAdminChatFixture() { + adminUser = persist(UserFixture.createAdmin(university)); + clearPersistenceContext(); + } + + @Test + @DisplayName("관리자 전용 방을 만들고 목록에서 조회한다") + void createAdminChatRoomAndGetRoomsSuccess() throws Exception { + // given + mockLoginUser(normalUser.getId()); + + // when + performPost("/chats/rooms/admin") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.chatRoomId").isNumber()); + + // then + performGet("/chats/rooms") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.rooms[0].chatType").value("DIRECT")) + .andExpect(jsonPath("$.rooms[0].roomName").value(adminUser.getName())) + .andExpect(jsonPath("$.rooms[0].lastMessage").doesNotExist()) + .andExpect(jsonPath("$.rooms[0].isMuted").value(false)); + } + } + + @Nested + @DisplayName("GET /chats/rooms/invitables - 초대 가능 사용자 조회") + class GetInvitableUsers { + + private User bcsdUser; + private User cseUser; + private User directOnlyUser; + private User multiClubUser; + private User withdrawnUser; + private User adminCandidate; + + @BeforeEach + void setUpInvitableUsersFixture() { + bcsdUser = createUser("김비씨", "2021136002"); + cseUser = createUser("이씨에스", "2021136003"); + directOnlyUser = createUser("박다이렉트", "2021136004"); + multiClubUser = createUser("정멀티", "2021136006"); + withdrawnUser = createUser("탈퇴예정", "2021136005"); + adminCandidate = persist(UserFixture.createAdmin(university)); + + Club bcsd = persist(ClubFixture.create(university, "BCSD")); + Club cse = persist(ClubFixture.create(university, "CSE&Biz")); + + User managedNormalUser = entityManager.getReference(User.class, normalUser.getId()); + User managedBcsdUser = entityManager.getReference(User.class, bcsdUser.getId()); + User managedCseUser = entityManager.getReference(User.class, cseUser.getId()); + User managedMultiClubUser = entityManager.getReference(User.class, multiClubUser.getId()); + Club managedBcsd = entityManager.getReference(Club.class, bcsd.getId()); + Club managedCse = entityManager.getReference(Club.class, cse.getId()); + + persist(ClubMember.builder() + .club(managedBcsd) + .user(managedNormalUser) + .clubPosition(ClubPosition.MEMBER) + .build()); + persist(ClubMember.builder() + .club(managedBcsd) + .user(managedBcsdUser) + .clubPosition(ClubPosition.MEMBER) + .build()); + persist(ClubMember.builder() + .club(managedBcsd) + .user(managedMultiClubUser) + .clubPosition(ClubPosition.MEMBER) + .build()); + persist(ClubMember.builder() + .club(managedCse) + .user(managedNormalUser) + .clubPosition(ClubPosition.MEMBER) + .build()); + persist(ClubMember.builder() + .club(managedCse) + .user(managedCseUser) + .clubPosition(ClubPosition.MEMBER) + .build()); + persist(ClubMember.builder() + .club(managedCse) + .user(managedMultiClubUser) + .clubPosition(ClubPosition.MEMBER) + .build()); + + ChatRoom bcsdRoom = persist(ChatRoom.clubGroupOf(bcsd)); + ChatRoom cseRoom = persist(ChatRoom.clubGroupOf(cse)); + createDirectChatRoom(normalUser, directOnlyUser); + createDirectChatRoom(normalUser, adminCandidate); + + addRoomMember(bcsdRoom, normalUser); + addRoomMember(bcsdRoom, bcsdUser); + addRoomMember(bcsdRoom, multiClubUser); + addRoomMember(cseRoom, normalUser); + addRoomMember(cseRoom, cseUser); + addRoomMember(cseRoom, multiClubUser); + addRoomMember(cseRoom, withdrawnUser); + + entityManager.getReference(User.class, withdrawnUser.getId()).withdraw(LocalDateTime.now()); + + clearPersistenceContext(); + } + + @Test + @DisplayName("기본 정렬은 동아리 섹션과 기타 섹션으로 반환한다") + void getInvitableUsersGroupedByClub() throws Exception { + mockLoginUser(normalUser.getId()); + + performGet("/chats/rooms/invitables") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalCount").value(4)) + .andExpect(jsonPath("$.currentCount").value(4)) + .andExpect(jsonPath("$.totalPage").value(1)) + .andExpect(jsonPath("$.currentPage").value(1)) + .andExpect(jsonPath("$.sortBy").value("CLUB")) + .andExpect(jsonPath("$.grouped").value(true)) + .andExpect(jsonPath("$.users").isEmpty()) + .andExpect(jsonPath("$.sections[0].clubName").value("BCSD")) + .andExpect(jsonPath("$.sections[0].users[0].name").value("김비씨")) + .andExpect(jsonPath("$.sections[0].users[1].name").value("정멀티")) + .andExpect(jsonPath("$.sections[1].clubName").value("CSE&Biz")) + .andExpect(jsonPath("$.sections[1].users[0].name").value("이씨에스")) + .andExpect(jsonPath("$.sections[1].users[1]").doesNotExist()) + .andExpect(jsonPath("$.sections[2].clubName").value("기타")) + .andExpect(jsonPath("$.sections[2].users[0].name").value("박다이렉트")) + .andExpect(jsonPath("$.sections[2].users[1]").doesNotExist()) + .andExpect(jsonPath("$.sections[*].users[*].name").value(org.hamcrest.Matchers.not( + org.hamcrest.Matchers.hasItem("탈퇴예정") + ))) + .andExpect(jsonPath("$.sections[*].users[*].name").value(org.hamcrest.Matchers.not( + org.hamcrest.Matchers.hasItem("관리자") + ))); + } + + @Test + @DisplayName("이름 정렬이면 섹션 없이 단일 리스트로 반환하고 검색이 적용된다") + void getInvitableUsersSortedByName() throws Exception { + mockLoginUser(normalUser.getId()); + LinkedMultiValueMap params = new LinkedMultiValueMap<>(Map.of( + "sortBy", List.of("NAME"), + "query", List.of("2021136004") + )); + + performGet("/chats/rooms/invitables", params) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalCount").value(1)) + .andExpect(jsonPath("$.currentCount").value(1)) + .andExpect(jsonPath("$.totalPage").value(1)) + .andExpect(jsonPath("$.currentPage").value(1)) + .andExpect(jsonPath("$.sortBy").value("NAME")) + .andExpect(jsonPath("$.grouped").value(false)) + .andExpect(jsonPath("$.sections").isEmpty()) + .andExpect(jsonPath("$.users[0].name").value("박다이렉트")) + .andExpect(jsonPath("$.users[1]").doesNotExist()) + .andExpect(jsonPath("$.users[*].name").value(org.hamcrest.Matchers.not( + org.hamcrest.Matchers.hasItem("탈퇴예정") + ))) + .andExpect(jsonPath("$.users[*].name").value(org.hamcrest.Matchers.not( + org.hamcrest.Matchers.hasItem("관리자") + ))); + } + + @Test + @DisplayName("페이지네이션을 적용하면 현재 페이지 사용자만 반환한다") + void getInvitableUsersWithPagination() throws Exception { + mockLoginUser(normalUser.getId()); + LinkedMultiValueMap params = new LinkedMultiValueMap<>(Map.of( + "sortBy", List.of("NAME"), + "page", List.of("2"), + "limit", List.of("2") + )); + + performGet("/chats/rooms/invitables", params) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalCount").value(4)) + .andExpect(jsonPath("$.currentCount").value(2)) + .andExpect(jsonPath("$.totalPage").value(2)) + .andExpect(jsonPath("$.currentPage").value(2)) + .andExpect(jsonPath("$.users[0].name").value("이씨에스")) + .andExpect(jsonPath("$.users[1].name").value("정멀티")) + .andExpect(jsonPath("$.users[2]").doesNotExist()); + } + + @Test + @DisplayName("동아리 정렬은 페이지 경계가 동아리를 가로질러도 섹션 헤더를 유지한다") + void getInvitableUsersWithClubPaginationAcrossSections() throws Exception { + mockLoginUser(normalUser.getId()); + + createGroupedInviteCandidates("분할A", "분할A", 10); + createGroupedInviteCandidates("분할B", "분할B", 20); + createGroupedInviteCandidates("분할C", "분할C", 30); + clearPersistenceContext(); + + LinkedMultiValueMap firstPageParams = new LinkedMultiValueMap<>(Map.of( + "sortBy", List.of("CLUB"), + "query", List.of("분할"), + "page", List.of("1"), + "limit", List.of("20") + )); + + performGet("/chats/rooms/invitables", firstPageParams) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalCount").value(60)) + .andExpect(jsonPath("$.currentCount").value(20)) + .andExpect(jsonPath("$.totalPage").value(3)) + .andExpect(jsonPath("$.currentPage").value(1)) + .andExpect(jsonPath("$.sections[0].clubName").value("분할A")) + .andExpect(jsonPath("$.sections[0].users.length()").value(10)) + .andExpect(jsonPath("$.sections[1].clubName").value("분할B")) + .andExpect(jsonPath("$.sections[1].users.length()").value(10)) + .andExpect(jsonPath("$.sections[2]").doesNotExist()); + + LinkedMultiValueMap secondPageParams = new LinkedMultiValueMap<>(Map.of( + "sortBy", List.of("CLUB"), + "query", List.of("분할"), + "page", List.of("2"), + "limit", List.of("20") + )); + + performGet("/chats/rooms/invitables", secondPageParams) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalCount").value(60)) + .andExpect(jsonPath("$.currentCount").value(20)) + .andExpect(jsonPath("$.totalPage").value(3)) + .andExpect(jsonPath("$.currentPage").value(2)) + .andExpect(jsonPath("$.sections[0].clubName").value("분할B")) + .andExpect(jsonPath("$.sections[0].users.length()").value(10)) + .andExpect(jsonPath("$.sections[0].users[0].name").value("분할B11")) + .andExpect(jsonPath("$.sections[0].users[9].name").value("분할B20")) + .andExpect(jsonPath("$.sections[1].clubName").value("분할C")) + .andExpect(jsonPath("$.sections[1].users.length()").value(10)) + .andExpect(jsonPath("$.sections[1].users[0].name").value("분할C01")) + .andExpect(jsonPath("$.sections[1].users[9].name").value("분할C10")) + .andExpect(jsonPath("$.sections[2]").doesNotExist()); + } + } + + @Nested + @DisplayName("POST /chats/rooms/{chatRoomId}/messages - 메시지 전송") + class SendMessage { + + @BeforeEach + void setUpMessageFixture() { + targetUser = createUser("상대유저", "2021136002"); + clearPersistenceContext(); + } + + @Test + @DisplayName("메시지를 전송하고 응답 형태를 반환한다") + void sendMessageSuccess() throws Exception { + // given + ChatRoom chatRoom = createDirectChatRoom(normalUser, targetUser); + mockLoginUser(normalUser.getId()); + + // when & then + performPost("/chats/rooms/" + chatRoom.getId() + "/messages", new ChatMessageSendRequest("안녕하세요")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.messageId").isNumber()) + .andExpect(jsonPath("$.senderId").value(normalUser.getId())) + .andExpect(jsonPath("$.senderName").doesNotExist()) + .andExpect(jsonPath("$.content").value("안녕하세요")) + .andExpect(jsonPath("$.isRead").value(true)) + .andExpect(jsonPath("$.unreadCount").isNumber()) + .andExpect(jsonPath("$.isMine").value(true)); + + clearPersistenceContext(); + assertThat( + chatMessageRepository.findByChatRoomId(chatRoom.getId(), null, PageRequest.of(0, 20)).getContent() + ) + .hasSize(1) + .extracting(ChatMessage::getContent) + .containsExactly("안녕하세요"); + } + + @Test + @DisplayName("빈 메시지를 전송하면 400을 반환한다") + void sendBlankMessageFails() throws Exception { + // given + ChatRoom chatRoom = createDirectChatRoom(normalUser, targetUser); + mockLoginUser(normalUser.getId()); + + // when & then + performPost("/chats/rooms/" + chatRoom.getId() + "/messages", new ChatMessageSendRequest(" ")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_REQUEST_BODY")) + .andExpect(jsonPath("$.fieldErrors[0].field").value("content")); + } + + @Test + @DisplayName("1000자를 초과한 메시지를 전송하면 400을 반환한다") + void sendTooLongMessageFails() throws Exception { + // given + ChatRoom chatRoom = createDirectChatRoom(normalUser, targetUser); + mockLoginUser(normalUser.getId()); + String tooLongContent = "a".repeat(1001); + + // when & then + performPost("/chats/rooms/" + chatRoom.getId() + "/messages", new ChatMessageSendRequest(tooLongContent)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_REQUEST_BODY")) + .andExpect(jsonPath("$.fieldErrors[0].field").value("content")); + } + } + + @Nested + @DisplayName("PATCH /chats/rooms/{chatRoomId}/name - 채팅방 이름 수정") + class UpdateChatRoomName { + + @BeforeEach + void setUpRoomNameFixture() { + targetUser = createUser("상대유저", "2021136002"); + outsiderUser = createUser("외부유저", "2021136003"); + clearPersistenceContext(); + } + + @Test + @DisplayName("내가 수정한 채팅방 이름은 내 목록에만 반영된다") + void updateChatRoomNameOnlyForCurrentUser() throws Exception { + // given + ChatRoom chatRoom = createDirectChatRoom(normalUser, targetUser); + + mockLoginUser(normalUser.getId()); + performPatch("/chats/rooms/" + chatRoom.getId() + "/name", new ChatRoomNameUpdateRequest("알바 이야기방")) + .andExpect(status().isOk()); + + clearPersistenceContext(); + assertThat(chatRoomMemberRepository.findByChatRoomIdAndUserId(chatRoom.getId(), normalUser.getId())) + .isPresent() + .get() + .extracting(ChatRoomMember::getCustomRoomName) + .isEqualTo("알바 이야기방"); + + // when & then + mockLoginUser(normalUser.getId()); + performGet("/chats/rooms") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.rooms[0].roomName").value("알바 이야기방")); + + mockLoginUser(targetUser.getId()); + performGet("/chats/rooms") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.rooms[0].roomName").value(normalUser.getName())); + } + + @Test + @DisplayName("공백 이름으로 요청하면 기본 채팅방 이름으로 되돌린다") + void blankRoomNameResetsToDefault() throws Exception { + // given + ChatRoom chatRoom = createDirectChatRoom(normalUser, targetUser); + mockLoginUser(normalUser.getId()); + + performPatch("/chats/rooms/" + chatRoom.getId() + "/name", new ChatRoomNameUpdateRequest("알바 이야기방")) + .andExpect(status().isOk()); + + // when + performPatch("/chats/rooms/" + chatRoom.getId() + "/name", new ChatRoomNameUpdateRequest(" ")) + .andExpect(status().isOk()); + + // then + clearPersistenceContext(); + assertThat(chatRoomMemberRepository.findByChatRoomIdAndUserId(chatRoom.getId(), normalUser.getId())) + .isPresent() + .get() + .extracting(ChatRoomMember::getCustomRoomName) + .isNull(); + + mockLoginUser(normalUser.getId()); + performGet("/chats/rooms") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.rooms[0].roomName").value(targetUser.getName())); + } + + @Test + @DisplayName("참여하지 않은 사용자는 채팅방 이름을 수정할 수 없다") + void updateChatRoomNameForbidden() throws Exception { + // given + ChatRoom chatRoom = createDirectChatRoom(normalUser, targetUser); + mockLoginUser(outsiderUser.getId()); + + // when & then + performPatch("/chats/rooms/" + chatRoom.getId() + "/name", new ChatRoomNameUpdateRequest("몰래 바꾸기")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); + } + } + + @Nested + @DisplayName("DELETE /chats/rooms/{chatRoomId} - 채팅방 나가기") + class LeaveChatRoom { + + @BeforeEach + void setUpLeaveFixture() { + targetUser = createUser("상대유저", "2021136002"); + clearPersistenceContext(); + } + + @Test + @DisplayName("1:1 채팅방을 나가면 목록에서 숨겨지고 새 메시지부터 다시 보인다") + void leaveDirectChatRoomAndShowOnlyNewMessages() throws Exception { + ChatRoom chatRoom = createDirectChatRoom(normalUser, targetUser); + + mockLoginUser(normalUser.getId()); + performPost("/chats/rooms/" + chatRoom.getId() + "/messages", new ChatMessageSendRequest("첫 메시지")) + .andExpect(status().isOk()); + + performDelete("/chats/rooms/" + chatRoom.getId()) + .andExpect(status().isNoContent()); + + clearPersistenceContext(); + ChatRoomMember leftMember = chatRoomMemberRepository + .findByChatRoomIdAndUserId(chatRoom.getId(), normalUser.getId()) + .orElseThrow(); + assertThat(leftMember.hasLeft()).isTrue(); + + mockLoginUser(normalUser.getId()); + performGet("/chats/rooms") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.rooms").isEmpty()); + + performGet("/chats/rooms/" + chatRoom.getId() + "?page=1&limit=20") + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); + + mockLoginUser(targetUser.getId()); + performPost("/chats/rooms/" + chatRoom.getId() + "/messages", new ChatMessageSendRequest("다시 안녕")) + .andExpect(status().isOk()); + + mockLoginUser(normalUser.getId()); + performGet("/chats/rooms") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.rooms[0].roomId").value(chatRoom.getId())) + .andExpect(jsonPath("$.rooms[0].lastMessage").value("다시 안녕")) + .andExpect(jsonPath("$.rooms[0].unreadCount").value(1)); + + performGet("/chats/rooms/" + chatRoom.getId() + "?page=1&limit=20") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalCount").value(1)) + .andExpect(jsonPath("$.messages[0].content").value("다시 안녕")); + + mockLoginUser(targetUser.getId()); + performGet("/chats/rooms/" + chatRoom.getId() + "?page=1&limit=20") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalCount").value(2)) + .andExpect(jsonPath("$.messages[0].content").value("다시 안녕")) + .andExpect(jsonPath("$.messages[1].content").value("첫 메시지")); + } + + @Test + @DisplayName("나간 뒤 다시 채팅방을 열면 처음 대화하는 것처럼 빈 메시지 목록을 본다") + void createOrGetChatRoomAfterLeaveStartsFresh() throws Exception { + ChatRoom chatRoom = createDirectChatRoom(normalUser, targetUser); + + mockLoginUser(normalUser.getId()); + performPost("/chats/rooms/" + chatRoom.getId() + "/messages", new ChatMessageSendRequest("첫 메시지")) + .andExpect(status().isOk()); + + performDelete("/chats/rooms/" + chatRoom.getId()) + .andExpect(status().isNoContent()); + + performPost("/chats/rooms", new ChatRoomCreateRequest(targetUser.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.chatRoomId").value(chatRoom.getId())); + + performGet("/chats/rooms") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.rooms[0].roomId").value(chatRoom.getId())) + .andExpect(jsonPath("$.rooms[0].lastMessage").doesNotExist()); + + performGet("/chats/rooms/" + chatRoom.getId() + "?page=1&limit=20") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalCount").value(0)) + .andExpect(jsonPath("$.messages").isEmpty()); + } + + @Test + @DisplayName("나간 뒤 새 메시지가 오기 전에는 방 조작이 불가능하다") + void cannotOperateHiddenDirectRoomBeforeNewMessage() throws Exception { + ChatRoom chatRoom = createDirectChatRoom(normalUser, targetUser); + + mockLoginUser(normalUser.getId()); + performDelete("/chats/rooms/" + chatRoom.getId()) + .andExpect(status().isNoContent()); + + performPost("/chats/rooms/" + chatRoom.getId() + "/messages", new ChatMessageSendRequest("몰래 보내기")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); + + performPatch("/chats/rooms/" + chatRoom.getId() + "/name", new ChatRoomNameUpdateRequest("숨김방")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); + + performPost("/chats/rooms/" + chatRoom.getId() + "/mute") + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); + } + + @Test + @DisplayName("동아리 채팅방은 나갈 수 없다") + void leaveGroupChatRoomFails() throws Exception { + Club club = persist(ClubFixture.create(university)); + ChatRoom groupRoom = persist(ChatRoom.clubGroupOf(club)); + ChatRoom managedGroupRoom = entityManager.getReference(ChatRoom.class, groupRoom.getId()); + User managedNormalUser = entityManager.getReference(User.class, normalUser.getId()); + persist(ChatRoomMember.of(managedGroupRoom, managedNormalUser, groupRoom.getCreatedAt())); + clearPersistenceContext(); + + mockLoginUser(normalUser.getId()); + + performDelete("/chats/rooms/" + groupRoom.getId()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("CANNOT_LEAVE_GROUP_CHAT_ROOM")); + } + + @Test + @DisplayName("일반 그룹 채팅방은 멤버십 삭제 방식으로 나갈 수 있다") + void leaveOpenGroupChatRoomDeletesMembership() throws Exception { + ChatRoom openGroupRoom = persist(ChatRoom.groupOf()); + ChatRoom managedOpenGroupRoom = entityManager.getReference(ChatRoom.class, openGroupRoom.getId()); + User managedNormalUser = entityManager.getReference(User.class, normalUser.getId()); + User managedTargetUser = entityManager.getReference(User.class, targetUser.getId()); + persist(ChatRoomMember.of(managedOpenGroupRoom, managedNormalUser, openGroupRoom.getCreatedAt())); + persist(ChatRoomMember.of(managedOpenGroupRoom, managedTargetUser, openGroupRoom.getCreatedAt())); + clearPersistenceContext(); + + mockLoginUser(normalUser.getId()); + + performDelete("/chats/rooms/" + openGroupRoom.getId()) + .andExpect(status().isNoContent()); + + clearPersistenceContext(); + assertThat(chatRoomMemberRepository.findByChatRoomIdAndUserId(openGroupRoom.getId(), normalUser.getId())) + .isEmpty(); + assertThat(chatRoomMemberRepository.findByChatRoomIdAndUserId(openGroupRoom.getId(), targetUser.getId())) + .isPresent(); + } + } + + @Nested + @DisplayName("GET /chats/rooms/{chatRoomId} - 채팅방 메시지 조회 실패") + class GetMessagesFail { + + @BeforeEach + void setUpMessageAccessFixture() { + targetUser = createUser("상대유저", "2021136002"); + outsiderUser = createUser("외부유저", "2021136003"); + clearPersistenceContext(); + } + + @Test + @DisplayName("존재하지 않는 채팅방이면 404를 반환한다") + void getMessagesNotFound() throws Exception { + // given + mockLoginUser(normalUser.getId()); + + // when & then + performGet("/chats/rooms/99999?page=1&limit=20") + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("NOT_FOUND_CHAT_ROOM")); + } + + @Test + @DisplayName("참여하지 않은 사용자가 조회하면 403을 반환한다") + void getMessagesForbidden() throws Exception { + // given + ChatRoom chatRoom = createDirectChatRoom(normalUser, targetUser); + mockLoginUser(outsiderUser.getId()); + + // when & then + performGet("/chats/rooms/" + chatRoom.getId() + "?page=1&limit=20") + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); + } + } + + @Nested + @DisplayName("GET /chats/rooms/search - 채팅 검색") + class SearchChats { + + private User secondTargetUser; + private Club developmentClub; + + @BeforeEach + void setUpSearchFixture() { + targetUser = createUser("개발팀", "2021136002"); + secondTargetUser = createUser("개발자", "2021136003"); + outsiderUser = createUser("외부유저", "2021136004"); + developmentClub = persist(ClubFixture.create(university, "개발동아리")); + createClubMember(developmentClub, normalUser); + clearPersistenceContext(); + } + + @Test + @DisplayName("채팅방 이름과 상대방 이름으로 검색 결과를 분리해서 반환한다") + void searchChatsReturnsRoomMatchesForDirectAndGroupRooms() throws Exception { + // given + ChatRoom directRoom = createDirectChatRoom(normalUser, targetUser); + ChatRoom groupRoom = persist(ChatRoom.clubGroupOf(developmentClub)); + addRoomMember(groupRoom, normalUser); + persistChatMessage(directRoom, normalUser, "안녕하세요"); + mockLoginUser(normalUser.getId()); + + // when & then + performGet("/chats/rooms/search?keyword=개발&page=1&limit=10") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.roomMatches.totalCount").value(2)) + .andExpect(jsonPath("$.roomMatches.currentCount").value(2)) + .andExpect(jsonPath("$.roomMatches.totalPage").value(1)) + .andExpect(jsonPath("$.roomMatches.currentPage").value(1)) + .andExpect(jsonPath("$.roomMatches.rooms[0].roomName").value("개발팀")) + .andExpect(jsonPath("$.roomMatches.rooms[0].chatType").value("DIRECT")) + .andExpect(jsonPath("$.roomMatches.rooms[1].roomName").value("개발동아리")) + .andExpect(jsonPath("$.roomMatches.rooms[1].chatType").value("GROUP")) + .andExpect(jsonPath("$.messageMatches.totalCount").value(0)) + .andExpect(jsonPath("$.messageMatches.currentCount").value(0)); + } + + @Test + @DisplayName("메시지 검색은 접근 가능한 방에서 방별 최신 매칭 메시지 1개만 반환한다") + void searchChatsReturnsLatestMatchingMessagePerAccessibleRoom() throws Exception { + // given + ChatRoom firstRoom = createDirectChatRoom(normalUser, targetUser); + ChatRoom secondRoom = createDirectChatRoom(normalUser, secondTargetUser); + ChatRoom outsiderRoom = createDirectChatRoom(outsiderUser, targetUser); + + persistChatMessage(firstRoom, normalUser, "첫 번째 키워드"); + persistChatMessage(secondRoom, secondTargetUser, "두 번째 키워드"); + persistChatMessage(outsiderRoom, outsiderUser, "외부 키워드"); + persistChatMessage(firstRoom, targetUser, "최신 키워드"); + mockLoginUser(normalUser.getId()); + + // when & then + performGet("/chats/rooms/search?keyword=키워드&page=1&limit=10") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.roomMatches.totalCount").value(0)) + .andExpect(jsonPath("$.messageMatches.totalCount").value(2)) + .andExpect(jsonPath("$.messageMatches.currentCount").value(2)) + .andExpect(jsonPath("$.messageMatches.messages[0].roomName").value("개발팀")) + .andExpect(jsonPath("$.messageMatches.messages[0].matchedMessage").value("최신 키워드")) + .andExpect(jsonPath("$.messageMatches.messages[1].roomName").value("개발자")) + .andExpect(jsonPath("$.messageMatches.messages[1].matchedMessage").value("두 번째 키워드")); + } + + @Test + @DisplayName("나간 1:1 채팅방의 숨김 메시지는 검색 결과에 노출되지 않는다") + void searchChatsExcludesHiddenMessagesFromLeftDirectRoom() throws Exception { + // given + ChatRoom directRoom = createDirectChatRoom(normalUser, targetUser); + + mockLoginUser(normalUser.getId()); + performPost("/chats/rooms/" + directRoom.getId() + "/messages", new ChatMessageSendRequest("비밀 키워드")) + .andExpect(status().isOk()); + performDelete("/chats/rooms/" + directRoom.getId()) + .andExpect(status().isNoContent()); + + mockLoginUser(targetUser.getId()); + performPost("/chats/rooms/" + directRoom.getId() + "/messages", new ChatMessageSendRequest("다시 안녕")) + .andExpect(status().isOk()); + + mockLoginUser(normalUser.getId()); + + // when & then + performGet("/chats/rooms/search?keyword=비밀&page=1&limit=10") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.roomMatches.totalCount").value(0)) + .andExpect(jsonPath("$.messageMatches.totalCount").value(0)) + .andExpect(jsonPath("$.messageMatches.currentCount").value(0)); + } + + @Test + @DisplayName("메시지 검색은 LIKE 특수문자를 리터럴로 처리한다") + void searchChatsTreatsLikeSpecialCharactersAsLiteral() throws Exception { + // given + ChatRoom firstRoom = createDirectChatRoom(normalUser, targetUser); + ChatRoom secondRoom = createDirectChatRoom(normalUser, secondTargetUser); + + persistChatMessage(firstRoom, normalUser, "100% 완료"); + persistChatMessage(secondRoom, secondTargetUser, "1000 완료"); + mockLoginUser(normalUser.getId()); + LinkedMultiValueMap params = new LinkedMultiValueMap<>(Map.of( + "keyword", List.of("100%"), + "page", List.of("1"), + "limit", List.of("10") + )); + + // when & then + performGet("/chats/rooms/search", params) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.roomMatches.totalCount").value(0)) + .andExpect(jsonPath("$.messageMatches.totalCount").value(1)) + .andExpect(jsonPath("$.messageMatches.currentCount").value(1)) + .andExpect(jsonPath("$.messageMatches.messages[0].roomName").value("개발팀")) + .andExpect(jsonPath("$.messageMatches.messages[0].matchedMessage").value("100% 완료")); + } + + @Test + @DisplayName("커스텀 채팅방 이름이 있어도 기본 상대방 이름으로 검색할 수 있다") + void searchChatsMatchesDefaultNameEvenWithCustomRoomName() throws Exception { + // given + ChatRoom directRoom = createDirectChatRoom(normalUser, targetUser); + mockLoginUser(normalUser.getId()); + performPatch("/chats/rooms/" + directRoom.getId() + "/name", new ChatRoomNameUpdateRequest("내 메모")) + .andExpect(status().isOk()); + + // when & then + performGet("/chats/rooms/search?keyword=개발팀&page=1&limit=10") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.roomMatches.totalCount").value(1)) + .andExpect(jsonPath("$.roomMatches.currentCount").value(1)) + .andExpect(jsonPath("$.roomMatches.rooms[0].roomId").value(directRoom.getId())) + .andExpect(jsonPath("$.roomMatches.rooms[0].roomName").value("내 메모")); + } + + @Test + @DisplayName("채팅방 검색 결과에 페이지네이션을 적용한다") + void searchChatsAppliesPaginationToRoomMatches() throws Exception { + // given + createDirectChatRoom(normalUser, targetUser); + createDirectChatRoom(normalUser, secondTargetUser); + ChatRoom groupRoom = persist(ChatRoom.clubGroupOf(developmentClub)); + addRoomMember(groupRoom, normalUser); + mockLoginUser(normalUser.getId()); + + // when & then + performGet("/chats/rooms/search?keyword=개발&page=2&limit=1") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.roomMatches.totalCount").value(3)) + .andExpect(jsonPath("$.roomMatches.currentCount").value(1)) + .andExpect(jsonPath("$.roomMatches.totalPage").value(3)) + .andExpect(jsonPath("$.roomMatches.currentPage").value(2)) + .andExpect(jsonPath("$.roomMatches.rooms[0].roomName").value("개발자")); + } + + @Test + @DisplayName("매우 큰 page 값이어도 빈 검색 결과를 안전하게 반환한다") + void searchChatsWithVeryLargePageReturnsEmptyResult() throws Exception { + // given + createDirectChatRoom(normalUser, targetUser); + ChatRoom groupRoom = persist(ChatRoom.clubGroupOf(developmentClub)); + addRoomMember(groupRoom, normalUser); + mockLoginUser(normalUser.getId()); + + // when & then + performGet("/chats/rooms/search?keyword=개발&page=2147483647&limit=100") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.roomMatches.totalCount").value(2)) + .andExpect(jsonPath("$.roomMatches.currentCount").value(0)) + .andExpect(jsonPath("$.roomMatches.currentPage").value(2147483647)); + } + + @Test + @DisplayName("limit가 최대값을 초과하면 400을 반환한다") + void searchChatsWithTooLargeLimitFails() throws Exception { + // given + mockLoginUser(normalUser.getId()); + + // when & then + performGet("/chats/rooms/search?keyword=개발&page=1&limit=101") + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_REQUEST_BODY")) + .andExpect(jsonPath("$.fieldErrors[0].field").value("searchChats.limit")); + } + } + + @Nested + @DisplayName("POST /chats/rooms/{chatRoomId}/mute - 채팅방 뮤트 토글") + class ToggleMute { + + @BeforeEach + void setUpMuteFixture() { + targetUser = createUser("상대유저", "2021136002"); + clearPersistenceContext(); + } + + @Test + @DisplayName("뮤트를 켰다가 다시 끈다") + void toggleMuteSuccessAndDuplicateProcessing() throws Exception { + // given + ChatRoom chatRoom = createDirectChatRoom(normalUser, targetUser); + mockLoginUser(normalUser.getId()); + + // when & then + performPost("/chats/rooms/" + chatRoom.getId() + "/mute") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isMuted").value(true)); + + performPost("/chats/rooms/" + chatRoom.getId() + "/mute") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isMuted").value(false)); + + clearPersistenceContext(); + assertThat(notificationMuteSettingRepository.findByTargetTypeAndTargetIdAndUserId( + NotificationTargetType.CHAT_ROOM, + chatRoom.getId(), + normalUser.getId() + )).matches(setting -> setting.isEmpty() || !setting.get().getIsMuted()); + } + } + + private ChatRoom createDirectChatRoom(User firstUser, User secondUser) { + ChatRoom chatRoom = persist(ChatRoom.directOf()); + LocalDateTime joinedAt = chatRoom.getCreatedAt(); + ChatRoom managedChatRoom = entityManager.getReference(ChatRoom.class, chatRoom.getId()); + User managedFirstUser = entityManager.getReference(User.class, firstUser.getId()); + User managedSecondUser = entityManager.getReference(User.class, secondUser.getId()); + + persist(ChatRoomMember.of(managedChatRoom, managedFirstUser, joinedAt)); + persist(ChatRoomMember.of(managedChatRoom, managedSecondUser, joinedAt)); + clearPersistenceContext(); + return chatRoom; + } + + private User createUser(String name, String studentId) { + return persist(UserFixture.createUser(university, name, studentId)); + } + + private ClubMember createClubMember(Club club, User user) { + Club managedClub = entityManager.getReference(Club.class, club.getId()); + User managedUser = entityManager.getReference(User.class, user.getId()); + ClubMember clubMember = persist(ClubMember.builder() + .club(managedClub) + .user(managedUser) + .clubPosition(ClubPosition.MEMBER) + .build()); + clearPersistenceContext(); + return clubMember; + } + + private ChatMessage persistChatMessage(ChatRoom chatRoom, User sender, String content) { + ChatRoom managedChatRoom = entityManager.getReference(ChatRoom.class, chatRoom.getId()); + User managedSender = entityManager.getReference(User.class, sender.getId()); + + ChatMessage chatMessage = persist(ChatMessage.of(managedChatRoom, managedSender, content)); + managedChatRoom.updateLastMessage(chatMessage.getContent(), chatMessage.getCreatedAt()); + entityManager.flush(); + clearPersistenceContext(); + return chatMessage; + } + + private void addRoomMember(ChatRoom chatRoom, User user) { + ChatRoom managedChatRoom = entityManager.getReference(ChatRoom.class, chatRoom.getId()); + User managedUser = entityManager.getReference(User.class, user.getId()); + persist(ChatRoomMember.of(managedChatRoom, managedUser, chatRoom.getCreatedAt())); + } + + private void createGroupedInviteCandidates(String clubName, String namePrefix, int count) { + Club club = persist(ClubFixture.create(university, clubName)); + Club managedClub = entityManager.getReference(Club.class, club.getId()); + User managedNormalUser = entityManager.getReference(User.class, normalUser.getId()); + + persist(ClubMember.builder() + .club(managedClub) + .user(managedNormalUser) + .clubPosition(ClubPosition.MEMBER) + .build()); + + ChatRoom groupRoom = persist(ChatRoom.clubGroupOf(club)); + addRoomMember(groupRoom, normalUser); + + for (int index = 1; index <= count; index++) { + User candidate = createUser( + String.format("%s%02d", namePrefix, index), + String.format("202199%04d", index + count * 10) + ); + User managedCandidate = entityManager.getReference(User.class, candidate.getId()); + persist(ClubMember.builder() + .club(managedClub) + .user(managedCandidate) + .clubPosition(ClubPosition.MEMBER) + .build()); + addRoomMember(groupRoom, candidate); + } + } + + private long countDirectRoomsBetween(User firstUser, User secondUser) { + return chatRoomRepository.findByUserId(firstUser.getId(), ChatType.DIRECT).stream() + .map(ChatRoom::getId) + .filter(roomId -> isDirectRoomBetween(roomId, firstUser.getId(), secondUser.getId())) + .count(); + } + + private boolean isDirectRoomBetween(Integer roomId, Integer firstUserId, Integer secondUserId) { + List roomMembers = chatRoomMemberRepository.findByChatRoomId(roomId); + return roomMembers.size() == 2 + && roomMembers.stream().anyMatch(member -> member.getUserId().equals(firstUserId)) + && roomMembers.stream().anyMatch(member -> member.getUserId().equals(secondUserId)); + } +} diff --git a/src/test/java/gg/agit/konect/integration/domain/club/ClubMemberApplicationsApiTest.java b/src/test/java/gg/agit/konect/integration/domain/club/ClubMemberApplicationsApiTest.java new file mode 100644 index 000000000..42bca00c9 --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/domain/club/ClubMemberApplicationsApiTest.java @@ -0,0 +1,325 @@ +package gg.agit.konect.integration.domain.club; + +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.ClubApply; +import gg.agit.konect.domain.club.model.ClubApplyAnswer; +import gg.agit.konect.domain.club.model.ClubApplyQuestion; +import gg.agit.konect.domain.club.model.ClubRecruitment; +import gg.agit.konect.domain.club.repository.ClubApplyAnswerRepository; +import gg.agit.konect.domain.club.repository.ClubApplyRepository; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.support.IntegrationTestSupport; +import gg.agit.konect.support.fixture.ClubFixture; +import gg.agit.konect.support.fixture.ClubMemberFixture; +import gg.agit.konect.support.fixture.ClubRecruitmentFixture; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.LocalDateTime; + +@DisplayName("동아리 승인 멤버 지원서 API 테스트") +class ClubMemberApplicationsApiTest extends IntegrationTestSupport { + + @Autowired + private ClubApplyRepository clubApplyRepository; + + @Autowired + private ClubApplyAnswerRepository clubApplyAnswerRepository; + + @Autowired + private ClubMemberRepository clubMemberRepository; + + private University university; + private Club club; + private User president; + private ClubRecruitment recruitment; + + @BeforeEach + void setUp() throws Exception { + university = persist(UniversityFixture.create()); + president = persist(UserFixture.createUser(university, "회장", "2020000001")); + club = persist(ClubFixture.createWithRecruitment(university, "BCSD Lab")); + persist(ClubMemberFixture.createPresident(club, president)); + recruitment = persist(ClubRecruitmentFixture.createAlwaysRecruiting(club)); + } + + @Nested + @DisplayName("GET /clubs/{clubId}/member-applications - 승인된 멤버 지원서 목록") + class GetApprovedMemberApplications { + + @Test + @DisplayName("승인된 멤버들의 지원서 목록을 조회한다") + void getApprovedMemberApplicationsSuccess() throws Exception { + // given + User approvedUser1 = persist(UserFixture.createUser(university, "승인자1", "2021000001")); + User approvedUser2 = persist(UserFixture.createUser(university, "승인자2", "2021000002")); + User pendingUser = persist(UserFixture.createUser(university, "대기자", "2021000003")); + + // 승인된 지원서 + ClubApply approvedApply1 = ClubApply.of(club, approvedUser1, null); + ClubApply approvedApply2 = ClubApply.of(club, approvedUser2, null); + approvedApply1.approve(); + approvedApply2.approve(); + persist(approvedApply1); + persist(approvedApply2); + + // 멤버로 등록 + persist(ClubMemberFixture.createMember(club, approvedUser1)); + persist(ClubMemberFixture.createMember(club, approvedUser2)); + + // 대기중인 지원서 + persist(ClubApply.of(club, pendingUser, null)); + clearPersistenceContext(); + + mockLoginUser(president.getId()); + + // when & then + performGet("/clubs/" + club.getId() + "/member-applications?page=1&limit=10") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.applications", hasSize(2))) + .andExpect(jsonPath("$.applications[0].name").exists()) + .andExpect(jsonPath("$.applications[1].name").exists()); + } + + @Test + @DisplayName("승인된 멤버가 없으면 빈 목록을 반환한다") + void getApprovedMemberApplicationsWhenEmpty() throws Exception { + // given + User pendingUser = persist(UserFixture.createUser(university, "대기자", "2021000004")); + persist(ClubApply.of(club, pendingUser, null)); + clearPersistenceContext(); + + mockLoginUser(president.getId()); + + // when & then + performGet("/clubs/" + club.getId() + "/member-applications?page=1&limit=10") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.applications", hasSize(0))); + } + + @Test + @DisplayName("페이지네이션이 정상 동작한다") + void paginationWorks() throws Exception { + // given - 15명의 승인된 멤버 생성 + for (int i = 1; i <= 15; i++) { + User user = persist(UserFixture.createUser(university, "승인자" + i, "202100" + String.format("%04d", i))); + ClubApply apply = ClubApply.of(club, user, null); + apply.approve(); + persist(apply); + persist(ClubMemberFixture.createMember(club, user)); + } + clearPersistenceContext(); + + mockLoginUser(president.getId()); + + // when & then - 첫 페이지 (10개) + performGet("/clubs/" + club.getId() + "/member-applications?page=1&limit=10") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.applications", hasSize(10))) + .andExpect(jsonPath("$.totalCount").value(15)); + + // 두 페이지 (5개) + performGet("/clubs/" + club.getId() + "/member-applications?page=2&limit=10") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.applications", hasSize(5))); + } + + @Test + @DisplayName("권한 없는 사용자는 조회할 수 없다") + void getApprovedMemberApplicationsWithoutPermissionFails() throws Exception { + // given + User regularMember = persist(UserFixture.createUser(university, "일반회원", "2021000005")); + persist(ClubMemberFixture.createMember(club, regularMember)); + clearPersistenceContext(); + + mockLoginUser(regularMember.getId()); + + // when & then + performGet("/clubs/" + club.getId() + "/member-applications?page=1&limit=10") + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("동아리에 속하지 않은 사용자는 조회할 수 없다") + void getApprovedMemberApplicationsByNonMemberFails() throws Exception { + // given + User outsider = persist(UserFixture.createUser(university, "외부인", "2021000006")); + clearPersistenceContext(); + + mockLoginUser(outsider.getId()); + + // when & then + performGet("/clubs/" + club.getId() + "/member-applications?page=1&limit=10") + .andExpect(status().isForbidden()); + } + } + + @Nested + @DisplayName("GET /clubs/{clubId}/member-applications/{userId}/answers - 특정 멤버 지원서 답변 조회") + class GetApprovedMemberApplicationAnswers { + + @Test + @DisplayName("승인된 멤버의 지원서 답변을 조회한다") + void getApprovedMemberApplicationAnswersSuccess() throws Exception { + // given + User approvedUser = persist(UserFixture.createUser(university, "승인자", "2021000007")); + + ClubApplyQuestion question1 = persist(ClubApplyQuestion.of(club, "지원 동기", true, 1)); + ClubApplyQuestion question2 = persist(ClubApplyQuestion.of(club, "관심 분야", false, 2)); + + ClubApply approvedApply = ClubApply.of(club, approvedUser, null); + approvedApply.approve(); + persist(approvedApply); + persist(ClubMemberFixture.createMember(club, approvedUser)); + + ClubApplyAnswer answer1 = ClubApplyAnswer.of(approvedApply, question1, "동아리 활동에 관심이 있습니다."); + ClubApplyAnswer answer2 = ClubApplyAnswer.of(approvedApply, question2, "백엔드 개발"); + persist(answer1); + persist(answer2); + clearPersistenceContext(); + + mockLoginUser(president.getId()); + + // when & then + performGet("/clubs/" + club.getId() + "/member-applications/" + approvedUser.getId() + "/answers") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.userId").value(approvedUser.getId())) + .andExpect(jsonPath("$.name").value("승인자")) + .andExpect(jsonPath("$.answers", hasSize(2))) + .andExpect(jsonPath("$.answers[0].question").exists()) + .andExpect(jsonPath("$.answers[0].answer").exists()); + } + + @Test + @DisplayName("질문이 없는 지원서도 조회할 수 있다") + void getApprovedMemberApplicationAnswersWithoutQuestions() throws Exception { + // given + User approvedUser = persist(UserFixture.createUser(university, "질문없음", "2021000008")); + + ClubApply approvedApply = ClubApply.of(club, approvedUser, null); + approvedApply.approve(); + persist(approvedApply); + persist(ClubMemberFixture.createMember(club, approvedUser)); + clearPersistenceContext(); + + mockLoginUser(president.getId()); + + // when & then + performGet("/clubs/" + club.getId() + "/member-applications/" + approvedUser.getId() + "/answers") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.answers", hasSize(0))); + } + + @Test + @DisplayName("대기중인 지원자의 답변은 조회할 수 없다") + void getPendingMemberApplicationAnswersFails() throws Exception { + // given + User pendingUser = persist(UserFixture.createUser(university, "대기자", "2021000009")); + persist(ClubApply.of(club, pendingUser, null)); + clearPersistenceContext(); + + mockLoginUser(president.getId()); + + // when & then + performGet("/clubs/" + club.getId() + "/member-applications/" + pendingUser.getId() + "/answers") + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("동아리 멤버가 아닌 사용자의 답변은 조회할 수 없다") + void getNonMemberApplicationAnswersFails() throws Exception { + // given + User nonMember = persist(UserFixture.createUser(university, "비회원", "2021000010")); + clearPersistenceContext(); + + mockLoginUser(president.getId()); + + // when & then + performGet("/clubs/" + club.getId() + "/member-applications/" + nonMember.getId() + "/answers") + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("시간이 지난 후 수정된 질문은 지원서에 반영되지 않는다") + void questionsModifiedAfterApplicationAreNotShown() throws Exception { + // given + User approvedUser = persist(UserFixture.createUser(university, "과거신청", "2021000011")); + + // 과거에 등록된 질문 + ClubApplyQuestion oldQuestion = ClubApplyQuestion.of(club, "구버전 질문", true, 1); + persist(oldQuestion); + + ClubApply approvedApply = ClubApply.of(club, approvedUser, null); + approvedApply.approve(); + persist(approvedApply); + persist(ClubMemberFixture.createMember(club, approvedUser)); + + ClubApplyAnswer answer = ClubApplyAnswer.of(approvedApply, oldQuestion, "과거 답변"); + persist(answer); + + // 새로운 질문으로 교체 + oldQuestion.softDelete(LocalDateTime.now()); + persist(oldQuestion); + persist(ClubApplyQuestion.of(club, "신규 질문", true, 1)); + clearPersistenceContext(); + + mockLoginUser(president.getId()); + + // when & then + performGet("/clubs/" + club.getId() + "/member-applications/" + approvedUser.getId() + "/answers") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.answers", hasSize(1))) + .andExpect(jsonPath("$.answers[0].question").value("구버전 질문")); + } + } + + @Nested + @DisplayName("GET /clubs/{clubId}/member-applications/answers - 승인 멤버 지원서 목록 일괄 조회") + class GetApprovedMemberApplicationAnswersList { + + @Test + @DisplayName("승인된 멤버들의 지원서 목록을 일괄 조회한다") + void getApprovedMemberApplicationAnswersListSuccess() throws Exception { + // given + User approvedUser1 = persist(UserFixture.createUser(university, "일괄승인1", "2021000012")); + User approvedUser2 = persist(UserFixture.createUser(university, "일괄승인2", "2021000013")); + + ClubApplyQuestion question = persist(ClubApplyQuestion.of(club, "공통질문", true, 1)); + + ClubApply apply1 = ClubApply.of(club, approvedUser1, null); + ClubApply apply2 = ClubApply.of(club, approvedUser2, null); + apply1.approve(); + apply2.approve(); + persist(apply1); + persist(apply2); + + persist(ClubMemberFixture.createMember(club, approvedUser1)); + persist(ClubMemberFixture.createMember(club, approvedUser2)); + + persist(ClubApplyAnswer.of(apply1, question, "답변1")); + persist(ClubApplyAnswer.of(apply2, question, "답변2")); + clearPersistenceContext(); + + mockLoginUser(president.getId()); + + // when & then + performGet("/clubs/" + club.getId() + "/member-applications/answers?page=1&limit=10") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.applications", hasSize(2))) + .andExpect(jsonPath("$.applications[0].answers", hasSize(1))) + .andExpect(jsonPath("$.applications[1].answers", hasSize(1))); + } + } +} diff --git a/src/test/java/gg/agit/konect/integration/domain/club/ClubSettingsControllerTest.java b/src/test/java/gg/agit/konect/integration/domain/club/ClubSettingsControllerTest.java new file mode 100644 index 000000000..daf441b1c --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/domain/club/ClubSettingsControllerTest.java @@ -0,0 +1,295 @@ +package gg.agit.konect.integration.domain.club; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.test.web.servlet.ResultActions; + +import gg.agit.konect.domain.club.dto.ClubSettingsUpdateRequest; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.support.IntegrationTestSupport; +import gg.agit.konect.support.fixture.ClubFixture; +import gg.agit.konect.support.fixture.ClubMemberFixture; +import gg.agit.konect.support.fixture.ClubRecruitmentFixture; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +@DisplayName("ClubSettingsController 통합 테스트") +class ClubSettingsControllerTest extends IntegrationTestSupport { + + private static final long NON_EXISTENT_ID = Long.MAX_VALUE; + + private University university; + private User president; + private User vicePresident; + private User manager; + private User regularMember; + private User nonMember; + private Club club; + + @BeforeEach + void setUp() throws Exception { + university = persist(UniversityFixture.create()); + + president = persist(UserFixture.createUser(university, "회장", "2020000001")); + vicePresident = persist(UserFixture.createUser(university, "부회장", "2020000002")); + manager = persist(UserFixture.createUser(university, "운영진", "2020000003")); + regularMember = persist(UserFixture.createUser(university, "일반회원", "2020000004")); + nonMember = persist(UserFixture.createUser(university, "비회원", "2020000005")); + + club = persist(ClubFixture.createWithRecruitment(university, "테스트 동아리")); + + persist(ClubMemberFixture.createPresident(club, president)); + persist(ClubMemberFixture.createVicePresident(club, vicePresident)); + persist(ClubMemberFixture.createManager(club, manager)); + persist(ClubMemberFixture.createMember(club, regularMember)); + + clearPersistenceContext(); + } + + @Nested + @DisplayName("GET /clubs/{clubId}/settings - 동아리 설정 조회") + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class GetSettings { + + @ParameterizedTest(name = "{0} 권한으로 설정 조회 시 {2}를 반환한다") + @MethodSource("getSettingsAccessCases") + void getSettingsByRole(String roleName, Integer userId, int expectedStatus, boolean verifyDetailedPayload) + throws Exception { + mockLoginUser(userId); + + ResultActions result = performGet("/clubs/" + club.getId() + "/settings") + .andExpect(status().is(expectedStatus)); + + if (expectedStatus == 200) { + result.andExpect(jsonPath("$.isRecruitmentEnabled").value(true)); + } + + if (verifyDetailedPayload) { + assertPresidentSettingsPayload(result); + } + } + + Stream getSettingsAccessCases() { + return Stream.of( + Arguments.of("회장", president.getId(), 200, true), + Arguments.of("부회장", vicePresident.getId(), 200, false), + Arguments.of("운영진", manager.getId(), 200, false), + Arguments.of("일반 회원", regularMember.getId(), 403, false), + Arguments.of("비회원", nonMember.getId(), 403, false) + ); + } + + private void assertPresidentSettingsPayload(ResultActions result) throws Exception { + result + .andExpect(jsonPath("$.isApplicationEnabled").value(true)) + .andExpect(jsonPath("$.isFeeEnabled").value(false)) + .andExpect(jsonPath("$.application").exists()) + .andExpect(jsonPath("$.application.questionCount").isNumber()) + .andExpect(jsonPath("$.fee").doesNotExist()); + } + + @Test + @DisplayName("존재하지 않는 동아리 조회 시 404를 반환한다") + void getSettingsNotFoundClub() throws Exception { + mockLoginUser(president.getId()); + + performGet("/clubs/" + NON_EXISTENT_ID + "/settings") + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("모집공고가 설정된 동아리는 recruitment 필드를 반환한다") + void getSettingsWithRecruitmentInfo() throws Exception { + persist(ClubRecruitmentFixture.createAlwaysRecruiting(club)); + clearPersistenceContext(); + + mockLoginUser(president.getId()); + + performGet("/clubs/" + club.getId() + "/settings") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isRecruitmentEnabled").value(true)) + .andExpect(jsonPath("$.recruitment").exists()) + .andExpect(jsonPath("$.recruitment.isAlwaysRecruiting").value(true)) + .andExpect(jsonPath("$.recruitment.startAt").doesNotExist()) + .andExpect(jsonPath("$.recruitment.endAt").doesNotExist()); + } + } + + @Nested + @DisplayName("PATCH /clubs/{clubId}/settings - 동아리 설정 수정") + class UpdateSettings { + + @Test + @DisplayName("회장 권한으로 설정 수정에 성공한다") + void updateSettingsAsPresident() throws Exception { + mockLoginUser(president.getId()); + + ClubSettingsUpdateRequest request = new ClubSettingsUpdateRequest( + false, + false, + true + ); + + performPatch("/clubs/" + club.getId() + "/settings", request) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isRecruitmentEnabled").value(false)) + .andExpect(jsonPath("$.isApplicationEnabled").value(false)) + .andExpect(jsonPath("$.isFeeEnabled").value(true)); + } + + @Test + @DisplayName("부회장 권한으로 설정 수정에 성공한다") + void updateSettingsAsVicePresident() throws Exception { + mockLoginUser(vicePresident.getId()); + + ClubSettingsUpdateRequest request = new ClubSettingsUpdateRequest( + true, + false, + false + ); + + performPatch("/clubs/" + club.getId() + "/settings", request) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isApplicationEnabled").value(false)); + } + + @Test + @DisplayName("운영진 권한으로 설정 수정에 성공한다") + void updateSettingsAsManager() throws Exception { + mockLoginUser(manager.getId()); + + ClubSettingsUpdateRequest request = new ClubSettingsUpdateRequest( + false, + true, + false + ); + + performPatch("/clubs/" + club.getId() + "/settings", request) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isRecruitmentEnabled").value(false)); + } + + @Test + @DisplayName("일부 필드만 수정하면 해당 필드만 변경된다") + void updatePartialSettings() throws Exception { + mockLoginUser(president.getId()); + + ClubSettingsUpdateRequest request = new ClubSettingsUpdateRequest( + false, + null, + null + ); + + performPatch("/clubs/" + club.getId() + "/settings", request) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isRecruitmentEnabled").value(false)) + .andExpect(jsonPath("$.isApplicationEnabled").value(true)) + .andExpect(jsonPath("$.isFeeEnabled").value(false)); + } + + @Test + @DisplayName("일반 회원은 설정 수정에 실패한다 (403 Forbidden)") + void updateSettingsAsRegularMemberFails() throws Exception { + mockLoginUser(regularMember.getId()); + + ClubSettingsUpdateRequest request = new ClubSettingsUpdateRequest( + false, + false, + false + ); + + performPatch("/clubs/" + club.getId() + "/settings", request) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("비회원은 설정 수정에 실패한다 (403 Forbidden)") + void updateSettingsAsNonMemberFails() throws Exception { + mockLoginUser(nonMember.getId()); + + ClubSettingsUpdateRequest request = new ClubSettingsUpdateRequest( + false, + false, + false + ); + + performPatch("/clubs/" + club.getId() + "/settings", request) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("존재하지 않는 동아리 수정 시 404를 반환한다") + void updateSettingsNotFoundClub() throws Exception { + mockLoginUser(president.getId()); + + ClubSettingsUpdateRequest request = new ClubSettingsUpdateRequest( + false, + false, + false + ); + + performPatch("/clubs/" + NON_EXISTENT_ID + "/settings", request) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("동일한 설정값으로 수정해도 성공한다") + void updateSettingsWithSameValues() throws Exception { + mockLoginUser(president.getId()); + + ClubSettingsUpdateRequest request = new ClubSettingsUpdateRequest( + true, + true, + false + ); + + performPatch("/clubs/" + club.getId() + "/settings", request) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isRecruitmentEnabled").value(true)) + .andExpect(jsonPath("$.isApplicationEnabled").value(true)) + .andExpect(jsonPath("$.isFeeEnabled").value(false)); + } + } + + @Nested + @DisplayName("권한 경계 테스트") + class PermissionBoundaryTests { + + @Test + @DisplayName("모든 토글을 동시에 변경할 수 있다") + void updateAllTogglesAtOnce() throws Exception { + mockLoginUser(president.getId()); + + ClubSettingsUpdateRequest request = new ClubSettingsUpdateRequest( + false, + false, + true + ); + + performPatch("/clubs/" + club.getId() + "/settings", request) + .andExpect(status().isOk()); + + clearPersistenceContext(); + mockLoginUser(president.getId()); + + performGet("/clubs/" + club.getId() + "/settings") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isRecruitmentEnabled").value(false)) + .andExpect(jsonPath("$.isApplicationEnabled").value(false)) + .andExpect(jsonPath("$.isFeeEnabled").value(true)); + } + } +} diff --git a/src/test/java/gg/agit/konect/integration/domain/club/ClubSheetMigrationApiTest.java b/src/test/java/gg/agit/konect/integration/domain/club/ClubSheetMigrationApiTest.java new file mode 100644 index 000000000..b6de3f6ea --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/domain/club/ClubSheetMigrationApiTest.java @@ -0,0 +1,101 @@ +package gg.agit.konect.integration.domain.club; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import gg.agit.konect.domain.club.dto.SheetImportRequest; +import gg.agit.konect.domain.club.dto.SheetImportResponse; +import gg.agit.konect.domain.club.service.ClubSheetIntegratedService; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.IntegrationTestSupport; + +class ClubSheetMigrationApiTest extends IntegrationTestSupport { + + @MockitoBean + private ClubSheetIntegratedService clubSheetIntegratedService; + + private static final Integer CLUB_ID = 1; + private static final Integer REQUESTER_ID = 100; + private static final String SPREADSHEET_URL = + "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms/edit"; + + @BeforeEach + void setUp() throws Exception { + mockLoginUser(REQUESTER_ID); + } + + @Nested + @DisplayName("POST /clubs/{clubId}/sheet/import/integrated - 시트 통합 가져오기") + class AnalyzeAndImportPreMembers { + + @Test + @DisplayName("시트 분석 등록 후 사전 회원 가져오기 결과를 반환한다") + void analyzeAndImportPreMembersSuccess() throws Exception { + given(clubSheetIntegratedService.analyzeAndImportPreMembers( + eq(CLUB_ID), + eq(REQUESTER_ID), + eq(SPREADSHEET_URL) + )).willReturn(SheetImportResponse.of(2, 1, List.of("전화번호 형식 경고"))); + + SheetImportRequest request = new SheetImportRequest(SPREADSHEET_URL); + + performPost("/clubs/" + CLUB_ID + "/sheet/import/integrated", request) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.importedCount").value(2)) + .andExpect(jsonPath("$.autoRegisteredCount").value(1)) + .andExpect(jsonPath("$.warnings[0]").value("전화번호 형식 경고")); + } + + @Test + @DisplayName("구글 스프레드시트 403 오류를 response body로 반환한다") + void analyzeAndImportPreMembersForbiddenGoogleSheetAccess() throws Exception { + given(clubSheetIntegratedService.analyzeAndImportPreMembers( + eq(CLUB_ID), + eq(REQUESTER_ID), + eq(SPREADSHEET_URL) + )).willThrow(CustomException.of(ApiResponseCode.FORBIDDEN_GOOGLE_SHEET_ACCESS)); + + SheetImportRequest request = new SheetImportRequest(SPREADSHEET_URL); + + performPost("/clubs/" + CLUB_ID + "/sheet/import/integrated", request) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code") + .value(ApiResponseCode.FORBIDDEN_GOOGLE_SHEET_ACCESS.name())) + .andExpect(jsonPath("$.message") + .value(ApiResponseCode.FORBIDDEN_GOOGLE_SHEET_ACCESS.getMessage())); + } + + @Test + @DisplayName("Google Drive invalid_grant 400 오류를 response body detail로 반환한다") + void analyzeAndImportPreMembersInvalidGoogleDriveAuth() throws Exception { + String detail = + "400 Bad Request\nPOST https://oauth2.googleapis.com/token\n{\"error\":\"invalid_grant\"}"; + given(clubSheetIntegratedService.analyzeAndImportPreMembers( + eq(CLUB_ID), + eq(REQUESTER_ID), + eq(SPREADSHEET_URL) + )).willThrow(CustomException.of(ApiResponseCode.INVALID_GOOGLE_DRIVE_AUTH, detail)); + + SheetImportRequest request = new SheetImportRequest(SPREADSHEET_URL); + + performPost("/clubs/" + CLUB_ID + "/sheet/import/integrated", request) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code") + .value(ApiResponseCode.INVALID_GOOGLE_DRIVE_AUTH.name())) + .andExpect(jsonPath("$.message") + .value(ApiResponseCode.INVALID_GOOGLE_DRIVE_AUTH.getMessage())) + .andExpect(jsonPath("$.detail").value(detail)); + } + } +} diff --git a/src/test/java/gg/agit/konect/integration/domain/notification/NotificationInboxApiTest.java b/src/test/java/gg/agit/konect/integration/domain/notification/NotificationInboxApiTest.java new file mode 100644 index 000000000..97a6d6d5e --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/domain/notification/NotificationInboxApiTest.java @@ -0,0 +1,257 @@ +package gg.agit.konect.integration.domain.notification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; + +import gg.agit.konect.domain.notification.enums.NotificationInboxType; +import gg.agit.konect.domain.notification.model.NotificationInbox; +import gg.agit.konect.domain.notification.repository.NotificationInboxRepository; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.support.IntegrationTestSupport; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +class NotificationInboxApiTest extends IntegrationTestSupport { + + @Autowired + private NotificationInboxRepository notificationInboxRepository; + + private User user; + private User otherUser; + + @BeforeEach + void setUp() { + University university = persist(UniversityFixture.create()); + user = persist(UserFixture.createUser(university, "테스트유저", "2021136001")); + otherUser = persist(UserFixture.createUser(university, "다른유저", "2021136002")); + } + + private NotificationInbox createInbox(User owner, NotificationInboxType type, String title) { + return persist(NotificationInbox.of(owner, type, title, "테스트 본문입니다.", "clubs/1")); + } + + @Nested + @DisplayName("GET /notifications/inbox - 인앱 알림 목록 조회") + class GetMyInboxes { + + @Test + @DisplayName("알림 목록을 최신순으로 조회한다") + void getMyInboxesSuccess() throws Exception { + NotificationInbox first = createInbox(user, NotificationInboxType.CLUB_APPLICATION_APPROVED, "동아리 승인"); + NotificationInbox second = createInbox(user, NotificationInboxType.CLUB_APPLICATION_REJECTED, "동아리 거절"); + clearPersistenceContext(); + mockLoginUser(user.getId()); + + performGet("/notifications/inbox") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.notifications").isArray()) + .andExpect(jsonPath("$.notifications.length()").value(2)) + .andExpect(jsonPath("$.currentPage").value(1)) + .andExpect(jsonPath("$.notifications[0].id").value(second.getId())) + .andExpect(jsonPath("$.notifications[1].id").value(first.getId())); + } + + @Test + @DisplayName("자신의 알림만 조회된다") + void getMyInboxesOnlyMine() throws Exception { + createInbox(user, NotificationInboxType.CLUB_APPLICATION_APPROVED, "내 알림"); + createInbox(otherUser, NotificationInboxType.CLUB_APPLICATION_APPROVED, "다른 유저 알림"); + clearPersistenceContext(); + mockLoginUser(user.getId()); + + performGet("/notifications/inbox") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.notifications.length()").value(1)) + .andExpect(jsonPath("$.notifications[0].title").value("내 알림")); + } + + @Test + @DisplayName("채팅 관련 인앱 알림은 목록에서 제외된다") + void getMyInboxesExcludesChatNotifications() throws Exception { + createInbox(user, NotificationInboxType.CHAT_MESSAGE, "개인 채팅 알림"); + createInbox(user, NotificationInboxType.GROUP_CHAT_MESSAGE, "그룹 채팅 알림"); + createInbox(user, NotificationInboxType.UNREAD_CHAT_COUNT, "안 읽은 채팅 개수 알림"); + createInbox(user, NotificationInboxType.CLUB_APPLICATION_APPROVED, "동아리 승인 알림"); + clearPersistenceContext(); + mockLoginUser(user.getId()); + + performGet("/notifications/inbox") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.notifications.length()").value(1)) + .andExpect(jsonPath("$.notifications[0].type").value("CLUB_APPLICATION_APPROVED")) + .andExpect(jsonPath("$.notifications[0].title").value("동아리 승인 알림")); + } + + @Test + @DisplayName("page=0으로 요청하면 400을 반환한다") + void getMyInboxesWithInvalidPageFails() throws Exception { + mockLoginUser(user.getId()); + + performGet("/notifications/inbox?page=0") + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("알림이 없으면 빈 목록을 반환한다") + void getMyInboxesWhenEmptyReturnsEmptyList() throws Exception { + mockLoginUser(user.getId()); + + performGet("/notifications/inbox") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.notifications").isEmpty()) + .andExpect(jsonPath("$.totalElements").value(0)); + } + } + + @Nested + @DisplayName("GET /notifications/inbox/unread-count - 미읽음 알림 개수 조회") + class GetUnreadCount { + + @Test + @DisplayName("미읽음 알림 개수를 반환한다") + void getUnreadCountSuccess() throws Exception { + createInbox(user, NotificationInboxType.CLUB_APPLICATION_APPROVED, "알림1"); + createInbox(user, NotificationInboxType.CLUB_APPLICATION_APPROVED, "알림2"); + clearPersistenceContext(); + mockLoginUser(user.getId()); + + performGet("/notifications/inbox/unread-count") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.unreadCount").value(2)); + } + + @Test + @DisplayName("알림이 없으면 미읽음 개수가 0이다") + void getUnreadCountWhenNoneReturnsZero() throws Exception { + mockLoginUser(user.getId()); + + performGet("/notifications/inbox/unread-count") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.unreadCount").value(0)); + } + + @Test + @DisplayName("채팅 관련 인앱 알림은 미읽음 개수에서 제외된다") + void getUnreadCountExcludesChatNotifications() throws Exception { + createInbox(user, NotificationInboxType.CHAT_MESSAGE, "개인 채팅 알림"); + createInbox(user, NotificationInboxType.GROUP_CHAT_MESSAGE, "그룹 채팅 알림"); + createInbox(user, NotificationInboxType.CLUB_APPLICATION_APPROVED, "동아리 승인 알림"); + clearPersistenceContext(); + mockLoginUser(user.getId()); + + performGet("/notifications/inbox/unread-count") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.unreadCount").value(1)); + } + } + + @Nested + @DisplayName("PATCH /notifications/inbox/{id}/read - 단건 읽음 처리") + class MarkAsRead { + + @Test + @DisplayName("알림을 읽음 처리한다") + void markAsReadSuccess() throws Exception { + NotificationInbox inbox = createInbox(user, NotificationInboxType.CLUB_APPLICATION_APPROVED, "읽을 알림"); + clearPersistenceContext(); + mockLoginUser(user.getId()); + + mockMvc.perform(patch("/notifications/inbox/" + inbox.getId() + "/read") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + + clearPersistenceContext(); + assertThat(notificationInboxRepository.findByIdAndUserId(inbox.getId(), user.getId())) + .isPresent() + .get() + .extracting(NotificationInbox::getIsRead) + .isEqualTo(true); + } + + @Test + @DisplayName("다른 유저의 알림을 읽음 처리하면 404를 반환한다") + void markAsReadOtherUserInboxFails() throws Exception { + NotificationInbox otherInbox = createInbox( + otherUser, + NotificationInboxType.CLUB_APPLICATION_APPROVED, + "다른 유저 알림" + ); + clearPersistenceContext(); + mockLoginUser(user.getId()); + + mockMvc.perform(patch("/notifications/inbox/" + otherInbox.getId() + "/read") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("NOT_FOUND_NOTIFICATION_INBOX")); + } + } + + @Nested + @DisplayName("PATCH /notifications/inbox/read-all - 전체 읽음 처리") + class MarkAllAsRead { + + @Test + @DisplayName("자신의 모든 일반 알림을 읽음 처리한다") + void markAllAsReadSuccess() throws Exception { + createInbox(user, NotificationInboxType.CLUB_APPLICATION_APPROVED, "알림1"); + createInbox(user, NotificationInboxType.CLUB_APPLICATION_SUBMITTED, "알림2"); + clearPersistenceContext(); + mockLoginUser(user.getId()); + + mockMvc.perform(patch("/notifications/inbox/read-all") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + + clearPersistenceContext(); + long unreadCount = notificationInboxRepository.countByUserIdAndIsReadFalse(user.getId()); + assertThat(unreadCount).isZero(); + } + + @Test + @DisplayName("채팅 관련 인앱 알림은 전체 읽음 처리에서 제외된다") + void markAllAsReadExcludesChatNotifications() throws Exception { + createInbox(user, NotificationInboxType.CLUB_APPLICATION_APPROVED, "일반 알림"); + createInbox(user, NotificationInboxType.CHAT_MESSAGE, "채팅 알림"); + clearPersistenceContext(); + mockLoginUser(user.getId()); + + mockMvc.perform(patch("/notifications/inbox/read-all") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + + clearPersistenceContext(); + assertThat(notificationInboxRepository.countByUserIdAndIsReadFalse(user.getId())).isEqualTo(1L); + performGet("/notifications/inbox/unread-count") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.unreadCount").value(0)); + } + + @Test + @DisplayName("전체 읽음 처리는 다른 유저의 미읽음에 영향을 주지 않는다") + void markAllAsReadDoesNotAffectOtherUsersUnreadCount() throws Exception { + createInbox(user, NotificationInboxType.CLUB_APPLICATION_APPROVED, "알림1"); + createInbox(user, NotificationInboxType.CLUB_APPLICATION_REJECTED, "알림2"); + createInbox(otherUser, NotificationInboxType.CLUB_APPLICATION_APPROVED, "다른 유저 알림"); + clearPersistenceContext(); + mockLoginUser(user.getId()); + + mockMvc.perform(patch("/notifications/inbox/read-all") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + + clearPersistenceContext(); + assertThat(notificationInboxRepository.countByUserIdAndIsReadFalse(user.getId())).isZero(); + assertThat(notificationInboxRepository.countByUserIdAndIsReadFalse(otherUser.getId())).isEqualTo(1L); + } + } +} diff --git a/src/test/java/gg/agit/konect/integration/domain/studytime/StudyTimeApiTest.java b/src/test/java/gg/agit/konect/integration/domain/studytime/StudyTimeApiTest.java index eaf6d07ef..9f5a84025 100644 --- a/src/test/java/gg/agit/konect/integration/domain/studytime/StudyTimeApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/studytime/StudyTimeApiTest.java @@ -4,6 +4,9 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import java.time.LocalDate; +import java.time.LocalDateTime; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -11,6 +14,14 @@ import org.springframework.beans.factory.annotation.Autowired; import gg.agit.konect.domain.studytime.dto.StudyTimerStopRequest; +import gg.agit.konect.domain.studytime.dto.StudyTimerSyncRequest; +import gg.agit.konect.domain.studytime.model.StudyTimeDaily; +import gg.agit.konect.domain.studytime.model.StudyTimeMonthly; +import gg.agit.konect.domain.studytime.model.StudyTimeTotal; +import gg.agit.konect.domain.studytime.model.StudyTimer; +import gg.agit.konect.domain.studytime.repository.StudyTimeDailyRepository; +import gg.agit.konect.domain.studytime.repository.StudyTimeMonthlyRepository; +import gg.agit.konect.domain.studytime.repository.StudyTimeTotalRepository; import gg.agit.konect.domain.studytime.repository.StudyTimerRepository; import gg.agit.konect.domain.university.model.University; import gg.agit.konect.domain.user.model.User; @@ -25,6 +36,15 @@ class StudyTimeApiTest extends IntegrationTestSupport { @Autowired private StudyTimerRepository studyTimerRepository; + @Autowired + private StudyTimeDailyRepository studyTimeDailyRepository; + + @Autowired + private StudyTimeMonthlyRepository studyTimeMonthlyRepository; + + @Autowired + private StudyTimeTotalRepository studyTimeTotalRepository; + private University university; private User user; @@ -156,5 +176,202 @@ void stopTimerWithTimeMismatchFails() throws Exception { performDelete("/studytimes/timers", request) .andExpect(status().isBadRequest()); } + + @Test + @DisplayName("타이머 중지 후 시간이 누적된다") + void stopTimerAccumulatesTime() throws Exception { + // given + mockLoginUser(user.getId()); + performPost("/studytimes/timers") + .andExpect(status().isOk()); + + StudyTimer timer = studyTimerRepository.getByUserId(user.getId()); + timer.updateStartedAt(LocalDateTime.now().minusSeconds(5)); + persist(timer); + clearPersistenceContext(); + + StudyTimerStopRequest request = new StudyTimerStopRequest(5L); + + // when + performDelete("/studytimes/timers", request) + .andExpect(status().isOk()); + + // then + clearPersistenceContext(); + StudyTimeDaily daily = studyTimeDailyRepository + .findByUserIdAndStudyDate(user.getId(), LocalDate.now()) + .orElse(null); + StudyTimeMonthly monthly = studyTimeMonthlyRepository + .findByUserIdAndStudyMonth(user.getId(), LocalDate.now().withDayOfMonth(1)) + .orElse(null); + StudyTimeTotal total = studyTimeTotalRepository.findByUserId(user.getId()).orElse(null); + + assertThat(daily).isNotNull(); + assertThat(daily.getTotalSeconds()).isGreaterThanOrEqualTo(5L); + assertThat(monthly).isNotNull(); + assertThat(monthly.getTotalSeconds()).isGreaterThanOrEqualTo(5L); + assertThat(total).isNotNull(); + assertThat(total.getTotalSeconds()).isGreaterThanOrEqualTo(5L); + } + } + + @Nested + @DisplayName("POST /studytimes/timers/sync - 타이머 동기화") + class SyncTimer { + + @Test + @DisplayName("타이머를 동기화하면 시간이 누적되고 시작 시간이 갱신된다") + void syncTimerAccumulatesTime() throws Exception { + // given + mockLoginUser(user.getId()); + performPost("/studytimes/timers") + .andExpect(status().isOk()); + + StudyTimer timer = studyTimerRepository.getByUserId(user.getId()); + LocalDateTime originalStartedAt = timer.getStartedAt(); + timer.updateStartedAt(LocalDateTime.now().minusSeconds(5)); + persist(timer); + clearPersistenceContext(); + + StudyTimerSyncRequest request = new StudyTimerSyncRequest(5L); + + // when + performPost("/studytimes/timers/sync", request) + .andExpect(status().isOk()); + + // then + clearPersistenceContext(); + StudyTimer updatedTimer = studyTimerRepository.getByUserId(user.getId()); + assertThat(updatedTimer.getStartedAt()).isAfter(originalStartedAt); + + StudyTimeDaily daily = studyTimeDailyRepository + .findByUserIdAndStudyDate(user.getId(), LocalDate.now()) + .orElse(null); + assertThat(daily).isNotNull(); + assertThat(daily.getTotalSeconds()).isGreaterThanOrEqualTo(5L); + } + + @Test + @DisplayName("실행 중인 타이머가 없으면 동기화에 실패한다") + void syncTimerWithoutRunningFails() throws Exception { + // given + mockLoginUser(user.getId()); + StudyTimerSyncRequest request = new StudyTimerSyncRequest(0L); + + // when & then + performPost("/studytimes/timers/sync", request) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("클라이언트 시간과 서버 시간이 크게 차이나면 타이머가 삭제된다") + void syncTimerWithTimeMismatchDeletesTimer() throws Exception { + // given + mockLoginUser(user.getId()); + performPost("/studytimes/timers") + .andExpect(status().isOk()); + + StudyTimerSyncRequest request = new StudyTimerSyncRequest(MISMATCHED_CLIENT_SECONDS); + + // when & then + performPost("/studytimes/timers/sync", request) + .andExpect(status().isBadRequest()); + + assertThat(studyTimerRepository.existsByUserId(user.getId())).isFalse(); + } + + @Test + @DisplayName("여러 번 동기화해도 시간이 정확히 누적된다") + void multipleSyncAccumulatesCorrectly() throws Exception { + // given + mockLoginUser(user.getId()); + performPost("/studytimes/timers") + .andExpect(status().isOk()); + + // 첫 번째 동기화 + StudyTimer timer = studyTimerRepository.getByUserId(user.getId()); + timer.updateStartedAt(LocalDateTime.now().minusSeconds(3)); + persist(timer); + clearPersistenceContext(); + + performPost("/studytimes/timers/sync", new StudyTimerSyncRequest(3L)) + .andExpect(status().isOk()); + + // 두 번째 동기화 + timer = studyTimerRepository.getByUserId(user.getId()); + timer.updateStartedAt(LocalDateTime.now().minusSeconds(5)); + persist(timer); + clearPersistenceContext(); + + performPost("/studytimes/timers/sync", new StudyTimerSyncRequest(5L)) + .andExpect(status().isOk()); + + // then + clearPersistenceContext(); + StudyTimeDaily daily = studyTimeDailyRepository + .findByUserIdAndStudyDate(user.getId(), LocalDate.now()) + .orElse(null); + assertThat(daily).isNotNull(); + assertThat(daily.getTotalSeconds()).isGreaterThanOrEqualTo(8L); + } + } + + @Nested + @DisplayName("타이머 엣지 케이스") + class TimerEdgeCases { + + @Test + @DisplayName("타이머 시작 후 즉시 중지해도 정상 동작한다") + void stopImmediatelyAfterStart() throws Exception { + // given + mockLoginUser(user.getId()); + performPost("/studytimes/timers") + .andExpect(status().isOk()); + + StudyTimerStopRequest request = new StudyTimerStopRequest(0L); + + // when & then + performDelete("/studytimes/timers", request) + .andExpect(status().isOk()); + + assertThat(studyTimerRepository.existsByUserId(user.getId())).isFalse(); + } + + @Test + @DisplayName("타이머 시작 후 3초 이내의 시간 차이는 허용된다") + void timerAllowsSmallTimeDifference() throws Exception { + // given + mockLoginUser(user.getId()); + performPost("/studytimes/timers") + .andExpect(status().isOk()); + + StudyTimer timer = studyTimerRepository.getByUserId(user.getId()); + timer.updateStartedAt(LocalDateTime.now().minusSeconds(1)); + persist(timer); + clearPersistenceContext(); + + // 1초 차이는 3초 임계값 이내 + StudyTimerStopRequest request = new StudyTimerStopRequest(1L); + + // when & then + performDelete("/studytimes/timers", request) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("0초 동안 타이머를 실행해도 정상 동작한다") + void timerWithZeroSeconds() throws Exception { + // given + mockLoginUser(user.getId()); + performPost("/studytimes/timers") + .andExpect(status().isOk()); + + StudyTimerStopRequest request = new StudyTimerStopRequest(0L); + + // when & then + performDelete("/studytimes/timers", request) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.sessionSeconds").value(0)); + } } } diff --git a/src/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java b/src/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java new file mode 100644 index 000000000..050c59a1d --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java @@ -0,0 +1,354 @@ +package gg.agit.konect.integration.domain.upload; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; + +import javax.imageio.ImageIO; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.ResultActions; + +import com.jayway.jsonpath.JsonPath; +import com.google.auth.oauth2.GoogleCredentials; + +import gg.agit.konect.domain.upload.enums.UploadTarget; +import gg.agit.konect.support.IntegrationTestSupport; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; + +class UploadApiTest extends IntegrationTestSupport { + + private static final int LOGIN_USER_ID = 2024001001; + private static final int MAX_UPLOAD_BYTES = 20 * 1024 * 1024; + + @MockitoBean + private S3Client s3Client; + + @MockitoBean + private GoogleCredentials googleCredentials; + + @BeforeEach + void setUp() throws Exception { + mockLoginUser(LOGIN_USER_ID); + } + + @Nested + @DisplayName("POST /upload/image - 이미지 업로드") + class UploadImage { + + @Test + @DisplayName("지원하는 이미지를 업로드하면 원본 확장자로 key와 CDN URL을 반환한다") + void uploadImageSuccess() throws Exception { + // given + byte[] pngBytes = createPngBytes(8, 8); + MockMultipartFile file = imageFile("club.png", "image/png", pngBytes); + + // when + MvcResult result = uploadImage(file, UploadTarget.CLUB) + .andExpect(status().isOk()) + .andReturn(); + + // then + String responseBody = result.getResponse().getContentAsString(); + String key = JsonPath.read(responseBody, "$.key"); + String fileUrl = JsonPath.read(responseBody, "$.fileUrl"); + + assertThat(key).startsWith("test/club/"); + assertThat(key).endsWith(".png"); + assertThat(fileUrl).isEqualTo("https://cdn.test.com/" + key); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); + verify(s3Client).putObject(requestCaptor.capture(), any(RequestBody.class)); + assertThat(requestCaptor.getValue().bucket()).isEqualTo("test-bucket"); + assertThat(requestCaptor.getValue().key()).isEqualTo(key); + assertThat(requestCaptor.getValue().contentType()).isEqualTo("image/png"); + } + + @Test + @DisplayName("jpeg 이미지를 업로드하면 원본 형태로 저장한다") + void uploadJpegImageSuccess() throws Exception { + // given + byte[] jpegBytes = createJpegBytes(8, 8); + MockMultipartFile file = imageFile("club.jpg", "image/jpeg", jpegBytes); + + // when + MvcResult result = uploadImage(file, UploadTarget.CLUB) + .andExpect(status().isOk()) + .andReturn(); + + // then + String responseBody = result.getResponse().getContentAsString(); + String key = JsonPath.read(responseBody, "$.key"); + + assertThat(key).startsWith("test/club/"); + assertThat(key).endsWith(".jpg"); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); + verify(s3Client).putObject(requestCaptor.capture(), any(RequestBody.class)); + assertThat(requestCaptor.getValue().contentType()).isEqualTo("image/jpeg"); + } + + @Test + @DisplayName("jpg content type 이미지를 업로드하면 원본 형태로 저장한다") + void uploadJpgContentTypeImageSuccess() throws Exception { + // given + byte[] jpegBytes = createJpegBytes(8, 8); + MockMultipartFile file = imageFile("club.jpg", "image/jpg", jpegBytes); + + // when + MvcResult result = uploadImage(file, UploadTarget.CLUB) + .andExpect(status().isOk()) + .andReturn(); + + // then + String responseBody = result.getResponse().getContentAsString(); + String key = JsonPath.read(responseBody, "$.key"); + + assertThat(key).startsWith("test/club/"); + assertThat(key).endsWith(".jpg"); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); + verify(s3Client).putObject(requestCaptor.capture(), any(RequestBody.class)); + assertThat(requestCaptor.getValue().contentType()).isEqualTo("image/jpg"); + } + + @Test + @DisplayName("webp 이미지를 업로드하면 원본 형태로 저장한다") + void uploadWebpImageSuccess() throws Exception { + // given - webp 형태로 mock (실제 webp 변환 없이 단순 bytes로 처리) + byte[] webpBytes = new byte[] {0x52, 0x49, 0x46, 0x46}; // RIFF header mock + MockMultipartFile file = imageFile("club.webp", "image/webp", webpBytes); + + // when + MvcResult result = uploadImage(file, UploadTarget.CLUB) + .andExpect(status().isOk()) + .andReturn(); + + // then + String responseBody = result.getResponse().getContentAsString(); + String key = JsonPath.read(responseBody, "$.key"); + + assertThat(key).startsWith("test/club/"); + assertThat(key).endsWith(".webp"); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); + verify(s3Client).putObject(requestCaptor.capture(), any(RequestBody.class)); + assertThat(requestCaptor.getValue().contentType()).isEqualTo("image/webp"); + } + + @Test + @DisplayName("큰 이미지도 원본 형태로 업로드한다 (리사이징 없음)") + void uploadLargeImageWithoutResizing() throws Exception { + // given + byte[] pngBytes = createPngBytes(2160, 1080); + MockMultipartFile file = imageFile("wide.png", "image/png", pngBytes); + + // when + MvcResult result = uploadImage(file, UploadTarget.CLUB) + .andExpect(status().isOk()) + .andReturn(); + + // then + String responseBody = result.getResponse().getContentAsString(); + String key = JsonPath.read(responseBody, "$.key"); + + assertThat(key).startsWith("test/club/"); + assertThat(key).endsWith(".png"); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); + ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(RequestBody.class); + verify(s3Client).putObject(requestCaptor.capture(), bodyCaptor.capture()); + assertThat(requestCaptor.getValue().contentType()).isEqualTo("image/png"); + } + + @Test + @DisplayName("빈 파일을 업로드하면 400을 반환한다") + void uploadEmptyFileFails() throws Exception { + // given + MockMultipartFile file = imageFile("empty.png", "image/png", new byte[0]); + + // when & then + uploadImage(file, UploadTarget.CLUB) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_REQUEST_BODY")); + + verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("허용하지 않는 content type이면 400을 반환한다") + void uploadImageWithInvalidContentTypeFails() throws Exception { + // given + MockMultipartFile file = imageFile("note.txt", "text/plain", "not-image".getBytes()); + + // when & then + uploadImage(file, UploadTarget.CLUB) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_FILE_CONTENT_TYPE")); + + verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("content type 이 없으면 400을 반환한다") + void uploadImageWithoutContentTypeFails() throws Exception { + // given + MockMultipartFile file = imageFile("club.png", null, createPngBytes(8, 8)); + + // when & then + uploadImage(file, UploadTarget.CLUB) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_FILE_CONTENT_TYPE")); + + verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("content type 이 비어 있으면 400을 반환한다") + void uploadImageWithBlankContentTypeFails() throws Exception { + // given + MockMultipartFile file = imageFile("club.png", " ", createPngBytes(8, 8)); + + // when & then + uploadImage(file, UploadTarget.CLUB) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_FILE_CONTENT_TYPE")); + + verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("최대 업로드 크기를 넘기면 413을 반환한다") + void uploadImageWithTooLargeFileFails() throws Exception { + // given + MockMultipartFile file = imageFile( + "large.png", + "image/png", + new byte[MAX_UPLOAD_BYTES + 1] + ); + + // when & then + uploadImage(file, UploadTarget.CLUB) + .andExpect(status().isPayloadTooLarge()) + .andExpect(jsonPath("$.code").value("PAYLOAD_TOO_LARGE")); + + verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("target 파라미터가 없으면 400을 반환한다") + void uploadImageWithoutTargetFails() throws Exception { + // given + MockMultipartFile file = imageFile("club.png", "image/png", createPngBytes(8, 8)); + + // when & then + uploadImageWithoutTarget(file) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("MISSING_REQUIRED_PARAMETER")); + + verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("지원하지 않는 target 이면 400을 반환한다") + void uploadImageWithInvalidTargetFails() throws Exception { + // given + MockMultipartFile file = imageFile("club.png", "image/png", createPngBytes(8, 8)); + + // when & then + uploadImage(file, "INVALID") + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_TYPE_VALUE")); + + verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("S3 업로드에 실패하면 500을 반환한다") + void uploadImageWhenS3FailsReturnsInternalServerError() throws Exception { + // given + MockMultipartFile file = imageFile("club.png", "image/png", createPngBytes(8, 8)); + willThrow(S3Exception.builder().statusCode(500).message("upload failed").build()) + .given(s3Client) + .putObject(any(PutObjectRequest.class), any(RequestBody.class)); + + // when & then + uploadImage(file, UploadTarget.CLUB) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.code").value("FAILED_UPLOAD_FILE")); + } + + @Test + @DisplayName("S3 클라이언트 오류가 발생하면 500을 반환한다") + void uploadImageWhenS3ClientFailsReturnsInternalServerError() throws Exception { + // given + MockMultipartFile file = imageFile("club.png", "image/png", createPngBytes(8, 8)); + willThrow(SdkClientException.create("network failure")) + .given(s3Client) + .putObject(any(PutObjectRequest.class), any(RequestBody.class)); + + // when & then + uploadImage(file, UploadTarget.CLUB) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.code").value("FAILED_UPLOAD_FILE")); + } + } + + private ResultActions uploadImage(MockMultipartFile file, UploadTarget target) throws Exception { + return uploadImage(file, target.name()); + } + + private ResultActions uploadImage(MockMultipartFile file, String target) throws Exception { + return mockMvc.perform( + multipart("/upload/image") + .file(file) + .param("target", target) + ); + } + + private ResultActions uploadImageWithoutTarget(MockMultipartFile file) throws Exception { + return mockMvc.perform( + multipart("/upload/image") + .file(file) + ); + } + + private MockMultipartFile imageFile(String fileName, String contentType, byte[] bytes) { + return new MockMultipartFile("file", fileName, contentType, bytes); + } + + private byte[] createPngBytes(int width, int height) throws Exception { + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + ImageIO.write(image, "png", outputStream); + return outputStream.toByteArray(); + } + } + + private byte[] createJpegBytes(int width, int height) throws Exception { + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + ImageIO.write(image, "jpg", outputStream); + return outputStream.toByteArray(); + } + } +} diff --git a/src/test/java/gg/agit/konect/integration/domain/user/UserSignupApiTest.java b/src/test/java/gg/agit/konect/integration/domain/user/UserSignupApiTest.java new file mode 100644 index 000000000..6c3f7afcd --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/domain/user/UserSignupApiTest.java @@ -0,0 +1,362 @@ +package gg.agit.konect.integration.domain.user; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import gg.agit.konect.domain.club.enums.ClubPosition; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.ClubMember; +import gg.agit.konect.domain.club.model.ClubPreMember; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.user.dto.SignupRequest; +import gg.agit.konect.domain.user.model.UnRegisteredUser; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UnRegisteredUserRepository; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.support.IntegrationTestSupport; +import gg.agit.konect.support.fixture.ClubFixture; +import gg.agit.konect.support.fixture.UnRegisteredUserFixture; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.List; + +import jakarta.servlet.http.Cookie; + +@DisplayName("회원가입 API 테스트") +class UserSignupApiTest extends IntegrationTestSupport { + + @Autowired + private UserRepository userRepository; + + @Autowired + private UnRegisteredUserRepository unRegisteredUserRepository; + + @Autowired + private ClubPreMemberRepository clubPreMemberRepository; + + @Autowired + private ClubMemberRepository clubMemberRepository; + + private static final String SIGNUP_TOKEN_COOKIE_NAME = "signup_token"; + private static final String VALID_SIGNUP_TOKEN = "valid-test-signup-token"; + + private University university; + private Club club; + private User existingPresident; + + @BeforeEach + void setUp() throws Exception { + university = persist(UniversityFixture.create()); + club = persist(ClubFixture.create(university, "BCSD Lab")); + existingPresident = persist(UserFixture.createUser(university, "기존회장", "2020000001")); + persist(gg.agit.konect.support.fixture.ClubMemberFixture.createPresident(club, existingPresident)); + clearPersistenceContext(); + + // signup_token 쿠키 설정 + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/test-setup") + .cookie(new Cookie(SIGNUP_TOKEN_COOKIE_NAME, VALID_SIGNUP_TOKEN))); + } + + @Nested + @DisplayName("POST /users/signup - 회원가입") + class Signup { + + @Test + @DisplayName("정상 회원가입을 성공한다") + void signupSuccess() throws Exception { + // given + String email = "newuser@koreatech.ac.kr"; + String studentNumber = "2021136001"; + UnRegisteredUser unRegisteredUser = UnRegisteredUserFixture.createGoogle(email); + persist(unRegisteredUser); + clearPersistenceContext(); + + SignupRequest request = new SignupRequest( + "홍길동", + university.getId(), + studentNumber, + true + ); + + // when & then + performPost("/users/signup", request) + .andExpect(status().isOk()); + + // 회원이 생성되었는지 확인 + clearPersistenceContext(); + User savedUser = findSavedUser(studentNumber); + assertThat(savedUser).isNotNull(); + assertThat(savedUser.getName()).isEqualTo("홍길동"); + assertThat(savedUser.getEmail()).isEqualTo(email); + } + + @Test + @DisplayName("회원가입 시 PreMember가 있으면 자동으로 동아리에 가입된다") + void signupWithPreMemberAutoJoinsClub() throws Exception { + // given + String email = "premember@koreatech.ac.kr"; + String studentNumber = "2021136002"; + String name = "김프리"; + + UnRegisteredUser unRegisteredUser = UnRegisteredUserFixture.createGoogle(email); + persist(unRegisteredUser); + + // PreMember로 등록 (MEMBER 직급) + ClubPreMember preMember = ClubPreMember.builder() + .club(club) + .studentNumber(studentNumber) + .name(name) + .clubPosition(ClubPosition.MEMBER) + .build(); + persist(preMember); + clearPersistenceContext(); + + SignupRequest request = new SignupRequest(name, university.getId(), studentNumber, true); + + // when + performPost("/users/signup", request) + .andExpect(status().isOk()); + + // then + clearPersistenceContext(); + User savedUser = findSavedUser(studentNumber); + assertThat(savedUser).isNotNull(); + + // 동아리 멤버로 등록되었는지 확인 + boolean isMember = clubMemberRepository.existsByClubIdAndUserId(club.getId(), savedUser.getId()); + assertThat(isMember).isTrue(); + + ClubMember clubMember = clubMemberRepository.getByClubIdAndUserId(club.getId(), savedUser.getId()); + assertThat(clubMember.getClubPosition()).isEqualTo(ClubPosition.MEMBER); + + // PreMember는 삭제되었는지 확인 + List remainingPreMembers = clubPreMemberRepository.findAllByClubId(club.getId()); + assertThat(remainingPreMembers).isEmpty(); + } + + @Test + @DisplayName("회원가입 시 PreMember가 회장이면 기존 회장을 교체한다") + void signupWithPreMemberPresidentReplacesExistingPresident() throws Exception { + // given + String email = "newpresident@koreatech.ac.kr"; + String studentNumber = "2021136003"; + String name = "신임회장"; + + UnRegisteredUser unRegisteredUser = UnRegisteredUserFixture.createGoogle(email); + persist(unRegisteredUser); + + // PreMember로 회장 등록 + ClubPreMember preMemberPresident = ClubPreMember.builder() + .club(club) + .studentNumber(studentNumber) + .name(name) + .clubPosition(ClubPosition.PRESIDENT) + .build(); + persist(preMemberPresident); + clearPersistenceContext(); + + // 기존 회장이 존재하는지 확인 + assertThat(clubMemberRepository.findPresidentByClubId(club.getId())).isPresent(); + + SignupRequest request = new SignupRequest(name, university.getId(), studentNumber, true); + + // when + performPost("/users/signup", request) + .andExpect(status().isOk()); + + // then + clearPersistenceContext(); + User savedUser = findSavedUser(studentNumber); + assertThat(savedUser).isNotNull(); + + // 새로운 사용자가 회장으로 등록되었는지 확인 + ClubMember newPresident = clubMemberRepository.getByClubIdAndUserId(club.getId(), savedUser.getId()); + assertThat(newPresident.getClubPosition()).isEqualTo(ClubPosition.PRESIDENT); + + // 기존 회장은 삭제되었는지 확인 + assertThat(clubMemberRepository.findPresidentByClubId(club.getId())).isPresent(); + assertThat(clubMemberRepository.findPresidentByClubId(club.getId()).get().getUser().getId()) + .isEqualTo(savedUser.getId()); + } + + @Test + @DisplayName("회원가입 시 복수 동아리의 PreMember가 있으면 모두 가입된다") + void signupWithMultiplePreMembersJoinsAllClubs() throws Exception { + // given + String email = "multi@koreatech.ac.kr"; + String studentNumber = "2021136004"; + String name = "멀티동아리"; + + Club club2 = persist(ClubFixture.create(university, "Another Club")); + + UnRegisteredUser unRegisteredUser = UnRegisteredUserFixture.createGoogle(email); + persist(unRegisteredUser); + + // 두 동아리에 PreMember 등록 + ClubPreMember preMember1 = ClubPreMember.builder() + .club(club) + .studentNumber(studentNumber) + .name(name) + .clubPosition(ClubPosition.MEMBER) + .build(); + ClubPreMember preMember2 = ClubPreMember.builder() + .club(club2) + .studentNumber(studentNumber) + .name(name) + .clubPosition(ClubPosition.MANAGER) + .build(); + persist(preMember1); + persist(preMember2); + clearPersistenceContext(); + + SignupRequest request = new SignupRequest(name, university.getId(), studentNumber, true); + + // when + performPost("/users/signup", request) + .andExpect(status().isOk()); + + // then + clearPersistenceContext(); + User savedUser = findSavedUser(studentNumber); + assertThat(savedUser).isNotNull(); + + // 두 동아리 모두 가입되었는지 확인 + boolean isMemberOfClub1 = clubMemberRepository.existsByClubIdAndUserId(club.getId(), savedUser.getId()); + boolean isMemberOfClub2 = clubMemberRepository.existsByClubIdAndUserId(club2.getId(), savedUser.getId()); + assertThat(isMemberOfClub1).isTrue(); + assertThat(isMemberOfClub2).isTrue(); + + // 각각의 직급 확인 + ClubMember memberInClub1 = clubMemberRepository.getByClubIdAndUserId(club.getId(), savedUser.getId()); + ClubMember memberInClub2 = clubMemberRepository.getByClubIdAndUserId(club2.getId(), savedUser.getId()); + assertThat(memberInClub1.getClubPosition()).isEqualTo(ClubPosition.MEMBER); + assertThat(memberInClub2.getClubPosition()).isEqualTo(ClubPosition.MANAGER); + } + + @Test + @DisplayName("회원가입 시 이름이 유효하지 않으면 400을 반환한다") + void signupWithInvalidNameReturns400() throws Exception { + // given + String email = "test@koreatech.ac.kr"; + UnRegisteredUser unRegisteredUser = UnRegisteredUserFixture.createGoogle(email); + persist(unRegisteredUser); + clearPersistenceContext(); + + // 이름 1글자 (유효하지 않음) + SignupRequest request = new SignupRequest("홍", university.getId(), "2021136005", true); + + // when & then + performPost("/users/signup", request) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("회원가입 시 학번이 숫자가 아니면 400을 반환한다") + void signupWithNonNumericStudentNumberReturns400() throws Exception { + // given + String email = "test@koreatech.ac.kr"; + UnRegisteredUser unRegisteredUser = UnRegisteredUserFixture.createGoogle(email); + persist(unRegisteredUser); + clearPersistenceContext(); + + SignupRequest request = new SignupRequest("홍길동", university.getId(), "ABC123", true); + + // when & then + performPost("/users/signup", request) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("회원가입 시 존재하지 않는 대학 ID면 404를 반환한다") + void signupWithNonExistentUniversityReturns404() throws Exception { + // given + String email = "test@koreatech.ac.kr"; + UnRegisteredUser unRegisteredUser = UnRegisteredUserFixture.createGoogle(email); + persist(unRegisteredUser); + clearPersistenceContext(); + + SignupRequest request = new SignupRequest("홍길동", 99999, "2021136006", true); + + // when & then + performPost("/users/signup", request) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("회원가입 시 마케팅 동의 여부가 null이면 400을 반환한다") + void signupWithNullMarketingAgreementReturns400() throws Exception { + // given + String email = "test@koreatech.ac.kr"; + UnRegisteredUser unRegisteredUser = UnRegisteredUserFixture.createGoogle(email); + persist(unRegisteredUser); + clearPersistenceContext(); + + // 마케팅 동의 null인 DTO 생성 + String jsonRequest = String.format( + "{\"name\": \"홍길동\", \"universityId\": %d, \"studentNumber\": \"2021136007\"}", + university.getId() + ); + + // when & then + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/users/signup") + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .content(jsonRequest)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("PreMember가 없는 이름/학번 조합이면 자동 가입되지 않는다") + void signupWithoutMatchingPreMemberDoesNotAutoJoin() throws Exception { + // given + String email = "nomatch@koreatech.ac.kr"; + String studentNumber = "2021136008"; + String name = "노매치"; + + UnRegisteredUser unRegisteredUser = UnRegisteredUserFixture.createGoogle(email); + persist(unRegisteredUser); + + // 다른 학번으로 PreMember 등록 + ClubPreMember preMember = ClubPreMember.builder() + .club(club) + .studentNumber("99999999") // 다른 학번 + .name(name) + .clubPosition(ClubPosition.MEMBER) + .build(); + persist(preMember); + clearPersistenceContext(); + + SignupRequest request = new SignupRequest(name, university.getId(), studentNumber, true); + + // when + performPost("/users/signup", request) + .andExpect(status().isOk()); + + // then + clearPersistenceContext(); + User savedUser = findSavedUser(studentNumber); + assertThat(savedUser).isNotNull(); + + // 동아리에 가입되지 않았는지 확인 + boolean isMember = clubMemberRepository.existsByClubIdAndUserId(club.getId(), savedUser.getId()); + assertThat(isMember).isFalse(); + } + } + + private User findSavedUser(String studentNumber) { + return userRepository.findAllByUniversityIdAndStudentNumber( + university.getId(), + studentNumber + ).stream() + .findFirst() + .orElse(null); + } +} diff --git a/src/test/java/gg/agit/konect/integration/domain/user/UserWithdrawApiTest.java b/src/test/java/gg/agit/konect/integration/domain/user/UserWithdrawApiTest.java new file mode 100644 index 000000000..d2e87ea1d --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/domain/user/UserWithdrawApiTest.java @@ -0,0 +1,227 @@ +package gg.agit.konect.integration.domain.user; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.support.IntegrationTestSupport; +import gg.agit.konect.support.fixture.ClubFixture; +import gg.agit.konect.support.fixture.ClubMemberFixture; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.LocalDateTime; + +@DisplayName("회원 탈퇴 API 테스트") +class UserWithdrawApiTest extends IntegrationTestSupport { + + @Autowired + private UserRepository userRepository; + + @Autowired + private ClubMemberRepository clubMemberRepository; + + private University university; + private Club club; + + @BeforeEach + void setUp() { + university = persist(UniversityFixture.create()); + club = persist(ClubFixture.create(university, "BCSD Lab")); + } + + @Nested + @DisplayName("DELETE /users/withdraw - 회원 탈퇴") + class Withdraw { + + @Test + @DisplayName("일반 멤버는 탈퇴할 수 있다") + void withdrawAsRegularMemberSuccess() throws Exception { + // given + User user = persist(UserFixture.createUser(university, "일반회원", "2021136001")); + persist(ClubMemberFixture.createMember(club, user)); + clearPersistenceContext(); + + mockLoginUser(user.getId()); + + // when & then + performDelete("/users/withdraw") + .andExpect(status().isNoContent()); + + // 탈퇴 처리되었는지 확인 + clearPersistenceContext(); + User withdrawnUser = userRepository.findById(user.getId()).orElse(null); + assertThat(withdrawnUser).isNotNull(); + assertThat(withdrawnUser.getDeletedAt()).isNotNull(); + } + + @Test + @DisplayName("동아리에 가입하지 않은 사용자도 탈퇴할 수 있다") + void withdrawWithoutClubMembershipSuccess() throws Exception { + // given + User user = persist(UserFixture.createUser(university, "미가입자", "2021136002")); + clearPersistenceContext(); + + mockLoginUser(user.getId()); + + // when & then + performDelete("/users/withdraw") + .andExpect(status().isNoContent()); + + clearPersistenceContext(); + User withdrawnUser = userRepository.findById(user.getId()).orElse(null); + assertThat(withdrawnUser).isNotNull(); + assertThat(withdrawnUser.getDeletedAt()).isNotNull(); + } + + @Test + @DisplayName("회장은 탈퇴할 수 없다") + void withdrawAsPresidentFails() throws Exception { + // given + User president = persist(UserFixture.createUser(university, "회장", "2021136003")); + persist(ClubMemberFixture.createPresident(club, president)); + clearPersistenceContext(); + + mockLoginUser(president.getId()); + + // when & then + performDelete("/users/withdraw") + .andExpect(status().isBadRequest()); + + // 탈퇴 처리되지 않았는지 확인 + clearPersistenceContext(); + User notWithdrawnUser = userRepository.findById(president.getId()).orElse(null); + assertThat(notWithdrawnUser).isNotNull(); + assertThat(notWithdrawnUser.getDeletedAt()).isNull(); + } + + @Test + @DisplayName("복수 동아리의 회장이면 탈퇴할 수 없다") + void withdrawAsPresidentOfMultipleClubsFails() throws Exception { + // given + Club club2 = persist(ClubFixture.create(university, "Another Club")); + User president = persist(UserFixture.createUser(university, "다중회장", "2021136004")); + + persist(ClubMemberFixture.createPresident(club, president)); + persist(ClubMemberFixture.createPresident(club2, president)); + clearPersistenceContext(); + + mockLoginUser(president.getId()); + + // when & then + performDelete("/users/withdraw") + .andExpect(status().isBadRequest()); + + clearPersistenceContext(); + User notWithdrawnUser = userRepository.findById(president.getId()).orElse(null); + assertThat(notWithdrawnUser).isNotNull(); + assertThat(notWithdrawnUser.getDeletedAt()).isNull(); + } + + @Test + @DisplayName("한 동아리의 회장이고 다른 동아리의 일반 멤버면 탈퇴할 수 없다") + void withdrawAsPresidentInOneClubAndMemberInAnotherFails() throws Exception { + // given + Club club2 = persist(ClubFixture.create(university, "Another Club")); + User user = persist(UserFixture.createUser(university, "회장이자일반", "2021136005")); + + persist(ClubMemberFixture.createPresident(club, user)); + persist(ClubMemberFixture.createMember(club2, user)); + clearPersistenceContext(); + + mockLoginUser(user.getId()); + + // when & then + performDelete("/users/withdraw") + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("부회장은 탈퇴할 수 있다") + void withdrawAsVicePresidentSuccess() throws Exception { + // given + User vicePresident = persist(UserFixture.createUser(university, "부회장", "2021136006")); + persist(ClubMemberFixture.createVicePresident(club, vicePresident)); + clearPersistenceContext(); + + mockLoginUser(vicePresident.getId()); + + // when & then + performDelete("/users/withdraw") + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("운영진은 탈퇴할 수 있다") + void withdrawAsManagerSuccess() throws Exception { + // given + User manager = persist(UserFixture.createUser(university, "운영진", "2021136007")); + persist(ClubMemberFixture.createManager(club, manager)); + clearPersistenceContext(); + + mockLoginUser(manager.getId()); + + // when & then + performDelete("/users/withdraw") + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("탈퇴 후 재가입을 위해 7일 유예기간이 설정된다") + void withdrawSetsDeletedAt() throws Exception { + // given + User user = persist(UserFixture.createUser(university, "탈퇴자", "2021136008")); + persist(ClubMemberFixture.createMember(club, user)); + clearPersistenceContext(); + + LocalDateTime beforeWithdraw = LocalDateTime.now(); + mockLoginUser(user.getId()); + + // when + performDelete("/users/withdraw") + .andExpect(status().isNoContent()); + + // then + clearPersistenceContext(); + User withdrawnUser = userRepository.findById(user.getId()).orElse(null); + assertThat(withdrawnUser).isNotNull(); + assertThat(withdrawnUser.getDeletedAt()).isNotNull(); + assertThat(withdrawnUser.getDeletedAt()).isAfterOrEqualTo(beforeWithdraw); + } + + @Test + @DisplayName("이미 탈퇴한 사용자가 다시 탈퇴하면 정상 처리된다") + void doubleWithdrawSucceeds() throws Exception { + // given + User user = persist(UserFixture.createUser(university, "이중탈퇴", "2021136009")); + persist(ClubMemberFixture.createMember(club, user)); + user.withdraw(LocalDateTime.now().minusDays(1)); + persist(user); + clearPersistenceContext(); + + mockLoginUser(user.getId()); + + // when & then + performDelete("/users/withdraw") + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("미인증 사용자는 탈퇴할 수 없다") + void withdrawWithoutAuthFails() throws Exception { + // when & then + performDelete("/users/withdraw") + .andExpect(status().isUnauthorized()); + } + } +} diff --git a/src/test/java/gg/agit/konect/integration/infrastructure/oauth/GoogleDriveOAuthControllerTest.java b/src/test/java/gg/agit/konect/integration/infrastructure/oauth/GoogleDriveOAuthControllerTest.java new file mode 100644 index 000000000..503fc8dbd --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/infrastructure/oauth/GoogleDriveOAuthControllerTest.java @@ -0,0 +1,46 @@ +package gg.agit.konect.integration.infrastructure.oauth; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import gg.agit.konect.infrastructure.oauth.GoogleDriveOAuthService; +import gg.agit.konect.support.IntegrationTestSupport; + +class GoogleDriveOAuthControllerTest extends IntegrationTestSupport { + + @MockitoBean + private GoogleDriveOAuthService googleDriveOAuthService; + + private static final Integer USER_ID = 100; + private static final String AUTHORIZATION_URL = + "https://accounts.google.com/o/oauth2/v2/auth?client_id=test-client&state=test-state"; + + @BeforeEach + void setUp() throws Exception { + mockLoginUser(USER_ID); + } + + @Nested + @DisplayName("GET /auth/oauth/google/drive/authorize-url - Google Drive 권한 연결 URL 조회") + class GetAuthorizationUrl { + + @Test + @DisplayName("로그인 사용자의 authorize URL을 JSON으로 반환한다") + void getAuthorizationUrl() throws Exception { + given(googleDriveOAuthService.buildAuthorizationUrl(eq(USER_ID))) + .willReturn(AUTHORIZATION_URL); + + performGet("/auth/oauth/google/drive/authorize-url") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authorizationUrl").value(AUTHORIZATION_URL)); + } + } +} diff --git a/src/test/java/gg/agit/konect/support/IntegrationTestSupport.java b/src/test/java/gg/agit/konect/support/IntegrationTestSupport.java index 7dc95b204..84b44df82 100644 --- a/src/test/java/gg/agit/konect/support/IntegrationTestSupport.java +++ b/src/test/java/gg/agit/konect/support/IntegrationTestSupport.java @@ -24,6 +24,10 @@ import org.springframework.util.MultiValueMap; import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.api.services.drive.Drive; +import com.google.api.services.sheets.v4.Sheets; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.auth.oauth2.ServiceAccountCredentials; import jakarta.persistence.EntityManager; @@ -71,6 +75,18 @@ public abstract class IntegrationTestSupport { @MockitoBean protected LoggingProperties loggingProperties; + @MockitoBean + protected GoogleCredentials googleCredentials; + + @MockitoBean + protected ServiceAccountCredentials serviceAccountCredentials; + + @MockitoBean + protected Sheets googleSheetsService; + + @MockitoBean + protected Drive googleDriveService; + @BeforeEach void setUpCommonMocks() throws Exception { given(loginCheckInterceptor.preHandle(any(), any(), any())).willReturn(true); diff --git a/src/test/java/gg/agit/konect/support/fixture/AdvertisementFixture.java b/src/test/java/gg/agit/konect/support/fixture/AdvertisementFixture.java new file mode 100644 index 000000000..24f4d0658 --- /dev/null +++ b/src/test/java/gg/agit/konect/support/fixture/AdvertisementFixture.java @@ -0,0 +1,16 @@ +package gg.agit.konect.support.fixture; + +import gg.agit.konect.domain.advertisement.model.Advertisement; + +public class AdvertisementFixture { + + public static Advertisement create(String title, boolean isVisible) { + return Advertisement.of( + title, + title + " 설명", + "https://example.com/advertisement.png", + "https://example.com", + isVisible + ); + } +} diff --git a/src/test/java/gg/agit/konect/unit/global/auth/web/AuthorizationInterceptorTest.java b/src/test/java/gg/agit/konect/unit/global/auth/web/AuthorizationInterceptorTest.java new file mode 100644 index 000000000..9d80b1117 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/global/auth/web/AuthorizationInterceptorTest.java @@ -0,0 +1,136 @@ +package gg.agit.konect.unit.global.auth.web; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.web.servlet.HandlerExceptionResolver; + +import gg.agit.konect.domain.user.enums.UserRole; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.auth.annotation.Auth; +import gg.agit.konect.global.auth.web.AuthorizationInterceptor; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; + +@ExtendWith(MockitoExtension.class) +class AuthorizationInterceptorTest { + + @Mock + private UserRepository userRepository; + + @Mock + private HandlerExceptionResolver handlerExceptionResolver; + + private AuthorizationInterceptor interceptor; + + @BeforeEach + void setUp() { + interceptor = new AuthorizationInterceptor(userRepository, handlerExceptionResolver); + } + + @Nested + @DisplayName("validateRole - 권한 판별 로직") + class ValidateRole { + + @Test + @DisplayName("관리자 권한이 있으면 통과한다") + void adminRolePass() throws Exception { + // given + Integer userId = 1; + User admin = createUser(UserRole.ADMIN); + + given(userRepository.getById(userId)).willReturn(admin); + + // when & then - 예외 없이 통과 + invokeValidateRole(userId, adminOnlyAuth()); + } + + @Test + @DisplayName("일반 사용자는 관리자 권한이 필요한 API에 접근할 수 없다") + void userRoleCannotAccessAdminApi() throws Exception { + // given + Integer userId = 1; + User normalUser = createUser(UserRole.USER); + + given(userRepository.getById(userId)).willReturn(normalUser); + + // when & then + assertThatThrownBy(() -> invokeValidateRole(userId, adminOnlyAuth())) + .isInstanceOf(InvocationTargetException.class) + .extracting("cause") + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ApiResponseCode.FORBIDDEN_ROLE_ACCESS); + } + + @Test + @DisplayName("다중 권한 중 하나라도 일치하면 통과한다") + void oneOfMultipleRolesMatch() throws Exception { + // given + Integer userId = 1; + User admin = createUser(UserRole.ADMIN); + + given(userRepository.getById(userId)).willReturn(admin); + + // when & then - 예외 없이 통과 + invokeValidateRole(userId, multiRoleAuth()); + } + + @Test + @DisplayName("일반 사용자도 USER 권한 API에는 접근할 수 있다") + void userCanAccessUserApi() throws Exception { + // given + Integer userId = 1; + User normalUser = createUser(UserRole.USER); + + given(userRepository.getById(userId)).willReturn(normalUser); + + // when & then - 예외 없이 통과 + invokeValidateRole(userId, multiRoleAuth()); + } + } + + private void invokeValidateRole(Integer userId, Auth auth) throws Exception { + Method method = AuthorizationInterceptor.class.getDeclaredMethod("validateRole", Integer.class, Auth.class); + method.setAccessible(true); + method.invoke(interceptor, userId, auth); + } + + private Auth adminOnlyAuth() throws Exception { + Method method = AuthMethods.class.getDeclaredMethod("adminOnly"); + return AnnotatedElementUtils.findMergedAnnotation(method, Auth.class); + } + + private Auth multiRoleAuth() throws Exception { + Method method = AuthMethods.class.getDeclaredMethod("multiRole"); + return AnnotatedElementUtils.findMergedAnnotation(method, Auth.class); + } + + private User createUser(UserRole role) { + return User.builder() + .role(role) + .build(); + } + + static class AuthMethods { + @Auth(roles = {UserRole.ADMIN}) + void adminOnly() { + } + + @Auth(roles = {UserRole.ADMIN, UserRole.USER}) + void multiRole() { + } + } +} diff --git a/src/test/resources/.env.test.properties b/src/test/resources/.env.test.properties new file mode 100644 index 000000000..d1c4a7b9b --- /dev/null +++ b/src/test/resources/.env.test.properties @@ -0,0 +1,35 @@ +# Test-only placeholder for @TestPropertySource. +ALLOWED_ORIGINS=http://localhost:3000 +APP_JWT_SECRET=test-secret-key-for-testing-purposes-only-32-chars +APP_JWT_ISSUER=test-issuer +APP_COOKIE_DOMAIN=localhost +APP_FRONTEND_BASE_URL=http://localhost:3000 +APP_BACKEND_BASE_URL=http://localhost:8080 +SESSION_COOKIE_DOMAIN=localhost + +MYSQL_URL=jdbc:h2:mem:test;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE +MYSQL_USERNAME=sa +MYSQL_PASSWORD= +REDIS_HOST=localhost +REDIS_PORT=6379 + +OAUTH_GOOGLE_CLIENT_ID=test-google-client-id +OAUTH_GOOGLE_CLIENT_SECRET=test-google-client-secret +OAUTH_NAVER_CLIENT_ID=test-naver-client-id +OAUTH_NAVER_CLIENT_SECRET=test-naver-client-secret +OAUTH_KAKAO_CLIENT_ID=test-kakao-client-id +OAUTH_KAKAO_CLIENT_SECRET=test-kakao-client-secret +OAUTH_APPLE_TEAM_ID=test-apple-team-id +OAUTH_APPLE_CLIENT_ID=test-apple-client-id +OAUTH_APPLE_CLIENT_SECRET=test-apple-client-secret +OAUTH_APPLE_KEY_ID=test-apple-key-id +OAUTH_APPLE_PRIVATE_KEY_PATH=classpath:test-key.p8 + +STORAGE_S3_BUCKET=test-bucket +STORAGE_S3_REGION=ap-northeast-2 +STORAGE_CDN_BASE_URL=https://cdn.test.com +SLACK_WEBHOOK_ERROR=https://hooks.slack.com/test +SLACK_WEBHOOK_EVENT=https://hooks.slack.com/test-event +SLACK_SIGNING_SECRET=test-signing-secret +SLACK_BOT_TOKEN=test-slack-bot-token +CLAUDE_API_KEY=test-api-key diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 30c005763..9ee862954 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -129,6 +129,14 @@ claude: mcp: url: http://localhost:3100 +google: + sheets: + credentials-path: classpath:test-credentials.json + application-name: test-app + oauth-client-id: test-google-sheets-client-id + oauth-client-secret: test-google-sheets-client-secret + oauth-callback-base-url: http://localhost:8080 + logging: ignored-url-patterns: - /**/api-docs/** diff --git a/src/test/resources/test-credentials.json b/src/test/resources/test-credentials.json new file mode 100644 index 000000000..da248032e --- /dev/null +++ b/src/test/resources/test-credentials.json @@ -0,0 +1,14 @@ +{ + "type": "service_account", + "project_id": "test-project", + "private_key_id": "test-key-id", + "private_key": "-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCtest +-----END PRIVATE KEY----- +", + "client_email": "test@test-project.iam.gserviceaccount.com", + "client_id": "123456789", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs" +} diff --git a/src/test/resources/test-key.p8 b/src/test/resources/test-key.p8 new file mode 100644 index 000000000..caeda779f --- /dev/null +++ b/src/test/resources/test-key.p8 @@ -0,0 +1,6 @@ +-----BEGIN PRIVATE KEY----- +MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgTestKeyForTesting +PurposesOnlyNotARealKeyDoNotUseInProductionAAAAAAAAAAAAAAMCgYIKoZI +zj0DAQehRANCAATTestKeyForTestingPurposesOnlyNotARealKeyDoNotUseIn +ProductionTestKeyForTestingPurposesOnly +-----END PRIVATE KEY-----