diff --git a/.dockerignore b/.dockerignore index 47ad3113c..76e4eafb7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,4 +5,3 @@ !build/ !build/libs/ !build/libs/KONECT_API.jar -!opentelemetry-javaagent.jar diff --git a/.env.example b/.env.example index 056fd20e3..19137532d 100644 --- a/.env.example +++ b/.env.example @@ -58,12 +58,3 @@ CLAUDE_MODEL=claude-sonnet-4-20250514 # MCP Bridge Configuration MCP_BRIDGE_URL=http://localhost:3100 - -# Tracing Configuration (OTel Java Agent -> Tempo) -JAVA_TOOL_OPTIONS=-javaagent:/app/opentelemetry-javaagent.jar -OTEL_SERVICE_NAME=your-service-name -OTEL_TRACES_EXPORTER=otlp -OTEL_METRICS_EXPORTER=none -OTEL_LOGS_EXPORTER=none -OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf -OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://your-monitoring-host:4318/v1/traces diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 84ef95947..72b9ad1d7 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1 +1,61 @@ -Always respond in Korean. +# Copilot 코드 리뷰 지침 + +모든 리뷰 코멘트는 한국어로 작성한다. + +## 리뷰 우선순위 + +- 정확성, 보안, 권한 검증, 데이터 무결성, 트랜잭션 경계, API 응답 계약, 동시성 문제, 테스트 누락을 우선 확인한다. +- 변경된 코드에서 실제로 발생 가능한 문제만 지적한다. 단순 취향, 스타일 선호, 광범위한 리팩터링, 근거가 약한 추측은 리뷰 코멘트로 남기지 않는다. +- 보안 관련 변경에서는 인증/인가 우회, 사용자 입력 검증 누락, 민감정보 노출, 운영 환경 설정 노출 가능성을 우선 확인한다. +- 데이터베이스 변경에서는 마이그레이션 순서, 기존 데이터 호환성, nullable/default 처리, 롤백 난이도, 인덱스 필요성을 확인한다. +- 외부 API, OAuth, Slack, Claude, MCP, 파일 저장소처럼 외부 시스템과 연결되는 코드는 장애 전파, timeout, 예외 처리, 설정값 누락 시 동작을 확인한다. + +## 심각도 판단 + +- P0: 데이터 손실, 보안 취약점, 인증/인가 우회, 운영 장애처럼 반드시 병합 전에 고쳐야 하는 문제만 해당한다. +- P1: 정상 사용 흐름에서 기능이 깨지거나 API 계약이 바뀌거나 기존 데이터와 호환되지 않는 문제다. +- P2: 특정 조건에서 재현되는 버그, 성능 저하, 유지보수 위험, 테스트 공백처럼 수정 가치가 분명한 문제다. +- P3: 영향이 작고 선택적인 개선이다. 단순 취향이면 코멘트하지 않는다. +- nitpick을 P0/P1처럼 과장하지 말고, 문제의 실제 영향과 재현 가능성에 맞춰 판단한다. + +## 프로젝트 관례 + +- 이 저장소의 기존 Spring Boot, JPA, QueryDSL, Bean Validation, 예외 처리, 테스트 패턴을 우선 따른다. +- 요청 범위를 벗어난 추상화, 확장 포인트, 방어 코드, unrelated cleanup을 제안하지 않는다. +- Java 코드에서 import로 해결할 수 있는 경우 FQCN(Full Qualified Class Name)을 사용하지 않도록 지적한다. +- 기존 스타일과 일치하지 않는 코드가 실제 유지보수 위험을 만들 때만 지적한다. 단순히 다른 스타일도 가능하다는 이유로 코멘트를 남기지 않는다. +- 도메인별 `AGENTS.md`가 있는 경로를 수정하는 PR에서는 해당 문서의 정책, 역할, 생명주기, 불변식과 구현이 어긋나지 않는지 확인한다. +- 변경 범위 밖의 오래된 죽은 코드, 이름 변경, 포맷 정리는 현재 PR의 버그나 회귀와 직접 연결될 때만 언급한다. + +## 주석 기준 + +- 조건이 2개 이상 결합된 비즈니스 규칙, 권한 조건, soft delete 제외, 중복 제거, fallback 우선순위, 대표값 선택, DTO 변환, count 쿼리 분리, fetch join 선택 이유처럼 코드만으로 의도가 숨겨지는 지점에는 주석을 권장한다. +- 주석은 코드가 무엇을 하는지보다 왜 그렇게 해야 하는지를 설명해야 한다. +- 단순 생성자 호출, 필드 매핑, 컬렉션 반환, 이름만으로 명확한 분기에는 주석을 요구하지 않는다. + +## 테스트 기준 + +- 버그 수정, 검증 로직, 권한 정책, 마이그레이션, API 응답 변경, 회귀 위험이 있는 변경은 실패를 재현하는 테스트 또는 정책을 고정하는 테스트가 있는지 확인한다. +- 리팩터링 PR에서는 새 기능 테스트보다 기존 동작이 유지되는지 확인한다. +- 테스트는 동작과 정책을 검증해야 한다. private 메서드 호출 여부, 내부 구현 순서, 과도한 mock 호출 횟수처럼 brittle한 구현 세부사항을 강제하지 않는다. +- 이미 컨트롤러/서비스/레포지토리 테스트 패턴이 있는 영역에서는 같은 계층의 기존 테스트 스타일을 따르도록 제안한다. +- mock 호출 횟수 검증은 해당 호출 자체가 외부 부작용, 이벤트 발행, 저장, 알림 전송 같은 비즈니스 결과일 때만 요구한다. +- 테스트가 성공 경로만 확인하고 실패/권한/경계값을 놓친 경우에는 어떤 입력이 빠졌는지 구체적으로 제안한다. + +## KONECT 특화 체크포인트 + +- 권한 로직은 관리자 우회, 요청자와 대상자 관계, 클럽/채팅방/공지/일정의 소속 검증이 빠지지 않았는지 확인한다. +- soft delete, 탈퇴 사용자, 차단/제외 조건, 중복 제거가 필요한 조회에서는 응답에 노출되면 안 되는 데이터가 포함되는지 확인한다. +- DTO 응답 변경은 기존 클라이언트가 기대하는 필드명, nullability, enum/string 값, 정렬 순서를 깨지 않는지 확인한다. +- JPA/QueryDSL 조회 변경은 N+1, 잘못된 fetch join, count 쿼리 왜곡, pagination 깨짐, distinct 누락을 확인한다. +- Flyway 마이그레이션은 파일명 버전 순서, 기존 운영 데이터 backfill, NOT NULL 추가 순서, 기본값 처리까지 확인한다. +- 운영 설정 변경은 prod/stage/local 프로파일 차이, Swagger/OpenAPI 노출, secret/env 누락 시 실패 방식을 확인한다. + +## 리뷰 코멘트 작성 방식 + +- 가장 작은 관련 라인 범위에 코멘트를 단다. +- 첫 문장에 문제와 영향을 바로 쓰고, 가능하면 `이 입력/상태에서 이런 잘못된 결과가 난다` 형태로 실패 모드를 설명한다. +- 수정 제안은 가장 작은 변경 단위로 제시한다. +- 문제가 없으면 구현을 다시 설명하는 코멘트를 남기지 않는다. +- 확신이 낮은 경우 단정하지 말고 어떤 전제가 맞을 때 문제가 되는지 명시한다. +- 리뷰어 제안이 기존 동작, 호환성, 사용자 결정, YAGNI 원칙과 충돌할 수 있으면 무조건 수정하라고 하지 말고 확인 질문이나 근거 확인으로 표현한다. diff --git a/.github/workflows/deploy-monitoring.yml b/.github/workflows/deploy-monitoring.yml deleted file mode 100644 index 77d0d595b..000000000 --- a/.github/workflows/deploy-monitoring.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: Monitoring Deploy - -on: - push: - branches: [ main ] - paths: - - "monitoring/**" - - ".github/workflows/deploy-monitoring.yml" - workflow_dispatch: - -jobs: - deploy-monitoring: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Transfer monitoring configs to server - uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7 - with: - host: ${{ secrets.SERVER_IP }} - username: ${{ secrets.SERVER_USER }} - key: ${{ secrets.SERVER_SSH_KEY }} - port: ${{ secrets.SERVER_PORT }} - source: "monitoring" - target: ${{ secrets.PROD_WORK_DIR }} - rm: false - - - name: Deploy monitoring stack - uses: appleboy/ssh-action@v1.2.0 - env: - WORK_DIR: ${{ secrets.PROD_WORK_DIR }} - MONITORING_ENV: ${{ secrets.MONITORING_ENV }} - with: - host: ${{ secrets.SERVER_IP }} - username: ${{ secrets.SERVER_USER }} - key: ${{ secrets.SERVER_SSH_KEY }} - port: ${{ secrets.SERVER_PORT }} - envs: WORK_DIR,MONITORING_ENV - script: | - set -euo pipefail - : "${WORK_DIR:?}" - : "${MONITORING_ENV:?}" - cd "${WORK_DIR}/monitoring" - umask 077 - printf '%s' "${MONITORING_ENV}" > .env - chmod 600 .env - - docker volume inspect monitoring_prometheus-data >/dev/null 2>&1 || docker volume create monitoring_prometheus-data - docker volume inspect monitoring_grafana-data >/dev/null 2>&1 || docker volume create monitoring_grafana-data - docker volume inspect monitoring_loki-data >/dev/null 2>&1 || docker volume create monitoring_loki-data - docker volume inspect monitoring_promtail-positions >/dev/null 2>&1 || docker volume create monitoring_promtail-positions - - docker compose up -d - curl -fsS -X POST "http://127.0.0.1:${PROMETHEUS_PORT:-9090}/-/reload" || true - docker compose ps diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 97e74ea10..3cb97b6f6 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -5,6 +5,10 @@ on: branches: - main +permissions: + contents: read + id-token: write + jobs: deploy: runs-on: ubuntu-latest @@ -29,23 +33,6 @@ 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 @@ -85,38 +72,60 @@ jobs: 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@v1.2.0 + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@ff717079ee2060e4bcee96c4779b553acc87447c # v4 with: - host: ${{ secrets.DB_SERVER_IP }} - username: ${{ secrets.DB_SERVER_USER }} - key: ${{ secrets.DB_SERVER_SSH_KEY }} - port: ${{ secrets.DB_SERVER_PORT }} - script: | - set -euo pipefail - START_TIME=$(date +%s) - - WORK_DIR="/home/ubuntu/konect/prod-db-compose" - MYSQL_CONTAINER="mysql-prod" - - set -a - source "$WORK_DIR/.env" - set +a - - BACKUP_DIR="$WORK_DIR/db-backups/" - mkdir -p "$BACKUP_DIR" - - DUMP_FILE="$BACKUP_DIR/$(date '+%Y%m%d_%H%M%S').sql" - docker exec -e MYSQL_PWD="$MYSQL_PASSWORD" "$MYSQL_CONTAINER" \ - mysqldump --no-tablespaces -u"$MYSQL_USERNAME" "$MYSQL_DATABASE" > "$DUMP_FILE" - - find "$BACKUP_DIR" -type f -name '*.sql' -mtime +5 -delete - - END_TIME=$(date +%s) - echo "Prod MySQL backup completed in $((END_TIME - START_TIME))s" + role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Backup prod MySQL before deploy + env: + DB_INSTANCE_ID: ${{ secrets.DB_INSTANCE_ID }} + run: | + set -euo pipefail + + COMMAND_ID=$(aws ssm send-command \ + --instance-ids "$DB_INSTANCE_ID" \ + --document-name "AWS-RunShellScript" \ + --comment "KONECT prod MySQL backup before deploy" \ + --parameters commands='[ + "bash -lc '\''START_TIME=$(date +%s); bash /home/ubuntu/konect/prod-db-compose/backup-db.sh; END_TIME=$(date +%s); echo \"Prod MySQL backup completed in $((END_TIME - START_TIME))s\"'\''" + ]' \ + --query "Command.CommandId" \ + --output text) + + set +e + aws ssm wait command-executed \ + --command-id "$COMMAND_ID" \ + --instance-id "$DB_INSTANCE_ID" + WAITER_EXIT=$? + set -e + + BACKUP_STATUS=$(aws ssm get-command-invocation \ + --command-id "$COMMAND_ID" \ + --instance-id "$DB_INSTANCE_ID" \ + --query "Status" \ + --output text) + + BACKUP_STATUS_DETAILS=$(aws ssm get-command-invocation \ + --command-id "$COMMAND_ID" \ + --instance-id "$DB_INSTANCE_ID" \ + --query "StatusDetails" \ + --output text) + + BACKUP_RESPONSE_CODE=$(aws ssm get-command-invocation \ + --command-id "$COMMAND_ID" \ + --instance-id "$DB_INSTANCE_ID" \ + --query "ResponseCode" \ + --output text) + + echo "Prod MySQL backup status: $BACKUP_STATUS (details: $BACKUP_STATUS_DETAILS, response-code: $BACKUP_RESPONSE_CODE)" + + if [ "$WAITER_EXIT" -ne 0 ] || [ "$BACKUP_STATUS" != "Success" ] || [ "$BACKUP_RESPONSE_CODE" != "0" ]; then + echo "Prod MySQL backup failed. Remote stdout/stderr is intentionally not printed to avoid leaking sensitive information." + exit 1 + fi - name: Deploy to prod server uses: appleboy/ssh-action@v1.2.0 diff --git a/.github/workflows/deploy-stage.yml b/.github/workflows/deploy-stage.yml index 4cfacc032..fd99bd0c7 100644 --- a/.github/workflows/deploy-stage.yml +++ b/.github/workflows/deploy-stage.yml @@ -5,6 +5,10 @@ on: branches: - develop +permissions: + contents: read + id-token: write + jobs: deploy: runs-on: ubuntu-latest @@ -29,23 +33,6 @@ 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 @@ -85,38 +72,60 @@ jobs: 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@v1.2.0 + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@ff717079ee2060e4bcee96c4779b553acc87447c # v4 with: - host: ${{ secrets.DB_SERVER_IP }} - username: ${{ secrets.DB_SERVER_USER }} - key: ${{ secrets.DB_SERVER_SSH_KEY }} - port: ${{ secrets.DB_SERVER_PORT }} - script: | - set -euo pipefail - START_TIME=$(date +%s) - - WORK_DIR="/home/ubuntu/konect/stage-db-compose" - MYSQL_CONTAINER="mysql-stage" - - set -a - source "$WORK_DIR/.env" - set +a - - BACKUP_DIR="$WORK_DIR/db-backups/" - mkdir -p "$BACKUP_DIR" - - DUMP_FILE="$BACKUP_DIR/$(date '+%Y%m%d_%H%M%S').sql" - docker exec -e MYSQL_PWD="$MYSQL_PASSWORD" "$MYSQL_CONTAINER" \ - mysqldump --no-tablespaces -u"$MYSQL_USERNAME" "$MYSQL_DATABASE" > "$DUMP_FILE" - - 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" + role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Backup stage MySQL before deploy + env: + DB_INSTANCE_ID: ${{ secrets.DB_INSTANCE_ID }} + run: | + set -euo pipefail + + COMMAND_ID=$(aws ssm send-command \ + --instance-ids "$DB_INSTANCE_ID" \ + --document-name "AWS-RunShellScript" \ + --comment "KONECT stage MySQL backup before deploy" \ + --parameters commands='[ + "bash -lc '\''START_TIME=$(date +%s); bash /home/ubuntu/konect/stage-db-compose/backup-db.sh; END_TIME=$(date +%s); echo \"Stage MySQL backup completed in $((END_TIME - START_TIME))s\"'\''" + ]' \ + --query "Command.CommandId" \ + --output text) + + set +e + aws ssm wait command-executed \ + --command-id "$COMMAND_ID" \ + --instance-id "$DB_INSTANCE_ID" + WAITER_EXIT=$? + set -e + + BACKUP_STATUS=$(aws ssm get-command-invocation \ + --command-id "$COMMAND_ID" \ + --instance-id "$DB_INSTANCE_ID" \ + --query "Status" \ + --output text) + + BACKUP_STATUS_DETAILS=$(aws ssm get-command-invocation \ + --command-id "$COMMAND_ID" \ + --instance-id "$DB_INSTANCE_ID" \ + --query "StatusDetails" \ + --output text) + + BACKUP_RESPONSE_CODE=$(aws ssm get-command-invocation \ + --command-id "$COMMAND_ID" \ + --instance-id "$DB_INSTANCE_ID" \ + --query "ResponseCode" \ + --output text) + + echo "Stage MySQL backup status: $BACKUP_STATUS (details: $BACKUP_STATUS_DETAILS, response-code: $BACKUP_RESPONSE_CODE)" + + if [ "$WAITER_EXIT" -ne 0 ] || [ "$BACKUP_STATUS" != "Success" ] || [ "$BACKUP_RESPONSE_CODE" != "0" ]; then + echo "Stage MySQL backup failed. Remote stdout/stderr is intentionally not printed to avoid leaking sensitive information." + exit 1 + fi - name: Deploy to stage server uses: appleboy/ssh-action@v1.2.0 diff --git a/.gitignore b/.gitignore index 83f128d4e..fd394e626 100644 --- a/.gitignore +++ b/.gitignore @@ -41,7 +41,6 @@ out/ *adminsdk.json logs -/monitoring/.env .env* !.env.example @@ -52,3 +51,6 @@ mcp-bridge/.env **/google-service-account.json .omx/ + +skills-lock.json +/package-lock.json diff --git a/Dockerfile b/Dockerfile index a3f1c8d10..d2a5df041 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,6 @@ RUN addgroup -g 1000 -S konect \ && chmod 755 /app /app/logs COPY --chown=1000:1000 build/libs/KONECT_API.jar KONECT_API.jar -COPY --chown=1000:1000 opentelemetry-javaagent.jar opentelemetry-javaagent.jar USER 1000:1000 @@ -26,4 +25,4 @@ ENTRYPOINT ["java", \ "-Xlog:gc*:file=/app/logs/gc.log:time,uptime,level,tags:filecount=5,filesize=10m", \ "-XX:+HeapDumpOnOutOfMemoryError", \ "-XX:HeapDumpPath=/app/logs/heapdump.hprof", \ - "-jar", "KONECT_API.jar"] \ No newline at end of file + "-jar", "KONECT_API.jar"] diff --git a/build.gradle b/build.gradle index ed42fe26e..23415d2ba 100644 --- a/build.gradle +++ b/build.gradle @@ -65,8 +65,6 @@ dependencies { implementation platform('software.amazon.awssdk:bom:2.41.14') implementation 'software.amazon.awssdk:s3' - // monitoring - implementation 'io.micrometer:micrometer-registry-prometheus' // notification implementation 'com.google.firebase:firebase-admin:9.2.0' @@ -139,6 +137,9 @@ jacocoTestReport { // Exception "**/exception/*.class", "**/*Exception.class", + // 외부 API 클라이언트 (단위 테스트 어려운 클래스) + "**/infrastructure/claude/client/ClaudeClient.class", + "**/infrastructure/mcp/client/McpClient.class", // 기타 "**/Application.class", // Spring Boot 메인 클래스 ]) diff --git a/monitoring/.env.example b/monitoring/.env.example deleted file mode 100644 index 9a9434aa9..000000000 --- a/monitoring/.env.example +++ /dev/null @@ -1,8 +0,0 @@ -GRAFANA_ADMIN_USER=temp -GRAFANA_ADMIN_PASSWORD=temp - -PROMETHEUS_PORT=9090 - -GRAFANA_PORT=3000 - -LOKI_PORT=3100 diff --git a/monitoring/docker-compose.yml b/monitoring/docker-compose.yml deleted file mode 100644 index 44e3bbef5..000000000 --- a/monitoring/docker-compose.yml +++ /dev/null @@ -1,66 +0,0 @@ -services: - prometheus: - image: prom/prometheus:v3.5.0 - command: - - "--config.file=/etc/prometheus/prometheus.yml" - - "--web.enable-lifecycle" - ports: - - "127.0.0.1:${PROMETHEUS_PORT:-9090}:9090" - extra_hosts: - - "host.docker.internal:host-gateway" - volumes: - - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro - - ./targets:/etc/prometheus/targets:ro - - prometheus-data:/prometheus - restart: unless-stopped - - grafana: - image: grafana/grafana:12.3.1 - ports: - - "127.0.0.1:${GRAFANA_PORT:-3000}:3000" - env_file: - - .env - environment: - - GF_SECURITY_ADMIN_USER=${GRAFANA_ADMIN_USER:?} - - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:?} - volumes: - - grafana-data:/var/lib/grafana - restart: unless-stopped - depends_on: - - prometheus - - loki: - image: grafana/loki:3.1.0 - ports: - - "127.0.0.1:${LOKI_PORT:-3100}:3100" - command: -config.file=/etc/loki/config.yml - volumes: - - ./loki.yml:/etc/loki/config.yml:ro - - loki-data:/loki - restart: unless-stopped - - promtail: - image: grafana/promtail:3.1.0 - command: -config.file=/etc/promtail/config.yml - volumes: - - ./promtail.yml:/etc/promtail/config.yml:ro - - ../logs:/var/log/konect-backend:ro - - /var/log/nginx:/var/log/nginx:ro - - promtail-positions:/var/lib/promtail - restart: unless-stopped - depends_on: - - loki - -volumes: - prometheus-data: - external: true - name: monitoring_prometheus-data - grafana-data: - external: true - name: monitoring_grafana-data - loki-data: - external: true - name: monitoring_loki-data - promtail-positions: - external: true - name: monitoring_promtail-positions diff --git a/monitoring/loki.yml b/monitoring/loki.yml deleted file mode 100644 index b822f04be..000000000 --- a/monitoring/loki.yml +++ /dev/null @@ -1,25 +0,0 @@ -auth_enabled: false - -server: - http_listen_port: 3100 - -common: - path_prefix: /loki - storage: - filesystem: - chunks_directory: /loki/chunks - rules_directory: /loki/rules - replication_factor: 1 - ring: - kvstore: - store: inmemory - -schema_config: - configs: - - from: 2026-01-01 - store: tsdb - object_store: filesystem - schema: v13 - index: - prefix: index_ - period: 24h diff --git a/monitoring/prometheus.yml b/monitoring/prometheus.yml deleted file mode 100644 index 3b7ea667e..000000000 --- a/monitoring/prometheus.yml +++ /dev/null @@ -1,12 +0,0 @@ -global: - scrape_interval: 15s - evaluation_interval: 15s - -scrape_configs: - - job_name: "konect-backend" - metrics_path: "/actuator/prometheus" - scrape_interval: 10s - file_sd_configs: - - files: - - /etc/prometheus/targets/backend-active.json - refresh_interval: 5s diff --git a/monitoring/promtail.yml b/monitoring/promtail.yml deleted file mode 100644 index 0b7eb3a72..000000000 --- a/monitoring/promtail.yml +++ /dev/null @@ -1,25 +0,0 @@ -server: - http_listen_port: 9080 - grpc_listen_port: 0 - -positions: - filename: /var/lib/promtail/positions.yml - sync_period: 1s - -clients: - - url: http://loki:3100/loki/api/v1/push - -scrape_configs: - - job_name: konect-backend - static_configs: - - targets: [localhost] - labels: - job: konect-backend - __path__: /var/log/konect-backend/konect-backend*.log - - - job_name: nginx - static_configs: - - targets: [localhost] - labels: - job: nginx - __path__: /var/log/nginx/*.log diff --git a/monitoring/scripts/update-backend-active-target.sh b/monitoring/scripts/update-backend-active-target.sh deleted file mode 100755 index 1d243befd..000000000 --- a/monitoring/scripts/update-backend-active-target.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ACTIVE_PORT="${1:-}" - -if [[ -z "${ACTIVE_PORT}" ]]; then - echo "Usage: $0 " >&2 - exit 1 -fi - -if [[ "${ACTIVE_PORT}" != "8080" && "${ACTIVE_PORT}" != "8081" ]]; then - echo "active-port must be 8080 or 8081" >&2 - exit 1 -fi - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -TARGETS_FILE="${TARGETS_FILE:-${SCRIPT_DIR}/../targets/backend-active.json}" -TARGETS_DIR="$(dirname "${TARGETS_FILE}")" -PROMETHEUS_RELOAD_URL="${PROMETHEUS_RELOAD_URL:-http://127.0.0.1:${PROMETHEUS_PORT:-9090}/-/reload}" - -mkdir -p "${TARGETS_DIR}" - -TMP_FILE="$(mktemp)" -trap 'rm -f "${TMP_FILE}"' EXIT - -cat > "${TMP_FILE}" </dev/null; then - echo "warning: failed to reload Prometheus (${PROMETHEUS_RELOAD_URL})" >&2 -fi - -echo "Updated backend metrics target to host.docker.internal:${ACTIVE_PORT}" diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 0768fb21e..000000000 --- a/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "KONECT_BACK_END", - "lockfileVersion": 3, - "requires": true, - "packages": {} -} diff --git a/skills-lock.json b/skills-lock.json deleted file mode 100644 index ec5db745d..000000000 --- a/skills-lock.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "version": 1, - "skills": { - "gh-address-comments": { - "source": "smithery.ai", - "sourceType": "well-known", - "computedHash": "39acf09da5896afde2b61b22f4354a72dbed6633da9e8a578eacec4899b0ecfb" - } - } -} diff --git a/src/main/java/gg/agit/konect/domain/chat/AGENTS.md b/src/main/java/gg/agit/konect/domain/chat/AGENTS.md new file mode 100644 index 000000000..a6e10ebb9 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/AGENTS.md @@ -0,0 +1,270 @@ +# 채팅 도메인 가이드 + +## 이 도메인은 무엇을 하는가 + +채팅 도메인은 사용자가 채팅방에서 메시지를 주고받고, 읽음 상태를 관리하고, 채팅방 목록에서 현재 대화 상태를 확인하게 하는 도메인이다. + +이 도메인에서 중요한 것은 메시지 저장 자체보다 아래 상태가 서로 같은 정책을 바라보는 것이다. + +- 메시지 자체 +- 사용자별 `lastReadAt` +- 채팅방 목록에 보이는 마지막 메시지와 마지막 전송 시각 +- 사용자별 `unreadCount` +- 멤버십 상태와 메시지 가시 범위 + +채팅 관련 작업을 할 때는 항상 "이 변경이 목록 요약, 읽음 상태, 멤버십 가시성까지 같이 맞는가"를 먼저 확인해야 한다. + +## 채팅방 타입 + +### `DIRECT` + +- 1:1 채팅방이다. +- 멤버가 나가도 레코드를 바로 삭제하지 않는다. +- `leftAt`, `visibleMessageFrom`, `lastReadAt`로 사용자의 현재 상태를 관리한다. +- 나간 뒤에는 과거 메시지가 그대로 다시 보이는 것이 아니라, 현재 사용자에게 다시 보여야 하는 메시지 범위가 따로 관리된다. +- 새 메시지가 오거나 사용자가 다시 대화를 열면 방이 다시 보이는 상태로 복원될 수 있다. +- 일반 direct와 `SYSTEM_ADMIN` 문의방은 같은 `DIRECT` 타입이지만 멤버십/읽음 처리 정책이 다를 수 있다. + +### `GROUP` + +- 일반 그룹 채팅방이다. +- direct처럼 `나감 -> 복원` 상태를 유지하기보다 현재 참여 여부 중심으로 관리한다. +- 나가면 멤버 제거에 가깝게 동작한다. +- 강퇴는 group 계열에서만 허용된다. + +### `CLUB_GROUP` + +- 동아리 기반 그룹 채팅방이다. +- 사용자가 직접 나갈 수 없다. +- 접근 가능 여부는 채팅방 멤버 레코드만이 아니라 동아리 멤버십과 함께 본다. +- 동아리 멤버 변화에 따라 채팅방 접근 가능 여부와 멤버 보장이 함께 움직일 수 있다. + +### `SYSTEM_ADMIN` 문의방 + +- 구현상 `DIRECT` 채팅방이지만 일반 1:1 방과 똑같이 보면 안 된다. +- 방 재사용 기준은 `SYSTEM_ADMIN + 일반 사용자` 2인 구조다. +- 다른 admin 사용자들도 같은 문의방을 조회하고 메시지를 보낼 수 있지만, 멤버로 추가되지는 않는다. +- 일반 admin을 멤버로 추가하면 `findByTwoUsers` 재사용 규칙이 깨져 문의방이 중복 생성될 수 있다. +- unreadCount와 읽음 기준도 일반 direct와 달리 `SYSTEM_ADMIN` 기준으로 집계되는 흐름이 있다. + +## 기능이 실제로 어떻게 동작해야 하는가 + +### 채팅방 생성/재사용 + +- direct 채팅방은 같은 두 사용자 조합이면 기존 방을 재사용한다. +- admin이 일반 사용자와 direct 채팅을 시작하면 일반 admin과의 1:1 방이 아니라 `SYSTEM_ADMIN` 문의방 재사용 정책을 따른다. +- 다른 admin이 같은 일반 사용자와 문의를 이어도 새로운 방을 만들지 않고 같은 `SYSTEM_ADMIN` 문의방을 본다. +- group 채팅방 생성은 요청자 자신을 제외한 중복 없는 사용자 집합을 기준으로 한다. +- 자기 자신만으로는 direct/group 채팅방을 만들 수 없다. +- club group 채팅방은 동아리별로 하나를 보장하려고 하며, 동시 생성 상황에서는 중복 생성 대신 재조회한다. + +### 메시지 전송 + +- 메시지를 보내면 메시지 저장만 끝나면 안 된다. +- `chat_room.last_message_*` 메타데이터도 함께 최신 상태로 맞아야 한다. +- direct 채팅방에서는 상대방이 이미 나간 상태였다면, 새 메시지에 의해 다시 보여야 하는지까지 함께 처리한다. +- direct 채팅방에서 보낸 사람이 나간 상태였다면, 본인이 다시 대화를 시작한 것으로 보고 나간 상태를 해제한다. +- 보낸 사람의 `lastReadAt`도 새 메시지 시각에 맞춰 갱신된다. +- club group과 group 메시지는 전송 시 발신자의 읽음 기준을 함께 올린다. +- club group 메시지는 발신자가 현재 동아리 멤버여야 하며, 필요하면 채팅방 멤버십도 보장한다. +- group 메시지는 `leftAt`이 있는 사용자가 보낼 수 없다. +- 문의방에서 일반 admin이 메시지를 보내는 경우는 일반 direct와 달리 admin 멤버십을 추가하지 않고 특수 경로로 처리한다. +- 따라서 메시지 전송 로직 수정은 항상 마지막 메시지, 목록 요약, unreadCount, direct 방 복원 정책과 함께 본다. + +### 읽음 처리 + +- 읽음 처리는 별도 boolean이 아니라 `lastReadAt` 기준 시각 갱신이다. +- 더 최신 시각으로만 갱신되어야 한다. +- unreadCount는 `lastReadAt`만으로 계산되지 않는다. +- direct 채팅방에서는 `visibleMessageFrom` 이후에 실제로 보이는 메시지 범위와 함께 계산되어야 한다. +- 읽음 처리와 unreadCount가 서로 다른 메시지 집합을 바라보면 바로 회귀가 난다. +- 문의방에서 다른 admin이 메시지를 읽는 경우도 admin 본인 멤버가 아니라 `SYSTEM_ADMIN`의 `lastReadAt`을 갱신한다. +- 읽음 기준 갱신은 항상 더 최신 시각에만 반영되어야 하며, 이전 시각으로 되돌아가면 안 된다. + +### 채팅방 목록 요약 + +- 채팅방 목록은 단순 방 목록이 아니라 사용자에게 보이는 현재 대화 상태 요약이다. +- 마지막 메시지, 마지막 전송 시각, unreadCount, 방 이름, mute 여부가 함께 조합된다. +- direct 채팅방은 현재 사용자에게 보이지 않는 메시지를 마지막 메시지처럼 노출하면 안 된다. +- direct 채팅방은 나간 상태여도 새 메시지가 있으면 목록에 다시 보일 수 있다. +- direct와 group 계열은 목록 구성 방식이 다르므로 공통 처리로 단순화할 때 특히 조심해야 한다. +- direct 방 이름은 상대 사용자 이름이 기본값이지만, 사용자별 커스텀 방 이름이 우선 적용될 수 있다. +- club group 방 이름은 동아리 이름이 기본값이다. +- 일반 group 방 이름은 기본적으로 `그룹 채팅`이다. +- mute 여부는 채팅방 단위 설정으로 목록 응답에 합쳐진다. +- admin이 보는 문의방 목록은 일반 direct 목록과 별도 최적화 조회를 사용하며, 사용자가 실제로 응답한 방만 노출한다. +- direct 방 목록은 `chat_room.last_message_*`를 직접 쓰지만, club/group 목록은 최신 메시지 조회 결과를 조합한다. 둘을 같은 방식이라고 가정하면 안 된다. + +### 방 이름 변경과 뮤트 + +- 커스텀 방 이름은 사용자별 설정이다. +- 커스텀 방 이름은 기본 방 이름을 덮어쓰지만, 다른 사용자에게는 영향을 주지 않는다. +- 공백만 들어오면 커스텀 방 이름을 제거하고 기본 이름으로 되돌린다. +- 뮤트 설정도 사용자별 채팅방 설정이다. +- 뮤트 토글은 direct, group, club group 모두 현재 사용자가 접근 가능한 방에서만 가능하다. +- 문의방에서는 admin이 멤버가 아니어도 같은 `SYSTEM_ADMIN` 문의방에 접근 가능한 경우 뮤트 토글이 허용된다. + +### 멤버십 변경 + +- direct 채팅방에서 나가기는 멤버 삭제가 아니라 상태 변경이다. +- direct 채팅방에서 나가면 `leftAt`이 기록되고, `visibleMessageFrom`과 `lastReadAt`도 함께 조정된다. +- direct 채팅방은 상대방의 새 메시지로 다시 보이는 상태가 될 수 있다. +- direct 채팅방은 사용자가 직접 다시 대화를 열 때 새 대화처럼 다시 시작하는 정책이 있다. +- group 채팅방과 club group 채팅방은 direct처럼 복원 정책을 쓰지 않는다. +- club group은 임의로 나갈 수 없다. +- 강퇴는 group 계열에서만 허용되며, 요청자 권한과 대상 조건을 함께 본다. +- 그룹 채팅방 멤버 목록 조회는 `leftAt IS NULL`인 active 멤버만 대상으로 하며, 삭제된 사용자도 제외한다. +- 다른 admin은 `SYSTEM_ADMIN` 문의방의 멤버 목록을 조회할 수 있다. +- club 멤버가 추가되면 club group 채팅방 멤버도 보장될 수 있고, club 멤버에서 제거되면 채팅방 멤버도 제거될 수 있다. +- 멤버 추가와 방 생성은 동시성 상황에서 중복 생성이 나도 결과적으로 동일한 방/멤버십으로 수렴하도록 처리한다. + +### 메시지 조회와 접근 + +- direct 메시지 조회는 `visibleMessageFrom` 이후 메시지만 보여야 한다. +- direct 방을 조회할 때 사용자가 이미 나간 상태라도 새 메시지가 있어 다시 볼 수 있는 상태면 조회와 함께 복원될 수 있다. +- group 메시지 조회는 현재 active 멤버만 허용된다. +- club group 메시지 조회는 채팅 멤버 테이블만이 아니라 현재 동아리 멤버십 기준으로 접근을 보장한다. +- messageId로 특정 메시지 페이지를 계산할 때는 방 접근 권한과 메시지 가시 범위를 먼저 검증해야 한다. +- 권한 없음과 메시지 미존재를 구분해서 정보가 새지 않도록, 특정 조회 경로는 동일한 `NOT_FOUND_CHAT_ROOM`으로 처리한다. + +### 검색 + +- 채팅 검색은 `방 이름 검색`과 `메시지 내용 검색` 결과를 함께 반환한다. +- 현재 구현의 채팅 검색 대상은 direct와 club group 방이며, 일반 group 방은 검색 대상에 포함되지 않는다. +- 방 이름 검색은 현재 사용자에게 접근 가능한 방 집합만 대상으로 한다. +- 메시지 내용 검색은 각 방에서 키워드와 매칭되는 가장 최신 메시지만 대상으로 한다. +- direct 채팅방 메시지 검색은 `visibleMessageFrom` 이전 메시지를 결과에 포함하면 안 된다. +- 검색 결과의 방 이름도 커스텀 방 이름과 기본 방 이름 정책을 함께 반영해야 한다. + +### 초대 가능 사용자 조회 + +- 초대 가능 사용자는 현재 사용자가 같은 active 채팅방에서 함께 본 적 있는 사용자 집합을 기준으로 한다. +- 자기 자신은 제외한다. +- 나간 멤버는 제외한다. +- 삭제된 사용자는 제외한다. +- admin 사용자는 초대 후보에서 제외한다. +- 이름순 정렬과 동아리순 정렬은 다른 정책이다. +- 동아리순 정렬은 "현재 요청자와 실제로 공유하는 대표 동아리"를 기준으로 섹션을 만든다. +- 공유 동아리가 없는 사용자는 `기타` 섹션으로 떨어질 수 있다. + +## 절대 놓치면 안 되는 정책 + +- 마지막 메시지 메타데이터는 사용자에게 실제로 보이는 최신 메시지 기준과 어긋나면 안 된다. +- unreadCount는 `lastReadAt`만이 아니라 메시지 가시 범위와 함께 계산되어야 한다. +- direct 채팅방에서 `leftAt`, `visibleMessageFrom`, `lastReadAt`는 서로 분리된 독립 값처럼 다루면 안 된다. +- direct 채팅방의 나감, 복원, 재오픈 정책은 group 계열에 그대로 일반화하면 안 된다. +- club group 접근 가능 여부는 채팅 멤버 테이블만으로 판단하지 않는다. +- direct 채팅방에서 보이지 않는 메시지를 목록 요약이나 검색 결과의 기준으로 삼으면 안 된다. +- 문의방에서 일반 admin을 멤버로 추가하는 변경은 방 재사용 정책을 깨뜨릴 수 있으므로 금지에 가깝게 다뤄야 한다. +- 권한 없음과 메시지 미존재를 다른 에러로 노출하면 messageId 조회/검색 경로에서 정보 누출이 생길 수 있다. +- `chat_room.last_message_*`는 동시 전송 상황에서도 가장 최신 메시지만 반영되어야 한다. +- club group과 group의 멤버 삭제 정책을 direct의 `leftAt` 상태 관리로 바꾸면 안 된다. +- 검색이 모든 방 타입을 다루는 것으로 가정하면 안 된다. 현재 검색 정책은 목록 정책과 범위가 다를 수 있다. + +## 수정 시 함께 확인해야 하는 것 + +### 메시지 저장 로직을 바꿀 때 + +- 마지막 메시지 메타데이터 +- direct 방 복원 여부 +- 보낸 사람 읽음 기준 갱신 +- 목록 요약 정렬 +- 문의방 특수 처리 +- 알림/이벤트 후속 효과 + +### 읽음 처리 로직을 바꿀 때 + +- unreadCount 계산 +- direct 방 가시 범위와의 일관성 +- 목록 배지와 요약 값 +- 문의방의 `SYSTEM_ADMIN` 읽음 기준 처리 + +### direct 멤버십 정책을 바꿀 때 + +- `leftAt` +- `visibleMessageFrom` +- `lastReadAt` +- 방 재노출 조건 +- 과거 메시지 노출 범위 +- 검색/메시지 점프 가시성 +- 목록 재노출 조건 + +### group / club group 멤버십 정책을 바꿀 때 + +- 접근 권한 +- 멤버 제거와 강퇴 정책 +- 목록 노출 여부 +- club 멤버십 연동 + +### 목록/검색 쿼리를 바꿀 때 + +- 커스텀 방 이름 우선순위 +- mute 여부 합성 +- 검색 대상 방 타입 범위 +- direct 방의 가시 메시지 기준 +- messageId 페이지 계산 정렬 일관성 +- 문의방 목록 최적화 쿼리 조건 + +### 초대 가능 사용자 조회를 바꿀 때 + +- active 멤버만 포함되는지 +- admin/삭제 사용자 제외가 유지되는지 +- 동아리 섹션 기준이 실제 공유 동아리인지 +- `기타` 섹션 분류가 유지되는지 + +## 주요 클래스와 책임 + +### `ChatService` + +- 메시지 전송, 목록 요약, 접근 처리, 멤버십 변화가 만나는 중심 서비스다. +- 정책 변경 영향이 가장 넓게 퍼지는 진입점이다. +- 문의방 특수 처리, 검색, 메시지 점프, 방 이름/mute 조합도 여기서 다룬다. + +### `ChatRoom` + +- 방 타입과 마지막 메시지 메타데이터를 가진다. +- 목록 요약 기준과 직접 연결된다. + +### `ChatRoomMember` + +- 사용자별 `lastReadAt`, `visibleMessageFrom`, `leftAt`, 커스텀 방 이름, 소유자 여부를 가진다. +- direct 채팅방의 가시성 정책 핵심 상태가 여기에 있다. + +### `ChatMessage` + +- 실제 메시지 데이터다. +- 목록 요약과 unreadCount 계산의 기준 데이터다. + +### Repository 계층 + +- 최신 메시지 조회, unreadCount 계산, 목록 조회 최적화 쿼리가 들어 있다. +- 조회 쿼리를 바꾸면 정책이 깨지기 쉬우므로 결과 의미를 먼저 확인해야 한다. + +### `ChatRoomMembershipService` + +- club group 멤버 보장, direct/group 멤버십 업데이트, 문의방 읽음 예외 처리를 맡는다. +- 동시 생성/중복 멤버십을 흡수하는 방어 로직이 있다. + +## 이 문서로 먼저 이해해야 하는 것 + +채팅 도메인 작업을 시작할 때는 아래 질문에 답할 수 있어야 한다. + +- 이 변경이 direct 채팅방의 `나감 -> 다시 보임 -> 새 대화 시작` 정책을 깨뜨리지 않는가 +- 이 변경이 마지막 메시지와 unreadCount를 서로 다른 기준으로 계산하게 만들지 않는가 +- 이 변경이 사용자에게 보이지 않는 메시지를 목록에 노출하게 만들지 않는가 +- 이 변경이 group 정책을 direct에, 또는 direct 정책을 group에 잘못 일반화하지 않는가 +- 이 변경이 문의방의 `SYSTEM_ADMIN + 일반 사용자` 재사용 규칙을 깨뜨리지 않는가 +- 이 변경이 messageId 조회나 검색에서 보이면 안 되는 메시지 존재를 노출하지 않는가 +- 이 변경이 커스텀 방 이름, mute, 섹션 정렬 같은 목록 부가 정책을 누락시키지 않는가 + +이 질문에 바로 답할 수 없으면 코드를 먼저 고치지 말고, 관련 정책과 상태를 다시 확인해야 한다. + +## 이번 문서의 범위 밖 + +아래 항목은 이 문서의 상세 구현 설명에서는 다루지 않는다. + +- Presence 기록 +- 알림 전송 구현 세부 +- AdminChatReceivedEvent 후속 소비 흐름 + +이 항목을 수정할 때도 위 핵심 정책과의 연결 여부를 먼저 확인해야 한다. 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 b42c87be9..d83a897af 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 @@ -16,6 +16,8 @@ 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.ChatRoomMembersInviteRequest; +import gg.agit.konect.domain.chat.dto.ChatRoomMembersResponse; import gg.agit.konect.domain.chat.dto.ChatRoomNameUpdateRequest; import gg.agit.konect.domain.chat.dto.ChatRoomResponse; import gg.agit.konect.domain.chat.dto.ChatRoomsSummaryResponse; @@ -23,6 +25,7 @@ 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.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import jakarta.validation.constraints.Max; @@ -136,6 +139,7 @@ ResponseEntity getInvitableUsers( - 채팅방에 진입하면 읽지 않은 메시지를 자동으로 읽음 처리합니다. - 최신 메시지가 먼저 오도록 정렬됩니다 (DESC). - isMine 필드로 내가 보낸 메시지인지 구분할 수 있습니다. + - SYSTEM_ADMIN 문의방을 admin이 조회하는 경우 admin 발신 메시지는 모두 isMine=true로 반환됩니다. - 채팅방 참여자만 메시지를 조회할 수 있습니다. - 일반 유저는 자신이 참여한 채팅방만 조회할 수 있습니다. - 어드민은 모든 어드민 채팅방을 조회할 수 있습니다. @@ -250,6 +254,28 @@ ResponseEntity kickMember( @UserId Integer userId ); + @Operation(summary = "그룹 채팅방에 멤버를 초대한다.", description = """ + ## 설명 + - 일반 그룹 채팅방의 기존 멤버가 여러 유저를 추가 초대합니다. + + ## 로직 + - 방장 여부와 관계없이 현재 참여 중인 멤버라면 초대할 수 있습니다. + - 1:1 채팅방과 동아리 채팅방에는 초대할 수 없습니다. + - 이미 참여 중인 멤버, 요청자 자신, 중복 userId는 무시합니다. + + ## 에러 + - NOT_FOUND_CHAT_ROOM (404): 채팅방을 찾을 수 없습니다. + - FORBIDDEN_CHAT_ROOM_ACCESS (403): 채팅방에 접근할 권한이 없습니다. + - CANNOT_INVITE_IN_NON_GROUP_ROOM (400): 일반 그룹 채팅방에서만 초대할 수 있습니다. + - NOT_FOUND_USER (404): 유저를 찾을 수 없습니다. + """) + @PostMapping("/rooms/{chatRoomId}/members") + ResponseEntity inviteMembers( + @PathVariable(value = "chatRoomId") Integer chatRoomId, + @Valid @RequestBody ChatRoomMembersInviteRequest request, + @UserId Integer userId + ); + @Operation(summary = "그룹 채팅방을 생성한다.", description = """ ## 설명 - 여러 유저를 초대하여 그룹 채팅방을 생성합니다. @@ -267,4 +293,23 @@ ResponseEntity createGroupChatRoom( @Valid @RequestBody ChatRoomCreateRequest.Group request, @UserId Integer userId ); + + @Operation(summary = "채팅방 멤버 목록 조회", description = """ + ## 설명 + - 특정 채팅방의 모든 멤버 목록을 조회합니다. + + ## 로직 + - 채팅방에 참여 중인 멤버만 조회할 수 있습니다. + - 나간 멤버(leftAt이 설정된 멤버)는 목록에 포함되지 않습니다. + - 각 멤버의 userId, 이름, 프로필 이미지, 방장 여부, 참여 시간을 반환합니다. + + ## 에러 + - FORBIDDEN_CHAT_ROOM_ACCESS (403): 채팅방에 접근할 권한이 없습니다. + - NOT_FOUND_CHAT_ROOM (404): 채팅방을 찾을 수 없습니다. + """) + @GetMapping("/rooms/{chatRoomId}/members") + ResponseEntity getChatRoomMembers( + @Parameter(description = "채팅방 ID") @PathVariable Integer chatRoomId, + @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 844a25f53..e42244d25 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 @@ -2,6 +2,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -14,11 +15,14 @@ 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.ChatRoomMembersInviteRequest; +import gg.agit.konect.domain.chat.dto.ChatRoomMembersResponse; 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.ChatRoomMembershipService; import gg.agit.konect.domain.chat.service.ChatService; import gg.agit.konect.global.auth.annotation.UserId; import jakarta.validation.Valid; @@ -31,6 +35,7 @@ public class ChatController implements ChatApi { private final ChatService chatService; + private final ChatRoomMembershipService chatRoomMembershipService; @Override public ResponseEntity createOrGetChatRoom( @@ -139,6 +144,16 @@ public ResponseEntity kickMember( return ResponseEntity.noContent().build(); } + @Override + public ResponseEntity inviteMembers( + @PathVariable(value = "chatRoomId") Integer chatRoomId, + @Valid @RequestBody ChatRoomMembersInviteRequest request, + @UserId Integer userId + ) { + chatService.inviteMembers(userId, chatRoomId, request); + return ResponseEntity.noContent().build(); + } + @Override public ResponseEntity createGroupChatRoom( @Valid @RequestBody ChatRoomCreateRequest.Group request, @@ -147,4 +162,13 @@ public ResponseEntity createGroupChatRoom( ChatRoomResponse response = chatService.createGroupChatRoom(userId, request); return ResponseEntity.ok(response); } + + @Override + @GetMapping("/rooms/{chatRoomId}/members") + public ResponseEntity getChatRoomMembers( + @PathVariable Integer chatRoomId, + @UserId Integer userId) { + ChatRoomMembersResponse response = chatRoomMembershipService.getChatRoomMembers(chatRoomId, userId); + 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 index a277a19a6..96e871757 100644 --- a/src/main/java/gg/agit/konect/domain/chat/dto/AdminChatRoomProjection.java +++ b/src/main/java/gg/agit/konect/domain/chat/dto/AdminChatRoomProjection.java @@ -4,7 +4,7 @@ /** * 관리자용 1:1 채팅방 목록 조회를 위한 Projection DTO - * 필드 순서와 타입이 JPQL SELECT 절과 정확히 일치해야 합니다. + * 필드 순서와 타입이 조회 쿼리의 constructor projection과 정확히 일치해야 합니다. */ public record AdminChatRoomProjection( Integer roomId, diff --git a/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageDetailResponse.java b/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageDetailResponse.java index aa5906633..9f2960667 100644 --- a/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageDetailResponse.java +++ b/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageDetailResponse.java @@ -32,7 +32,11 @@ public record ChatMessageDetailResponse( @Schema(description = "미확인 인원 수(그룹채팅에서 제공)", example = "3", requiredMode = NOT_REQUIRED) Integer unreadCount, - @Schema(description = "내가 보낸 메시지 여부", example = "true", requiredMode = REQUIRED) + @Schema( + description = "내가 보낸 메시지 여부(SYSTEM_ADMIN 문의방을 admin이 조회할 때는 admin 발신 메시지를 모두 true로 반환)", + example = "true", + requiredMode = REQUIRED + ) Boolean isMine ) { } 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 index 1c68031c9..d2161ed92 100644 --- a/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchResult.java +++ b/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchResult.java @@ -32,7 +32,13 @@ public record ChatMessageMatchResult( LocalDateTime matchedMessageSentAt, @Schema(description = "검색에 매칭된 메시지 ID", example = "42", requiredMode = REQUIRED) - Integer matchedMessageId + Integer matchedMessageId, + + @Schema(description = "읽지 않은 메시지 수", example = "3", requiredMode = REQUIRED) + Integer unreadCount, + + @Schema(description = "채팅방 알림 뮤트 여부", example = "false", requiredMode = REQUIRED) + Boolean isMuted ) { public static ChatMessageMatchResult from(ChatRoomSummaryResponse room, ChatMessage message) { @@ -43,7 +49,9 @@ public static ChatMessageMatchResult from(ChatRoomSummaryResponse room, ChatMess room.roomImageUrl(), message.getContent(), message.getCreatedAt(), - message.getId() + message.getId(), + room.unreadCount(), + room.isMuted() ); } } diff --git a/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomMemberResponse.java b/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomMemberResponse.java new file mode 100644 index 000000000..7a1fc1309 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomMemberResponse.java @@ -0,0 +1,12 @@ +package gg.agit.konect.domain.chat.dto; + +import java.time.LocalDateTime; + +public record ChatRoomMemberResponse( + Integer userId, + String name, + String profileImageUrl, + boolean isOwner, + LocalDateTime joinedAt +) { +} diff --git a/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomMembersInviteRequest.java b/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomMembersInviteRequest.java new file mode 100644 index 000000000..5bbb1a04d --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomMembersInviteRequest.java @@ -0,0 +1,16 @@ +package gg.agit.konect.domain.chat.dto; + +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 ChatRoomMembersInviteRequest( + @NotEmpty(message = "초대할 유저 ID 목록은 필수입니다.") + @Schema(description = "초대할 유저 ID 목록", example = "[10, 11, 12]", requiredMode = REQUIRED) + List<@NotNull Integer> userIds +) { +} diff --git a/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomMembersResponse.java b/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomMembersResponse.java new file mode 100644 index 000000000..3534d197c --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomMembersResponse.java @@ -0,0 +1,8 @@ +package gg.agit.konect.domain.chat.dto; + +import java.util.List; + +public record ChatRoomMembersResponse( + List members +) { +} diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageQueryRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageQueryRepository.java new file mode 100644 index 000000000..41f898fbb --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageQueryRepository.java @@ -0,0 +1,65 @@ +package gg.agit.konect.domain.chat.repository; + +import static gg.agit.konect.domain.chat.model.QChatMessage.chatMessage; +import static gg.agit.konect.domain.chat.model.QChatRoom.chatRoom; + +import java.util.List; +import java.util.Objects; + +import org.springframework.stereotype.Repository; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import gg.agit.konect.domain.chat.model.ChatMessage; +import gg.agit.konect.domain.chat.model.QChatMessage; +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class ChatMessageQueryRepository { + + private final JPAQueryFactory jpaQueryFactory; + + public List searchLatestMatchingMessagesByChatRoomIds( + List roomIds, + String keyword + ) { + Objects.requireNonNull(keyword, "keyword must not be null"); + + if (roomIds.isEmpty()) { + return List.of(); + } + + QChatMessage innerMessage = new QChatMessage("innerMessage"); + + return jpaQueryFactory + .selectFrom(chatMessage) + .join(chatMessage.chatRoom, chatRoom).fetchJoin() + .where( + chatRoom.id.in(roomIds), + containsKeyword(chatMessage, keyword), + chatMessage.id.eq( + JPAExpressions + .select(innerMessage.id.max()) + .from(innerMessage) + .where( + innerMessage.chatRoom.id.eq(chatRoom.id), + containsKeyword(innerMessage, keyword) + ) + ) + ) + .orderBy(chatMessage.createdAt.desc(), chatMessage.id.desc()) + .fetch(); + } + + private BooleanExpression containsKeyword(QChatMessage message, String keyword) { + return Expressions.booleanTemplate( + "LOCATE(LOWER({0}), LOWER({1})) > 0", + keyword, + message.content + ); + } +} 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 921513d14..d06ddb8ce 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 @@ -109,40 +109,6 @@ List findRoomIdsWithUserReplyByRoomIds( @Param("adminRole") UserRole adminRole ); - @Query(""" - SELECT m - FROM ChatMessage m - JOIN FETCH m.sender - WHERE m.id IN ( - SELECT MAX(m2.id) - FROM ChatMessage m2 - WHERE m2.chatRoom.id IN :roomIds - GROUP BY m2.chatRoom.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 cm FROM ChatMessage cm JOIN FETCH cm.chatRoom WHERE cm.id = :messageId") Optional findByIdWithChatRoom(@Param("messageId") Integer messageId); 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 1b7252648..86a64ba62 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 @@ -120,6 +120,39 @@ List countUnreadByRoomIdsAndUserId( @Param("userId") Integer userId ); + @Query(""" + SELECT COUNT(crm) > 0 + FROM ChatRoomMember crm + WHERE crm.id.chatRoomId = :chatRoomId + AND crm.id.userId = :userId + AND crm.leftAt IS NULL + """) + boolean existsActiveByChatRoomIdAndUserId( + @Param("chatRoomId") Integer chatRoomId, + @Param("userId") Integer userId + ); + + @Query(""" + SELECT crm.id.userId + FROM ChatRoomMember crm + WHERE crm.id.chatRoomId = :chatRoomId + AND crm.id.userId IN :userIds + AND crm.leftAt IS NULL + """) + List findActiveUserIdsByChatRoomIdAndUserIdIn( + @Param("chatRoomId") Integer chatRoomId, + @Param("userIds") List userIds + ); + + @Query(""" + SELECT crm + FROM ChatRoomMember crm + JOIN FETCH crm.user + WHERE crm.id.chatRoomId = :chatRoomId + AND crm.leftAt IS NULL + """) + List findActiveMembersByChatRoomId(@Param("chatRoomId") Integer chatRoomId); + List saveAll(Iterable chatRoomMembers); } diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomQueryRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomQueryRepository.java new file mode 100644 index 000000000..36e3e6d73 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomQueryRepository.java @@ -0,0 +1,98 @@ +package gg.agit.konect.domain.chat.repository; + +import static gg.agit.konect.domain.chat.model.QChatMessage.chatMessage; +import static gg.agit.konect.domain.chat.model.QChatRoom.chatRoom; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import gg.agit.konect.domain.chat.dto.AdminChatRoomProjection; +import gg.agit.konect.domain.chat.enums.ChatType; +import gg.agit.konect.domain.chat.model.QChatMessage; +import gg.agit.konect.domain.chat.model.QChatRoomMember; +import gg.agit.konect.domain.user.enums.UserRole; +import gg.agit.konect.domain.user.model.QUser; +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class ChatRoomQueryRepository { + + private final JPAQueryFactory jpaQueryFactory; + + public List findAdminChatRoomsOptimized( + Integer systemAdminId, + Integer viewerAdminId, + UserRole adminRole, + ChatType roomType + ) { + QChatRoomMember roomMember = new QChatRoomMember("roomMember"); + QChatRoomMember systemAdminMember = new QChatRoomMember("systemAdminMember"); + QChatRoomMember viewerAdminMember = new QChatRoomMember("viewerAdminMember"); + QUser nonAdminUser = new QUser("nonAdminUser"); + QChatMessage userReply = new QChatMessage("userReply"); + QUser userReplySender = new QUser("userReplySender"); + + return jpaQueryFactory + .select(Projections.constructor( + AdminChatRoomProjection.class, + chatRoom.id, + chatRoom.lastMessageContent, + chatRoom.lastMessageSentAt, + chatRoom.createdAt, + nonAdminUser.id, + nonAdminUser.name, + nonAdminUser.imageUrl, + chatMessage.count() + )) + .from(chatRoom) + .join(roomMember).on(roomMember.id.chatRoomId.eq(chatRoom.id)) + .join(roomMember.user, nonAdminUser) + .join(systemAdminMember).on( + systemAdminMember.id.chatRoomId.eq(chatRoom.id), + systemAdminMember.id.userId.eq(systemAdminId) + ) + .leftJoin(viewerAdminMember).on( + viewerAdminMember.id.chatRoomId.eq(chatRoom.id), + viewerAdminMember.id.userId.eq(viewerAdminId) + ) + .leftJoin(chatMessage).on( + chatMessage.chatRoom.id.eq(chatRoom.id), + chatMessage.sender.id.ne(systemAdminId), + chatMessage.createdAt.gt(systemAdminMember.lastReadAt) + ) + .where( + chatRoom.roomType.eq(roomType), + nonAdminUser.role.ne(adminRole), + nonAdminUser.deletedAt.isNull(), + // 관리자는 문의방 멤버가 아니거나 나갔어도 새 메시지가 있으면 목록에서 다시 볼 수 있다. + viewerAdminMember.leftAt.isNull() + .or(chatRoom.lastMessageSentAt.gt(viewerAdminMember.visibleMessageFrom)), + JPAExpressions + .selectOne() + .from(userReply) + .join(userReply.sender, userReplySender) + .where( + userReply.chatRoom.id.eq(chatRoom.id), + userReplySender.role.ne(adminRole) + ) + .exists() + ) + .groupBy( + chatRoom.id, + chatRoom.lastMessageContent, + chatRoom.lastMessageSentAt, + chatRoom.createdAt, + nonAdminUser.id, + nonAdminUser.name, + nonAdminUser.imageUrl + ) + .orderBy(chatRoom.lastMessageSentAt.coalesce(chatRoom.createdAt).desc()) + .fetch(); + } +} 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 a3a064f5b..078ad36a3 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 @@ -2,14 +2,15 @@ import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CHAT_ROOM; +import java.time.LocalDateTime; 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.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; @@ -19,6 +20,30 @@ public interface ChatRoomRepository extends Repository { ChatRoom save(ChatRoom chatRoom); + @Modifying(flushAutomatically = true) + @Query(""" + UPDATE ChatRoom cr + SET cr.lastMessageContent = :content, + cr.lastMessageSentAt = :sentAt + WHERE cr.id = :roomId + AND NOT EXISTS ( + SELECT 1 + FROM ChatMessage cm + WHERE cm.chatRoom.id = :roomId + AND cm.id <> :messageId + AND ( + cm.createdAt > :sentAt + OR (cm.createdAt = :sentAt AND cm.id > :messageId) + ) + ) + """) + int updateLastMessageIfLatest( + @Param("roomId") Integer roomId, + @Param("messageId") Integer messageId, + @Param("content") String content, + @Param("sentAt") LocalDateTime sentAt + ); + @Query(""" SELECT DISTINCT cr FROM ChatRoom cr @@ -145,61 +170,4 @@ List findAllSystemAdminDirectRooms( @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 systemAdminCrm ON systemAdminCrm.id.chatRoomId = cr.id - AND systemAdminCrm.id.userId = :systemAdminId - LEFT JOIN ChatRoomMember viewerAdminCrm ON viewerAdminCrm.id.chatRoomId = cr.id - AND viewerAdminCrm.id.userId = :viewerAdminId - LEFT JOIN ChatMessage cm ON cm.chatRoom.id = cr.id - AND cm.sender.id <> :systemAdminId - AND cm.createdAt > systemAdminCrm.lastReadAt - WHERE cr.roomType = :roomType - AND u.role != :adminRole - AND ( - viewerAdminCrm.leftAt IS NULL - OR viewerAdminCrm.id.userId IS NULL - OR ( - viewerAdminCrm.leftAt IS NOT NULL - AND cr.lastMessageSentAt > viewerAdminCrm.visibleMessageFrom - ) - ) - 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("viewerAdminId") Integer viewerAdminId, - @Param("adminRole") UserRole adminRole, - @Param("roomType") ChatType roomType - ); } diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatDirectRoomAccessService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatDirectRoomAccessService.java new file mode 100644 index 000000000..cfc663571 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatDirectRoomAccessService.java @@ -0,0 +1,57 @@ +package gg.agit.konect.domain.chat.service; + +import static gg.agit.konect.global.code.ApiResponseCode.FORBIDDEN_CHAT_ROOM_ACCESS; + +import java.time.LocalDateTime; + +import org.springframework.stereotype.Service; +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.user.model.User; +import gg.agit.konect.global.exception.CustomException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class ChatDirectRoomAccessService { + + private final ChatRoomMemberRepository chatRoomMemberRepository; + + public ChatRoomMember getAccessibleMember(ChatRoom chatRoom, User user) { + ChatRoomMember member = getMember(chatRoom, user); + restoreIfVisible(member, chatRoom); + return member; + } + + public LocalDateTime prepareAccessAndGetVisibleMessageFrom(ChatRoom chatRoom, User user) { + ChatRoomMember member = getMember(chatRoom, user); + LocalDateTime visibleMessageFrom = member.getVisibleMessageFrom(); + restoreIfVisible(member, chatRoom); + return visibleMessageFrom; + } + + private ChatRoomMember getMember(ChatRoom chatRoom, User user) { + return chatRoomMemberRepository.findByChatRoomIdAndUserId(chatRoom.getId(), user.getId()) + .orElseThrow(() -> CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS)); + } + + /** + * direct 채팅방에서 나간 사용자가 다시 볼 수 있는 상태인지 확인하고, + * 새 메시지가 이미 존재하면 나간 상태를 해제한다. + */ + private void restoreIfVisible(ChatRoomMember member, ChatRoom chatRoom) { + if (!member.hasLeft()) { + return; + } + + if (!member.hasVisibleMessages(chatRoom)) { + throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); + } + + member.restoreDirectRoom(); + } +} diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatInviteService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatInviteService.java new file mode 100644 index 000000000..199fc5f42 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatInviteService.java @@ -0,0 +1,137 @@ +package gg.agit.konect.domain.chat.service; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +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.Transactional; + +import gg.agit.konect.domain.chat.dto.ChatInvitableUsersResponse; +import gg.agit.konect.domain.chat.enums.ChatInviteSortBy; +import gg.agit.konect.domain.chat.repository.ChatInviteQueryRepository; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ChatInviteService { + + private static final String ETC_SECTION_NAME = "기타"; + + private final ChatInviteQueryRepository chatInviteQueryRepository; + private final UserRepository userRepository; + + 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); + } +} diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatMessagePageResolver.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatMessagePageResolver.java new file mode 100644 index 000000000..3acd7187c --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatMessagePageResolver.java @@ -0,0 +1,120 @@ +package gg.agit.konect.domain.chat.service; + +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CHAT_ROOM; +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CLUB_MEMBER; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +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.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.global.exception.CustomException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ChatMessagePageResolver { + + private final ChatMessageRepository chatMessageRepository; + private final ChatRoomMemberRepository chatRoomMemberRepository; + private final ClubMemberRepository clubMemberRepository; + private final ChatRoomSystemAdminService chatRoomSystemAdminService; + + public int resolvePageForMessage( + Integer roomId, + Integer messageId, + ChatRoom room, + User user, + int limit + ) { + AccessContext accessContext = ensureMessageLookupAccess(room, user); + + ChatMessage targetMessage = chatMessageRepository.findByIdWithChatRoom(messageId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + + if (!targetMessage.getChatRoom().getId().equals(roomId)) { + throw CustomException.of(NOT_FOUND_CHAT_ROOM); + } + + LocalDateTime visibleMessageFrom = resolveVisibleMessageFrom(room, user, accessContext); + if (visibleMessageFrom != null && !targetMessage.getCreatedAt().isAfter(visibleMessageFrom)) { + throw CustomException.of(NOT_FOUND_CHAT_ROOM); + } + + long newerCount = chatMessageRepository.countNewerMessagesByChatRoomId( + roomId, messageId, targetMessage.getCreatedAt(), visibleMessageFrom + ); + return (int)(newerCount / limit) + 1; + } + + /** + * messageId 조회 전 방 접근 권한을 검증한다. + * 권한 없음과 메시지 미존재를 구분할 수 없게 NOT_FOUND_CHAT_ROOM으로 통일한다. + */ + private AccessContext ensureMessageLookupAccess(ChatRoom room, User user) { + if (room.isDirectRoom()) { + Optional member = chatRoomMemberRepository + .findByChatRoomIdAndUserId(room.getId(), user.getId()); + if (member.isPresent()) { + return new AccessContext(member, false); + } + + boolean isAdminViewingSystemRoom = user.isAdmin() + && chatRoomSystemAdminService.isSystemAdminRoom(room.getId()); + if (!isAdminViewingSystemRoom) { + throw CustomException.of(NOT_FOUND_CHAT_ROOM); + } + return new AccessContext(Optional.empty(), true); + } + + if (room.isClubGroupRoom()) { + try { + clubMemberRepository.getByClubIdAndUserId(room.getClub().getId(), user.getId()); + } catch (CustomException e) { + if (e.getErrorCode() == NOT_FOUND_CLUB_MEMBER) { + throw CustomException.of(NOT_FOUND_CHAT_ROOM); + } + throw e; + } + return AccessContext.none(); + } + + ChatRoomMember member = chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId()) + .filter(roomMember -> !roomMember.hasLeft()) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + return new AccessContext(Optional.of(member), false); + } + + private LocalDateTime resolveVisibleMessageFrom(ChatRoom room, User user, AccessContext accessContext) { + if (!room.isDirectRoom()) { + return null; + } + + if (user.isAdmin() && accessContext.isAdminViewingSystemRoom()) { + List members = chatRoomMemberRepository.findByChatRoomId(room.getId()); + ChatRoomMember systemAdminMember = chatRoomSystemAdminService.findSystemAdminMember(members); + return systemAdminMember != null ? systemAdminMember.getVisibleMessageFrom() : null; + } + + return accessContext.member() + .map(ChatRoomMember::getVisibleMessageFrom) + .orElse(null); + } + + private record AccessContext(Optional member, boolean isAdminViewingSystemRoom) { + + private static AccessContext none() { + return new AccessContext(Optional.empty(), false); + } + } +} diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatMessageReadService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatMessageReadService.java new file mode 100644 index 000000000..1a8d05299 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatMessageReadService.java @@ -0,0 +1,294 @@ +package gg.agit.konect.domain.chat.service; + +import static gg.agit.konect.domain.chat.service.ChatRoomMembershipService.SYSTEM_ADMIN_ID; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +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.chat.dto.ChatMessageDetailResponse; +import gg.agit.konect.domain.chat.dto.ChatMessagePageResponse; +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.user.model.User; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ChatMessageReadService { + + private final ChatMessageRepository chatMessageRepository; + private final ChatRoomMemberRepository chatRoomMemberRepository; + private final ChatRoomSystemAdminService chatRoomSystemAdminService; + private final ChatDirectRoomAccessService chatDirectRoomAccessService; + + @Transactional + public ChatMessagePageResponse getDirectChatRoomMessages( + User user, + ChatRoom chatRoom, + Integer page, + Integer limit, + LocalDateTime readAt + ) { + Integer roomId = chatRoom.getId(); + List members = chatRoomMemberRepository.findByChatRoomId(roomId); + LocalDateTime visibleMessageFrom = + chatDirectRoomAccessService.prepareAccessAndGetVisibleMessageFrom(chatRoom, user); + + List sortedReadBaselines = toSortedReadBaselines(members); + Integer maskedAdminId = getMaskedAdminId(user, members); + + return buildDirectChatRoomMessages(user, roomId, page, limit, readAt, + visibleMessageFrom, sortedReadBaselines, maskedAdminId, false); + } + + public ChatMessagePageResponse getAdminSystemDirectChatRoomMessages( + User user, + ChatRoom chatRoom, + Integer page, + Integer limit, + LocalDateTime readAt + ) { + Integer roomId = chatRoom.getId(); + List members = chatRoomMemberRepository.findByChatRoomId(roomId); + LocalDateTime visibleMessageFrom = resolveAdminSystemRoomVisibleMessageFrom(members); + + List sortedReadBaselines = toAdminChatReadBaselines(members); + Integer maskedAdminId = getMaskedAdminId(user, members); + + return buildDirectChatRoomMessages(user, roomId, page, limit, readAt, + visibleMessageFrom, sortedReadBaselines, maskedAdminId, true); + } + + public ChatMessagePageResponse getClubMessagesByRoom( + ChatRoom room, + Integer userId, + Integer page, + Integer limit + ) { + Integer roomId = room.getId(); + 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, + room.getClub().getId(), + responseMessages + ); + } + + public ChatMessagePageResponse getGroupMessagesByRoom( + Integer roomId, + Integer userId, + Integer page, + Integer limit + ) { + 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 ChatMessagePageResponse buildDirectChatRoomMessages( + User user, + Integer roomId, + Integer page, + Integer limit, + LocalDateTime readAt, + LocalDateTime visibleMessageFrom, + List sortedReadBaselines, + Integer maskedAdminId, + boolean isAdminViewingSystemRoom + ) { + PageRequest pageable = PageRequest.of(page - 1, limit); + Page messages = chatMessageRepository.findByChatRoomId(roomId, visibleMessageFrom, pageable); + + List responseMessages = messages.getContent().stream() + .map(message -> { + Integer senderId = maskedAdminId != null + ? resolveDirectSenderId(message, maskedAdminId) + : message.getSender().getId(); + boolean isMine = isOwnDirectMessage(user, message, isAdminViewingSystemRoom); + boolean isRead = isMine || !message.getCreatedAt().isAfter(readAt); + int unreadCount = countUnreadSince(message.getCreatedAt(), sortedReadBaselines); + return new ChatMessageDetailResponse( + message.getId(), + senderId, + null, + message.getContent(), + message.getCreatedAt(), + isRead, + unreadCount, + isMine + ); + }) + .toList(); + + return new ChatMessagePageResponse( + messages.getTotalElements(), + messages.getNumberOfElements(), + messages.getTotalPages(), + messages.getNumber() + 1, + null, + responseMessages + ); + } + + private boolean isOwnDirectMessage(User user, ChatMessage message, boolean isAdminViewingSystemRoom) { + if (isAdminViewingSystemRoom && user.isAdmin()) { + return message.getSender().isAdmin(); + } + return message.isSentBy(user.getId()); + } + + private List toSortedReadBaselines(List members) { + return members.stream() + .map(this::resolveUnreadBaseline) + .sorted() + .toList(); + } + + private List toAdminChatReadBaselines(List members) { + LocalDateTime adminLastReadAt = null; + LocalDateTime userLastReadAt = null; + + for (ChatRoomMember member : members) { + LocalDateTime unreadBaseline = resolveUnreadBaseline(member); + if (member.getUser().isAdmin()) { + if (adminLastReadAt == null || unreadBaseline.isAfter(adminLastReadAt)) { + adminLastReadAt = unreadBaseline; + } + } else { + userLastReadAt = unreadBaseline; + } + } + + List baselines = new ArrayList<>(); + if (adminLastReadAt != null) { + baselines.add(adminLastReadAt); + } + if (userLastReadAt != null) { + baselines.add(userLastReadAt); + } + baselines.sort(Comparator.naturalOrder()); + return baselines; + } + + private LocalDateTime resolveUnreadBaseline(ChatRoomMember member) { + LocalDateTime lastReadAt = member.getLastReadAt(); + LocalDateTime visibleMessageFrom = member.getVisibleMessageFrom(); + + // direct 방에서 다시 보이기 시작한 시각 이전 메시지는 unreadCount에도 포함하지 않는다. + if (visibleMessageFrom == null) { + return lastReadAt; + } + if (lastReadAt == null) { + return visibleMessageFrom; + } + return lastReadAt.isAfter(visibleMessageFrom) ? lastReadAt : visibleMessageFrom; + } + + private int countUnreadSince(LocalDateTime messageCreatedAt, List sortedReadBaselines) { + int left = 0; + int right = sortedReadBaselines.size(); + + while (left < right) { + int mid = (left + right) >>> 1; + LocalDateTime baseline = sortedReadBaselines.get(mid); + + if (baseline.isBefore(messageCreatedAt)) { + left = mid + 1; + } else { + right = mid; + } + } + + return left; + } + + private LocalDateTime resolveAdminSystemRoomVisibleMessageFrom(List members) { + ChatRoomMember systemAdminMember = chatRoomSystemAdminService.findSystemAdminMember(members); + return systemAdminMember != null ? systemAdminMember.getVisibleMessageFrom() : null; + } + + private Integer resolveDirectSenderId(ChatMessage message, Integer maskedAdminId) { + if (maskedAdminId != null && message.getSender().isAdmin()) { + return maskedAdminId; + } + return message.getSender().getId(); + } + + private Integer getMaskedAdminId(User user, List members) { + if (user.isAdmin()) { + return null; + } + + boolean hasSystemAdmin = members.stream() + .map(ChatRoomMember::getUserId) + .anyMatch(memberUserId -> memberUserId.equals(SYSTEM_ADMIN_ID)); + + return hasSystemAdmin ? SYSTEM_ADMIN_ID : null; + } +} diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatMessageSendService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatMessageSendService.java new file mode 100644 index 000000000..f44d13f49 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatMessageSendService.java @@ -0,0 +1,327 @@ +package gg.agit.konect.domain.chat.service; + +import static gg.agit.konect.domain.chat.service.ChatRoomMembershipService.SYSTEM_ADMIN_ID; +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.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import gg.agit.konect.domain.chat.dto.ChatMessageDetailResponse; +import gg.agit.konect.domain.chat.dto.ChatMessageSendRequest; +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.ChatMessageRepository; +import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; +import gg.agit.konect.domain.chat.repository.ChatRoomRepository; +import gg.agit.konect.domain.club.model.ClubMember; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.notification.service.NotificationService; +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; + +@Service +@RequiredArgsConstructor +@Transactional +public class ChatMessageSendService { + + private static final String DEFAULT_GROUP_ROOM_NAME = "그룹 채팅"; + + private final ChatRoomRepository chatRoomRepository; + private final ChatMessageRepository chatMessageRepository; + private final ChatRoomMemberRepository chatRoomMemberRepository; + private final ClubMemberRepository clubMemberRepository; + private final UserRepository userRepository; + private final ChatRoomSystemAdminService chatRoomSystemAdminService; + private final ChatDirectRoomAccessService chatDirectRoomAccessService; + private final NotificationService notificationService; + private final ApplicationEventPublisher eventPublisher; + + public ChatMessageDetailResponse sendMessage(Integer userId, Integer roomId, ChatMessageSendRequest request) { + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + + if (room.isDirectRoom()) { + return sendDirectMessage(userId, room, request); + } + + if (room.isClubGroupRoom()) { + return sendClubMessageByRoomId(room, userId, request.content()); + } + + return sendGroupMessageByRoomId(room, userId, request.content()); + } + + private ChatMessageDetailResponse sendDirectMessage( + Integer userId, + ChatRoom chatRoom, + ChatMessageSendRequest request + ) { + Integer roomId = chatRoom.getId(); + User sender = userRepository.getById(userId); + + // 어드민이 SYSTEM_ADMIN 방에 메시지를 보내는 경우 + boolean isAdminSendingToSystemAdminRoom = sender.isAdmin() + && chatRoomSystemAdminService.isSystemAdminRoom(chatRoom.getId()); + + if (!isAdminSendingToSystemAdminRoom) { + chatDirectRoomAccessService.getAccessibleMember(chatRoom, sender); + } + + List members = chatRoomMemberRepository.findByChatRoomId(roomId); + User receiver = resolveDirectMessageReceiver(members, sender); + + ChatMessage chatMessage = chatMessageRepository.save( + ChatMessage.of(chatRoom, sender, request.content()) + ); + + syncLastMessage(chatRoom, chatMessage); + members.stream() + .filter(member -> !member.getUserId().equals(userId)) + .filter(ChatRoomMember::hasLeft) + .forEach(member -> member.restoreDirectRoomFromIncomingMessage(chatMessage.getCreatedAt())); + + // 어드민이 보낸 경우는 lastReadAt 업데이트하지 않음 (멤버가 아니므로) + if (!isAdminSendingToSystemAdminRoom) { + updateLastReadAtOrEnsureMember(roomId, userId, chatMessage.getCreatedAt()); + } + + List sortedReadBaselines = toSortedReadBaselines(members); + + notificationService.sendChatNotification(receiver.getId(), roomId, sender.getName(), request.content()); + + boolean isSystemAdminRoom = members.stream() + .map(ChatRoomMember::getUserId) + .anyMatch(memberUserId -> memberUserId.equals(SYSTEM_ADMIN_ID)); + publishAdminChatEventIfNeeded(isSystemAdminRoom, sender, request.content()); + + return new ChatMessageDetailResponse( + chatMessage.getId(), + chatMessage.getSender().getId(), + null, + chatMessage.getContent(), + chatMessage.getCreatedAt(), + true, + countUnreadSince(chatMessage.getCreatedAt(), sortedReadBaselines), + true + ); + } + + private ChatMessageDetailResponse sendClubMessageByRoomId(ChatRoom room, Integer userId, String content) { + Integer roomId = room.getId(); + ClubMember member = clubMemberRepository.getByClubIdAndUserId(room.getClub().getId(), userId); + User sender = member.getUser(); + + ensureRoomMember(room, sender, member.getCreatedAt()); + + ChatMessage message = chatMessageRepository.save(ChatMessage.of(room, sender, content)); + syncLastMessage(room, message); + updateLastReadAtOrEnsureMember(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 ChatMessageDetailResponse sendGroupMessageByRoomId(ChatRoom room, Integer userId, String content) { + Integer roomId = room.getId(); + 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)); + syncLastMessage(room, message); + updateLastReadAt(roomId, userId, message.getCreatedAt()); + + List members = chatRoomMemberRepository.findByChatRoomId(roomId); + List recipientUserIds = members.stream() + .map(ChatRoomMember::getUserId) + .filter(id -> !id.equals(userId)) + .toList(); + List sortedReadBaselines = toSortedReadBaselines(members); + + notificationService.sendGroupChatNotification( + roomId, + sender.getId(), + DEFAULT_GROUP_ROOM_NAME, + 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 ChatRoomMember getRoomMember(Integer roomId, Integer userId) { + return chatRoomMemberRepository.findByChatRoomIdAndUserId(roomId, userId) + .orElseThrow(() -> CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS)); + } + + private void ensureRoomMember(ChatRoom room, User user, LocalDateTime joinedAt) { + chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId()) + .ifPresentOrElse(member -> { + LocalDateTime lastReadAt = member.getLastReadAt(); + if (lastReadAt == null || lastReadAt.isBefore(joinedAt)) { + member.updateLastReadAt(joinedAt); + } + }, () -> chatRoomMemberRepository.save(ChatRoomMember.of(room, user, joinedAt))); + } + + private void updateLastReadAtOrEnsureMember(Integer roomId, Integer userId, LocalDateTime lastReadAt) { + int updated = chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, userId, lastReadAt); + if (updated == 0) { + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + User user = userRepository.getById(userId); + ensureRoomMember(room, user, lastReadAt); + } + } + + private void updateLastReadAt(Integer roomId, Integer userId, LocalDateTime lastReadAt) { + chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, userId, lastReadAt); + } + + private List toSortedReadBaselines(List members) { + return members.stream() + .map(ChatRoomMember::getLastReadAt) + .sorted() + .toList(); + } + + private int countUnreadSince(LocalDateTime messageCreatedAt, List sortedReadBaselines) { + int left = 0; + int right = sortedReadBaselines.size(); + + while (left < right) { + int mid = (left + right) >>> 1; + LocalDateTime baseline = sortedReadBaselines.get(mid); + + if (baseline.isBefore(messageCreatedAt)) { + left = mid + 1; + } else { + right = mid; + } + } + + return left; + } + + private void syncLastMessage(ChatRoom room, ChatMessage message) { + // 채팅방 목록은 chat_room.last_message_*를 직접 조회하므로 + // 동시 전송에서도 가장 최신 메시지만 메타데이터를 덮어쓰도록 DB 조건을 같이 건다. + int updated = chatRoomRepository.updateLastMessageIfLatest( + room.getId(), + message.getId(), + message.getContent(), + message.getCreatedAt() + ); + if (updated > 0) { + room.updateLastMessage(message.getContent(), message.getCreatedAt()); + } + } + + private void publishAdminChatEventIfNeeded(boolean isSystemAdminRoom, User sender, String content) { + if (isSystemAdminRoom && !sender.isAdmin()) { + eventPublisher.publishEvent(AdminChatReceivedEvent.of(sender.getId(), sender.getName(), content)); + } + } + + private User resolveDirectMessageReceiver(List members, User sender) { + Map userMap = members.stream() + .collect(Collectors.toMap( + ChatRoomMember::getUserId, + ChatRoomMember::getUser, + (existing, replacement) -> existing + )); + List memberInfos = members.stream() + .map(member -> new MemberInfo(member.getUserId(), member.getCreatedAt())) + .toList(); + return resolveMessageReceiverFromMemberInfo(sender, memberInfos, userMap); + } + + private User findDirectPartnerFromMemberInfo( + List memberInfos, + Integer userId, + Map userMap + ) { + return memberInfos.stream() + .filter(info -> !info.userId().equals(userId)) + .min(Comparator.comparing(MemberInfo::createdAt)) + .map(info -> userMap.get(info.userId())) + .orElse(null); + } + + private User findNonAdminUserFromMemberInfo(List memberInfos, Map userMap) { + return memberInfos.stream() + .sorted(Comparator.comparing(MemberInfo::createdAt)) + .map(info -> userMap.get(info.userId())) + .filter(user -> user != null && !user.isAdmin()) + .findFirst() + .orElse(null); + } + + private User resolveMessageReceiverFromMemberInfo( + User sender, + List memberInfos, + Map userMap + ) { + if (sender.isAdmin()) { + User nonAdminUser = findNonAdminUserFromMemberInfo(memberInfos, userMap); + if (nonAdminUser != null) { + return nonAdminUser; + } + } + + User partner = findDirectPartnerFromMemberInfo(memberInfos, sender.getId(), userMap); + if (partner == null) { + throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); + } + return partner; + } + + private record MemberInfo(Integer userId, LocalDateTime createdAt) { + } +} diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomAccessService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomAccessService.java new file mode 100644 index 000000000..d7dc78b05 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomAccessService.java @@ -0,0 +1,69 @@ +package gg.agit.konect.domain.chat.service; + +import static gg.agit.konect.global.code.ApiResponseCode.FORBIDDEN_CHAT_ROOM_ACCESS; + +import org.springframework.stereotype.Service; +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.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.exception.CustomException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ChatRoomAccessService { + + private final ChatRoomMemberRepository chatRoomMemberRepository; + private final UserRepository userRepository; + private final ChatRoomMembershipService chatRoomMembershipService; + private final ChatRoomSystemAdminService chatRoomSystemAdminService; + private final ChatDirectRoomAccessService chatDirectRoomAccessService; + + public ChatRoomMember getAccessibleMember(ChatRoom room, Integer userId) { + if (room.isDirectRoom()) { + User user = userRepository.getById(userId); + return chatDirectRoomAccessService.getAccessibleMember(room, user); + } + + return getAccessibleNonDirectMember(room, userId); + } + + public ChatRoomMember getAccessibleMember(ChatRoom room, User user) { + if (room.isDirectRoom()) { + return chatDirectRoomAccessService.getAccessibleMember(room, user); + } + + return getAccessibleNonDirectMember(room, user.getId()); + } + + private ChatRoomMember getAccessibleNonDirectMember(ChatRoom room, Integer userId) { + if (room.isClubGroupRoom()) { + chatRoomMembershipService.ensureClubRoomMember(room, userId); + return getRoomMember(room.getId(), userId); + } + + ChatRoomMember member = getRoomMember(room.getId(), userId); + if (member.hasLeft()) { + throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); + } + return member; + } + + public void ensureMuteAccess(ChatRoom room, User user) { + if (room.isDirectRoom() && user.isAdmin() && chatRoomSystemAdminService.isSystemAdminRoom(room.getId())) { + return; + } + + getAccessibleMember(room, user); + } + + private ChatRoomMember getRoomMember(Integer roomId, Integer userId) { + return chatRoomMemberRepository.findByChatRoomIdAndUserId(roomId, userId) + .orElseThrow(() -> CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS)); + } +} diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomCreationService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomCreationService.java new file mode 100644 index 000000000..8034a3311 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomCreationService.java @@ -0,0 +1,121 @@ +package gg.agit.konect.domain.chat.service; + +import static gg.agit.konect.domain.chat.service.ChatRoomMembershipService.SYSTEM_ADMIN_ID; +import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_CREATE_CHAT_ROOM_WITH_SELF; +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_USER; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import gg.agit.konect.domain.chat.dto.ChatRoomCreateRequest; +import gg.agit.konect.domain.chat.dto.ChatRoomResponse; +import gg.agit.konect.domain.chat.enums.ChatType; +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.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; + +@Service +@RequiredArgsConstructor +@Transactional +public class ChatRoomCreationService { + + private final ChatRoomRepository chatRoomRepository; + private final ChatRoomMemberRepository chatRoomMemberRepository; + private final UserRepository userRepository; + private final ChatRoomMembershipService chatRoomMembershipService; + + public ChatRoomResponse createOrGetChatRoom(Integer currentUserId, ChatRoomCreateRequest request) { + User currentUser = userRepository.getById(currentUserId); + User targetUser = userRepository.getById(request.userId()); + + if (currentUser.getId().equals(targetUser.getId())) { + throw CustomException.of(CANNOT_CREATE_CHAT_ROOM_WITH_SELF); + } + + if (currentUser.isAdmin() && !targetUser.isAdmin()) { + return getOrCreateSystemAdminChatRoomForUser(targetUser, currentUser); + } + + 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"); + chatRoomMembershipService.ensureDirectRoomRequester(chatRoom, currentUser, joinedAt); + chatRoomMembershipService.ensureMember(chatRoom, targetUser, joinedAt); + + return ChatRoomResponse.from(chatRoom); + } + + public ChatRoomResponse createOrGetAdminChatRoom(Integer currentUserId) { + User adminUser = userRepository.findFirstByRoleAndDeletedAtIsNullOrderByIdAsc(UserRole.ADMIN) + .orElseThrow(() -> CustomException.of(NOT_FOUND_USER)); + + return createOrGetChatRoom(currentUserId, new ChatRoomCreateRequest(adminUser.getId())); + } + + 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); + } + + private ChatRoomResponse getOrCreateSystemAdminChatRoomForUser(User targetUser, User adminUser) { + 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); + LocalDateTime joinedAt = Objects.requireNonNull( + newRoom.getCreatedAt(), "chatRoom.createdAt must not be null" + ); + chatRoomMembershipService.ensureMember(newRoom, systemAdmin, joinedAt); + chatRoomMembershipService.ensureMember(newRoom, targetUser, joinedAt); + return newRoom; + }); + + LocalDateTime joinedAt = Objects.requireNonNull( + chatRoom.getCreatedAt(), "chatRoom.createdAt must not be null" + ); + chatRoomMembershipService.ensureDirectRoomRequester(chatRoom, adminUser, joinedAt); + + return ChatRoomResponse.from(chatRoom); + } +} diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMemberCommandService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMemberCommandService.java new file mode 100644 index 000000000..c4559a705 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMemberCommandService.java @@ -0,0 +1,136 @@ +package gg.agit.konect.domain.chat.service; + +import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_KICK_IN_NON_GROUP_ROOM; +import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_KICK_ROOM_OWNER; +import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_KICK_SELF; +import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_INVITE_IN_NON_GROUP_ROOM; +import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_LEAVE_GROUP_CHAT_ROOM; +import static gg.agit.konect.global.code.ApiResponseCode.FORBIDDEN_CHAT_ROOM_ACCESS; +import static gg.agit.konect.global.code.ApiResponseCode.FORBIDDEN_CHAT_ROOM_KICK; +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CHAT_ROOM; +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_USER; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +import org.springframework.stereotype.Service; +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.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.exception.CustomException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class ChatRoomMemberCommandService { + + private final ChatRoomRepository chatRoomRepository; + private final ChatRoomMemberRepository chatRoomMemberRepository; + private final UserRepository userRepository; + private final ChatRoomMembershipService chatRoomMembershipService; + + 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); + } + + 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 void inviteMembers(Integer requesterId, Integer roomId, List userIds) { + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + + validateGroupRoomForInvite(room); + validateActiveRequester(roomId, requesterId); + + List distinctUserIds = userIds.stream() + .distinct() + .toList(); + List requestedUsers = userRepository.findAllByIdIn(distinctUserIds); + if (requestedUsers.size() != distinctUserIds.size()) { + throw CustomException.of(NOT_FOUND_USER); + } + + Set existingActiveUserIds = Set.copyOf( + chatRoomMemberRepository.findActiveUserIdsByChatRoomIdAndUserIdIn(roomId, distinctUserIds) + ); + LocalDateTime joinedAt = LocalDateTime.now(); + requestedUsers.stream() + .filter(user -> !user.getId().equals(requesterId)) + .filter(user -> !existingActiveUserIds.contains(user.getId())) + .forEach(user -> chatRoomMembershipService.ensureMember(room, user, joinedAt)); + } + + private ChatRoomMember getRoomMember(Integer roomId, Integer userId) { + return ChatRoomMemberLookup.getRoomMember(chatRoomMemberRepository, roomId, userId); + } + + private void validateGroupRoomForKick(ChatRoom room) { + if (!room.isGroupRoom() || room.isClubGroupRoom()) { + throw CustomException.of(CANNOT_KICK_IN_NON_GROUP_ROOM); + } + } + + private void validateGroupRoomForInvite(ChatRoom room) { + if (!room.isGroupRoom() || room.isClubGroupRoom()) { + throw CustomException.of(CANNOT_INVITE_IN_NON_GROUP_ROOM); + } + } + + private void validateActiveRequester(Integer roomId, Integer requesterId) { + if (!chatRoomMemberRepository.existsActiveByChatRoomIdAndUserId(roomId, requesterId)) { + throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); + } + } + + 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); + } + } +} diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMemberLookup.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMemberLookup.java new file mode 100644 index 000000000..238abb8b7 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMemberLookup.java @@ -0,0 +1,22 @@ +package gg.agit.konect.domain.chat.service; + +import static gg.agit.konect.global.code.ApiResponseCode.FORBIDDEN_CHAT_ROOM_ACCESS; + +import gg.agit.konect.domain.chat.model.ChatRoomMember; +import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; +import gg.agit.konect.global.exception.CustomException; + +final class ChatRoomMemberLookup { + + private ChatRoomMemberLookup() { + } + + static ChatRoomMember getRoomMember( + ChatRoomMemberRepository chatRoomMemberRepository, + Integer roomId, + Integer userId + ) { + return chatRoomMemberRepository.findByChatRoomIdAndUserId(roomId, userId) + .orElseThrow(() -> CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS)); + } +} 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 3dd25d68c..987f13866 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 @@ -2,6 +2,7 @@ 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 java.time.LocalDateTime; import java.util.List; @@ -15,6 +16,8 @@ import gg.agit.konect.domain.chat.model.ChatRoom; import gg.agit.konect.domain.chat.model.ChatRoomMember; +import gg.agit.konect.domain.chat.dto.ChatRoomMemberResponse; +import gg.agit.konect.domain.chat.dto.ChatRoomMembersResponse; import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; import gg.agit.konect.domain.chat.repository.ChatRoomRepository; import gg.agit.konect.domain.club.model.Club; @@ -38,6 +41,47 @@ public class ChatRoomMembershipService { private final ChatRoomMemberRepository chatRoomMemberRepository; private final ClubMemberRepository clubMemberRepository; private final UserRepository userRepository; + private final ChatRoomSystemAdminService chatRoomSystemAdminService; + + @Transactional(readOnly = true) + public ChatRoomMembersResponse getChatRoomMembers(Integer chatRoomId, Integer currentUserId) { + User currentUser = userRepository.findById(currentUserId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_USER)); + + // 채팅방 존재 여부 먼저 확인 + ChatRoom chatRoom = chatRoomRepository.findById(chatRoomId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + + validateMembership(chatRoom, currentUser); + + List members = chatRoomMemberRepository.findActiveMembersByChatRoomId(chatRoomId); + + return new ChatRoomMembersResponse(members.stream() + .filter(member -> member.getUser().getDeletedAt() == null) + .map(this::toMemberResponse) + .toList()); + } + + private void validateMembership(ChatRoom chatRoom, User currentUser) { + // 어드민은 시스템 어드민 방의 멤버를 조회할 수 있음 + if (currentUser.isAdmin() && chatRoomSystemAdminService.isSystemAdminRoom(chatRoom.getId())) { + return; + } + + if (!chatRoomMemberRepository.existsActiveByChatRoomIdAndUserId(chatRoom.getId(), currentUser.getId())) { + throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); + } + } + + private ChatRoomMemberResponse toMemberResponse(ChatRoomMember member) { + return new ChatRoomMemberResponse( + member.getUser().getId(), + member.getUser().getName(), + member.getUser().getImageUrl(), + member.isOwner(), + member.getCreatedAt() + ); + } @Transactional public void addClubMember(ClubMember clubMember) { @@ -67,7 +111,7 @@ public void updateLastReadAt(Integer roomId, Integer userId, LocalDateTime readA @Transactional(propagation = Propagation.REQUIRES_NEW) public void updateDirectRoomLastReadAt(Integer roomId, User user, LocalDateTime readAt, ChatRoom room) { // 어드민이 SYSTEM_ADMIN 방의 메시지를 읽으면 SYSTEM_ADMIN의 lastReadAt을 업데이트 - if (user.isAdmin() && isSystemAdminRoom(roomId)) { + if (user.isAdmin() && chatRoomSystemAdminService.isSystemAdminRoom(roomId)) { chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, SYSTEM_ADMIN_ID, readAt); return; } @@ -81,6 +125,11 @@ public void updateDirectRoomLastReadAt(Integer roomId, User user, LocalDateTime public void ensureClubRoomMember(Integer roomId, Integer userId) { ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + ensureClubRoomMember(room, userId); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void ensureClubRoomMember(ChatRoom room, Integer userId) { if (!room.isGroupRoom() || room.getClub() == null) { throw CustomException.of(NOT_FOUND_CHAT_ROOM); } @@ -104,7 +153,8 @@ private ChatRoom findOrCreateClubRoom(Club club) { }); } - private void ensureMember(ChatRoom room, User user, LocalDateTime baseline) { + @Transactional + public void ensureMember(ChatRoom room, User user, LocalDateTime baseline) { chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId()) .ifPresentOrElse(member -> { LocalDateTime lastReadAt = member.getLastReadAt(); @@ -114,6 +164,26 @@ private void ensureMember(ChatRoom room, User user, LocalDateTime baseline) { }, () -> saveRoomMemberIgnoringDuplicate(room, user, baseline)); } + @Transactional + public void ensureDirectRoomRequester(ChatRoom room, User user, LocalDateTime joinedAt) { + if (shouldSkipSystemAdminMembership(room, user)) { + return; + } + + 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); + } + }, () -> saveRoomMemberIgnoringDuplicate(room, user, joinedAt)); + } + private void saveRoomMemberIgnoringDuplicate(ChatRoom room, User user, LocalDateTime baseline) { try { chatRoomMemberRepository.save(ChatRoomMember.of(room, user, baseline)); @@ -133,18 +203,17 @@ private void ensureDirectRoomMemberExists(ChatRoom room, User user, LocalDateTim // 어드민은 SYSTEM_ADMIN 방의 메시지를 조회할 수 있지만, 멤버로 추가되지는 않는다 // (멤버가 추가되면 findByTwoUsers에서 해당 방을 찾지 못해 채팅방이 중복 생성됨) - if (user.isAdmin() && isSystemAdminRoom(room.getId())) { + if (user.isAdmin() && chatRoomSystemAdminService.isSystemAdminRoom(room.getId())) { 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 shouldSkipSystemAdminMembership(ChatRoom room, User user) { + // 문의방은 SYSTEM_ADMIN + 일반 사용자 2인 구조를 전제로 재사용(findByTwoUsers)되므로, + // 생성/재오픈 경로에서도 일반 ADMIN을 멤버로 추가하면 안 된다. + return user.isAdmin() && chatRoomSystemAdminService.isSystemAdminRoom(room.getId()); } private boolean isDuplicateKeyException(DataIntegrityViolationException e) { diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomSettingsService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomSettingsService.java new file mode 100644 index 000000000..69dd33f46 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomSettingsService.java @@ -0,0 +1,87 @@ +package gg.agit.konect.domain.chat.service; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import gg.agit.konect.domain.chat.dto.ChatRoomSummaryResponse; +import gg.agit.konect.domain.chat.model.ChatRoomMember; +import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; +import gg.agit.konect.domain.notification.enums.NotificationTargetType; +import gg.agit.konect.domain.notification.model.NotificationMuteSetting; +import gg.agit.konect.domain.notification.repository.NotificationMuteSettingRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ChatRoomSettingsService { + + private final NotificationMuteSettingRepository notificationMuteSettingRepository; + private final ChatRoomMemberRepository chatRoomMemberRepository; + + public List applyUserSettings( + List rooms, + Integer userId + ) { + List roomIds = rooms.stream() + .map(ChatRoomSummaryResponse::roomId) + .toList(); + Map muteMap = getMuteMap(roomIds, userId); + Map customRoomNameMap = getCustomRoomNameMap(roomIds, userId); + + return rooms.stream() + .map(room -> applyRoomSettings(room, muteMap, customRoomNameMap)) + .toList(); + } + + private ChatRoomSummaryResponse applyRoomSettings( + ChatRoomSummaryResponse room, + Map muteMap, + Map customRoomNameMap + ) { + return new ChatRoomSummaryResponse( + room.roomId(), + room.chatType(), + customRoomNameMap.getOrDefault(room.roomId(), room.roomName()), + room.roomImageUrl(), + room.lastMessage(), + room.lastSentAt(), + room.createdAt(), + room.unreadCount(), + muteMap.getOrDefault(room.roomId(), false) + ); + } + + private Map getMuteMap(List roomIds, Integer userId) { + if (roomIds.isEmpty()) { + return Map.of(); + } + + List settings = notificationMuteSettingRepository + .findByTargetTypeAndTargetIdsAndUserId(NotificationTargetType.CHAT_ROOM, roomIds, userId); + + Map muteMap = new HashMap<>(); + for (NotificationMuteSetting setting : settings) { + Integer targetId = setting.getTargetId(); + if (targetId != null) { + muteMap.put(targetId, setting.getIsMuted()); + } + } + + return muteMap; + } + + private Map getCustomRoomNameMap(List roomIds, Integer userId) { + if (roomIds.isEmpty()) { + return Map.of(); + } + + return chatRoomMemberRepository.findByChatRoomIdsAndUserId(roomIds, userId).stream() + .filter(member -> StringUtils.hasText(member.getCustomRoomName())) + .collect(Collectors.toMap(ChatRoomMember::getChatRoomId, ChatRoomMember::getCustomRoomName)); + } +} diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomSummaryService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomSummaryService.java new file mode 100644 index 000000000..023096768 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomSummaryService.java @@ -0,0 +1,70 @@ +package gg.agit.konect.domain.chat.service; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Service; + +import gg.agit.konect.domain.chat.dto.ChatRoomSummaryResponse; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ChatRoomSummaryService { + + private final ChatRoomSettingsService chatRoomSettingsService; + + public List summarizeChatRooms( + Integer userId, + List directRooms, + List clubRooms, + List groupRooms + ) { + List rooms = new ArrayList<>(); + rooms.addAll(directRooms); + rooms.addAll(clubRooms); + rooms.addAll(groupRooms); + + rooms = new ArrayList<>(chatRoomSettingsService.applyUserSettings(rooms, userId)); + rooms.sort(Comparator + .comparing( + (ChatRoomSummaryResponse room) -> + room.lastSentAt() != null ? room.lastSentAt() : room.createdAt(), + Comparator.reverseOrder() + )); + + return rooms; + } + + public List summarizeSearchableRooms( + Integer userId, + List directRooms, + List clubRooms + ) { + List rooms = new ArrayList<>(); + rooms.addAll(directRooms); + rooms.addAll(clubRooms); + + rooms = new ArrayList<>(chatRoomSettingsService.applyUserSettings(rooms, userId)); + rooms.sort( + Comparator.comparing(ChatRoomSummaryResponse::lastSentAt, + Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(ChatRoomSummaryResponse::roomId) + ); + + return rooms; + } + + public 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; + } +} diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomSystemAdminService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomSystemAdminService.java new file mode 100644 index 000000000..f7116d8f7 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomSystemAdminService.java @@ -0,0 +1,31 @@ +package gg.agit.konect.domain.chat.service; + +import static gg.agit.konect.domain.chat.service.ChatRoomMembershipService.SYSTEM_ADMIN_ID; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import gg.agit.konect.domain.chat.model.ChatRoomMember; +import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ChatRoomSystemAdminService { + + private final ChatRoomMemberRepository chatRoomMemberRepository; + + public boolean isSystemAdminRoom(Integer roomId) { + return chatRoomMemberRepository.existsByChatRoomIdAndUserId(roomId, SYSTEM_ADMIN_ID); + } + + public ChatRoomMember findSystemAdminMember(List members) { + return members.stream() + .filter(member -> member.getUserId().equals(SYSTEM_ADMIN_ID)) + .findFirst() + .orElse(null); + } +} diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatSearchService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatSearchService.java new file mode 100644 index 000000000..1b8ef0650 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatSearchService.java @@ -0,0 +1,167 @@ +package gg.agit.konect.domain.chat.service; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +import gg.agit.konect.domain.chat.dto.ChatMessageMatchResult; +import gg.agit.konect.domain.chat.dto.ChatMessageMatchesResponse; +import gg.agit.konect.domain.chat.dto.ChatRoomMatchesResponse; +import gg.agit.konect.domain.chat.dto.ChatRoomSummaryResponse; +import gg.agit.konect.domain.chat.dto.ChatSearchResponse; +import gg.agit.konect.domain.chat.enums.ChatType; +import gg.agit.konect.domain.chat.model.ChatMessage; +import gg.agit.konect.domain.chat.model.ChatRoomMember; +import gg.agit.konect.domain.chat.repository.ChatMessageQueryRepository; +import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ChatSearchService { + + private final ChatMessageQueryRepository chatMessageQueryRepository; + private final ChatRoomMemberRepository chatRoomMemberRepository; + + public ChatSearchResponse search( + Integer userId, + String keyword, + List accessibleRooms, + Map defaultRoomNameMap, + Integer page, + Integer limit + ) { + String normalizedKeyword = normalizeKeyword(keyword); + ChatRoomMatchesResponse roomMatches = searchRoomsByName( + accessibleRooms, + defaultRoomNameMap, + normalizedKeyword, + page, + limit + ); + ChatMessageMatchesResponse messageMatches = searchByMessageContent( + userId, + accessibleRooms, + normalizedKeyword, + page, + limit + ); + + return new ChatSearchResponse(roomMatches, messageMatches); + } + + private ChatRoomMatchesResponse searchRoomsByName( + List accessibleRooms, + Map defaultRoomNameMap, + String keyword, + Integer page, + Integer limit + ) { + List matchedRooms = accessibleRooms.stream() + .filter(room -> matchesRoomName(room, keyword, 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 = chatMessageQueryRepository + .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) { + return keyword == null ? "" : keyword.trim(); + } + + private boolean matchesRoomName( + ChatRoomSummaryResponse room, + String keyword, + Map defaultRoomNameMap + ) { + return containsKeyword(room.roomName(), keyword) + || containsKeyword(defaultRoomNameMap.get(room.roomId()), keyword); + } + + private boolean containsKeyword(String text, String keyword) { + return text != null + && !keyword.isBlank() + && text.toLowerCase(Locale.ROOT).contains(keyword.toLowerCase(Locale.ROOT)); + } + + private Map getVisibleMessageFromMap(List roomIds, Integer userId) { + if (roomIds.isEmpty()) { + return Map.of(); + } + + Map visibleMessageFromMap = new HashMap<>(); + for (ChatRoomMember roomMember : chatRoomMemberRepository.findByChatRoomIdsAndUserId(roomIds, userId)) { + visibleMessageFromMap.put(roomMember.getChatRoomId(), roomMember.getVisibleMessageFrom()); + } + return visibleMessageFromMap; + } + + private boolean isVisibleMessageMatch( + ChatMessage message, + Map roomMap, + Map visibleMessageFromMap + ) { + ChatRoomSummaryResponse room = roomMap.get(message.getChatRoom().getId()); + if (room == null || room.chatType() != ChatType.DIRECT) { + return true; + } + + LocalDateTime visibleMessageFrom = visibleMessageFromMap.get(room.roomId()); + return visibleMessageFrom == null || message.getCreatedAt().isAfter(visibleMessageFrom); + } + + 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); + } +} 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 1aa03cc44..2ed68ed66 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 @@ -7,18 +7,11 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Locale; import java.util.Map; -import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; -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.Transactional; import org.springframework.util.StringUtils; @@ -26,13 +19,11 @@ 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.ChatRoomMembersInviteRequest; import gg.agit.konect.domain.chat.dto.ChatRoomNameUpdateRequest; import gg.agit.konect.domain.chat.dto.ChatRoomResponse; import gg.agit.konect.domain.chat.dto.ChatRoomSummaryResponse; @@ -41,13 +32,11 @@ 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.ChatRoomQueryRepository; import gg.agit.konect.domain.chat.repository.ChatRoomRepository; import gg.agit.konect.domain.chat.repository.RoomUnreadCountProjection; import gg.agit.konect.domain.club.model.ClubMember; @@ -55,7 +44,6 @@ import gg.agit.konect.domain.notification.enums.NotificationTargetType; import gg.agit.konect.domain.notification.model.NotificationMuteSetting; import gg.agit.konect.domain.notification.repository.NotificationMuteSettingRepository; -import gg.agit.konect.domain.notification.service.NotificationService; import gg.agit.konect.domain.user.enums.UserRole; import gg.agit.konect.domain.user.model.User; import gg.agit.konect.domain.user.repository.UserRepository; @@ -70,141 +58,57 @@ @Transactional(readOnly = true) public class ChatService { - private static final String ETC_SECTION_NAME = "기타"; private static final String DEFAULT_GROUP_ROOM_NAME = "그룹 채팅"; private final ChatRoomRepository chatRoomRepository; + private final ChatRoomQueryRepository chatRoomQueryRepository; 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; + private final ChatRoomMemberCommandService chatRoomMemberCommandService; + private final ChatRoomSummaryService chatRoomSummaryService; + private final ChatSearchService chatSearchService; + private final ChatInviteService chatInviteService; + private final ChatMessageReadService chatMessageReadService; + private final ChatMessagePageResolver chatMessagePageResolver; + private final ChatRoomAccessService chatRoomAccessService; + private final ChatRoomCreationService chatRoomCreationService; + private final ChatRoomSystemAdminService chatRoomSystemAdminService; + private final ChatDirectRoomAccessService chatDirectRoomAccessService; + private final ChatMessageSendService chatMessageSendService; @Transactional public ChatRoomResponse createOrGetChatRoom(Integer currentUserId, ChatRoomCreateRequest request) { - User currentUser = userRepository.getById(currentUserId); - User targetUser = userRepository.getById(request.userId()); - - if (currentUser.getId().equals(targetUser.getId())) { - throw CustomException.of(CANNOT_CREATE_CHAT_ROOM_WITH_SELF); - } - - if (currentUser.isAdmin() && !targetUser.isAdmin()) { - return getOrCreateSystemAdminChatRoomForUser(targetUser, currentUser); - } - - 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"); - 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(), ChatType.DIRECT) - .orElseGet(() -> { - ChatRoom newRoom = chatRoomRepository.save(ChatRoom.directOf()); - User systemAdmin = userRepository.getById(SYSTEM_ADMIN_ID); - LocalDateTime joinedAt = Objects.requireNonNull( - newRoom.getCreatedAt(), "chatRoom.createdAt must not be null" - ); - ensureRoomMember(newRoom, systemAdmin, joinedAt); - ensureRoomMember(newRoom, targetUser, joinedAt); - return newRoom; - }); - - LocalDateTime joinedAt = Objects.requireNonNull( - chatRoom.getCreatedAt(), "chatRoom.createdAt must not be null" - ); - ensureDirectRoomRequester(chatRoom, adminUser, joinedAt); - - return ChatRoomResponse.from(chatRoom); + return chatRoomCreationService.createOrGetChatRoom(currentUserId, request); } @Transactional public ChatRoomResponse createOrGetAdminChatRoom(Integer currentUserId) { - User adminUser = userRepository.findFirstByRoleAndDeletedAtIsNullOrderByIdAsc(UserRole.ADMIN) - .orElseThrow(() -> CustomException.of(NOT_FOUND_USER)); - - return createOrGetChatRoom(currentUserId, new ChatRoomCreateRequest(adminUser.getId())); + return chatRoomCreationService.createOrGetAdminChatRoom(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); + return chatRoomCreationService.createGroupChatRoom(currentUserId, request); } @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); + chatRoomMemberCommandService.leaveChatRoom(userId, roomId); } @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); + chatRoomMemberCommandService.kickMember(requesterId, roomId, targetUserId); + } - chatRoomMemberRepository.deleteByChatRoomIdAndUserId(roomId, targetUserId); + @Transactional + public void inviteMembers(Integer requesterId, Integer roomId, ChatRoomMembersInviteRequest request) { + chatRoomMemberCommandService.inviteMembers(requesterId, roomId, request.userIds()); } public ChatRoomsSummaryResponse getChatRooms(Integer userId) { @@ -212,75 +116,20 @@ public ChatRoomsSummaryResponse getChatRooms(Integer 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(), - resolveRoomName(room.roomId(), room.roomName(), customRoomNameMap), - room.roomImageUrl(), - room.lastMessage(), - room.lastSentAt(), - room.createdAt(), - room.unreadCount(), - muteMap.getOrDefault(room.roomId(), false) - ))); - - clubRooms.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) - ))); - - 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 room) -> - room.lastSentAt() != null ? room.lastSentAt() : room.createdAt(), - Comparator.reverseOrder() - ) + List rooms = chatRoomSummaryService.summarizeChatRooms( + userId, + directRooms, + clubRooms, + groupRooms ); return new ChatRoomsSummaryResponse(rooms); } 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); + return chatSearchService.search(userId, keyword, accessibleChatRooms.rooms(), + accessibleChatRooms.defaultRoomNameMap(), page, limit); } public ChatInvitableUsersResponse getInvitableUsers( @@ -290,109 +139,15 @@ public ChatInvitableUsersResponse getInvitableUsers( 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); + return chatInviteService.getInvitableUsers(userId, query, sortBy, page, limit); } - @Transactional(readOnly = true) + @Transactional public ChatMessagePageResponse getMessages(Integer userId, Integer roomId, Integer page, Integer limit) { return getMessages(userId, roomId, page, limit, null); } - @Transactional(readOnly = true) + @Transactional public ChatMessagePageResponse getMessages( Integer userId, Integer roomId, Integer page, Integer limit, Integer messageId ) { @@ -401,52 +156,41 @@ public ChatMessagePageResponse getMessages( User user = userRepository.getById(userId); if (messageId != null) { - ensureMessageLookupAccess(room, user, userId); - page = resolvePageForMessage(roomId, messageId, room, user, limit); + page = chatMessagePageResolver.resolvePageForMessage(roomId, messageId, room, user, limit); } LocalDateTime readAt = LocalDateTime.now(); if (room.isDirectRoom()) { - boolean isAdminViewingSystemRoom = user.isAdmin() && isSystemAdminRoom(room); + boolean isAdminViewingSystemRoom = user.isAdmin() + && chatRoomSystemAdminService.isSystemAdminRoom(room.getId()); if (isAdminViewingSystemRoom) { chatRoomMembershipService.updateLastReadAt(roomId, SYSTEM_ADMIN_ID, readAt); recordPresenceSafely(roomId, userId); - return getAdminSystemDirectChatRoomMessages(user, room, roomId, page, limit, readAt); + return chatMessageReadService.getAdminSystemDirectChatRoomMessages(user, room, page, limit, readAt); } chatRoomMembershipService.updateDirectRoomLastReadAt(roomId, user, readAt, room); recordPresenceSafely(roomId, userId); - return getDirectChatRoomMessages(userId, roomId, page, limit, readAt); + return chatMessageReadService.getDirectChatRoomMessages(user, room, 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 chatMessageReadService.getClubMessagesByRoom(room, userId, page, limit); } - getAccessibleRoomMember(room, userId); + chatRoomAccessService.getAccessibleMember(room, userId); chatRoomMembershipService.updateLastReadAt(roomId, userId, readAt); recordPresenceSafely(roomId, userId); - return getGroupMessagesByRoomId(roomId, userId, page, limit); + return chatMessageReadService.getGroupMessagesByRoom(roomId, userId, page, limit); } @Transactional public ChatMessageDetailResponse sendMessage(Integer userId, Integer roomId, ChatMessageSendRequest request) { - ChatRoom room = chatRoomRepository.findById(roomId) - .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); - - if (room.isDirectRoom()) { - return sendDirectMessage(userId, roomId, request); - } - - if (room.isClubGroupRoom()) { - return sendClubMessageByRoomId(roomId, userId, request.content()); - } - - return sendGroupMessageByRoomId(roomId, userId, request.content()); + return chatMessageSendService.sendMessage(userId, roomId, request); } @Transactional @@ -455,19 +199,7 @@ public ChatMuteResponse toggleMute(Integer userId, Integer roomId) { .orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_CHAT_ROOM)); User user = userRepository.getById(userId); - if (room.isClubGroupRoom()) { - ClubMember member = clubMemberRepository.getByClubIdAndUserId(room.getClub().getId(), userId); - ensureRoomMember(room, member.getUser(), member.getCreatedAt()); - } else if (room.isDirectRoom()) { - // 어드민이 SYSTEM_ADMIN 방에 접근하는 경우는 멤버십 체크를 건너뜀 - boolean isAdminAccessingSystemAdminRoom = user.isAdmin() - && isSystemAdminRoom(room); - if (!isAdminAccessingSystemAdminRoom) { - getAccessibleDirectRoomMember(room, user); - } - } else { - getAccessibleRoomMember(room, userId); - } + chatRoomAccessService.ensureMuteAccess(room, user); Boolean isMuted = notificationMuteSettingRepository.findByTargetTypeAndTargetIdAndUserId( NotificationTargetType.CHAT_ROOM, roomId, @@ -496,7 +228,7 @@ public void updateChatRoomName(Integer userId, Integer roomId, ChatRoomNameUpdat ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); - ChatRoomMember roomMember = getAccessibleRoomMember(room, userId); + ChatRoomMember roomMember = chatRoomAccessService.getAccessibleMember(room, userId); roomMember.updateCustomRoomName(normalizeCustomRoomName(request.roomName())); } @@ -548,7 +280,7 @@ private List getDirectChatRooms(Integer userId) { } private List getAdminDirectChatRooms(Integer adminUserId) { - List projections = chatRoomRepository.findAdminChatRoomsOptimized( + List projections = chatRoomQueryRepository.findAdminChatRoomsOptimized( SYSTEM_ADMIN_ID, adminUserId, UserRole.ADMIN, ChatType.DIRECT ); @@ -583,24 +315,20 @@ private List getClubChatRooms(Integer userId) { .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 - ); - }) + .map(room -> new ChatRoomSummaryResponse( + room.getId(), + ChatType.CLUB_GROUP, + room.getClub().getName(), + room.getClub().getImageUrl(), + room.getLastMessageContent(), + room.getLastMessageSentAt(), + room.getCreatedAt(), + unreadCountMap.getOrDefault(room.getId(), 0), + false + )) .toList(); } @@ -611,552 +339,37 @@ private List getGroupChatRooms(Integer 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, - 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 buildDirectChatRoomMessages( - User user, - Integer roomId, - Integer page, - Integer limit, - LocalDateTime readAt, - LocalDateTime visibleMessageFrom, - List sortedReadBaselines, - Integer maskedAdminId - ) { - PageRequest pageable = PageRequest.of(page - 1, limit); - Page messages = chatMessageRepository.findByChatRoomId(roomId, visibleMessageFrom, pageable); - - List responseMessages = messages.getContent().stream() - .map(message -> { - Integer senderId = maskedAdminId != null - ? resolveDirectSenderId(message, maskedAdminId) - : message.getSender().getId(); - boolean isMine = maskedAdminId != null - ? shouldDisplayAsOwnMessage(user, message, true) - : message.isSentBy(user.getId()); - boolean isRead = isMine || !message.getCreatedAt().isAfter(readAt); - int unreadCount = countUnreadSince(message.getCreatedAt(), sortedReadBaselines); - return new ChatMessageDetailResponse( - message.getId(), - senderId, - null, - message.getContent(), - message.getCreatedAt(), - isRead, - unreadCount, - isMine - ); - }) - .toList(); - - return new ChatMessagePageResponse( - messages.getTotalElements(), - messages.getNumberOfElements(), - messages.getTotalPages(), - messages.getNumber() + 1, - null, - responseMessages - ); - } - - private ChatMessagePageResponse getDirectChatRoomMessages( - Integer userId, - Integer roomId, - Integer page, - Integer limit, - LocalDateTime readAt - ) { - ChatRoom chatRoom = getDirectRoom(roomId); - User user = userRepository.getById(userId); - List members = chatRoomMemberRepository.findByChatRoomId(roomId); - LocalDateTime visibleMessageFrom = prepareDirectRoomAccess(getOrCreateDirectRoomMember(chatRoom, user), - chatRoom); - - List sortedReadBaselines = toSortedReadBaselines(members); - - return buildDirectChatRoomMessages(user, roomId, page, limit, readAt, - visibleMessageFrom, sortedReadBaselines, null); - } - - private ChatMessagePageResponse getAdminSystemDirectChatRoomMessages( - User user, - ChatRoom chatRoom, - Integer roomId, - Integer page, - Integer limit, - LocalDateTime readAt - ) { - List members = chatRoomMemberRepository.findByChatRoomId(roomId); - LocalDateTime visibleMessageFrom = resolveAdminSystemRoomVisibleMessageFrom(members); - - List sortedReadBaselines = toAdminChatReadBaselines(members); - Integer maskedAdminId = getMaskedAdminId(user, chatRoom); - - return buildDirectChatRoomMessages(user, roomId, page, limit, readAt, - visibleMessageFrom, sortedReadBaselines, maskedAdminId); - } - - private ChatMessageDetailResponse sendDirectMessage( - Integer userId, - Integer roomId, - ChatMessageSendRequest request - ) { - ChatRoom chatRoom = getDirectRoom(roomId); - User sender = userRepository.getById(userId); - - // 어드민이 SYSTEM_ADMIN 방에 메시지를 보내는 경우 - boolean isAdminSendingToSystemAdminRoom = sender.isAdmin() - && isSystemAdminRoom(chatRoom); - - ChatRoomMember senderMember = null; - boolean senderHadLeft = false; - - if (!isAdminSendingToSystemAdminRoom) { - senderMember = getAccessibleDirectRoomMember(chatRoom, sender); - senderHadLeft = senderMember.hasLeft(); - } - - List members = chatRoomMemberRepository.findByChatRoomId(roomId); - User receiver = resolveDirectMessageReceiver(members, sender); - - ChatMessage chatMessage = chatMessageRepository.save( - ChatMessage.of(chatRoom, sender, request.content()) - ); - - if (senderHadLeft && senderMember != null) { - senderMember.restoreDirectRoom(); - } - - chatRoom.updateLastMessage(chatMessage.getContent(), chatMessage.getCreatedAt()); - members.stream() - .filter(member -> !member.getUserId().equals(userId)) - .filter(ChatRoomMember::hasLeft) - .forEach(member -> member.restoreDirectRoomFromIncomingMessage(chatMessage.getCreatedAt())); - - // 어드민이 보낸 경우는 lastReadAt 업데이트하지 않음 (멤버가 아니므로) - if (!isAdminSendingToSystemAdminRoom) { - updateMemberLastReadAt(roomId, userId, chatMessage.getCreatedAt()); - } - - List sortedReadBaselines = toSortedReadBaselines(members); - - notificationService.sendChatNotification(receiver.getId(), roomId, sender.getName(), request.content()); - - boolean isSystemAdminRoom = members.stream() - .map(ChatRoomMember::getUserId) - .anyMatch(memberUserId -> memberUserId.equals(SYSTEM_ADMIN_ID)); - publishAdminChatEventIfNeeded(isSystemAdminRoom, sender, request.content()); - - return new ChatMessageDetailResponse( - chatMessage.getId(), - chatMessage.getSender().getId(), - null, - chatMessage.getContent(), - chatMessage.getCreatedAt(), - true, - countUnreadSince(chatMessage.getCreatedAt(), sortedReadBaselines), - true - ); - } - - private ChatMessagePageResponse getClubMessagesByRoomId( - Integer roomId, - Integer userId, - Integer page, - Integer limit - ) { - ChatRoom room = getClubRoom(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, - room.getClub().getId(), - responseMessages - ); - } - - private ChatMessageDetailResponse sendClubMessageByRoomId(Integer roomId, Integer userId, String content) { - ChatRoom room = getClubRoom(roomId); - ClubMember member = clubMemberRepository.getByClubIdAndUserId(room.getClub().getId(), userId); - User sender = member.getUser(); - - 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) - .filter(id -> !id.equals(userId)) + .map(room -> new ChatRoomSummaryResponse( + room.getId(), + ChatType.GROUP, + DEFAULT_GROUP_ROOM_NAME, + null, + room.getLastMessageContent(), + room.getLastMessageSentAt(), + room.getCreatedAt(), + unreadCountMap.getOrDefault(room.getId(), 0), + false + )) .toList(); - List sortedReadBaselines = toSortedReadBaselines(members); - - notificationService.sendGroupChatNotification( - roomId, - sender.getId(), - DEFAULT_GROUP_ROOM_NAME, - 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 AccessibleChatRooms getAccessibleChatRooms(Integer 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) + Map defaultRoomNameMap = chatRoomSummaryService.getDefaultRoomNameMap( + directRooms, + clubRooms ); - 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) + List rooms = chatRoomSummaryService.summarizeSearchableRooms( + userId, + directRooms, + clubRooms ); - } - - 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(); - } - - List settings = notificationMuteSettingRepository - .findByTargetTypeAndTargetIdsAndUserId(NotificationTargetType.CHAT_ROOM, roomIds, userId); - - Map muteMap = new HashMap<>(); - for (NotificationMuteSetting setting : settings) { - Integer targetId = setting.getTargetId(); - if (targetId != null) { - muteMap.put(targetId, setting.getIsMuted()); - } - } - - return muteMap; - } - - private Map getVisibleMessageFromMap(List roomIds, Integer userId) { - if (roomIds.isEmpty()) { - return Map.of(); - } - - Map visibleMessageFromMap = new HashMap<>(); - for (ChatRoomMember roomMember : chatRoomMemberRepository.findByChatRoomIdsAndUserId(roomIds, userId)) { - visibleMessageFromMap.put(roomMember.getChatRoomId(), roomMember.getVisibleMessageFrom()); - } - return visibleMessageFromMap; - } - - 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 Map getCustomRoomNameMap(List roomIds, Integer userId) { - if (roomIds.isEmpty()) { - return Map.of(); - } - - return chatRoomMemberRepository.findByChatRoomIdsAndUserId(roomIds, userId).stream() - .filter(member -> StringUtils.hasText(member.getCustomRoomName())) - .collect(Collectors.toMap(ChatRoomMember::getChatRoomId, ChatRoomMember::getCustomRoomName)); - } - - private String resolveRoomName(Integer roomId, String - defaultRoomName, Map customRoomNameMap) { - return customRoomNameMap.getOrDefault(roomId, defaultRoomName); - } - - private boolean matchesRoomName( - ChatRoomSummaryResponse room, - String keyword, - Map defaultRoomNameMap - ) { - if (containsKeyword(room.roomName(), keyword)) { - return true; - } - - return containsKeyword(defaultRoomNameMap.get(room.roomId()), keyword); - } - - private boolean isVisibleMessageMatch( - ChatMessage message, - Map roomMap, - Map visibleMessageFromMap - ) { - ChatRoomSummaryResponse room = roomMap.get(message.getChatRoom().getId()); - if (room == null || room.chatType() != ChatType.DIRECT) { - return true; - } - - LocalDateTime visibleMessageFrom = visibleMessageFromMap.get(room.roomId()); - return visibleMessageFrom == null || message.getCreatedAt().isAfter(visibleMessageFrom); - } - - 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); - } - - return chatRoom; - } - - 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; + return new AccessibleChatRooms(rooms, defaultRoomNameMap); } private List extractChatRoomIds(List chatRooms) { @@ -1182,58 +395,8 @@ private Map getUnreadCountMap(List chatRoomIds, Integ )); } - private Integer getMaskedAdminId(User user, ChatRoom chatRoom) { - if (user.isAdmin()) { - return null; - } - - List memberResults = chatRoomMemberRepository.findRoomMemberIdsByChatRoomIds( - List.of(chatRoom.getId()) - ); - List memberInfos = memberResults.stream() - .map(row -> new MemberInfo((Integer)row[1], (LocalDateTime)row[2])) - .toList(); - - boolean hasSystemAdmin = memberInfos.stream() - .anyMatch(info -> info.userId().equals(SYSTEM_ADMIN_ID)); - - if (hasSystemAdmin) { - return SYSTEM_ADMIN_ID; - } - - return null; - } - - private void publishAdminChatEventIfNeeded(boolean isSystemAdminRoom, User sender, String content) { - if (isSystemAdminRoom && !sender.isAdmin()) { - eventPublisher.publishEvent(AdminChatReceivedEvent.of(sender.getId(), sender.getName(), content)); - } - } - private ChatRoomMember getRoomMember(Integer roomId, Integer userId) { - return chatRoomMemberRepository.findByChatRoomIdAndUserId(roomId, 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; + return ChatRoomMemberLookup.getRoomMember(chatRoomMemberRepository, roomId, userId); } private void ensureRoomMember(ChatRoom room, User user, LocalDateTime joinedAt) { @@ -1246,31 +409,6 @@ 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) { - if (shouldSkipSystemAdminMembership(room, user)) { - return; - } - - 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 boolean shouldSkipSystemAdminMembership(ChatRoom room, User user) { - // 문의방은 SYSTEM_ADMIN + 일반 사용자 2인 구조를 전제로 재사용(findByTwoUsers)되므로, - // 생성/재오픈 경로에서도 일반 ADMIN을 멤버로 추가하면 안 된다. - return user.isAdmin() && isSystemAdminRoom(room); - } - private String normalizeCustomRoomName(String roomName) { if (!StringUtils.hasText(roomName)) { return null; @@ -1279,89 +417,6 @@ private String normalizeCustomRoomName(String roomName) { return roomName.trim(); } - private void updateMemberLastReadAt(Integer roomId, Integer userId, LocalDateTime lastReadAt) { - int updated = chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, userId, lastReadAt); - if (updated == 0) { - ChatRoom room = chatRoomRepository.findById(roomId) - .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); - User user = userRepository.getById(userId); - ensureRoomMember(room, user, lastReadAt); - } - } - - 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 = chatRoomRepository.findById(roomId) - .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); - User user = userRepository.getById(userId); - ensureRoomMember(room, user, lastReadAt); - } - } - - private List toSortedReadBaselines(List members) { - return members.stream() - .map(ChatRoomMember::getLastReadAt) - .sorted() - .toList(); - } - - private List toAdminChatReadBaselines(List members) { - LocalDateTime adminLastReadAt = null; - LocalDateTime userLastReadAt = null; - - for (ChatRoomMember member : members) { - if (member.getUser().isAdmin()) { - if (adminLastReadAt == null || member.getLastReadAt().isAfter(adminLastReadAt)) { - adminLastReadAt = member.getLastReadAt(); - } - } else { - userLastReadAt = member.getLastReadAt(); - } - } - - List baselines = new ArrayList<>(); - if (adminLastReadAt != null) { - baselines.add(adminLastReadAt); - } - if (userLastReadAt != null) { - baselines.add(userLastReadAt); - } - baselines.sort(Comparator.naturalOrder()); - return baselines; - } - - private int countUnreadSince(LocalDateTime messageCreatedAt, List sortedReadBaselines) { - int left = 0; - int right = sortedReadBaselines.size(); - - while (left < right) { - int mid = (left + right) >>> 1; - LocalDateTime baseline = sortedReadBaselines.get(mid); - - if (baseline.isBefore(messageCreatedAt)) { - left = mid + 1; - } else { - right = mid; - } - } - - return left; - } - - private Map getLastMessageMap(List roomIds) { - if (roomIds.isEmpty()) { - return Map.of(); - } - - return chatMessageRepository.findLatestMessagesByRoomIds(roomIds).stream() - .collect(Collectors.toMap(message -> message.getChatRoom().getId(), message -> message)); - } - private Map getRoomUnreadCountMap(List roomIds, Integer userId) { if (roomIds.isEmpty()) { return Map.of(); @@ -1386,158 +441,6 @@ private Map getRoomUnreadCountMap(List roomIds, Integ return unreadCountMap; } - private ChatRoomMember getOrCreateDirectRoomMember(ChatRoom chatRoom, User user) { - return chatRoomMemberRepository.findByChatRoomIdAndUserId(chatRoom.getId(), user.getId()) - .orElseGet(() -> { - // 어드민은 SYSTEM_ADMIN 방에 멤버로 추가되지 않음 - if (user.isAdmin() && isSystemAdminRoom(chatRoom)) { - throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); - } - throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); - }); - } - - 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; - } - - private LocalDateTime resolveAdminSystemRoomVisibleMessageFrom(List members) { - ChatRoomMember systemAdminMember = findRoomMember(members, SYSTEM_ADMIN_ID); - return systemAdminMember != null ? systemAdminMember.getVisibleMessageFrom() : null; - } - - /** - * 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()) - ); - List userIds = memberIds.stream() - .map(row -> (Integer)row[1]) - .toList(); - - return userIds.contains(SYSTEM_ADMIN_ID); - } - - /** - * messageId 조회 전 방 접근 권한을 검증한다. - * 권한 없음과 메시지 미존재를 구분할 수 없게 NOT_FOUND_CHAT_ROOM으로 통일하여 - * 메시지 존재 여부 오라클을 방지한다. - */ - private void ensureMessageLookupAccess(ChatRoom room, User user, Integer userId) { - if (room.isDirectRoom()) { - boolean isMember = chatRoomMemberRepository - .findByChatRoomIdAndUserId(room.getId(), userId) - .isPresent(); - if (!isMember && !(user.isAdmin() && isSystemAdminRoom(room))) { - throw CustomException.of(NOT_FOUND_CHAT_ROOM); - } - } else if (room.isClubGroupRoom()) { - try { - clubMemberRepository.getByClubIdAndUserId(room.getClub().getId(), userId); - } catch (CustomException e) { - // 동아리 멤버십 없음만 404로 변환, 다른 예외는 그대로 전파 - if (e.getErrorCode() == NOT_FOUND_CLUB_MEMBER) { - throw CustomException.of(NOT_FOUND_CHAT_ROOM); - } - throw e; - } - } else { - chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), userId) - .filter(member -> !member.hasLeft()) - .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); - } - } - - /** - * messageId가 가리키는 메시지가 포함된 페이지 번호를 계산한다. - * 가시성 검증 및 정보 누출 방지를 위해 동일한 에러 코드를 사용한다. - */ - private int resolvePageForMessage( - Integer roomId, Integer messageId, ChatRoom room, User user, int limit - ) { - ChatMessage targetMessage = chatMessageRepository.findByIdWithChatRoom(messageId) - .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); - - // 정보 누출 방지를 위해 동일한 에러 코드 사용 - if (!targetMessage.getChatRoom().getId().equals(roomId)) { - throw CustomException.of(NOT_FOUND_CHAT_ROOM); - } - - LocalDateTime visibleMessageFrom = resolveVisibleMessageFromPure(room, user); - - if (visibleMessageFrom != null && !targetMessage.getCreatedAt().isAfter(visibleMessageFrom)) { - throw CustomException.of(NOT_FOUND_CHAT_ROOM); - } - - // NOTE: count와 fetch 사이에 새 메시지가 삽입될 수 있으나, - // 호출부(getMessages)에서 응답에 타겟 메시지가 없으면 1회 재계산함 - long newerCount = chatMessageRepository.countNewerMessagesByChatRoomId( - roomId, messageId, targetMessage.getCreatedAt(), visibleMessageFrom - ); - return (int)(newerCount / limit) + 1; - } - - /** - * 채팅방 타입에 따른 메시지 가시성 기준 시간을 조회한다. - * 기존 getMessages() 흐름의 가시성 로직과 동일한 값을 반환하되, - * 방 복원 등 부수효과는 발생시키지 않는다. - */ - private LocalDateTime resolveVisibleMessageFromPure(ChatRoom room, User user) { - if (!room.isDirectRoom()) { - return null; - } - - if (user.isAdmin() && isSystemAdminRoom(room)) { - List members = chatRoomMemberRepository.findByChatRoomId(room.getId()); - return resolveAdminSystemRoomVisibleMessageFrom(members); - } - - return chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId()) - .map(ChatRoomMember::getVisibleMessageFrom) - .orElse(null); - } - - private boolean shouldDisplayAsOwnMessage( - User currentUser, - ChatMessage message, - boolean isAdminViewingSystemRoom - ) { - if (isAdminViewingSystemRoom) { - return message.getSender().isAdmin(); - } - return message.isSentBy(currentUser.getId()); - } - - private Integer resolveDirectSenderId(ChatMessage message, Integer maskedAdminId) { - if (maskedAdminId != null && message.getSender().isAdmin()) { - return maskedAdminId; - } - return message.getSender().getId(); - } - private ChatRoomMember findRoomMember(List members, Integer userId) { return members.stream() .filter(member -> member.getUserId().equals(userId)) @@ -1616,32 +519,6 @@ private User resolveDirectChatPartner(List members, Integer user return findDirectPartner(members, userId); } - private User findNonAdminUser(List members) { - Map userMap = members.stream() - .collect(Collectors.toMap( - ChatRoomMember::getUserId, - ChatRoomMember::getUser, - (existing, replacement) -> existing - )); - List memberInfos = members.stream() - .map(m -> new MemberInfo(m.getUserId(), m.getCreatedAt())) - .toList(); - return findNonAdminUserFromMemberInfo(memberInfos, userMap); - } - - private User resolveDirectMessageReceiver(List members, User sender) { - Map userMap = members.stream() - .collect(Collectors.toMap( - ChatRoomMember::getUserId, - ChatRoomMember::getUser, - (existing, replacement) -> existing - )); - List memberInfos = members.stream() - .map(m -> new MemberInfo(m.getUserId(), m.getCreatedAt())) - .toList(); - return resolveMessageReceiverFromMemberInfo(sender, memberInfos, userMap); - } - private User findDirectPartnerFromMemberInfo( List memberInfos, Integer userId, @@ -1669,59 +546,6 @@ private User resolveDirectChatPartner( return findDirectPartnerFromMemberInfo(memberInfos, userId, userMap); } - private User findNonAdminUserFromMemberInfo(List memberInfos, Map userMap) { - return memberInfos.stream() - .sorted(Comparator.comparing(MemberInfo::createdAt)) - .map(info -> userMap.get(info.userId())) - .filter(Objects::nonNull) - .filter(user -> !user.isAdmin()) - .findFirst() - .orElse(null); - } - - private User resolveMessageReceiverFromMemberInfo( - User sender, - List memberInfos, - Map userMap - ) { - if (sender.isAdmin()) { - User nonAdminUser = findNonAdminUserFromMemberInfo(memberInfos, userMap); - if (nonAdminUser != null) { - return nonAdminUser; - } - } - - User partner = findDirectPartnerFromMemberInfo(memberInfos, sender.getId(), userMap); - if (partner == null) { - throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); - } - 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); diff --git a/src/main/java/gg/agit/konect/domain/club/AGENTS.md b/src/main/java/gg/agit/konect/domain/club/AGENTS.md new file mode 100644 index 000000000..00174bdaa --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/AGENTS.md @@ -0,0 +1,414 @@ +# 동아리 도메인 가이드 + +## 이 도메인은 무엇을 하는가 + +동아리 도메인은 동아리의 생성과 공개 조회, 운영 설정, 모집 공고, 지원서, 회원/사전 회원 관리, 구글 시트 연동을 함께 다루는 도메인이다. + +이 도메인에서 중요한 것은 단순 CRUD가 아니라 아래 상태가 서로 같은 정책을 바라보는 것이다. + +- 동아리 자체 정보와 공개 노출 상태 +- 역할 체계 (`PRESIDENT`, `VICE_PRESIDENT`, `MANAGER`, `MEMBER`) +- 모집 가능 여부와 지원 가능 여부 +- 지원서 문항의 현재 버전과 과거 지원 시점의 문항 버전 +- 실제 회원(`ClubMember`)과 가입 전 사전 회원(`ClubPreMember`) +- 구글 시트에 반영되는 인명부 상태 +- 동아리 전용 그룹 채팅방 멤버십 + +동아리 관련 작업을 할 때는 항상 “이 변경이 권한 경계, 회원 상태, 지원 이력, 시트/채팅 연동까지 같이 맞는가”를 먼저 확인해야 한다. + +## 역할과 권한 + +### 역할 계층 + +역할 우선순위는 아래와 같다. + +- `PRESIDENT` +- `VICE_PRESIDENT` +- `MANAGER` +- `MEMBER` + +`ClubPosition.priority`가 낮을수록 더 높은 권한이며, `canManage()`는 자신보다 낮은 직책만 관리할 수 있다는 뜻이다. + +### 권한 검증 기준 + +`ClubPermissionValidator`는 아래 세 가지 경계를 쓴다. + +- `validatePresidentAccess` → 회장만 +- `validateLeaderAccess` → 회장/부회장 +- `validateManagerAccess` → 회장/부회장/운영진 + +중요한 점은 **어드민은 위 세 검증을 모두 우회한다**는 것이다. 즉 도메인 정책을 볼 때 “어드민 예외”를 항상 별도로 생각해야 한다. + +### 권한이 실제로 어떻게 쓰이는가 + +- 동아리 생성은 **어드민 전용**이다. +- 동아리 일반 정보 수정(`description`, `imageUrl`, `location`, `introduce`)은 **운영진 이상**이 가능하다. +- 동아리 설정 토글(모집공고/지원서/회비), 모집 공고 수정, 지원서 문항 교체, 회비 정보 변경, 시트 관련 작업은 **운영진 이상**이 가능하다. +- 직책 변경과 회원 제거는 **리더(회장/부회장) 이상**이 가능하다. +- 회장 위임과 부회장 지정/해제는 **회장만** 할 수 있다. +- 멤버 목록 조회는 **동아리 회원이면 가능**하지만, 학번 원문 노출은 관리자 계층과 일반 회원이 다르다. + +### 권한 관련 절대 놓치면 안 되는 것 + +- 어드민 우회가 있다고 해서 모든 제약이 사라지는 것은 아니다. 예를 들어 `removeMember`는 어드민이어도 회장을 제거할 수 없고, `MEMBER`가 아닌 대상을 바로 제거할 수도 없다. +- `LEADERS`와 `MANAGERS`는 다르다. 운영진(`MANAGER`)은 설정/모집/지원서 관리가 가능하지만, 직책 변경이나 회원 제거는 리더 검증을 통과해야 한다. +- 현재 구현 기준으로 `updateBasicInfo`는 API 설명과 달리 **매니저 권한 검증**을 사용한다. 문서/스웨거만 보고 admin-only라고 단정하면 안 된다. + +## 기능이 실제로 어떻게 동작해야 하는가 + +### 동아리 생성과 기본 조회 + +- 동아리 생성은 어드민만 가능하다. +- 생성 시 동아리 엔티티만 저장하는 것이 아니라 다음이 함께 일어난다. + - 동아리 그룹 채팅방 생성 + - 지정된 회장 유저를 `PRESIDENT`로 가입 + - 회장을 채팅방 멤버로 추가 + - 기본 지원 문항 2개 생성 + - `본인의 전화번호를 입력해주세요.` (필수) + - `지원 동기` (선택) +- 동아리 상세 조회의 `isApplied`는 “현재 회원”이거나 “현재 pending 지원이 있음”이면 `true`다. +- 동아리 목록 조회의 pending 표시는 지원 이력만 보면 안 되고, **이미 회원이 된 경우 pending 집합에서 제거**해야 한다. + +### 공개 조회와 관리 조회 + +- `getClubs`는 같은 대학의 동아리만 조회한다. +- 모집 필터가 없을 때도 모집 중인 동아리를 먼저 보여주고, 모집 필터가 있을 때는 현재 모집 중인 동아리만 보여준다. +- 모집 중 판단은 `isRecruitmentEnabled`가 켜져 있고 모집공고가 존재하며, 아래 둘 중 하나를 만족할 때다. + - `isAlwaysRecruiting = true` + - `startAt <= now <= endAt` +- 관리 중인 동아리 조회는 일반 사용자에게는 `MANAGERS` 이상 소속만 돌려주고, 어드민에게는 전체 동아리를 돌려준다. + +### 멤버 목록 조회 + +- 멤버 목록은 동아리 회원만 조회할 수 있다. 비회원은 `FORBIDDEN_CLUB_MEMBER_ACCESS`다. +- 학번 노출 정책은 역할에 따라 다르다. + - 어드민: 원문 노출 + - 회장/부회장/운영진: 원문 노출 + - 일반 회원: 마스킹 노출 +- 즉 멤버 목록 조회는 “조회 가능 여부”와 “민감정보 원문 노출 여부”를 따로 봐야 한다. + +### 모집 공고 + +- 모집 공고는 없으면 생성, 있으면 수정하는 upsert 정책이다. +- 상시 모집이면 `startAt`, `endAt`이 있으면 안 된다. +- 상시 모집이 아니면 `startAt`, `endAt`이 둘 다 있어야 하고, `startAt <= endAt` 이어야 한다. +- 공고 수정 시 이미 등록된 이미지는 전부 비우고 새 요청 기준으로 다시 채운다. +- 모집 공고 조회의 `isApplied`는 회원 여부 또는 pending 지원 여부에 의해 결정된다. + +### 설정 토글 + +- 설정은 `isRecruitmentEnabled`, `isApplicationEnabled`, `isFeeRequired` 세 토글을 독립적으로 가진다. +- PATCH 요청은 **들어온 필드만 바꾼다**. 누락된 필드는 유지된다. +- 설정 API는 토글을 바꿀 뿐, 모집공고/지원서 문항/회비 상세를 자동으로 생성하거나 정합성 보정을 해주지 않는다. +- 따라서 “토글이 켜졌다 = 실제 운영에 필요한 상세 데이터가 완비됐다”라고 보면 안 된다. + +### 회비 정보 + +- 회비 정보는 부분 업데이트처럼 보이지만 실제 정책은 더 엄격하다. +- `Club.replaceFeeInfo()` 헬퍼 자체는 회비 관련 값이 전부 비어 있으면 회비 상세를 **전부 제거**한다. +- 회비 관련 값이 전부 채워져 있으면 새 값으로 **전부 교체**한다. +- 일부만 채워진 partial update는 허용되지 않는다. +- 다만 현재 `ClubApplicationService.replaceFeeInfo()` 경로는 먼저 `bankId`로 은행명을 해석하므로, 공개 API 관점에서는 사실상 “전체 교체” 흐름으로 이해하는 편이 안전하다. + +### 지원서와 지원 이력 + +- 현재 회원은 지원할 수 없다. +- 이미 pending 지원이 있으면 다시 지원할 수 없다. +- 회비가 필수인 동아리는 납부 이미지가 없으면 지원할 수 없다. +- 지원 시점의 활성 문항 목록으로 답변을 검증한다. + - 존재하지 않는 문항 id에 답하면 안 된다. + - 필수 문항 답변이 비어 있으면 안 된다. + - 빈 답변은 저장하지 않지만, 필수 검증은 통과해야 한다. +- 지원 저장 후 운영진 이상에게 제출 이벤트를 발행한다. +- `getAppliedClubs`는 pending 지원만 보여주되, 이미 회원이 된 경우는 제외한다. +- 운영진이 보는 pending 지원 목록은 현재 모집공고 기준을 탄다. + - 상시 모집이면 club 전체 pending 지원을 본다. + - 기간 모집이면 `startAt ~ endAt` 사이에 생성된 pending 지원만 본다. + +### 지원서 문항 버전 관리 + +이 부분은 이 도메인에서 특히 잘 깨지는 정책이다. + +- 문항 교체는 hard delete가 아니라 **soft delete(`deletedAt`)** 기반이다. +- 기존 문항과 내용/필수 여부가 완전히 같으면 display order만 바꾼다. +- 기존 문항의 내용 또는 필수 여부가 바뀌면 기존 문항을 soft delete 하고 새 문항을 만든다. +- 요청에서 빠진 기존 문항도 soft delete 된다. +- 즉 “지원 문항 수정”은 현재 문항 집합을 바꾸는 작업이지, 과거 지원서의 질문 텍스트를 덮어쓰는 작업이 아니다. + +### 과거 지원서 답변 조회 + +- 승인된 회원의 지원서 답변 조회는 **현재 문항 목록**이 아니라 **지원 당시 보였던 문항 목록**을 기준으로 재구성한다. +- 질문 가시성은 아래 기준이다. + - `createdAt <= appliedAt` + - `deletedAt == null || deletedAt > appliedAt` +- 특히 `deletedAt == appliedAt`이면 그 문항은 이미 보이지 않는 것으로 처리된다. +- 따라서 문항 versioning 로직을 바꾸면 승인된 회원의 과거 지원서 조회가 바로 깨질 수 있다. + +### 지원 승인 / 거절 + +- 승인/거절은 `PENDING` 상태에서만 가능하다. +- 승인/거절 대상 지원서는 pessimistic lock으로 가져오므로 동시 처리 경쟁을 줄이려는 의도가 있다. +- 이미 처리된 지원서를 다시 승인/거절하면 `ALREADY_PROCESSED_CLUB_APPLY`다. +- 승인 시 다음이 함께 일어난다. + - `ClubMember`를 `MEMBER` 직책으로 생성 + - 동아리 채팅방 멤버십 추가 + - 지원 상태를 `APPROVED`로 변경 + - 승인 이벤트 발행 +- 거절 시에는 상태를 `REJECTED`로 바꾸고 거절 이벤트를 발행한다. + +### 회원 직책 변경 + +- 자기 자신의 직책은 변경할 수 없다. +- 직책 변경은 리더 이상만 가능하다. +- 일반 리더(어드민 아님)는 두 가지를 모두 통과해야 한다. + - 현재 대상 회원을 관리할 수 있는가 (`requester.canManage(target)`) + - 바꾸려는 새 직책을 부여할 수 있는가 (`requester.getClubPosition().canManage(newPosition)`) +- 즉 부회장은 회장을 만들 수 없고, 운영진/부회장 같은 상위 직책을 자기 권한 밖으로 올릴 수 없다. +- 부회장은 최대 1명이다. +- 운영진은 최대 20명이다. + +### 회장 위임 / 부회장 변경 + +- 회장 위임은 회장만 가능하다. +- 자기 자신에게 회장을 다시 위임할 수 없다. +- 회장 위임은 현재 회장을 `MEMBER`로 내리고 새 회장을 `PRESIDENT`로 올린다. +- 부회장 변경도 회장만 가능하다. +- 부회장 userId가 `null`이면 현재 부회장을 해제한다. +- 새 부회장을 지정하면 기존 부회장은 `MEMBER`로 내리고 새 대상을 `VICE_PRESIDENT`로 올린다. + +### 회원 제거 + +- 자기 자신은 제거할 수 없다. +- 회원 제거는 리더 이상만 가능하다. +- 회장은 제거할 수 없다. +- **직접 제거 가능한 대상은 `MEMBER`만** 이다. +- 즉 운영진/부회장/회장을 내보내려면 먼저 직책을 내리는 별도 흐름을 거쳐야 한다. +- 어드민이 아닌 요청자는 `requester.canManage(target)` 검증을 통과해야 하므로, 자기보다 높거나 같은 위상의 대상은 제거할 수 없다. +- 어드민은 위 계층 검증은 우회하지만, `MEMBER`만 직접 제거 가능하다는 제약 자체는 그대로 적용된다. +- 제거 시에는 club member row 삭제와 함께 동아리 채팅방 멤버십도 제거된다. + +### 사전 회원(`ClubPreMember`) + +사전 회원은 아직 가입하지 않은 사용자를 동아리 쪽에서 먼저 등록해두는 임시 상태다. + +- 운영진 이상이 추가/배치추가/조회/삭제할 수 있다. +- `(clubId, studentNumber, name)` 기준 중복은 허용되지 않는다. +- 추가 시 같은 대학 + 같은 학번 사용자 후보를 찾고, 이름까지 일치시키는 방식으로 매칭한다. +- 정확히 한 명만 매칭되면 `ClubPreMember`를 만들지 않고 바로 `ClubMember`로 넣는다. +- 일치 사용자가 없으면 `ClubPreMember`로 저장한다. +- 이름까지 일치하는 동명이인이 여러 명이면 자동 등록하지 않고 `AMBIGUOUS_USER_MATCH`로 막는다. +- 배치 추가는 각 행을 독립 트랜잭션처럼 처리해서 일부 성공 / 일부 실패 결과를 같이 반환한다. + +### 회원가입 시 사전 회원 흡수 + +이 흐름은 club 서비스가 아니라 `UserService.joinPreMembers`에 있다. + +- 사용자가 가입하면 같은 대학/학번/이름으로 매칭되는 `ClubPreMember`를 찾아 실제 `ClubMember`로 전환한다. +- 전환 후에는 채팅방 멤버십도 추가된다. +- 사전 회원 직책이 `PRESIDENT`였으면 현재 회장을 먼저 제거하고 새 가입자를 회장으로 올린다. +- 즉 사전 회원 정책을 수정할 때는 동아리 내부 코드뿐 아니라 회원가입 흐름과 채팅 멤버십 반영도 같이 봐야 한다. + +### 시트 등록 / 동기화 / 불러오기 + +시트 기능은 운영진 이상만 다룰 수 있다. + +- 시트 URL 등록은 Spreadsheet ID를 추출하고, 헤더 분석 결과를 저장한다. +- 등록 시 `googleSheetId`뿐 아니라 `sheetColumnMapping`도 함께 갱신된다. +- 시트 동기화는 현재 회원 + 사전 회원을 함께 대상으로 한다. +- 시트 동기화는 등록된 sheet id가 없거나 blank면 바로 실패한다. +- 동기화 결과의 count에는 실제 회원 수와 사전 회원 수가 모두 포함된다. +- 컬럼 매핑이 있으면 해당 컬럼만 갱신하고, 없으면 기본 헤더(`Name`, `StudentId`, `Email`, `Phone`, `Position`, `JoinedAt`) 기준으로 전체를 다시 쓴다. +- 구글 시트 접근 거부가 나면 Slack 알림용 `SheetSyncFailedEvent`가 발행된다. + +### 시트 기반 사전 회원 Import + +시트 import는 “동아리 인명부를 읽어서 실회원과 사전 회원을 동시에 정리하는 흐름”이다. + +- 이름/학번이 비어 있으면 해당 row는 건너뛴다. +- 전화번호 형식이 이상하면 warning을 남긴다. +- 직책 텍스트가 인식되지 않으면 기본값은 `MEMBER`다. +- 회장이 2명 이상 감지되면 warning을 남긴다. +- 이미 현재 회원으로 존재하는 학번은 다시 등록하지 않는다. +- 학번 기준 후보 중 이름까지 정확히 맞는 유저가 1명이면 자동으로 실제 회원 등록을 준비한다. +- 이름까지 맞는 후보가 여러 명이면 자동 매칭하지 않고 warning을 남긴다. +- 기존 pre-member와 동일 `(studentNumber, name)`이면 다시 만들지 않는다. +- 적용 시 실제 회원으로 들어간 학번은 기존 pre-member 후보에서 정리하고, 저장된 실제 회원은 모두 채팅방 멤버십에 추가한다. + +### 시트 마이그레이션 + +- 시트 마이그레이션은 기존 스프레드시트를 공식 템플릿 기반 시트로 복사하는 흐름이다. +- 운영진 이상만 가능하다. +- 사용자 OAuth Drive 권한, 서비스 계정 권한 부여, 템플릿 복사, 폴더 이동, 컬럼 분석, 데이터 쓰기, rollback cleanup이 모두 한 흐름에 들어 있다. +- 따라서 이 영역은 단순 CRUD가 아니라 외부 API 실패, 권한 회수, orphan 파일 정리까지 같이 보는 것이 맞다. + +### 채팅 도메인과의 결합 + +동아리 도메인은 채팅 도메인과 느슨하지 않다. + +- 동아리 생성 시 동아리 그룹 채팅방을 만든다. +- 회장 생성, 지원 승인, 사전 회원 직접 등록, 시트 import로 실제 회원 등록, 회원가입 시 pre-member 흡수 시점마다 채팅방 멤버십이 추가될 수 있다. +- 회원 제거와 회장 교체 일부 흐름에서는 채팅방 멤버십이 제거된다. +- 따라서 club_group 접근 권한이나 메시지 조회 권한을 판단할 때는 chat member row만 믿으면 안 되고, 현재 `ClubMember` 상태/직책도 함께 확인해야 한다. +- 따라서 회원 상태를 바꾸는 코드를 수정할 때는 club 테이블만 맞추고 끝났다고 보면 안 된다. + +## 절대 놓치면 안 되는 정책 + +- 권한 검증은 `manager / leader / president` 세 층으로 나뉘며, 서로 대체 가능하지 않다. +- 어드민 bypass와 club 내부 역할 검증은 같은 개념이 아니다. 둘을 섞어 단순화하면 안 된다. +- `removeMember`는 “회원 제거” API이지 “아무 직책이나 제거” API가 아니다. 현재 구현상 `MEMBER`만 직접 제거 가능하다. +- 부회장은 최대 1명, 운영진은 최대 20명이다. +- 지원서 문항은 soft delete/versioning을 쓰므로 과거 지원서 답변은 현재 문항 기준으로 재구성하면 안 된다. +- 승인된 회원의 과거 답변 조회는 항상 **지원 시점에 보였던 질문** 기준이어야 한다. +- pending 지원과 실제 회원 상태를 동시에 보고 “지원 중” 표기를 계산해야 한다. +- 회비 필수 여부는 납부 이미지 요구와 직접 연결된다. +- 설정 토글은 관련 상세 데이터까지 자동 정비해주지 않는다. +- 사전 회원 자동 매칭은 학번만으로 끝나지 않고 이름까지 확인한다. 동명이인 모호성은 실패로 처리한다. +- 회원 상태 변경은 채팅방 멤버십 반영까지 같이 봐야 한다. +- 시트 기능은 외부 권한/네트워크/API 실패가 끼어드는 영역이라, 단순한 도메인 로직처럼 다루면 안 된다. +- 현재 저장소 기준으로 시트 동기화 실행은 명시적 sync API 경로에서만 직접 확인된다. 멤버 변경 시 자동 sync를 당연한 전제로 두면 안 된다. + +## 수정 시 함께 확인해야 하는 것 + +### 권한 로직을 바꿀 때 + +- `ClubPermissionValidator` 호출 지점 +- 어드민 bypass 유무 +- manager / leader / president 경계가 올바른지 +- API 문서 설명과 구현이 어긋나는 부분이 없는지 + +### 회원 직책/제거 로직을 바꿀 때 + +- 자기 자신 변경/제거 금지 +- 대상 관리 가능 여부 +- 새 직책 부여 가능 여부 +- 부회장 1명 제한 +- 운영진 20명 제한 +- 회장 제거 금지 +- `MEMBER`만 직접 제거 가능한 현재 정책 +- 채팅방 멤버십 추가/삭제 반영 + +### 지원/지원서 문항 로직을 바꿀 때 + +- pending 중복 지원 차단 +- 현재 회원 지원 차단 +- 회비 필수 시 이미지 요구 +- 필수 문항 검증 +- soft delete 기반 문항 버전 유지 +- 과거 지원서 답변 조회의 시점별 가시성 +- 승인/거절 idempotency (`ALREADY_PROCESSED_CLUB_APPLY`) +- 승인 시 회원 + 채팅 멤버십 생성 + +### 공개 조회 / 관리 조회를 바꿀 때 + +- 같은 대학 필터 +- 모집 중 정렬과 필터 조건 +- pending 표시에서 이미 회원인 케이스 제거 +- 멤버 목록 접근 제어 +- 학번 마스킹/비마스킹 정책 + +### 설정 / 모집공고를 바꿀 때 + +- 모집공고 on/off와 실제 공고 존재 여부를 혼동하지 않는지 +- 상시 모집 vs 기간 모집 날짜 검증 +- 이미지 교체 시 clear + re-add 정책 유지 여부 +- PATCH가 부분 업데이트라는 점 +- 토글과 상세 데이터 정합성 책임이 어디에 있는지 + +### 사전 회원 / 회원가입 연동을 바꿀 때 + +- 동일 대학 + 학번 + 이름 매칭 정책 +- 동명이인 모호성 처리 +- pre-member -> member 전환 시 채팅 멤버십 추가 +- pre-member가 회장일 때 기존 회장 교체 정책 +- batch add의 item별 독립 실패 처리 + +### 시트 관련 로직을 바꿀 때 + +- `googleSheetId`와 `sheetColumnMapping` 갱신 +- 멤버 + 사전 회원 동시 반영 +- warning 생성 규칙 +- access denied / invalid grant / rollback cleanup 처리 +- service account 권한 부여와 제거 +- 매핑 기반 부분 업데이트와 기본 전체 쓰기 fallback + +## 주요 클래스와 책임 + +### `ClubPermissionValidator` + +- club 도메인 권한 경계의 중심이다. +- admin bypass를 포함한 회장/리더/매니저 접근 검증을 담당한다. +- 대부분의 정책 변경은 이 클래스 호출 범위를 먼저 확인해야 한다. + +### `ClubService` + +- 동아리 생성, 공개 조회, 관리 조회, 멤버 목록 조회를 담당한다. +- 생성 시 채팅방 생성과 기본 지원 문항 생성까지 끌고 들어간다. +- 목록/상세의 `isApplied` 같은 사용자별 파생 상태를 만든다. + +### `ClubApplicationService` + +- 지원, 승인/거절, 지원서 문항 교체, 회비 정보, 승인 이력 답변 조회를 담당한다. +- 문항 versioning과 “지원 시점 기준 가시성” 정책의 핵심 서비스다. +- 승인 시 회원 생성과 채팅방 멤버십 추가까지 책임진다. + +### `ClubMemberManagementService` + +- 직책 변경, 회장 위임, 부회장 변경, 회원 제거, 사전 회원 수동 등록을 담당한다. +- 직책 hierarchy와 cardinality 제한이 가장 많이 모여 있는 서비스다. +- 채팅방 멤버십 추가/삭제까지 연결된다. + +### `ClubRecruitmentService` + +- 모집 공고 조회/저장을 담당한다. +- 상시 모집 / 기간 모집 날짜 정책과 이미지 교체 정책의 중심이다. + +### `ClubSettingsService` + +- 모집공고/지원서/회비 토글과 그 요약 정보 조회를 담당한다. +- 토글과 실제 상세 데이터의 분리를 이해할 때 봐야 하는 서비스다. + +### `ClubMemberSheetService` + +- 시트 id 등록과 명시적 sheet sync를 담당한다. +- 헤더 분석 결과를 도메인 설정에 저장하고, sync 진입점을 제공한다. + +### `SheetImportService` + +- 스프레드시트에서 회원/사전 회원 후보를 읽어 import plan을 만들고 적용한다. +- 자동 매칭, warning, pre-member 정리, 채팅 멤버십 반영이 여기 모여 있다. + +### `SheetMigrationService` + +- 기존 시트를 공식 템플릿 시트로 이관하는 외부 연동 서비스다. +- Drive/Sheets 권한, 복사, rollback cleanup, 컬럼 분석까지 포함한다. + +### `ClubSheetIntegratedService` + +- 시트 분석 + 등록 + import를 한 번에 묶는 orchestration 서비스다. + +### `Club`, `ClubMember`, `ClubPreMember`, `ClubApply`, `ClubApplyQuestion`, `ClubRecruitment` + +- 동아리 설정, 회원 상태, 사전 회원 상태, 지원 상태, 질문 버전, 모집공고 기간 정책을 각각 보관한다. +- 특히 `ClubApplyQuestion.deletedAt`, `ClubMember.clubPosition`, `Club.isRecruitmentEnabled / isApplicationEnabled / isFeeRequired`, `Club.googleSheetId / sheetColumnMapping`은 영향 범위가 크다. + +### Repository 계층 + +- `ClubQueryRepository`는 공개 목록의 필터/정렬 의미를 만든다. +- `ClubMemberRepository`는 역할 정렬, 멤버 조회, 인원 제한 계산에 직접 연결된다. +- `ClubApplyQuestionRepository`는 문항 시점 가시성의 기준 쿼리를 가진다. +- `ClubApplyRepository`는 pending 중복 검사와 승인 대상 lock 조회를 담당한다. + +## 이 문서로 먼저 이해해야 하는 것 + +동아리 도메인 작업을 시작할 때는 아래 질문에 답할 수 있어야 한다. + +- 이 변경이 `manager / leader / president` 권한 경계를 흐리게 만들지 않는가 +- 이 변경이 어드민 bypass와 club 내부 역할 정책을 섞어버리지 않는가 +- 이 변경이 직책 변경/회원 제거에서 부회장 1명, 운영진 20명, 회장 제거 금지 정책을 깨뜨리지 않는가 +- 이 변경이 지원서 문항 versioning과 과거 답변 조회를 현재 문항 기준으로 잘못 단순화하지 않는가 +- 이 변경이 pending 지원, 실제 회원, 공개 상세의 `isApplied` 계산을 서로 다르게 만들지 않는가 +- 이 변경이 사전 회원 등록, 회원가입 전환, 시트 import, 채팅 멤버십 반영을 서로 어긋나게 만들지 않는가 +- 이 변경이 시트 기능을 단순 내부 로직처럼 다뤄 외부 권한/rollback 위험을 놓치지 않는가 +- 이 변경이 API 문서 설명만 믿고 실제 구현 경계를 잘못 이해하게 만들지 않는가 + +이 질문에 바로 답할 수 없으면 코드를 먼저 고치지 말고, 관련 서비스와 cross-domain 연동부터 다시 확인해야 한다. diff --git a/src/main/java/gg/agit/konect/domain/club/repository/ClubRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubRepository.java index 5dbab27af..94ff955f4 100644 --- a/src/main/java/gg/agit/konect/domain/club/repository/ClubRepository.java +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubRepository.java @@ -3,6 +3,7 @@ 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; @@ -42,6 +43,22 @@ default Club getByIdWithUniversity(Integer id) { List findAll(); + boolean existsById(Integer id); + + @Modifying(flushAutomatically = true) + @Query(value = """ + UPDATE Club c + SET c.googleSheetId = :googleSheetId, + c.sheetColumnMapping = :sheetColumnMapping, + c.updatedAt = CURRENT_TIMESTAMP + WHERE c.id = :clubId + """) + int updateSheetRegistration( + @Param(value = "clubId") Integer clubId, + @Param(value = "googleSheetId") String googleSheetId, + @Param(value = "sheetColumnMapping") String sheetColumnMapping + ); + Club save(Club club); @Query("SELECT COUNT(c) FROM Club c") 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 index f92c9b22d..823af30e2 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java @@ -3,10 +3,6 @@ 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; @@ -15,11 +11,10 @@ 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.code.ApiResponseCode; import gg.agit.konect.global.exception.CustomException; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -@Slf4j @Service @RequiredArgsConstructor public class ClubMemberSheetService { @@ -30,55 +25,39 @@ public class ClubMemberSheetService { private final ClubPermissionValidator clubPermissionValidator; private final SheetSyncExecutor sheetSyncExecutor; private final SheetHeaderMapper sheetHeaderMapper; - private final ObjectMapper objectMapper; + private final ClubSheetRegistrationService clubSheetRegistrationService; - @Transactional public void updateSheetId( Integer clubId, Integer requesterId, ClubSheetIdUpdateRequest request ) { - Club club = clubRepository.getById(clubId); - clubPermissionValidator.validateManagerAccess(clubId, requesterId); - + validateClubExists(clubId); String spreadsheetId = SpreadsheetUrlParser.extractId(request.spreadsheetUrl()); + clubPermissionValidator.validateManagerAccess(clubId, requesterId); SheetHeaderMapper.SheetAnalysisResult result = sheetHeaderMapper.analyzeAllSheets(spreadsheetId); - applySheetRegistration(club, spreadsheetId, result); + clubSheetRegistrationService.updateSheetRegistration(clubId, spreadsheetId, result); } - @Transactional void updateSheetId( Integer clubId, Integer requesterId, String spreadsheetId, SheetHeaderMapper.SheetAnalysisResult result ) { - Club club = clubRepository.getById(clubId); + validateClubExists(clubId); clubPermissionValidator.validateManagerAccess(clubId, requesterId); - applySheetRegistration(club, spreadsheetId, result); + clubSheetRegistrationService.updateSheetRegistration(clubId, 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); + private void validateClubExists(Integer clubId) { + if (!clubRepository.existsById(clubId)) { + throw CustomException.of(ApiResponseCode.NOT_FOUND_CLUB); } } - @Transactional(readOnly = true) public ClubMemberSheetSyncResponse syncMembersToSheet( Integer clubId, Integer requesterId, diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubSheetRegistrationService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubSheetRegistrationService.java new file mode 100644 index 000000000..5bc34a39b --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubSheetRegistrationService.java @@ -0,0 +1,60 @@ +package gg.agit.konect.domain.club.service; + +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.model.SheetColumnMapping; +import gg.agit.konect.domain.club.repository.ClubRepository; +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 +public class ClubSheetRegistrationService { + + private final ClubRepository clubRepository; + private final ObjectMapper objectMapper; + + @Transactional + public void updateSheetRegistration( + Integer clubId, + String spreadsheetId, + SheetHeaderMapper.SheetAnalysisResult result + ) { + String mappingJson = serializeMemberListMapping(clubId, spreadsheetId, result); + + int updatedCount = clubRepository.updateSheetRegistration(clubId, spreadsheetId, mappingJson); + if (updatedCount == 0) { + throw CustomException.of(ApiResponseCode.NOT_FOUND_CLUB); + } + } + + private String serializeMemberListMapping( + Integer clubId, + String spreadsheetId, + SheetHeaderMapper.SheetAnalysisResult result + ) { + SheetColumnMapping memberListMapping = result.memberListMapping(); + if (memberListMapping == null) { + throw CustomException.of(ApiResponseCode.CLUB_SHEET_ANALYSIS_REQUIRED); + } + + try { + return objectMapper.writeValueAsString(memberListMapping.toMap()); + } catch (JsonProcessingException e) { + log.warn( + "Failed to serialize sheet column mapping. clubId={}, spreadsheetId={}", + clubId, + spreadsheetId, + e + ); + throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); + } + } +} 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 index 31309e4e9..7479d3691 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java @@ -11,7 +11,6 @@ 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; @@ -64,7 +63,6 @@ public class SheetSyncExecutor { 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(); diff --git a/src/main/java/gg/agit/konect/domain/inquiry/AGENTS.md b/src/main/java/gg/agit/konect/domain/inquiry/AGENTS.md new file mode 100644 index 000000000..dec774eef --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/inquiry/AGENTS.md @@ -0,0 +1,170 @@ +# 문의 도메인 가이드 + +## 이 도메인은 무엇을 하는가 + +문의 도메인은 사용자가 어드민에게 보내는 일반 문의를 접수하고, +트랜잭션 commit 이후 Slack 알림으로 전달하는 도메인이다. + +이 도메인에서 중요한 것은 문의 내용을 DB에 저장하는 것이 아니라 +아래 경계가 같은 정책을 바라보는 것이다. + +- 공개 문의 API (`POST /inquiries`) +- 문의 요청 DTO (`InquiryRequest`) +- 문의 제출 이벤트 (`InquirySubmittedEvent`) +- Slack 문의 알림 리스너 (`InquirySlackListener`) +- Slack 메시지 포맷과 event webhook (`SlackNotificationService.notifyInquiry`) + +문의 관련 작업을 할 때는 항상 "이 변경이 공개 API 검증, 이벤트 발행, +commit 이후 Slack 알림, 채팅 문의방 정책과의 분리까지 같이 맞는가"를 먼저 확인해야 한다. + +## 주요 상태 + +### `InquiryRequest` + +- 문의 API의 요청 DTO다. +- `content`는 blank일 수 없다. +- 현재 요청 DTO에는 최대 길이 제한이 없다. +- `@NotBlank` 외의 trimming, sanitizing, masking 정책은 없다. + +### `InquirySubmittedEvent` + +- 문의 내용을 후속 알림으로 넘기는 이벤트다. +- 현재 이벤트 payload는 `content` 하나뿐이다. +- 사용자 id, 이메일, 요청 시각, 저장 id는 포함하지 않는다. +- 서비스는 전달받은 content를 그대로 이벤트에 담는다. + +### Slack 알림 + +- `InquirySlackListener`는 `InquirySubmittedEvent`를 `AFTER_COMMIT`에 처리한다. +- 리스너는 `slackTaskExecutor`로 비동기 실행된다. +- Slack 메시지는 `SlackMessageTemplate.INQUIRY` 형식으로 만든다. +- Slack 전송 대상은 event webhook (`slackProperties.webhooks().event()`)이다. + +## 기능이 실제로 어떻게 동작해야 하는가 + +### 문의 전송 + +- endpoint는 `POST /inquiries`다. +- `@PublicApi`이므로 로그인 없이 호출할 수 있다. +- 요청 body는 JSON이어야 하고 `content`를 포함해야 한다. +- `content`가 blank면 400 응답이며 `INVALID_REQUEST_BODY`다. +- 요청 body가 없거나 JSON 형식이 아니면 400 응답이며 `INVALID_JSON_FORMAT`이다. +- 성공 응답은 200 OK이고 body는 없다. +- 컨트롤러는 `request.content()`를 그대로 서비스에 넘긴다. + +### 이벤트 발행 + +- `InquiryService.submitInquiry`는 별도 저장 없이 `InquirySubmittedEvent`를 발행한다. +- 이벤트 content는 서비스에 들어온 content와 같아야 한다. +- 현재 서비스는 문의 내용을 trim하거나 마스킹하지 않는다. +- 문의 접수를 영속 데이터로 남기는 정책을 추가하려면 이벤트 payload와 저장 실패/알림 실패 정책을 함께 재정의해야 한다. + +### Slack 후속 처리 + +- Slack 알림은 원 트랜잭션이 commit된 뒤 실행되어야 한다. +- 원 트랜잭션이 rollback되면 Slack 알림도 실행되면 안 된다. +- 리스너는 이벤트 content를 그대로 `SlackNotificationService.notifyInquiry`에 위임한다. +- Slack 전송 실패를 문의 API 응답 정책으로 바꾸려면 비동기 리스너, 예외 처리, 재시도 정책을 함께 확인해야 한다. + +## 절대 놓치면 안 되는 정책 + +- 문의 API는 공개 API다. 인증 사용자 전제를 넣으면 안 된다. +- 문의 내용은 현재 DB에 저장하지 않는다. +- `content` blank만 막고, 최대 길이 제한은 없다. +- 서비스는 content를 그대로 이벤트로 발행한다. +- Slack 알림은 `AFTER_COMMIT` 이후 실행된다. +- 이 도메인은 채팅 도메인의 `SYSTEM_ADMIN` 문의방 정책과 다르다. +- 채팅 문의방 생성, 채팅방 reopen, 마지막 메시지 갱신 정책을 이 도메인에 섞으면 안 된다. +- 사용자 입력이 Slack으로 전달되므로, 로그나 에러 응답에 content를 불필요하게 노출하지 않는다. + +## 수정 시 함께 확인해야 하는 것 + +### API 검증을 바꿀 때 + +- `InquiryRequest.content`의 Bean Validation +- `InquiryApi.submitInquiry`의 `@Valid @RequestBody` +- blank content 400 응답 코드 +- body 누락 또는 JSON 형식 오류 응답 코드 +- 공개 API 유지 여부 + +### 이벤트 payload를 바꿀 때 + +- `InquirySubmittedEvent` +- `InquiryService.submitInquiry` +- `InquirySlackListener.handleInquirySubmitted` +- Slack 메시지 템플릿의 인자 순서 +- 새 payload가 개인정보를 포함할 경우 로그와 Slack 노출 범위 + +### Slack 알림 정책을 바꿀 때 + +- `@TransactionalEventListener(phase = AFTER_COMMIT)` 유지 여부 +- `@Async("slackTaskExecutor")` 유지 여부 +- `SlackNotificationService.notifyInquiry` +- `SlackMessageTemplate.INQUIRY` +- event webhook과 error webhook 중 어느 채널을 써야 하는지 +- Slack 실패가 API 성공 여부에 영향을 줘야 하는지 + +### 채팅 문의 정책과 함께 바꿀 때 + +- 채팅 도메인의 `SYSTEM_ADMIN` 직접 문의방 정책 +- 채팅방 reopen 정책 +- 채팅방 마지막 메시지 메타데이터 갱신 정책 +- Slack 문의 알림과 채팅 문의방 생성의 사용자 경험 차이 + +## 주요 클래스와 책임 + +### `InquiryApi` + +- 문의 API 스펙과 공개 API 여부를 정의한다. +- 요청 DTO 검증은 이 인터페이스의 `@Valid @RequestBody` 조합에 걸려 있다. + +### `InquiryController` + +- HTTP 요청을 서비스 호출로 넘기고 성공 시 200 OK를 반환한다. +- 별도 응답 body를 만들지 않는다. + +### `InquiryService` + +- 문의 접수의 도메인 경계다. +- 현재 책임은 문의 이벤트 발행뿐이다. + +### `InquirySubmittedEvent` + +- 문의 내용을 Slack 알림으로 넘기는 이벤트 payload다. +- 현재 content 외 상태를 갖지 않는다. + +### `InquirySlackListener` + +- 문의 이벤트를 commit 이후 비동기로 처리한다. +- Slack 알림 서비스에 content를 위임한다. + +### `SlackNotificationService` + +- 문의 Slack 메시지를 템플릿으로 포맷하고 event webhook으로 전송한다. + +## 테스트 전략 + +이미 통합 테스트는 아래 정책을 고정한다. + +- `POST /inquiries` 성공 시 200 OK +- blank content는 400과 `INVALID_REQUEST_BODY` +- 요청 body 누락은 400과 `INVALID_JSON_FORMAT` + +단위 테스트는 아래 정책을 고정한다. + +- `InquiryService.submitInquiry`가 content를 그대로 `InquirySubmittedEvent`로 발행한다. +- `InquirySlackListener`가 이벤트 content를 Slack 알림 서비스에 그대로 위임한다. + +이 도메인의 정책을 바꾸거나 가이드 claim을 강화한다면 +추가로 아래 회귀 테스트를 보강하는 것이 좋다. + +- content 최대 길이 제한을 추가할 경우 경계값 테스트 +- content trimming 또는 sanitizing 정책을 추가할 경우 원문/가공 결과 테스트 +- Slack 실패가 문의 API 응답에 영향을 주도록 바꿀 경우 실패 전파 테스트 +- 이벤트 payload에 사용자 정보를 추가할 경우 인증/비인증 호출 정책 테스트 + +검증할 때는 최소한 아래 테스트를 실행한다. + +```bash +CI=true ./gradlew test --tests 'gg.agit.konect.integration.domain.inquiry.*' --tests 'gg.agit.konect.unit.domain.inquiry.*' --tests 'gg.agit.konect.unit.infrastructure.slack.listener.InquirySlackListenerTest' +``` diff --git a/src/main/java/gg/agit/konect/domain/notice/AGENTS.md b/src/main/java/gg/agit/konect/domain/notice/AGENTS.md new file mode 100644 index 000000000..5ffee0a1c --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/notice/AGENTS.md @@ -0,0 +1,211 @@ +# 공지 도메인 가이드 + +## 이 도메인은 무엇을 하는가 + +공지 도메인은 총동아리연합회 공지사항의 목록/상세 조회, 생성/수정/삭제, +사용자별 읽음 이력을 관리하는 도메인이다. + +이 도메인에서 중요한 것은 단순 게시글 CRUD가 아니라 +아래 상태가 같은 정책을 바라보는 것이다. + +- 총동아리연합회 공지 (`CouncilNotice`) +- 사용자별 공지 읽음 이력 (`CouncilNoticeReadHistory`) +- 사용자의 대학과 해당 대학의 총동아리연합회 (`User.university`, `Council`) +- 마이페이지의 읽지 않은 공지 수 (`UserInfoResponse.unreadCouncilNoticeCount`) + +공지 관련 작업을 할 때는 항상 "이 변경이 대학별 공지 격리, 상세 조회 시 읽음 처리, +중복 읽음 이력 방지, 사용자 정보의 unread count까지 같이 맞는가"를 먼저 확인해야 한다. + +## 주요 상태 + +### `CouncilNotice` + +- 총동아리연합회 공지사항이다. +- `title`과 `content`는 null일 수 없다. +- `title`은 요청 DTO 기준 최대 255자다. +- `content`는 `TEXT` 컬럼이다. +- 각 공지는 하나의 `Council`에 속한다. +- DB 마이그레이션 기준으로 `council_notice.council_id`는 `council.id`를 참조한다. + +### `CouncilNoticeReadHistory` + +- 사용자별 공지 읽음 기록이다. +- `(user_id, council_notice_id)` unique 제약을 가진다. +- 같은 사용자가 같은 공지를 여러 번 조회해도 읽음 이력은 하나만 유지해야 한다. +- 읽음 여부는 notice row의 상태가 아니라 read history 존재 여부로 판단한다. + +### `Council` + +- 목록 조회는 로그인 사용자의 대학으로 `Council`을 찾은 뒤, 그 council의 공지만 조회한다. +- 현재 생성 API는 로그인 사용자 대학이 아니라 `councilRepository.getById(1)`로 + council id 1을 고정 사용한다. +- 생성 정책을 바꿀 때는 기존 테스트의 `insertCouncilWithIdOne` 전제와 + 운영 데이터의 기본 council 전제를 함께 확인해야 한다. + +## 기능이 실제로 어떻게 동작해야 하는가 + +### 공지 목록 조회 + +- endpoint는 `GET /councils/notices`다. +- `page` 기본값은 1이고, 1 이상이어야 한다. +- `limit` 기본값은 10이고, 1 이상이어야 한다. +- 서비스는 `PageRequest.of(page - 1, limit, createdAt DESC)`로 조회한다. +- 로그인 사용자의 대학으로 council을 찾고, 해당 council id의 공지만 조회한다. +- 다른 대학 council의 공지는 목록에 포함되면 안 된다. +- 목록 응답의 `isRead`는 현재 페이지에 포함된 공지 id 중 read history가 있는지로 계산한다. +- 목록 응답은 `totalCount`, `currentCount`, `totalPage`, `currentPage`, `councilNotices`를 포함한다. +- 목록의 공지 날짜는 `createdAt.toLocalDate()`이며 JSON 형식은 `yyyy.MM.dd`다. + +### 공지 상세 조회와 읽음 처리 + +- endpoint는 `GET /councils/notices/{id}`다. +- 공지가 없으면 `NOT_FOUND_COUNCIL_NOTICE`다. +- 공지의 council university와 로그인 사용자 university가 다르면 `FORBIDDEN_COUNCIL_NOTICE_ACCESS`다. +- 상세 조회는 read transaction이 아니라 쓰기 transaction이다. +- 상세 조회는 읽음 이력을 만들 수 있기 때문이다. +- 같은 대학 공지를 상세 조회하면 read history가 없을 때만 `CouncilNoticeReadHistory`를 저장한다. +- 이미 read history가 있으면 새로 저장하지 않는다. +- 상세 응답은 `id`, `title`, `content`, `createdAt`, `updatedAt`를 반환한다. +- 상세 응답의 날짜/시간 JSON 형식은 `yyyy.MM.dd HH:mm:ss`다. + +### 공지 생성 + +- endpoint는 `POST /councils/notices`다. +- 요청의 `title`과 `content`는 blank일 수 없다. +- `title`은 최대 255자다. +- 현재 서비스는 `councilRepository.getById(1)`로 가져온 council에 공지를 생성한다. +- council id 1이 없으면 `NOT_FOUND_COUNCIL`이다. +- 생성 성공 응답은 200 OK이고 body는 없다. +- 생성 흐름을 로그인 사용자 대학 기준으로 바꾸려면 + 기존 id 1 전제와 API 권한 정책을 함께 재정의해야 한다. + +### 공지 수정 + +- endpoint는 `PUT /councils/notices/{id}`다. +- 공지가 없으면 `NOT_FOUND_COUNCIL_NOTICE`다. +- 요청의 `title`과 `content`는 blank일 수 없다. +- `title`은 최대 255자다. +- 수정은 기존 엔티티의 `title`, `content`만 교체한다. +- 공지의 council, 생성 시각, 읽음 이력은 수정하지 않는다. +- 수정 성공 응답은 200 OK이고 body는 없다. + +### 공지 삭제 + +- endpoint는 `DELETE /councils/notices/{id}`다. +- 공지가 없으면 `NOT_FOUND_COUNCIL_NOTICE`다. +- 삭제는 `deleteById`로 공지 row를 삭제한다. +- 삭제 성공 응답은 204 No Content다. + +### 읽지 않은 공지 수 + +- 사용자 정보 조회는 `CouncilNoticeReadRepository.countUnreadNoticesByUserId(userId)`를 사용한다. +- 이 쿼리는 read history가 없는 `CouncilNotice` 수를 센다. +- 현재 쿼리는 사용자의 대학 council로 범위를 제한하지 않는다. +- unread count 정책을 대학별로 바꾸려면 `UserService.getUserInfo`, repository query, + notice 목록/상세 테스트를 함께 확인해야 한다. + +## 절대 놓치면 안 되는 정책 + +- 공지 목록은 로그인 사용자 대학의 council 기준으로 격리되어야 한다. +- 공지 상세 조회는 다른 대학 공지를 읽을 수 없어야 한다. +- 다른 대학 공지를 상세 조회할 때 read history를 만들면 안 된다. +- read history는 `(userId, councilNoticeId)` 기준으로 중복 저장되면 안 된다. +- 목록의 `isRead`는 현재 페이지의 공지 id와 사용자별 read history를 매칭해서 만든다. +- 상세 조회만 읽음 처리한다. 목록 조회 자체는 읽음 이력을 만들지 않는다. +- 생성 API는 현재 council id 1 전제를 가진다. +- unread count는 현재 구현상 전체 `CouncilNotice` 기준이라는 점을 알고 수정해야 한다. + +## 수정 시 함께 확인해야 하는 것 + +### 목록 조회를 바꿀 때 + +- 로그인 사용자의 대학 조회 +- `CouncilRepository.getByUniversity` +- `CouncilNoticeRepository.findByCouncilId` +- `createdAt DESC` 정렬 +- page가 1-based로 들어와 `page - 1`로 변환되는 지점 +- 현재 페이지 공지 id만 read history 조회에 쓰는지 + +### 상세 조회와 읽음 처리를 바꿀 때 + +- `CouncilNoticeRepository.getById` +- 공지 council university와 사용자 university 비교 +- `FORBIDDEN_COUNCIL_NOTICE_ACCESS` +- `existsByUserIdAndCouncilNoticeId` 선검사 +- `(user_id, council_notice_id)` unique 제약 +- 같은 공지 반복 조회 시 read history 중복 저장 방지 + +### 생성/수정/삭제를 바꿀 때 + +- 요청 DTO의 `@NotBlank`, `@Size(max = 255)` +- 현재 생성 경로의 council id 1 고정 전제 +- 수정 시 `title`, `content`만 바꾸는 정책 +- 삭제 후 읽음 이력을 함께 정리해야 하는지에 대한 DB/JPA 정책 +- API 응답 status (생성/수정 200, 삭제 204) + +### 유저 도메인과 함께 바꿀 때 + +- `UserService.getUserInfo`의 `unreadCouncilNoticeCount` +- `countUnreadNoticesByUserId` 쿼리의 대학 범위 여부 +- 사용자 대학 변경 가능성이 생길 경우 read history와 unread count의 의미 +- 유저 삭제 시 read history cascade 삭제 + +## 주요 클래스와 책임 + +### `NoticeService` + +- 공지 목록/상세/생성/수정/삭제 정책이 모여 있는 중심 서비스다. +- 목록에서는 사용자 대학의 council 공지만 조회한다. +- 상세에서는 다른 대학 접근을 막고 읽음 이력을 생성한다. + +### `CouncilNoticeRepository` + +- 공지 조회와 저장/삭제를 담당한다. +- `getById`는 공지가 없으면 `NOT_FOUND_COUNCIL_NOTICE`를 던진다. + +### `CouncilNoticeReadRepository` + +- 읽음 이력 존재 여부, 현재 페이지 공지의 읽음 이력 목록, unread count를 담당한다. +- `countUnreadNoticesByUserId`는 마이페이지 unread count와 연결된다. + +### `CouncilNoticeResponse` + +- 공지 상세 응답 DTO다. +- 날짜/시간은 `yyyy.MM.dd HH:mm:ss` 형식으로 내려간다. + +### `CouncilNoticesResponse` + +- 공지 목록 응답 DTO다. +- 페이지 메타데이터와 목록 아이템의 `isRead`를 함께 내려준다. +- 목록 아이템 날짜는 `yyyy.MM.dd` 형식이다. + +## 테스트 전략 + +이미 통합 테스트는 아래 정책을 일부 고정한다. + +- 공지 목록 조회와 `isRead` 반영 +- 공지 목록 조회가 read history를 만들지 않는 정책 +- page가 1 미만이면 400 응답 +- 다른 대학 공지 목록 제외 +- 다른 대학 공지 상세 조회 403 +- 같은 공지 반복 상세 조회 시 read history 중복 방지 +- 사용자별 read history 격리 +- 공지 생성, 수정, 삭제 +- 생성 시 council id 1이 없으면 404 +- 생성 요청 title blank 검증 +- 수정/삭제 대상 공지가 없으면 404 + +이 도메인의 정책을 바꾸거나 가이드 claim을 강화한다면 +추가로 아래 회귀 테스트를 보강하는 것이 좋다. + +- `limit`이 1 미만이면 400을 반환하는 테스트 +- 생성/수정 요청의 title 255자 초과 검증 테스트 +- 공지 삭제 시 read history 정리 정책을 명확히 고정하는 테스트 +- `unreadCouncilNoticeCount`가 현재 전체 공지 기준인지, + 대학별 공지 기준인지 명확히 고정하는 테스트 + +검증할 때는 최소한 아래 테스트를 실행한다. + +```bash +CI=true ./gradlew test --tests 'gg.agit.konect.integration.domain.notice.*' +``` diff --git a/src/main/java/gg/agit/konect/domain/notification/AGENTS.md b/src/main/java/gg/agit/konect/domain/notification/AGENTS.md new file mode 100644 index 000000000..61a43c307 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/notification/AGENTS.md @@ -0,0 +1,266 @@ +# 알림 도메인 가이드 + +## 이 도메인은 무엇을 하는가 + +알림 도메인은 Expo push token 관리, 채팅 푸시 알림, 동아리 지원 관련 인앱 알림, SSE 실시간 전달, 알림함 읽음 상태를 관리하는 도메인이다. + +이 도메인에서 중요한 것은 단순히 외부 push API를 호출하는 것이 아니라 아래 상태와 후속 효과가 같은 정책을 바라보는 것이다. + +- 사용자별 Expo device token (`NotificationDeviceToken`) +- 인앱 알림함 (`NotificationInbox`) +- 채팅방별 mute 설정 (`NotificationMuteSetting`) +- 현재 채팅방 접속 상태 (`ChatPresenceService`) +- SSE emitter 연결 상태 (`NotificationInboxSseService`) +- Expo push 발송과 재시도 (`ExpoPushClient`) +- 동아리 지원 이벤트 리스너 (`ClubApplicationNotificationListener`) + +알림 관련 작업을 할 때는 항상 "이 변경이 push token, 인앱 알림 저장, SSE 전달, 채팅방 접속/뮤트 필터, 이벤트 commit 이후 발송까지 같이 맞는가"를 먼저 확인해야 한다. + +## 주요 상태 + +### `NotificationDeviceToken` + +- 사용자별 Expo push token을 저장한다. +- DB의 `notification_device_token.user_id`는 unique 제약을 가진다. +- 현재 구현 기준으로 한 사용자에게 활성 token row는 하나만 존재한다. +- token 문자열은 `ExponentPushToken[...]` 또는 `ExpoPushToken[...]` 형식만 허용한다. +- 내 토큰 조회 API 등 `getByUserId()`를 사용하는 조회는 row가 없으면 `NOT_FOUND_NOTIFICATION_TOKEN`으로 실패한다. +- 푸시 발송 경로의 `findTokensByUserId()` 조회는 row가 없으면 빈 리스트를 반환하며, 이 경우 푸시 발송만 생략한다. +- token 삭제는 요청한 userId와 token이 정확히 일치할 때만 삭제하고, 없으면 조용히 끝난다. + +### `NotificationInbox` + +- 인앱 알림함에 보이는 알림이다. +- 생성 시 `isRead`는 항상 `false`다. +- 알림 타입, 제목, 본문, 이동 path를 함께 저장한다. +- 단건 읽음 처리는 `notificationId + userId`로 찾은 본인 알림만 읽음 처리한다. +- 전체 읽음 처리는 채팅 관련 타입을 제외한 알림만 대상으로 한다. + +### `NotificationMuteSetting` + +- 현재 mute 대상 타입은 `CHAT_ROOM`뿐이다. +- unique 기준은 `(target_type, target_id, user_id)`다. +- `isMuted`가 null로 들어오면 false로 저장된다. +- `toggleMute()`는 현재 값을 반전한다. +- 채팅 알림 발송에서는 `isMuted = true`인 사용자만 제외한다. + +### `NotificationInboxSseService` + +- 사용자별 SSE emitter를 메모리 맵에 보관한다. +- 같은 사용자가 다시 구독하면 기존 emitter를 완료하고 새 emitter로 교체한다. +- 구독 성공 시 `connect` 이벤트로 `connected` 데이터를 보낸다. +- timeout, completion, error가 발생하면 현재 emitter와 일치할 때만 맵에서 제거한다. +- 알림 전송 실패가 `IOException` 또는 `IllegalStateException`이면 emitter를 제거한다. + +## 기능이 실제로 어떻게 동작해야 하는가 + +### Push token 등록과 삭제 + +- token 등록은 먼저 활성 사용자를 조회한다. +- Expo token 형식이 아니면 `INVALID_NOTIFICATION_TOKEN`이다. +- 기존 token row가 있으면 새 token 값으로 갱신한다. +- 기존 token row가 없으면 새 `NotificationDeviceToken`을 저장한다. +- 삭제는 `userId + token`으로 찾은 row만 삭제한다. +- 삭제 대상이 없으면 예외를 던지지 않는다. + +### 단일 채팅 푸시 알림 + +- `sendChatNotification`은 비동기(`notificationTaskExecutor`)로 실행된다. +- 수신자가 해당 채팅방에 접속 중이면 푸시를 보내지 않는다. +- 해당 채팅방을 mute한 사용자에게는 푸시를 보내지 않는다. +- 수신자의 push token이 없으면 푸시를 보내지 않는다. +- 메시지 본문 preview는 Unicode code point 기준 최대 30자다. +- 30자를 넘으면 앞 30 code point 뒤에 `...`를 붙인다. +- 메시지가 null이면 빈 문자열 preview를 쓴다. +- push payload의 path는 `chats/{roomId}`다. +- 이 흐름에서 발생한 예외는 잡아서 로그로 남기고 호출 흐름으로 전파하지 않는다. + +### 그룹 채팅 푸시 알림 + +- `sendGroupChatNotification`도 비동기(`notificationTaskExecutor`)로 실행된다. +- 수신자 목록에서 발신자를 먼저 제외한다. +- 남은 수신자가 없으면 푸시를 보내지 않는다. +- 채팅방에 접속 중인 사용자와 mute 사용자를 제외한다. +- 최종 대상이 없으면 푸시를 보내지 않는다. +- 최종 대상 사용자들의 token을 조회하고, token별 Expo batch message를 만든다. +- title은 동아리 이름, body는 `senderName + ": " + preview` 형식이다. +- payload path는 `chats/{roomId}`다. +- batch message가 비어 있지 않을 때만 Expo batch 발송을 호출한다. +- 이 흐름에서 발생한 예외는 잡아서 로그로 남기고 호출 흐름으로 전파하지 않는다. + +### 동아리 지원 알림 + +- 동아리 지원 알림은 push만 보내는 것이 아니라 인앱 알림 저장, SSE, push를 함께 수행한다. +- 지원 제출 알림: + - type: `CLUB_APPLICATION_SUBMITTED` + - title: 동아리 이름 + - body: `{applicantName}님이 동아리 가입을 신청했어요.` + - path: `mypage/manager/{clubId}/applications/{applicationId}` +- 지원 승인 알림: + - type: `CLUB_APPLICATION_APPROVED` + - title: 동아리 이름 + - body: `동아리 지원이 승인되었어요.` + - path: `clubs/{clubId}` +- 지원 거절 알림: + - type: `CLUB_APPLICATION_REJECTED` + - title: 동아리 이름 + - body: `동아리 지원이 거절되었어요.` + - path: `clubs/{clubId}` +- 각 알림은 `NotificationInbox`를 저장한 뒤 저장된 값을 `NotificationInboxResponse`로 바꿔 SSE로 보낸다. +- push token이 없으면 인앱 알림과 SSE는 유지하고 push만 생략한다. + +### 동아리 이벤트 리스너 + +- `ClubApplicationNotificationListener`는 동아리 지원 이벤트를 `AFTER_COMMIT`에 처리한다. +- 원 트랜잭션이 rollback되면 알림 후속 작업도 실행되지 않는다. +- 승인/거절 이벤트는 단일 수신자에게 알림을 보낸다. +- 제출 이벤트는 `receiverIds`의 각 사용자에게 개별 알림을 보낸다. +- 이벤트 리스너를 바꿀 때는 동아리 도메인의 이벤트 발행 시점과 트랜잭션 경계를 함께 확인해야 한다. + +### 인앱 알림 목록과 읽음 처리 + +- 목록 조회는 page 기본값 1, page size 20이다. +- 정렬은 `createdAt DESC, id DESC`다. +- 목록 조회와 미읽음 개수, 전체 읽음 처리는 채팅 관련 타입을 제외한다. +- 제외되는 채팅 관련 타입은 `CHAT_MESSAGE`, `GROUP_CHAT_MESSAGE`, `UNREAD_CHAT_COUNT`다. +- 단건 읽음은 본인 알림만 가능하고, 다른 사용자의 알림 id는 찾지 못한 알림으로 처리된다. +- 전체 읽음은 채팅 관련 타입을 제외한 미읽음 알림만 읽음 처리한다. + +### SSE 구독과 전송 + +- SSE timeout은 30분이다. +- 구독 직후 `connect` 이벤트를 보낸다. +- 한 사용자에게는 최신 emitter 하나만 유지한다. +- 이전 emitter의 completion callback이 늦게 실행되더라도 새 emitter를 제거하면 안 된다. +- 알림 전송 시 emitter가 없으면 조용히 종료한다. +- 전송 중 emitter가 이미 완료되어 있거나 IO 오류가 나면 맵에서 제거한다. +- SSE 실패는 인앱 알림 저장이나 push 발송 정책과 분리해서 생각해야 한다. + +### Expo push client + +- Expo push endpoint는 `https://exp.host/--/api/v2/push/send`다. +- 단건 사용자 발송도 token 목록을 message 목록으로 변환해서 Expo API를 호출한다. +- batch 발송은 100개씩 나눠 보낸다. +- HTTP 상태가 2xx가 아니거나 응답 body/data가 없으면 실패로 본다. +- Expo ticket의 status가 `ok`가 아니면 해당 token 실패를 error 로그로 남긴다. +- `@Retryable(maxAttempts = 2)`로 재시도한다. +- HTTP 오류, 연결 오류, 비정상 응답, 기타 RestClient 오류는 recover 메서드에서 로그로 남긴다. + +## 절대 놓치면 안 되는 정책 + +- 사용자별 device token은 하나만 유지한다. 새 token 등록은 row 추가가 아니라 기존 row 갱신일 수 있다. +- token 형식 검증은 Expo token 문자열 형식 기준이다. +- 채팅 push는 사용자가 이미 채팅방에 있거나 mute한 경우 보내면 안 된다. +- 그룹 채팅 push는 발신자를 수신자에서 제외해야 한다. +- 채팅 preview는 Java 문자열 길이가 아니라 Unicode code point 기준 30자를 사용한다. +- 채팅 알림 실패는 메시지 저장이나 채팅 흐름을 실패시키면 안 된다. +- 동아리 지원 알림은 인앱 저장, SSE, push가 함께 움직인다. +- 동아리 지원 이벤트는 commit 이후에만 알림으로 이어져야 한다. +- 인앱 알림 목록/미읽음/전체 읽음은 채팅 관련 타입을 제외한다. +- SSE는 사용자당 최신 연결 하나만 유지한다. +- Expo push 실패 ticket은 전체 요청 성공 여부와 별도로 token별 실패 로그를 확인해야 한다. + +## 수정 시 함께 확인해야 하는 것 + +### Push token 정책을 바꿀 때 + +- `notification_device_token.user_id` unique 제약 +- Expo token 정규식 +- 기존 token 갱신과 신규 저장 분기 +- 삭제 요청의 userId/token 일치 기준 +- 탈퇴 사용자 token 조회 제외 여부 + +### 채팅 알림을 바꿀 때 + +- `ChatPresenceService` 접속 상태 필터 +- `NotificationMuteSetting`의 `CHAT_ROOM` mute 필터 +- 발신자 제외 정책 +- Unicode code point 기준 preview 길이 +- payload path (`chats/{roomId}`) +- 예외를 삼키고 로그로 남기는 비동기 경계 + +### 동아리 지원 알림을 바꿀 때 + +- 동아리 이벤트 발행 시점 +- `AFTER_COMMIT` 리스너 유지 여부 +- inbox type/title/body/path +- SSE 전송 대상과 응답 DTO +- push token이 없을 때 인앱 알림을 유지하는 정책 + +### 인앱 알림함을 바꿀 때 + +- 채팅 관련 타입 제외 집합 +- page size 20 +- `createdAt DESC, id DESC` 정렬 +- 단건 읽음의 userId 소유권 조건 +- 전체 읽음에서 채팅 관련 타입 제외 + +### SSE를 바꿀 때 + +- 사용자별 emitter 교체 정책 +- completion/timeout/error callback의 조건부 제거 +- 최초 connect 이벤트 +- send 실패 시 emitter 제거 +- 이미 완료된 emitter 처리 + +### Expo push client를 바꿀 때 + +- batch size 100 +- channel id `default_notifications` +- 2xx 상태와 body/data 검증 +- ticket별 실패 로그 +- retry/recover 메서드 시그니처 + +## 주요 클래스와 책임 + +### `NotificationService` + +- push token 등록/삭제, 채팅 push, 동아리 지원 알림 발송을 담당한다. +- presence, mute, token 조회, inbox 저장, SSE, Expo push가 만나는 중심 서비스다. + +### `NotificationInboxService` + +- 인앱 알림 저장, 목록 조회, 미읽음 개수, 읽음 처리를 담당한다. +- 채팅 관련 알림을 알림함 목록/카운트/전체 읽음에서 제외하는 정책이 있다. + +### `NotificationInboxSseService` + +- 사용자별 SSE emitter 생명주기를 관리한다. +- 같은 사용자 재구독, connect 이벤트, 실패 시 emitter 제거 정책을 바꾸면 이 클래스를 먼저 확인해야 한다. + +### `ExpoPushClient` + +- Expo push API 호출, batch 분할, retry/recover, ticket 실패 로그를 담당한다. +- 외부 API 실패를 비즈니스 알림 흐름과 어떻게 분리할지 판단하는 지점이다. + +### `ClubApplicationNotificationListener` + +- 동아리 지원 이벤트를 commit 이후 알림 발송으로 연결한다. +- 동아리 도메인의 이벤트 payload가 바뀌면 이 리스너와 알림 path/body를 같이 확인해야 한다. + +## 테스트 전략 + +이미 단위 테스트는 아래 정책을 일부 고정한다. + +- token 등록/갱신/삭제와 잘못된 Expo token 거부 +- `ExpoPushToken[...]`과 `ExponentPushToken[...]` 형식 허용 +- 채팅방 접속 중 사용자와 mute 사용자 push 제외 +- 그룹 채팅의 발신자, 접속 중, mute 사용자 필터 +- Unicode emoji를 포함한 preview 30 code point 처리 +- 동아리 지원 알림의 inbox, SSE, push 호출 +- push token이 없어도 동아리 지원 인앱 알림과 SSE를 유지하는 정책 +- SSE 재구독과 완료된 emitter 정리 +- 인앱 알림 save/saveAll/read 처리 + +이 도메인의 정책을 바꾸거나 가이드 claim을 강화한다면 추가로 아래 회귀 테스트를 보강하는 것이 좋다. + +- 동아리 지원 이벤트가 rollback되면 알림이 생성되지 않는 `AFTER_COMMIT` 통합 테스트 +- 인앱 알림 목록/미읽음/전체 읽음에서 채팅 관련 타입이 제외되는 repository 통합 테스트 +- Expo push ticket 일부 실패가 전체 예외로 전파되지 않고 로그만 남는 테스트 +- 그룹 채팅 token 수와 대상 사용자 수가 다를 때 현재 정책을 명확히 고정하는 테스트 + +검증할 때는 최소한 아래 테스트를 실행한다. + +```bash +CI=true ./gradlew test --tests 'gg.agit.konect.unit.domain.notification.*' --tests 'gg.agit.konect.integration.domain.notification.*' +``` 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 index 4f357d06c..6889bf584 100644 --- a/src/main/java/gg/agit/konect/domain/notification/service/NotificationInboxSseService.java +++ b/src/main/java/gg/agit/konect/domain/notification/service/NotificationInboxSseService.java @@ -47,10 +47,17 @@ public void send(Integer userId, NotificationInboxResponse notification) { } try { emitter.send(SseEmitter.event().name("notification").data(notification)); - } catch (IOException e) { + } catch (IOException | IllegalStateException e) { log.warn("SSE send failed: userId={}", userId, e); emitters.remove(userId, emitter); - emitter.completeWithError(e); + if (e instanceof IOException ioException) { + try { + emitter.completeWithError(ioException); + } catch (IllegalStateException completeException) { + log.warn("SSE emitter already completed while closing after send failure: userId={}", userId, + completeException); + } + } } } } 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 15bd7ae82..c242829f0 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 @@ -10,6 +10,7 @@ import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import gg.agit.konect.domain.chat.service.ChatPresenceService; @@ -74,7 +75,7 @@ public void deleteToken(Integer userId, NotificationTokenDeleteRequest request) } @Async("notificationTaskExecutor") - @Transactional + @Transactional(propagation = Propagation.NOT_SUPPORTED) public void sendChatNotification(Integer receiverId, Integer roomId, String senderName, String messageContent) { try { if (chatPresenceService.isUserInChatRoom(roomId, receiverId)) { @@ -114,7 +115,7 @@ public void sendChatNotification(Integer receiverId, Integer roomId, String send } @Async("notificationTaskExecutor") - @Transactional + @Transactional(propagation = Propagation.NOT_SUPPORTED) public void sendGroupChatNotification( Integer roomId, Integer senderId, @@ -191,7 +192,7 @@ public void sendGroupChatNotification( } @Async("notificationTaskExecutor") - @Transactional + @Transactional(propagation = Propagation.NOT_SUPPORTED) public void sendClubApplicationSubmittedNotification( Integer receiverId, Integer applicationId, @@ -208,7 +209,7 @@ public void sendClubApplicationSubmittedNotification( } @Async("notificationTaskExecutor") - @Transactional + @Transactional(propagation = Propagation.NOT_SUPPORTED) public void sendClubApplicationApprovedNotification(Integer receiverId, Integer clubId, String clubName) { String body = "동아리 지원이 승인되었어요."; String path = "clubs/" + clubId; @@ -219,7 +220,7 @@ public void sendClubApplicationApprovedNotification(Integer receiverId, Integer } @Async("notificationTaskExecutor") - @Transactional + @Transactional(propagation = Propagation.NOT_SUPPORTED) public void sendClubApplicationRejectedNotification(Integer receiverId, Integer clubId, String clubName) { String body = "동아리 지원이 거절되었어요."; String path = "clubs/" + clubId; diff --git a/src/main/java/gg/agit/konect/domain/schedule/AGENTS.md b/src/main/java/gg/agit/konect/domain/schedule/AGENTS.md new file mode 100644 index 000000000..953d227a3 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/schedule/AGENTS.md @@ -0,0 +1,231 @@ +# 일정 도메인 가이드 + +## 이 도메인은 무엇을 하는가 + +일정 도메인은 사용자의 대학 기준 학사/행사 일정을 조회하고, +관리자가 같은 일정 모델을 생성/수정/삭제하는 도메인이다. + +이 도메인에서 중요한 것은 단순 날짜 조회가 아니라 +아래 상태가 같은 정책을 바라보는 것이다. + +- 공통 일정 본문 (`Schedule`) +- 대학별 일정 연결 (`UniversitySchedule`) +- 로그인 사용자의 대학 (`User.university`) +- 일정 타입 (`ScheduleType`) +- 월 범위 검색과 D-Day 계산 +- 관리자 일정 생성/수정/삭제 API + +일정 관련 작업을 할 때는 항상 "이 변경이 대학별 일정 격리, 월 경계 포함 규칙, +검색어 정규화, D-Day 계산, 관리자 upsert/delete 정책까지 같이 맞는가"를 먼저 확인해야 한다. + +## 주요 상태 + +### `Schedule` + +- 일정의 제목, 시작 일시, 종료 일시, 타입을 저장하는 공통 엔티티다. +- `title`, `startedAt`, `endedAt`, `scheduleType`은 null일 수 없다. +- `scheduleType`은 문자열 enum으로 저장된다. +- 현재 타입은 `UNIVERSITY`, `CLUB`, `COUNCIL`, `DORM`이다. +- 생성과 수정 모두 `startedAt <= endedAt`이어야 한다. +- `startedAt`이 `endedAt`보다 늦으면 `INVALID_DATE_TIME`이다. +- `startedAt == endedAt`인 단일 시점 일정은 허용된다. +- `calculateDDay(today)`는 오늘이 시작일 전이면 남은 일수를 반환하고, + 오늘이 시작일 당일이거나 이후이면 `null`을 반환한다. + +### `UniversitySchedule` + +- 특정 대학에 속한 일정 연결 엔티티다. +- `@MapsId` 기반으로 `Schedule`과 같은 id를 공유한다. +- `university_schedule.id`는 `schedule.id`를 참조한다. +- 조회와 관리자 수정/삭제는 항상 로그인 사용자의 university id로 + `UniversitySchedule`을 제한해야 한다. +- 다른 대학의 일정 id가 들어오면 존재하지 않는 일정처럼 `NOT_FOUND_SCHEDULE`로 처리된다. + +## 기능이 실제로 어떻게 동작해야 하는가 + +### 다가오는 일정 조회 + +- endpoint는 `GET /schedules/upcoming`이다. +- 로그인 사용자의 대학 id로 `UniversitySchedule`을 제한한다. +- 오늘 00:00 이상에 끝나는 일정만 조회한다. +- 즉 오늘 00:00에 끝나는 일정도 포함되며, 이미 시작했지만 오늘 이후까지 진행 중인 일정도 포함될 수 있다. +- 최대 3개만 반환한다. +- 정렬은 `startedAt ASC`다. +- 다른 대학의 일정은 포함되면 안 된다. +- 응답의 `dDay`는 시작일 전 일정에만 값이 있고, + 진행 중이거나 당일 시작 일정은 `null`이다. + +### 월별 일정 조회 + +- endpoint는 `GET /schedules`다. +- `year`는 필수이며 2000 이상 2100 이하만 허용한다. +- `month`는 필수이며 1 이상 12 이하만 허용한다. +- `query`가 null이면 빈 문자열로 바뀐다. +- 조회 월의 시작은 해당 월 1일 00:00이다. +- 조회 월의 끝은 해당 월 마지막 날 `LocalTime.MAX`다. +- 일정 포함 조건은 `startedAt < monthEnd AND endedAt > monthStart`다. +- 따라서 조회 월과 조금이라도 겹치는 일정은 포함된다. +- 정렬은 `startedAt ASC`다. +- `query`가 비어 있으면 전체 월별 조회를 사용한다. +- `query`가 있으면 trim 후 lower-case로 바꾸고, 일정 제목도 lower-case로 비교한다. +- 검색은 제목 contains 조건이다. + +### 일정 응답 + +- 응답 루트는 `schedules` 배열이다. +- 각 일정은 `title`, `startedAt`, `endedAt`, `dDay`, `scheduleCategory`를 포함한다. +- `startedAt`, `endedAt` JSON 형식은 `yyyy.MM.dd HH:mm`이다. +- `scheduleCategory`는 `ScheduleType.name()`이다. +- `dDay`는 요청 기준 날짜가 아니라 서버의 `LocalDate.now()` 기준이다. + +### 관리자 일정 생성 + +- endpoint는 `POST /admin/schedules`다. +- `ADMIN` 권한이 필요하다. +- 요청의 `title`은 blank일 수 없다. +- `startedAt`, `endedAt`, `scheduleType`은 null일 수 없다. +- 날짜 범위는 `Schedule` 엔티티에서 검증한다. +- 생성 시 먼저 `Schedule`을 저장하고, + 저장된 schedule과 로그인 사용자의 대학으로 `UniversitySchedule`을 저장한다. +- 생성 성공 응답은 200 OK이고 body는 없다. + +### 관리자 일정 일괄 생성/수정 + +- endpoint는 `PUT /admin/schedules/batch`다. +- `ADMIN` 권한이 필요하다. +- 요청의 `schedules`는 비어 있을 수 없다. +- 각 item의 `scheduleId`가 null이면 새 일정을 생성한다. +- 각 item의 `scheduleId`가 있으면 로그인 사용자 대학에 속한 + `UniversitySchedule`을 찾아 기존 `Schedule`을 수정한다. +- 수정 대상이 없거나 다른 대학 일정이면 `NOT_FOUND_SCHEDULE`이다. +- 생성과 수정은 한 transaction에서 순차 처리된다. +- 항목 중 하나라도 검증이나 수정 대상 조회에 실패하면 전체 요청이 실패해야 한다. +- 일정 50개 규모의 batch도 현재 테스트에서 고정한다. + +### 관리자 일정 삭제 + +- endpoint는 `DELETE /admin/schedules/{scheduleId}`다. +- `ADMIN` 권한이 필요하다. +- 로그인 사용자 대학에 속한 `UniversitySchedule`만 삭제할 수 있다. +- 다른 대학 일정이거나 이미 삭제된 일정이면 `NOT_FOUND_SCHEDULE`이다. +- 삭제는 `UniversitySchedule`을 먼저 삭제하고 연결된 `Schedule`도 삭제한다. +- 삭제 성공 응답은 200 OK이고 body는 없다. + +## 절대 놓치면 안 되는 정책 + +- 일반 조회와 관리자 수정/삭제 모두 로그인 사용자의 대학 기준으로 격리되어야 한다. +- 월별 일정은 시작일이 해당 월에 있는 일정만 보는 것이 아니라 + 월 범위와 겹치는 일정을 본다. +- 월 경계 조건은 `startedAt < monthEnd AND endedAt > monthStart`다. +- 다가오는 일정은 종료 시각이 오늘 00:00 이후인 일정이다. +- D-Day는 시작일 전일 때만 내려가고, 당일/진행 중 일정은 `null`이다. +- 검색어는 trim + lower-case 처리 후 제목 contains로 비교한다. +- 관리자 batch upsert는 일부 성공을 허용하지 않는 하나의 transaction이다. +- `UniversitySchedule`은 `Schedule`과 id를 공유하므로, + schedule id와 university schedule id를 같은 값으로 다룬다. +- 다른 대학 일정 수정/삭제 시 권한 오류가 아니라 `NOT_FOUND_SCHEDULE`로 숨긴다. + +## 수정 시 함께 확인해야 하는 것 + +### 조회 조건을 바꿀 때 + +- `ScheduleRepository.findUpcomingSchedules` +- `ScheduleRepository.findSchedulesByMonth` +- `ScheduleQueryRepository.findSchedulesByMonthAndQuery` +- 대학별 `UniversitySchedule` 조인 조건 +- 진행 중 일정의 upcoming 포함 여부 +- 월 경계에 걸친 일정 포함 여부 +- `startedAt ASC` 정렬 + +### 검색 정책을 바꿀 때 + +- `ScheduleCondition`의 null query 기본값 +- query trim 처리 +- query lower-case 처리 +- 제목 lower-case contains 조건 +- 빈 문자열 query와 공백-only query의 동작 + +### 날짜와 D-Day를 바꿀 때 + +- `Schedule.validateDateTimeRange` +- `startedAt == endedAt` 허용 여부 +- `Schedule.calculateDDay` +- `SchedulesResponse.InnerScheduleResponse.from` +- 서버 `LocalDate.now()` 기준 사용 여부 +- 응답 날짜 형식 `yyyy.MM.dd HH:mm` + +### 관리자 생성/수정/삭제를 바꿀 때 + +- `@Auth(roles = {UserRole.ADMIN})` +- 요청 DTO의 `@NotBlank`, `@NotNull`, `@NotEmpty` +- `AdminScheduleService.createUniversitySchedule` +- batch upsert의 transaction 경계 +- 다른 대학 일정 수정/삭제 차단 +- `UniversitySchedule` 삭제와 `Schedule` 삭제 순서 + +## 주요 클래스와 책임 + +### `ScheduleService` + +- 일반 사용자 일정 조회를 담당한다. +- 로그인 사용자 대학 기준으로 upcoming/monthly 일정을 조회하고 응답 DTO로 변환한다. + +### `ScheduleRepository` + +- query가 없는 upcoming/monthly 조회를 담당한다. +- 대학별 일정 격리와 월 범위 겹침 조건이 들어 있다. + +### `ScheduleQueryRepository` + +- query가 있는 월별 검색을 담당한다. +- QueryDSL로 대학, 월 범위, 제목 contains 조건을 조합한다. + +### `AdminScheduleService` + +- 관리자 일정 생성, batch upsert, 삭제를 담당한다. +- 같은 `Schedule`/`UniversitySchedule` 모델을 쓰므로 일반 조회 정책과 함께 봐야 한다. + +### `Schedule` + +- 일정 날짜 범위 검증과 D-Day 계산을 담당한다. +- 날짜 정책을 바꾸면 조회 응답과 관리자 생성/수정 검증이 함께 바뀐다. + +### `UniversitySchedule` + +- 일정과 대학을 연결한다. +- `Schedule`과 같은 id를 공유하는 구조이므로 + id 의미를 바꾸면 조회와 관리자 API가 같이 깨진다. + +## 테스트 전략 + +이미 통합 테스트는 아래 정책을 일부 고정한다. + +- 다가오는 일정 최대 3개 조회 +- 종료된 일정 upcoming 제외 +- 다른 대학 일정 조회 제외 +- 진행 중인 일정의 `dDay` null과 시작 전 일정의 `dDay` 계산 +- 특정 월 일정 조회 +- query 검색 +- query의 대소문자 무시와 trim 처리 +- 월을 걸치는 일정 조회 +- 관리자 일정 생성과 validation 실패 +- 시작/종료가 같은 일정 생성 허용 +- 관리자 batch 생성/수정/혼합 처리 +- batch 요청에서 하나라도 실패하면 전체 rollback +- 다른 대학 일정 수정/삭제 차단 +- 관리자 일정 삭제 시 `Schedule`과 `UniversitySchedule` 함께 삭제 +- 일반 사용자 관리자 API 접근 차단 + +이 도메인의 정책을 바꾸거나 가이드 claim을 강화한다면 +추가로 아래 회귀 테스트를 보강하는 것이 좋다. + +- `year`, `month` 범위 검증 테스트 +- 공백-only query가 현재 전체 조회와 같은지 명확히 고정하는 테스트 + +검증할 때는 최소한 아래 테스트를 실행한다. + +```bash +CI=true ./gradlew test \ + --tests 'gg.agit.konect.integration.domain.schedule.*' \ + --tests 'gg.agit.konect.integration.admin.schedule.*' +``` diff --git a/src/main/java/gg/agit/konect/domain/studytime/AGENTS.md b/src/main/java/gg/agit/konect/domain/studytime/AGENTS.md new file mode 100644 index 000000000..f4d71cfa2 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/studytime/AGENTS.md @@ -0,0 +1,260 @@ +# 순공 시간 도메인 가이드 + +## 이 도메인은 무엇을 하는가 + +순공 시간 도메인은 사용자의 공부 타이머, 일별 공부 시간 누적, 랭킹 캐시, 랭킹 초기화 스케줄러를 관리하는 도메인이다. + +이 도메인에서 중요한 것은 단순히 초 단위 시간을 더하는 것이 아니라 아래 상태가 같은 기준으로 움직이는 것이다. + +- 실행 중인 타이머(`StudyTimer`) +- 날짜별 누적 시간(`StudyTimeDaily`) +- 개인/동아리/학번 랭킹 캐시(`StudyTimeRanking`) +- 랭킹 타입(`RankingType`) +- 공부 시간 누적 이벤트(`StudyTimeAccumulatedEvent`) +- 일간/월간 랭킹 초기화 스케줄러 + +순공 시간 관련 작업을 할 때는 항상 "이 변경이 타이머 단일 실행, 서버/클라이언트 시간 검증, 자정 분할 누적, 랭킹 캐시 갱신, 초기화 스케줄러까지 같이 맞는가"를 먼저 확인해야 한다. + +## 주요 상태 + +### `StudyTimer` + +- 실행 중인 타이머를 나타낸다. +- 사용자당 동시에 하나만 존재할 수 있다. +- DB의 `study_timer.user_id`는 unique 제약을 가진다. +- `startedAt`은 마지막으로 누적 반영된 시각이다. +- `createdAt`은 현재 타이머 세션의 최초 시작 시각이며, 서버 기준 총 경과 시간 계산에 쓰인다. +- sync가 성공하면 `startedAt`만 현재 시각으로 갱신된다. +- stop이 성공하면 타이머 row는 삭제된다. + +### `StudyTimeDaily` + +- 사용자별, 날짜별 누적 공부 시간을 저장한다. +- DB의 `(user_id, study_date)`는 unique 제약을 가진다. +- 자정을 넘긴 세션은 날짜별 구간으로 나뉘어 각 날짜 row에 더해진다. +- 현재 구현은 일간 테이블을 기준으로 일간, 월간, 전체 누적 시간을 모두 계산한다. +- 마이그레이션에는 `study_time_monthly`, `study_time_total` 테이블도 있지만 현재 서비스 로직은 이 테이블들을 직접 사용하지 않는다. + +### `StudyTimeRanking` + +- 랭킹 조회를 빠르게 하기 위한 캐시성 테이블이다. +- 기본 키는 `(ranking_type_id, university_id, target_id)`다. +- 랭킹 타입은 `CLUB`, `STUDENT_NUMBER`, `PERSONAL` 세 가지다. +- `dailySeconds`와 `monthlySeconds`를 함께 저장한다. +- 랭킹 캐시는 공부 시간이 누적된 뒤 발행되는 이벤트 리스너에서 갱신된다. + +## 기능이 실제로 어떻게 동작해야 하는가 + +### 타이머 시작 + +- 사용자당 실행 중인 타이머는 하나만 허용된다. +- 서비스는 먼저 `existsByUserId`로 실행 중 타이머를 확인한다. +- 동시에 두 요청이 들어와도 DB unique 제약과 flush 시점의 `DataIntegrityViolationException`을 통해 `ALREADY_RUNNING_STUDY_TIMER`로 정리한다. +- 타이머 시작 시점에는 공부 시간이 누적되지 않고, 이벤트도 발행되지 않는다. + +### 타이머 sync + +- sync는 실행 중인 타이머가 있어야 가능하다. +- 서버 기준 경과 시간은 `createdAt`부터 현재 시각까지다. +- 클라이언트가 보낸 `totalSeconds`와 서버 기준 경과 시간이 3초 이상 차이나면 실패한다. +- 시간 불일치가 발생하면 타이머를 삭제하고 `STUDY_TIMER_TIME_MISMATCH`를 던진다. +- 시간 불일치 예외는 트랜잭션 rollback 대상에서 제외되어야 한다. 그래야 잘못된 타이머 삭제가 유지된다. +- sync 성공 시 마지막 sync 이후 구간만 `StudyTimeDaily`에 누적한다. +- sync 성공 후 `StudyTimeAccumulatedEvent`를 발행하고 `startedAt`을 현재 시각으로 갱신한다. + +### 타이머 stop + +- stop은 실행 중인 타이머가 있어야 가능하다. +- 서버/클라이언트 시간 차이가 3초 이상이면 sync와 동일하게 타이머를 삭제하고 실패한다. +- stop 성공 시 마지막 sync 이후 구간을 누적하고 `StudyTimeAccumulatedEvent`를 발행한다. +- stop 성공 후 실행 중인 타이머 row를 삭제한다. +- 응답의 `sessionSeconds`는 현재 세션의 서버 기준 총 경과 시간이다. +- 응답의 `dailySeconds`, `monthlySeconds`, `totalSeconds`는 누적 반영 후 다시 조회한 값이다. + +### 자정 분할 누적 + +- 공부 시간이 자정을 넘기면 하나의 총합으로 저장하지 않고 날짜별로 분할한다. +- 구간 시작일이 종료일보다 이전이면 해당 날짜의 자정까지를 먼저 누적한다. +- 마지막 날짜는 실제 종료 시각까지 누적한다. +- 0초 이하 구간은 저장하지 않는다. +- 이 정책을 바꾸면 일간 조회뿐 아니라 월간/전체 합계와 랭킹 갱신 결과도 함께 바뀐다. + +### 요약 조회 + +- 일간 공부 시간은 오늘 날짜의 `StudyTimeDaily` row를 조회한다. +- 월간 공부 시간은 이번 달 1일부터 오늘까지의 `StudyTimeDaily.totalSeconds` 합계다. +- 전체 공부 시간은 사용자의 모든 `StudyTimeDaily.totalSeconds` 합계다. +- 실행 중인 타이머의 아직 sync되지 않은 시간은 요약 조회에 포함되지 않는다. + +### 랭킹 갱신 + +- 공부 시간이 실제로 누적된 뒤 `StudyTimeAccumulatedEvent`가 발행된다. +- 랭킹 갱신 리스너는 `AFTER_COMMIT`에 실행된다. +- 리스너는 별도 트랜잭션(`REQUIRES_NEW`)으로 랭킹 캐시를 갱신한다. +- 즉 공부 시간 누적 트랜잭션이 rollback되면 랭킹 갱신도 실행되지 않는다. +- 랭킹 갱신은 개인, 사용자가 속한 동아리, 사용자의 학번 연도 랭킹을 함께 갱신한다. + +### 개인 랭킹 + +- 개인 랭킹의 target id는 user id다. +- target name은 사용자 이름이다. +- 랭킹 목록 응답에서 개인 이름은 마스킹된다. +- 한 글자 이름은 그대로, 두 글자 이름은 첫 글자와 `*`, 세 글자 이상은 첫 글자와 마지막 글자만 노출한다. + +### 동아리 랭킹 + +- 동아리 랭킹은 사용자가 속한 각 동아리에 대해 갱신된다. +- target id는 club id다. +- target name은 동아리 이름이다. +- 동아리 공부 시간은 현재 동아리 회원들의 일간/월간 공부 시간 합계다. +- 사용자의 공부 시간이 누적되면 사용자가 속한 동아리들의 랭킹만 갱신된다. +- 동아리 회원 구성 변경 자체가 순공 시간 이벤트를 발행하지는 않는다. 회원 변경 후 랭킹 정합성을 요구한다면 별도 갱신 지점을 확인해야 한다. + +### 학번 랭킹 + +- 학번 랭킹은 사용자의 `studentNumberYear` 기준으로 묶는다. +- target name은 학번 연도 문자열이다. +- 랭킹 목록 응답에서는 학번 연도의 뒤 두 자리만 노출한다. +- 학번 랭킹은 target name으로 기존 row를 찾는다. +- 새 학번 랭킹 row가 필요하면 같은 랭킹 타입과 대학 안에서 max target id를 찾아 다음 id를 부여한다. +- 따라서 학번 랭킹은 target id가 학번 값이 아니라 내부 순번이라는 점을 놓치면 안 된다. + +### 랭킹 조회 + +- 랭킹은 로그인한 사용자의 대학 기준으로만 조회된다. +- `type`은 `CLUB`, `STUDENT_NUMBER`, `PERSONAL`만 허용한다. +- `type`은 대소문자를 구분하지 않고 조회하지만, 요청 검증의 허용 값은 세 타입으로 제한된다. +- `page` 기본값은 1, `limit` 기본값은 20, `sort` 기본값은 `MONTHLY`다. +- `limit`은 1 이상 100 이하만 허용한다. +- 일간 정렬은 `dailySeconds DESC`, `monthlySeconds DESC`, `targetId ASC` 순서다. +- 월간 정렬은 `monthlySeconds DESC`, `dailySeconds DESC`, `targetId ASC` 순서다. +- 페이지 응답의 rank는 페이지 시작 번호 기준으로 계산된다. +- 내 랭킹 조회는 동아리 랭킹 목록, 학번 랭킹, 개인 랭킹을 함께 반환한다. +- 내 동아리 랭킹은 존재하는 랭킹 row만 반환하고 rank 오름차순으로 정렬한다. +- 학번/개인 랭킹 row가 아직 없으면 해당 응답 필드는 `null`일 수 있다. + +### 랭킹 초기화 스케줄러 + +- 매일 00:00에 모든 랭킹의 `dailySeconds`를 0으로 초기화한다. +- 매월 1일 00:00에 모든 랭킹의 `monthlySeconds`를 0으로 초기화한다. +- 초기화 대상은 `study_time_ranking` 캐시 테이블이다. +- `StudyTimeDaily` 원본 누적 데이터는 초기화하지 않는다. +- 스케줄러는 예외를 잡아 로그로 남기며, 예외를 외부로 다시 던지지 않는다. + +## 절대 놓치면 안 되는 정책 + +- 실행 중 타이머는 사용자당 하나뿐이다. 서비스 선검사와 DB unique 제약을 함께 봐야 한다. +- `startedAt`과 `createdAt`은 역할이 다르다. `startedAt`은 마지막 누적 지점, `createdAt`은 세션 전체 경과 시간 기준이다. +- 서버/클라이언트 시간 차이가 3초 이상이면 타이머를 삭제하고 실패한다. +- 시간 불일치 실패에서 타이머 삭제가 rollback되면 안 된다. +- sync/stop은 마지막 sync 이후 구간만 누적한다. +- 자정을 넘긴 세션은 날짜별로 분할 누적해야 한다. +- 요약 조회는 저장된 `StudyTimeDaily`만 본다. 실행 중 타이머의 미반영 시간은 포함하지 않는다. +- 랭킹 갱신은 공부 시간 누적 트랜잭션 commit 이후 별도 트랜잭션에서 실행된다. +- 랭킹 캐시는 원본 누적 데이터가 아니다. 일간/월간 초기화는 랭킹 캐시에만 적용된다. +- 개인 이름과 학번 연도는 랭킹 목록 응답에서 노출 정책이 다르다. +- 학번 랭킹 target id는 학번 자체가 아니라 내부 순번이다. +- 동아리 회원 변경은 순공 시간 랭킹 갱신 이벤트를 자동으로 만들지 않는다. + +## 수정 시 함께 확인해야 하는 것 + +### 타이머 시작/종료 정책을 바꿀 때 + +- `study_timer.user_id` unique 제약 +- 중복 시작 시 `ALREADY_RUNNING_STUDY_TIMER` +- `createdAt` 기준 서버 경과 시간 계산 +- `startedAt` 기준 마지막 누적 구간 계산 +- 시간 불일치 시 타이머 삭제 유지 +- sync/stop 성공 시 `StudyTimeAccumulatedEvent` 발행 + +### 시간 누적 로직을 바꿀 때 + +- 자정 분할 누적 +- `StudyTimeDaily`의 `(user_id, study_date)` unique 제약 +- 일간/월간/전체 조회 쿼리 +- 0초 이하 구간 무시 +- 랭킹 갱신에 쓰이는 일간/월간 집계 기준 + +### 랭킹 정책을 바꿀 때 + +- `RankingType` seed 값 (`CLUB`, `STUDENT_NUMBER`, `PERSONAL`) +- 대학별 랭킹 격리 +- 일간/월간 정렬 tie-breaker +- 개인 이름 마스킹 +- 학번 연도 뒤 두 자리 표시 +- 학번 랭킹 target id 생성 규칙 +- 내 랭킹 조회에서 null 허용 필드 + +### 스케줄러를 바꿀 때 + +- 매일 00:00 일간 랭킹 초기화 +- 매월 1일 00:00 월간 랭킹 초기화 +- 원본 `StudyTimeDaily`를 초기화하지 않는 정책 +- `scheduler.studytime` 로거 설정 +- 예외를 잡아 스케줄러 실행 흐름을 유지하는 정책 + +### 동아리/유저 도메인과 함께 바꿀 때 + +- 동아리 회원 목록 기반 동아리 랭킹 합산 +- 회원 탈퇴 또는 동아리 탈퇴 후 랭킹 캐시 정합성 +- 사용자 이름 변경 시 개인 랭킹 target name 갱신 여부 +- 학번 변경 가능성이 생길 경우 학번 랭킹 target name과 target id 정합성 +- 사용자 대학 변경 가능성이 생길 경우 대학별 랭킹 격리 + +## 주요 클래스와 책임 + +### `StudyTimerService` + +- 타이머 시작, sync, stop을 담당한다. +- 시간 불일치 검증, 날짜별 누적, 이벤트 발행이 모여 있는 중심 서비스다. + +### `StudyTimeQueryService` + +- 일간, 월간, 전체 누적 공부 시간 조회를 담당한다. +- 현재 구현은 `StudyTimeDaily` 합계만 사용한다. + +### `StudyTimeRankingUpdateService` + +- 공부 시간 누적 이후 개인/동아리/학번 랭킹 캐시를 갱신한다. +- 동아리 회원 합산과 학번 연도 합산 정책을 바꿀 때 가장 먼저 봐야 한다. + +### `StudyTimeRankingService` + +- 랭킹 목록 조회와 내 랭킹 조회를 담당한다. +- 정렬 기준, rank 계산, 이름/학번 노출 정책이 응답으로 나가는 지점이다. + +### `StudyTimeRankingUpdateListener` + +- `StudyTimeAccumulatedEvent`를 commit 이후 받아 랭킹 갱신을 실행한다. +- 트랜잭션 전파 방식을 바꾸면 누적 성공과 랭킹 갱신의 결합도가 달라진다. + +### `StudyTimeSchedulerService` / `StudyTimeScheduler` + +- 일간/월간 랭킹 캐시 초기화를 담당한다. +- 원본 누적 데이터가 아니라 랭킹 캐시만 초기화한다는 점을 유지해야 한다. + +## 테스트 전략 + +현재 `StudyTimeApiTest`는 비어 있으므로 API 통합 흐름은 아직 충분히 고정되어 있지 않다. 다만 핵심 단위 정책은 `gg.agit.konect.unit.domain.studytime` 아래에서 먼저 고정한다. + +이미 고정한 회귀 테스트는 아래와 같다. + +- 중복 타이머 시작은 `ALREADY_RUNNING_STUDY_TIMER`로 실패한다. +- 시간 불일치 sync/stop은 타이머를 삭제하고 공부 시간/랭킹 후속 효과를 만들지 않는다. +- 개인 랭킹 이름과 학번 랭킹 이름은 노출 정책에 맞게 마스킹된다. +- 랭킹 초기화 스케줄러는 `StudyTimeRanking`의 일간/월간 캐시만 초기화한다. + +이 도메인의 정책을 바꾸거나 가이드 claim을 강화한다면 추가로 아래 회귀 테스트를 보강하는 것이 좋다. + +- 자정을 넘긴 stop은 두 날짜의 `StudyTimeDaily`로 분할 누적한다. +- sync 후 stop은 이미 sync된 구간을 다시 더하지 않는다. +- 공부 시간 누적 commit 이후 개인/동아리/학번 랭킹 캐시가 갱신된다. +- 랭킹 목록은 일간/월간 정렬 tie-breaker와 이름/학번 노출 정책을 지킨다. + +검증할 때는 최소한 아래 테스트를 실행한다. + +```bash +CI=true ./gradlew test --tests 'gg.agit.konect.unit.domain.studytime.*' +``` + +API 통합 테스트를 추가한 뒤에는 `gg.agit.konect.integration.domain.studytime.*` 필터도 별도로 실행 가능하게 유지해야 한다. diff --git a/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeRankingRepository.java b/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeRankingRepository.java index 03eb32a3f..1eb752a0b 100644 --- a/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeRankingRepository.java +++ b/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeRankingRepository.java @@ -5,6 +5,7 @@ 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; @@ -132,4 +133,19 @@ Integer findMaxTargetId( List findAll(); void save(StudyTimeRanking studyTimeRanking); + + @Modifying + @Query(""" + UPDATE StudyTimeRanking r + SET r.dailySeconds = 0 + """) + int resetDailySeconds(); + + @Modifying + @Query(""" + UPDATE StudyTimeRanking r + SET r.dailySeconds = 0, + r.monthlySeconds = 0 + """) + int resetDailyAndMonthlySeconds(); } diff --git a/src/main/java/gg/agit/konect/domain/studytime/scheduler/StudyTimeScheduler.java b/src/main/java/gg/agit/konect/domain/studytime/scheduler/StudyTimeScheduler.java index b2a4e18c2..7ff1e43c6 100644 --- a/src/main/java/gg/agit/konect/domain/studytime/scheduler/StudyTimeScheduler.java +++ b/src/main/java/gg/agit/konect/domain/studytime/scheduler/StudyTimeScheduler.java @@ -1,5 +1,7 @@ package gg.agit.konect.domain.studytime.scheduler; +import java.time.LocalDate; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Scheduled; @@ -17,24 +19,14 @@ public class StudyTimeScheduler { private final StudyTimeSchedulerService studyTimeSchedulerService; @Scheduled(cron = "0 0 0 * * *") - public void resetStudyTimeRankingDaily() { - try { - SCHEDULER_LOGGER.info("일일 공부 시간 랭킹 초기화 시작"); - studyTimeSchedulerService.resetStudyTimeRankingDaily(); - SCHEDULER_LOGGER.info("일일 공부 시간 랭킹 초기화 완료"); - } catch (Exception e) { - SCHEDULER_LOGGER.error("일일 공부 시간 랭킹 초기화 과정에서 오류가 발생했습니다.", e); - } - } - - @Scheduled(cron = "0 0 0 1 * *") - public void resetStudyTimeRankingMonthly() { + public void resetStudyTimeRanking() { try { - SCHEDULER_LOGGER.info("월간 공부 시간 랭킹 초기화 시작"); - studyTimeSchedulerService.resetStudyTimeRankingMonthly(); - SCHEDULER_LOGGER.info("월간 공부 시간 랭킹 초기화 완료"); + LocalDate today = LocalDate.now(); + SCHEDULER_LOGGER.info("스터디 시간 랭킹 초기화를 시작합니다. targetDate={}", today); + int updatedCount = studyTimeSchedulerService.resetStudyTimeRanking(today); + SCHEDULER_LOGGER.info("스터디 시간 랭킹 초기화를 완료했습니다. targetDate={}, updatedCount={}", today, updatedCount); } catch (Exception e) { - SCHEDULER_LOGGER.error("월간 공부 시간 랭킹 초기화 과정에서 오류가 발생했습니다.", e); + SCHEDULER_LOGGER.error("스터디 시간 랭킹 초기화 중 오류가 발생했습니다.", e); } } } diff --git a/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimeSchedulerService.java b/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimeSchedulerService.java index b8c8f9288..49f92bd83 100644 --- a/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimeSchedulerService.java +++ b/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimeSchedulerService.java @@ -1,11 +1,10 @@ package gg.agit.konect.domain.studytime.service; -import java.util.List; +import java.time.LocalDate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import gg.agit.konect.domain.studytime.model.StudyTimeRanking; import gg.agit.konect.domain.studytime.repository.StudyTimeRankingRepository; import lombok.RequiredArgsConstructor; @@ -17,14 +16,10 @@ public class StudyTimeSchedulerService { private final StudyTimeRankingRepository studyTimeRankingRepository; @Transactional - public void resetStudyTimeRankingDaily() { - List studyTimeRankings = studyTimeRankingRepository.findAll(); - studyTimeRankings.forEach(ranking -> ranking.updateSeconds(0L, ranking.getMonthlySeconds())); - } - - @Transactional - public void resetStudyTimeRankingMonthly() { - List studyTimeRankings = studyTimeRankingRepository.findAll(); - studyTimeRankings.forEach(ranking -> ranking.updateSeconds(ranking.getDailySeconds(), 0L)); + public int resetStudyTimeRanking(LocalDate targetDate) { + if (targetDate.getDayOfMonth() == 1) { + return studyTimeRankingRepository.resetDailyAndMonthlySeconds(); + } + return studyTimeRankingRepository.resetDailySeconds(); } } diff --git a/src/main/java/gg/agit/konect/domain/university/enums/UniversityRegion.java b/src/main/java/gg/agit/konect/domain/university/enums/UniversityRegion.java new file mode 100644 index 000000000..e58653a72 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/university/enums/UniversityRegion.java @@ -0,0 +1,21 @@ +package gg.agit.konect.domain.university.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum UniversityRegion { + + SEOUL("서울"), + GYEONGGI("경기도"), + CHUNGCHEONG("충청도"), + JEOLLA("전라도"), + GYEONGSANG("경상도"), + GANGWON("강원도"), + JEJU("제주도"), + UNKNOWN("지역 미지정"), + ; + + private final String displayName; +} diff --git a/src/main/java/gg/agit/konect/domain/university/model/University.java b/src/main/java/gg/agit/konect/domain/university/model/University.java index 020505ac4..1574b3b8a 100644 --- a/src/main/java/gg/agit/konect/domain/university/model/University.java +++ b/src/main/java/gg/agit/konect/domain/university/model/University.java @@ -5,6 +5,7 @@ import static lombok.AccessLevel.PROTECTED; import gg.agit.konect.domain.university.enums.Campus; +import gg.agit.konect.domain.university.enums.UniversityRegion; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Enumerated; @@ -44,10 +45,21 @@ public class University { @Column(name = "campus", nullable = false) private Campus campus; + @NotNull + @Enumerated(value = STRING) + @Column(name = "region", nullable = false) + private UniversityRegion region; + + @NotNull + @Column(name = "image_url", nullable = false) + private String imageUrl; + @Builder - private University(Integer id, String koreanName, Campus campus) { + private University(Integer id, String koreanName, Campus campus, UniversityRegion region, String imageUrl) { this.id = id; this.koreanName = koreanName; this.campus = campus; + this.region = region; + this.imageUrl = imageUrl; } } 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 f070652aa..16f25548b 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 @@ -21,7 +21,7 @@ public interface UploadApi { @Operation(summary = "이미지 파일을 업로드한다.", description = """ 서버가 multipart 파일을 받아 S3에 업로드합니다. - - target 쿼리파라미터로 이미지 저장 대상 도메인을 지정합니다. (CLUB, BANK, COUNCIL, USER) + - target 쿼리파라미터로 이미지 저장 대상 도메인을 지정합니다. (CLUB, BANK, COUNCIL, USER, UNIVERSITY) - 응답의 fileUrl을 기존 도메인 API의 imageUrl로 사용합니다. ## 에러 diff --git a/src/main/java/gg/agit/konect/domain/upload/enums/UploadTarget.java b/src/main/java/gg/agit/konect/domain/upload/enums/UploadTarget.java index 6dd3d8697..ec0fb3d03 100644 --- a/src/main/java/gg/agit/konect/domain/upload/enums/UploadTarget.java +++ b/src/main/java/gg/agit/konect/domain/upload/enums/UploadTarget.java @@ -10,6 +10,7 @@ public enum UploadTarget { BANK("은행"), COUNCIL("총학생회"), USER("사용자"), + UNIVERSITY("대학교"), ; private final String description; diff --git a/src/main/java/gg/agit/konect/domain/user/AGENTS.md b/src/main/java/gg/agit/konect/domain/user/AGENTS.md new file mode 100644 index 000000000..698171b14 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/user/AGENTS.md @@ -0,0 +1,273 @@ +# 유저 도메인 가이드 + +## 이 도메인은 무엇을 하는가 + +유저 도메인은 OAuth 로그인 이후의 회원가입, 계정 연동, 토큰 재발급, 활동 시각 갱신, 회원 탈퇴와 복구 유예기간을 관리하는 도메인이다. + +이 도메인에서 중요한 것은 단순한 사용자 CRUD가 아니라 아래 상태가 서로 같은 정책을 바라보는 것이다. + +- 실제 회원(`User`) +- 회원가입 전 임시 사용자(`UnRegisteredUser`) +- OAuth 제공자별 계정 연결(`UserOAuthAccount`) +- 쿠키 기반 signup token과 refresh token +- 탈퇴 상태(`deletedAt`)와 7일 복구 유예기간 +- 동아리 사전 회원(`ClubPreMember`) 흡수 +- 동아리 회장 탈퇴 제한 +- 가입 환영 메시지용 direct 채팅방과 마지막 메시지 메타데이터 +- Apple / Google Drive refresh token + +유저 관련 작업을 할 때는 항상 "이 변경이 OAuth 식별자, 탈퇴/복구 정책, 동아리 사전 회원 전환, 채팅/알림 후속 효과까지 같이 맞는가"를 먼저 확인해야 한다. + +## 사용자 상태 + +### `UnRegisteredUser` + +- OAuth 로그인은 아직 `User`를 만들지 않고 임시 사용자인 `UnRegisteredUser`를 만든다. +- signup token은 이 임시 사용자 정보를 기반으로 추가 정보 입력 화면과 최종 회원가입을 이어준다. +- Google, Naver, Kakao 흐름은 이메일과 provider 조합으로 임시 사용자를 찾거나 만든다. +- Apple 흐름은 providerId가 더 중요한 식별자이며, 이메일이 없는 신규 Apple 사용자는 가입을 진행할 수 없다. +- Apple은 최초 로그인 시 받은 이름과 refresh token을 임시 사용자 또는 OAuth 계정에 보존할 수 있다. + +### `User` + +- 실제 회원은 대학, 이메일, 이름, 학번, 마케팅 동의, 기본 프로필 이미지를 가진다. +- 기본 역할은 `USER`다. +- `ADMIN`은 별도 역할이며, 동아리 도메인의 회장/부회장/운영진 권한과 같은 개념이 아니다. +- 탈퇴는 row 삭제가 아니라 `deletedAt`을 채우는 soft delete다. +- `UserRepository.getById()`는 `deletedAt IS NULL`인 사용자만 찾는다. +- 탈퇴한 사용자의 이름은 `User.getName()`에서 `탈퇴한 사용자`로 표시된다. + +### `UserOAuthAccount` + +- OAuth 계정은 `User`와 provider별 외부 계정을 연결한다. +- 한 사용자는 provider별로 하나의 OAuth 계정만 가질 수 있다. +- providerId와 oauthEmail은 활성 사용자 기준으로 다른 사용자와 충돌하면 안 된다. +- providerId가 없는 상태로 primary OAuth 계정이 만들어질 수 있지만, 일반 계정 연동(`linkOAuthAccount`)은 providerId가 필요하다. +- Apple refresh token과 Google Drive refresh token은 모두 `UserOAuthAccount`에 저장되지만, 쓰임과 생명주기가 다르다. + +## 기능이 실제로 어떻게 동작해야 하는가 + +### OAuth 로그인과 회원가입 전 상태 + +- OAuth 로그인에서 이미 가입된 활성 사용자가 있으면 임시 사용자를 새로 만들지 않는다. +- 가입되지 않은 사용자라면 `UnRegisteredUser`를 만들고 signup token으로 회원가입을 이어간다. +- signup token은 Redis에 `auth:signup:{token}` 형태로 저장되며 TTL은 10분이다. +- 회원가입 사전 입력 조회는 signup token을 읽기만 한다. +- 실제 회원가입은 signup token을 consume해서 한 번만 사용할 수 있게 한다. +- signup token 값은 이메일, provider, providerId, 이름을 직렬화한 값이다. +- 구분자(`|`)가 들어간 값이나 provider가 깨진 값은 유효하지 않은 signup token으로 처리된다. + +### 회원가입 완료 + +- 회원가입은 아래 순서의 부수 효과를 함께 가진다. + - signup token claims에서 이메일, provider, providerId를 가져온다. + - `UnRegisteredUser`를 찾는다. + - 대학을 검증한다. + - `User`를 생성한다. + - primary OAuth 계정을 연결한다. + - 같은 대학/학번/이름의 사전 동아리 회원을 실제 동아리 회원으로 전환한다. + - 운영자 계정이 있으면 환영 direct 메시지를 보낸다. + - 임시 사용자를 삭제한다. + - `UserRegisteredEvent`를 발행한다. + - refresh token 쿠키와 access token 헤더를 내려준다. +- Apple 회원가입은 providerId가 비어 있으면 실패한다. +- 이미 같은 providerId 또는 같은 provider/oauthEmail로 가입된 활성 사용자가 있으면 다시 가입할 수 없다. +- 회원가입 성공 후 signup token 쿠키는 제거되어야 한다. + +### 사전 동아리 회원 흡수 + +- 회원가입 시 같은 대학, 같은 학번, 같은 이름의 `ClubPreMember`를 모두 찾는다. +- 매칭된 사전 회원은 각 동아리의 실제 `ClubMember`로 전환된다. +- 전환된 회원은 동아리 그룹 채팅방 멤버십에도 추가된다. +- 사전 회원 직책이 `PRESIDENT`면 기존 회장을 먼저 제거하고 새 가입자를 회장으로 올린다. +- 기존 회장을 제거할 때도 동아리 채팅방 멤버십을 함께 제거한다. +- 전환이 끝난 `ClubPreMember`는 삭제된다. +- 즉 회원가입 로직을 바꿀 때는 동아리 회원 상태와 club group 채팅방 멤버십까지 같이 확인해야 한다. + +### 가입 환영 메시지 + +- 회원가입 후 가장 작은 id의 활성 admin 사용자를 운영자로 골라 환영 메시지를 보낸다. +- 운영자가 없으면 환영 메시지는 생략된다. +- 운영자와 신규 사용자가 같은 사용자가 되면 안 된다. +- direct 채팅방이 이미 있으면 재사용하고, 없으면 새로 만든다. +- direct 멤버십을 보장한 뒤 운영자 메시지를 저장한다. +- 저장된 메시지는 `chat_room.last_message_*`에도 최신 메시지 조건으로 동기화한다. +- 환영 메시지 실패는 회원가입 전체를 실패시키지 않고 warning 로그로 남긴다. +- 이 로직은 채팅 도메인의 마지막 메시지 메타데이터 정책과 맞아야 한다. + +### 로그인, refresh token, 활동 시각 + +- refresh token은 JWT이며 TTL은 30일이다. +- refresh token에는 issuer, 만료 시각, jti, user id, `token_type=refresh`가 들어간다. +- refresh token 검증은 서명, issuer, 만료, token type, user id claim을 모두 확인한다. +- refresh 요청은 기존 refresh token을 검증한 뒤 새 refresh token으로 rotate한다. +- refresh 성공 시 `lastLoginAt`과 `lastActivityAt`을 함께 현재 시각으로 갱신한다. +- 일반 활동 시각 갱신은 userId가 null이면 아무 것도 하지 않는다. +- `updateLastActivityAt`은 사용자가 이미 탈퇴했거나 없으면 조용히 건너뛴다. + +### OAuth 계정 연동 + +- 연동 상태 조회는 모든 `Provider`에 대해 linked 여부를 반환한다. +- 일반 OAuth 계정 연동은 provider 문자열을 대문자 enum으로 해석한다. +- 지원하지 않는 provider는 `UNSUPPORTED_PROVIDER`다. +- 연동 요청은 provider별 verifier로 토큰을 검증한 뒤 저장해야 한다. +- 다른 활성 사용자가 같은 providerId 또는 provider/oauthEmail을 이미 쓰고 있으면 연동할 수 없다. +- 같은 사용자에게 이미 같은 provider 계정이 있으면 기존 계정을 갱신한다. +- 기존 계정의 providerId가 비어 있는 경우에는 새 providerId를 채울 수 있다. +- 기존 계정에 다른 providerId가 이미 있으면 충돌로 막아야 한다. +- Apple 연동은 Apple refresh token이 있으면 기존 계정에 갱신한다. + +### 탈퇴와 복구 유예기간 + +- 회원 탈퇴는 사용자 row를 삭제하지 않고 `deletedAt`을 현재 시각으로 채운다. +- 회장인 사용자는 탈퇴할 수 없다. +- 여러 동아리 중 하나라도 회장이면 탈퇴할 수 없다. +- 부회장, 운영진, 일반 회원은 탈퇴할 수 있다. +- 탈퇴 시 연결된 Apple OAuth 계정의 refresh token은 즉시 revoke를 시도한다. +- 탈퇴 후에는 `UserWithdrawnEvent`가 발행된다. +- 탈퇴 API는 refresh token과 signup token 쿠키를 함께 제거한다. +- 탈퇴한 사용자는 일반 `getById` 경로에서 더 이상 활성 사용자로 조회되지 않는다. + +### 탈퇴 계정 복구와 OAuth 정리 + +- 탈퇴 사용자는 7일 복구 유예기간을 가진다. +- OAuth 연동/회원가입 과정에서 같은 providerId 또는 provider/oauthEmail의 탈퇴 계정이 발견되면 복구 또는 정리를 먼저 시도한다. +- stage 프로필이 아니고 7일 이내 탈퇴라면 기존 사용자를 복구한다. +- stage 프로필이거나 7일이 지난 탈퇴 계정이면 기존 OAuth 계정 연결을 삭제하고 새 연결이 가능하게 한다. +- 00:10 스케줄러는 7일이 지난 탈퇴 사용자의 OAuth 계정 연결을 삭제한다. +- Apple token revoke 스케줄러는 매일 00:00에 7일이 지난 탈퇴 Apple 계정의 refresh token을 revoke하고, 성공 시 저장된 refresh token을 비운다. +- 복구 유예기간 정책을 바꿀 때는 즉시 탈퇴 처리, OAuth 충돌 처리, 스케줄러 삭제/토큰 폐기 시점을 함께 맞춰야 한다. + +### Google Drive OAuth + +- Google Drive OAuth는 로그인용 Google OAuth와 같은 `UserOAuthAccount`의 `googleDriveRefreshToken` 필드를 쓴다. +- Drive OAuth state는 Redis에 10분 TTL로 저장되고 callback에서 한 번만 consume된다. +- Drive refresh token은 Google provider 계정이 있어야 저장할 수 있다. +- 재동의 과정에서 새 refresh token이 내려오지 않아도 기존 refresh token이 있으면 유지한다. +- 기존 refresh token도 없고 새 refresh token도 없으면 Drive 인증 실패다. +- 동아리 시트 기능은 이 refresh token 존재 여부에 영향을 받는다. + +## 절대 놓치면 안 되는 정책 + +- `User`의 탈퇴는 hard delete가 아니라 `deletedAt` soft delete다. +- 활성 사용자 조회는 대부분 `deletedAt IS NULL` 기준이다. +- `User.getName()`은 탈퇴 사용자 이름을 원문 그대로 노출하지 않는다. +- 회원가입 token은 읽기와 consume의 의미가 다르다. 실제 가입에서는 반드시 consume해야 한다. +- Apple은 providerId가 핵심 식별자다. Apple 가입에서 providerId가 없으면 정상 가입으로 보면 안 된다. +- providerId 없는 primary 계정 생성은 허용되지만, 일반 OAuth 계정 연동에는 providerId가 필요하다. +- OAuth providerId와 oauthEmail 중 하나만 봐서 중복을 판단하면 안 된다. +- 탈퇴 계정의 OAuth 연결은 7일 복구 유예기간과 stage 프로필 예외를 함께 본다. +- 회장 사용자는 탈퇴할 수 없다. 동아리 하나라도 회장이면 막아야 한다. +- 사전 동아리 회원 흡수는 이름까지 일치해야 한다. +- 사전 회원이 회장이면 기존 회장과 채팅방 멤버십도 함께 교체된다. +- 회원가입 환영 메시지 실패는 회원가입 실패로 전파하지 않는다. +- refresh token은 access token과 다른 `token_type=refresh` claim을 가져야 한다. +- refresh 성공은 토큰 재발급뿐 아니라 로그인 시각 갱신이다. +- Google Drive refresh token은 별도 OAuth state 흐름으로 저장되며, 로그인용 OAuth 계정과 같은 row를 쓴다. + +## 수정 시 함께 확인해야 하는 것 + +### 회원가입 로직을 바꿀 때 + +- signup token read/consume 구분 +- `UnRegisteredUser` 조회 기준 +- providerId와 oauthEmail 중복 검증 +- Apple providerId 필수 정책 +- primary OAuth 계정 생성 +- 사전 동아리 회원 흡수 +- 동아리 채팅방 멤버십 추가 +- 환영 direct 메시지와 마지막 메시지 동기화 +- `UserRegisteredEvent` 발행 +- signup token 쿠키 제거와 refresh token 설정 + +### OAuth 계정 연동을 바꿀 때 + +- provider별 verifier 검증 +- providerId 필수 여부 +- 같은 사용자 기존 provider 계정 갱신 규칙 +- 다른 활성 사용자와의 providerId/oauthEmail 충돌 +- 탈퇴 계정 복구 또는 OAuth 계정 정리 +- Apple refresh token 저장 +- Google Drive refresh token과의 row 공유 + +### 탈퇴/복구 정책을 바꿀 때 + +- 회장 탈퇴 제한 +- `deletedAt` 기반 활성 사용자 필터 +- Apple token revoke 시점 +- `UserWithdrawnEvent` 발행 +- refresh/signup 쿠키 제거 +- 7일 복구 유예기간 +- stage 프로필 예외 +- 탈퇴 OAuth 계정 삭제 스케줄러 +- 탈퇴 사용자 이름 노출 정책 + +### 활동 시각과 토큰을 바꿀 때 + +- refresh token TTL +- issuer와 secret 검증 +- `token_type=refresh` 검증 +- rotate 후 새 refresh token 쿠키 설정 +- `lastLoginAt`과 `lastActivityAt` 갱신 범위 +- 탈퇴 사용자의 활동 시각 갱신 처리 + +### 동아리/채팅 연동을 바꿀 때 + +- `ClubPreMember` 매칭 기준 +- 회장 사전 회원의 기존 회장 교체 +- `ClubMember` 저장과 club group 멤버십 추가 +- 기존 회장 제거 시 club group 멤버십 제거 +- 환영 direct 채팅방 재사용 기준 +- `chat_room.last_message_*` 최신 메시지 조건부 갱신 + +## 주요 클래스와 책임 + +### `UserService` + +- 회원가입, 사용자 정보 조회, 회원 탈퇴가 모이는 중심 서비스다. +- 동아리 사전 회원 흡수, 환영 메시지, 이벤트 발행처럼 다른 도메인과 만나는 지점이 많다. + +### `UserOAuthAccountService` + +- OAuth 계정 연동, 연동 상태 조회, 탈퇴 계정 복구/정리, OAuth 계정 cleanup을 담당한다. +- providerId와 oauthEmail 충돌 정책을 바꿀 때 가장 먼저 봐야 한다. + +### `SignupTokenService` + +- 회원가입 token 발급, 조회, consume, 직렬화/역직렬화를 담당한다. +- token TTL과 claim 구조를 바꾸면 회원가입 prefill과 실제 가입 흐름을 같이 확인해야 한다. + +### `RefreshTokenService` + +- refresh token 발급, 검증, rotate를 담당한다. +- JWT secret, issuer, claim 정책을 바꾸면 인증 전역에 영향을 준다. + +### `UserActivityService` + +- 로그인 시각과 활동 시각 갱신을 담당한다. +- null userId와 탈퇴 사용자 처리 방식이 다르므로 단순 공통화하면 안 된다. + +### `UserSchedulerService` / `UserSchedulerTxService` + +- 7일 유예기간이 지난 Apple token revoke를 담당한다. +- 외부 Apple revoke 성공 후 저장된 refresh token을 비우는 순서를 유지해야 한다. + +### `UserOAuthAccountCleanupScheduler` + +- 7일 유예기간이 지난 탈퇴 사용자의 OAuth 계정 연결 삭제를 담당한다. +- 복구 유예기간과 동일한 기준을 써야 한다. + +### `GoogleDriveOAuthService` + +- Google Drive 권한 위임용 OAuth state와 refresh token 저장을 담당한다. +- 동아리 구글 시트 기능과 직접 연결된다. + +### `UserRepository` + +- 활성 사용자 기준 조회를 담당한다. +- `getById()`는 탈퇴 사용자를 반환하지 않는다. + +### `UserOAuthAccountRepository` + +- OAuth providerId, oauthEmail, provider별 사용자 계정 조회를 담당한다. +- 활성 사용자만 찾는 조회와 탈퇴 사용자까지 포함하는 계정 조회가 섞여 있으므로 쿼리 조건을 바꿀 때 주의해야 한다. diff --git a/src/main/java/gg/agit/konect/domain/user/repository/UserOAuthAccountRepository.java b/src/main/java/gg/agit/konect/domain/user/repository/UserOAuthAccountRepository.java index d244d6095..e6e2acf23 100644 --- a/src/main/java/gg/agit/konect/domain/user/repository/UserOAuthAccountRepository.java +++ b/src/main/java/gg/agit/konect/domain/user/repository/UserOAuthAccountRepository.java @@ -101,8 +101,15 @@ Optional findByUserIdAndProvider( FROM UserOAuthAccount uoa WHERE uoa.user.deletedAt IS NOT NULL AND uoa.user.deletedAt <= :expiredAt + AND ( + uoa.provider <> :appleProvider + OR uoa.appleRefreshToken IS NULL + ) """) - int deleteAllByWithdrawnUsersBefore(@Param("expiredAt") LocalDateTime expiredAt); + int deleteRevokedExpiredWithdrawnOAuthAccountsBefore( + @Param("expiredAt") LocalDateTime expiredAt, + @Param("appleProvider") Provider appleProvider + ); @Query(""" SELECT (COUNT(uoa) > 0) @@ -122,7 +129,7 @@ boolean existsByProviderAndProviderId( JOIN FETCH uoa.user user WHERE uoa.provider = :provider AND user.deletedAt IS NOT NULL - AND user.deletedAt < :threshold + AND user.deletedAt <= :threshold AND uoa.appleRefreshToken IS NOT NULL """) List findAppleAccountsToRevoke( diff --git a/src/main/java/gg/agit/konect/domain/user/scheduler/UserOAuthAccountCleanupScheduler.java b/src/main/java/gg/agit/konect/domain/user/scheduler/UserOAuthAccountCleanupScheduler.java deleted file mode 100644 index 8e3d3f5a6..000000000 --- a/src/main/java/gg/agit/konect/domain/user/scheduler/UserOAuthAccountCleanupScheduler.java +++ /dev/null @@ -1,28 +0,0 @@ -package gg.agit.konect.domain.user.scheduler; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -import gg.agit.konect.domain.user.service.UserOAuthAccountService; -import lombok.RequiredArgsConstructor; - -@Component -@RequiredArgsConstructor -public class UserOAuthAccountCleanupScheduler { - - private static final Logger SCHEDULER_LOGGER = LoggerFactory.getLogger("scheduler.user-oauth-account"); - - private final UserOAuthAccountService userOAuthAccountService; - - @Scheduled(cron = "0 10 0 * * *") - public void cleanupExpiredWithdrawnUserOAuthAccounts() { - try { - int deletedCount = userOAuthAccountService.cleanupExpiredWithdrawnUserOAuthAccounts(); - SCHEDULER_LOGGER.info("탈퇴 유예기간 경과 OAuth 계정 정리 완료. deletedCount={}", deletedCount); - } catch (Exception e) { - SCHEDULER_LOGGER.error("탈퇴 유예기간 경과 OAuth 계정 정리 중 오류가 발생했습니다.", e); - } - } -} diff --git a/src/main/java/gg/agit/konect/domain/user/scheduler/UserScheduler.java b/src/main/java/gg/agit/konect/domain/user/scheduler/UserScheduler.java index 328123c03..7a97b8c87 100644 --- a/src/main/java/gg/agit/konect/domain/user/scheduler/UserScheduler.java +++ b/src/main/java/gg/agit/konect/domain/user/scheduler/UserScheduler.java @@ -14,19 +14,14 @@ public class UserScheduler { private final UserSchedulerService userSchedulerService; - /** - * 매일 자정(서버 기본 시간대 기준 00:00)에 실행되어 7일 경과한 Apple 사용자 토큰을 revoke합니다. - * cron 표현식: 초 분 시 일 월 요일 - * 0 0 0 * * *: 매일 00:00:00 실행 - */ - @Scheduled(cron = "0 0 0 * * *") - public void revokeAppleTokensAfterRestoreWindow() { + @Scheduled(cron = "0 10 0 * * *") + public void cleanupExpiredWithdrawnOAuthAccountsAfterRestoreWindow() { try { - log.info("Starting Apple token revocation task for users withdrawn more than 7 days ago"); - userSchedulerService.revokeAppleTokensAfterRestoreWindow(); - log.info("Successfully completed Apple token revocation task"); + log.info("Starting expired withdrawn OAuth cleanup task"); + userSchedulerService.cleanupExpiredWithdrawnOAuthAccountsAfterRestoreWindow(); + log.info("Successfully completed expired withdrawn OAuth cleanup task"); } catch (Exception e) { - log.error("Failed to revoke Apple tokens for withdrawn users", e); + log.error("Failed to cleanup expired withdrawn OAuth accounts", e); } } } diff --git a/src/main/java/gg/agit/konect/domain/user/service/UserOAuthAccountService.java b/src/main/java/gg/agit/konect/domain/user/service/UserOAuthAccountService.java index df2d1ef3b..e8098a6ae 100644 --- a/src/main/java/gg/agit/konect/domain/user/service/UserOAuthAccountService.java +++ b/src/main/java/gg/agit/konect/domain/user/service/UserOAuthAccountService.java @@ -82,7 +82,10 @@ public int cleanupExpiredWithdrawnUserOAuthAccounts() { @Transactional public int cleanupExpiredWithdrawnUserOAuthAccounts(LocalDateTime now) { LocalDateTime expiredAt = now.minusDays(RESTORE_WINDOW_DAYS); - int deletedCount = userOAuthAccountRepository.deleteAllByWithdrawnUsersBefore(expiredAt); + int deletedCount = userOAuthAccountRepository.deleteRevokedExpiredWithdrawnOAuthAccountsBefore( + expiredAt, + Provider.APPLE + ); userOAuthAccountRepository.flush(); return deletedCount; } diff --git a/src/main/java/gg/agit/konect/domain/user/service/UserSchedulerService.java b/src/main/java/gg/agit/konect/domain/user/service/UserSchedulerService.java index 78a7a6e82..cd399c855 100644 --- a/src/main/java/gg/agit/konect/domain/user/service/UserSchedulerService.java +++ b/src/main/java/gg/agit/konect/domain/user/service/UserSchedulerService.java @@ -5,8 +5,8 @@ import org.springframework.stereotype.Service; -import gg.agit.konect.domain.user.model.UserOAuthAccount; import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.model.UserOAuthAccount; import gg.agit.konect.infrastructure.oauth.AppleTokenRevocationService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -16,24 +16,19 @@ @RequiredArgsConstructor public class UserSchedulerService { - private static final int REVOKE_AFTER_DAYS = 7; + private static final int RESTORE_WINDOW_DAYS = 7; private final UserSchedulerTxService userSchedulerTxService; + private final UserOAuthAccountService userOAuthAccountService; private final AppleTokenRevocationService appleTokenRevocationService; - /** - * 7일 이상 경과한 Apple 사용자의 토큰을 revoke합니다. - * - 7일 복구 정책: 탈퇴 후 7일 이내 복구 가능하므로 즉시 revoke하지 않음 - * - 7일 경과 후: 복구 불가 시점이므로 Apple 토큰 영구 폐기 - */ - public void revokeAppleTokensAfterRestoreWindow() { - LocalDateTime threshold = LocalDateTime.now().minusDays(REVOKE_AFTER_DAYS); - List accountsToRevoke = userSchedulerTxService.findAccountsToRevoke(threshold); + public void cleanupExpiredWithdrawnOAuthAccountsAfterRestoreWindow() { + cleanupExpiredWithdrawnOAuthAccountsAfterRestoreWindow(LocalDateTime.now()); + } - if (accountsToRevoke.isEmpty()) { - log.info("No Apple users to revoke (threshold={})", threshold); - return; - } + public void cleanupExpiredWithdrawnOAuthAccountsAfterRestoreWindow(LocalDateTime now) { + LocalDateTime threshold = now.minusDays(RESTORE_WINDOW_DAYS); + List accountsToRevoke = userSchedulerTxService.findAccountsToRevoke(threshold); int successCount = 0; int failureCount = 0; @@ -52,9 +47,15 @@ public void revokeAppleTokensAfterRestoreWindow() { } } + int deletedCount = userOAuthAccountService.cleanupExpiredWithdrawnUserOAuthAccounts(now); + log.info( - "Apple token revoke task finished: total={}, success={}, failure={}" - , accountsToRevoke.size(), successCount, failureCount + "Expired withdrawn OAuth cleanup task finished: revokeTotal={}, revokeSuccess={}, revokeFailure={}, " + + "deleted={}", + accountsToRevoke.size(), + successCount, + failureCount, + deletedCount ); } } 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 aa3edc2e2..76ec4a8ad 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 @@ -131,12 +131,24 @@ private void sendWelcomeMessage(User newUser) { ChatMessage chatMessage = chatMessageRepository.save( ChatMessage.of(chatRoom, operator, DEFAULT_WELCOME_MESSAGE) ); - chatRoom.updateLastMessage(chatMessage.getContent(), chatMessage.getCreatedAt()); + syncLastMessage(chatRoom, chatMessage); } catch (Exception e) { log.warn("회원가입 환영 메시지 전송 실패. userId={}", newUser.getId(), e); } } + private void syncLastMessage(ChatRoom chatRoom, ChatMessage chatMessage) { + int updated = chatRoomRepository.updateLastMessageIfLatest( + chatRoom.getId(), + chatMessage.getId(), + chatMessage.getContent(), + chatMessage.getCreatedAt() + ); + if (updated > 0) { + chatRoom.updateLastMessage(chatMessage.getContent(), chatMessage.getCreatedAt()); + } + } + private UnRegisteredUser findUnregisteredUser(String email, String providerId, Provider provider) { if (StringUtils.hasText(providerId)) { if (unRegisteredUserRepository.existsByProviderIdAndProvider(providerId, provider)) { diff --git a/src/main/java/gg/agit/konect/domain/website/controller/WebsiteApi.java b/src/main/java/gg/agit/konect/domain/website/controller/WebsiteApi.java new file mode 100644 index 000000000..4cde81b7c --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/website/controller/WebsiteApi.java @@ -0,0 +1,63 @@ +package gg.agit.konect.domain.website.controller; + +import java.util.List; + +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +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.university.enums.UniversityRegion; +import gg.agit.konect.domain.website.dto.WebsiteClubDetailResponse; +import gg.agit.konect.domain.website.dto.WebsiteClubListCondition; +import gg.agit.konect.domain.website.dto.WebsiteClubsResponse; +import gg.agit.konect.domain.website.dto.WebsiteHomeResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Size; + +@Validated +@Tag(name = "(Public) Website: 웹사이트 공개 정보") +@RequestMapping("/konect") +public interface WebsiteApi { + + @Operation(summary = "웹사이트 메인 화면 정보를 조회한다.", description = """ + 로그인 없이 접근 가능한 웹사이트 메인 정보입니다. + 대학 검색 결과와 대학별 등록 동아리 수를 반환합니다. + """) + @GetMapping("/home") + ResponseEntity getHome( + @RequestParam(name = "query", required = false) String query, + @RequestParam(name = "region", required = false) UniversityRegion region + ); + + @Operation(summary = "웹사이트 대학별 동아리 목록을 조회한다.", description = """ + 로그인 없이 접근 가능한 대학별 동아리 목록입니다. + 동아리명 검색, 분과 필터, 페이지네이션을 지원합니다. + """) + @GetMapping("/universities/{universityId}/clubs") + ResponseEntity getUniversityClubs( + @PathVariable(name = "universityId") Integer universityId, + @Valid @ParameterObject @ModelAttribute WebsiteClubListCondition condition + ); + + @Operation(summary = "웹사이트 동아리 상세 정보를 조회한다.") + @GetMapping("/clubs/{clubId}") + ResponseEntity getClubDetail( + @PathVariable(name = "clubId") Integer clubId + ); + + @Operation(summary = "최근 본 동아리 카드 정보를 조회한다.", description = """ + 프론트엔드가 로컬에 보관한 동아리 ID 목록을 전달하면 카드 표시용 정보를 반환합니다. + 반환 순서는 요청한 clubIds 순서를 따릅니다. + """) + @GetMapping("/clubs/recent") + ResponseEntity getRecentClubs( + @RequestParam(name = "clubIds") @Size(min = 1, max = 100) List clubIds + ); +} diff --git a/src/main/java/gg/agit/konect/domain/website/controller/WebsiteController.java b/src/main/java/gg/agit/konect/domain/website/controller/WebsiteController.java new file mode 100644 index 000000000..4764e0573 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/website/controller/WebsiteController.java @@ -0,0 +1,62 @@ +package gg.agit.konect.domain.website.controller; + +import java.util.List; + +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import gg.agit.konect.domain.university.enums.UniversityRegion; +import gg.agit.konect.domain.website.dto.WebsiteClubDetailResponse; +import gg.agit.konect.domain.website.dto.WebsiteClubListCondition; +import gg.agit.konect.domain.website.dto.WebsiteClubsResponse; +import gg.agit.konect.domain.website.dto.WebsiteHomeResponse; +import gg.agit.konect.domain.website.service.WebsiteService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@Validated +@RequiredArgsConstructor +public class WebsiteController implements WebsiteApi { + + private final WebsiteService websiteService; + + @Override + public ResponseEntity getHome( + @RequestParam(name = "query", required = false) String query, + @RequestParam(name = "region", required = false) UniversityRegion region + ) { + WebsiteHomeResponse response = websiteService.getHome(query, region); + return ResponseEntity.ok(response); + } + + @Override + public ResponseEntity getUniversityClubs( + @PathVariable(name = "universityId") Integer universityId, + @Valid @ParameterObject @ModelAttribute WebsiteClubListCondition condition + ) { + WebsiteClubsResponse response = websiteService.getUniversityClubs(universityId, condition); + return ResponseEntity.ok(response); + } + + @Override + public ResponseEntity getClubDetail( + @PathVariable(name = "clubId") Integer clubId + ) { + WebsiteClubDetailResponse response = websiteService.getClubDetail(clubId); + return ResponseEntity.ok(response); + } + + @Override + public ResponseEntity getRecentClubs( + @RequestParam(name = "clubIds") List clubIds + ) { + WebsiteClubsResponse response = websiteService.getRecentClubs(clubIds); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubDetailResponse.java b/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubDetailResponse.java new file mode 100644 index 000000000..75783697e --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubDetailResponse.java @@ -0,0 +1,119 @@ +package gg.agit.konect.domain.website.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDateTime; + +import gg.agit.konect.domain.club.enums.ClubCategory; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.ClubRecruitment; +import gg.agit.konect.domain.university.enums.UniversityRegion; +import io.swagger.v3.oas.annotations.media.Schema; + +public record WebsiteClubDetailResponse( + @Schema(description = "동아리 고유 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "동아리명", example = "BCSD Lab", requiredMode = REQUIRED) + String name, + + @Schema(description = "동아리 로고 이미지 URL", requiredMode = REQUIRED) + String imageUrl, + + @Schema(description = "분과 코드", example = "ACADEMIC", requiredMode = REQUIRED) + ClubCategory category, + + @Schema(description = "분과명", example = "학술", requiredMode = REQUIRED) + String categoryName, + + @Schema(description = "한 줄 소개", example = "테스트 동아리 소개", requiredMode = REQUIRED) + String description, + + @Schema(description = "상세 소개", requiredMode = REQUIRED) + String introduce, + + @Schema(description = "활동 위치", example = "학생회관 101호", requiredMode = REQUIRED) + String location, + + @Schema(description = "등록 회원 수", example = "31", requiredMode = REQUIRED) + Long memberCount, + + @Schema(description = "대학 정보", requiredMode = REQUIRED) + University university, + + @Schema(description = "모집 정보", requiredMode = REQUIRED) + Recruitment recruitment +) { + + public record University( + @Schema(description = "대학 고유 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "대학명", example = "한국기술교육대학교", requiredMode = REQUIRED) + String name, + + @Schema(description = "캠퍼스명", example = "본교", requiredMode = REQUIRED) + String campusName, + + @Schema(description = "지역 코드", example = "CHUNGCHEONG", requiredMode = REQUIRED) + UniversityRegion region, + + @Schema(description = "지역명", example = "충청도", requiredMode = REQUIRED) + String regionName + ) { + } + + public record Recruitment( + @Schema(description = "모집 활성화 여부", example = "true", requiredMode = REQUIRED) + Boolean isRecruitmentEnabled, + + @Schema(description = "상시 모집 여부", example = "false", requiredMode = REQUIRED) + Boolean isAlwaysRecruiting, + + @Schema(description = "모집 시작 일시", requiredMode = REQUIRED) + LocalDateTime startAt, + + @Schema(description = "모집 마감 일시", requiredMode = REQUIRED) + LocalDateTime endAt, + + @Schema(description = "모집 공고 내용", requiredMode = REQUIRED) + String content + ) { + private static Recruitment from(Club club) { + ClubRecruitment recruitment = club.getClubRecruitment(); + if (recruitment == null) { + return new Recruitment(club.getIsRecruitmentEnabled(), false, null, null, null); + } + + return new Recruitment( + club.getIsRecruitmentEnabled(), + recruitment.getIsAlwaysRecruiting(), + recruitment.getStartAt(), + recruitment.getEndAt(), + recruitment.getContent() + ); + } + } + + public static WebsiteClubDetailResponse of(Club club, Long memberCount) { + return new WebsiteClubDetailResponse( + club.getId(), + club.getName(), + club.getImageUrl(), + club.getClubCategory(), + club.getClubCategory().getDescription(), + club.getDescription(), + club.getIntroduce(), + club.getLocation(), + memberCount, + new University( + club.getUniversity().getId(), + club.getUniversity().getKoreanName(), + club.getUniversity().getCampus().getDisplayName(), + club.getUniversity().getRegion(), + club.getUniversity().getRegion().getDisplayName() + ), + Recruitment.from(club) + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubListCondition.java b/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubListCondition.java new file mode 100644 index 000000000..f6f5c4678 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubListCondition.java @@ -0,0 +1,34 @@ +package gg.agit.konect.domain.website.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; + +import gg.agit.konect.domain.club.enums.ClubCategory; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +public record WebsiteClubListCondition( + @Schema(description = "페이지 번호", example = "1", requiredMode = NOT_REQUIRED) + @Min(1) + Integer page, + + @Schema(description = "페이지 크기", example = "12", requiredMode = NOT_REQUIRED) + @Min(1) + @Max(100) + Integer limit, + + @Schema(description = "동아리명 검색어", example = "BCSD", requiredMode = NOT_REQUIRED) + String query, + + @Schema(description = "동아리 분과", example = "ACADEMIC", requiredMode = NOT_REQUIRED) + ClubCategory category +) { + + private static final int DEFAULT_PAGE = 1; + private static final int DEFAULT_LIMIT = 12; + + public WebsiteClubListCondition { + page = page == null ? DEFAULT_PAGE : page; + limit = limit == null ? DEFAULT_LIMIT : limit; + } +} diff --git a/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubsResponse.java b/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubsResponse.java new file mode 100644 index 000000000..7ec84e588 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubsResponse.java @@ -0,0 +1,165 @@ +package gg.agit.konect.domain.website.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import org.springframework.data.domain.Page; + +import gg.agit.konect.domain.club.enums.ClubCategory; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.university.enums.UniversityRegion; +import gg.agit.konect.domain.university.model.University; +import io.swagger.v3.oas.annotations.media.Schema; + +public record WebsiteClubsResponse( + @Schema(description = "대학 정보", requiredMode = REQUIRED) + UniversityResponse university, + + @Schema(description = "전체 동아리 수", example = "28", requiredMode = REQUIRED) + Long totalCount, + + @Schema(description = "전체 페이지 수", example = "3", requiredMode = REQUIRED) + Integer totalPage, + + @Schema(description = "현재 페이지 번호", example = "1", requiredMode = REQUIRED) + Integer currentPage, + + @Schema(description = "분과별 동아리 수", requiredMode = REQUIRED) + List categories, + + @Schema(description = "동아리 목록", requiredMode = REQUIRED) + List clubs +) { + + @Schema(name = "WebsiteClubsUniversityResponse") + public record UniversityResponse( + @Schema(description = "대학 고유 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "대학명", example = "한국기술교육대학교", requiredMode = REQUIRED) + String name, + + @Schema(description = "캠퍼스명", example = "본교", requiredMode = REQUIRED) + String campusName, + + @Schema(description = "지역 코드", example = "CHUNGCHEONG", requiredMode = REQUIRED) + UniversityRegion region, + + @Schema(description = "지역명", example = "충청도", requiredMode = REQUIRED) + String regionName, + + @Schema( + description = "대학 로고 이미지 URL", + example = "https://example.com/koreatech-logo.png", + requiredMode = REQUIRED + ) + String imageUrl + ) { + public static UniversityResponse from(University university) { + if (university == null) { + return null; + } + + return new UniversityResponse( + university.getId(), + university.getKoreanName(), + university.getCampus().getDisplayName(), + university.getRegion(), + university.getRegion().getDisplayName(), + university.getImageUrl() + ); + } + } + + public record CategoryCountResponse( + @Schema(description = "분과 코드", example = "ACADEMIC", requiredMode = REQUIRED) + ClubCategory category, + + @Schema(description = "분과명", example = "학술", requiredMode = REQUIRED) + String categoryName, + + @Schema(description = "동아리 수", example = "5", requiredMode = REQUIRED) + Long count + ) { + public static CategoryCountResponse of(ClubCategory category, Long count) { + return new CategoryCountResponse(category, category.getDescription(), count); + } + } + + public record ClubResponse( + @Schema(description = "동아리 고유 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "동아리명", example = "BCSD Lab", requiredMode = REQUIRED) + String name, + + @Schema(description = "동아리 로고 이미지 URL", requiredMode = REQUIRED) + String imageUrl, + + @Schema(description = "분과 코드", example = "ACADEMIC", requiredMode = REQUIRED) + ClubCategory category, + + @Schema(description = "분과명", example = "학술", requiredMode = REQUIRED) + String categoryName, + + @Schema(description = "한 줄 소개", example = "테스트 동아리 소개", requiredMode = REQUIRED) + String description, + + @Schema(description = "등록 회원 수", example = "31", requiredMode = REQUIRED) + Long memberCount + ) { + public static ClubResponse of(Club club, Long memberCount) { + return new ClubResponse( + club.getId(), + club.getName(), + club.getImageUrl(), + club.getClubCategory(), + club.getClubCategory().getDescription(), + club.getDescription(), + memberCount + ); + } + } + + public static WebsiteClubsResponse of( + University university, + Page page, + Map memberCounts, + Map categoryCounts + ) { + return new WebsiteClubsResponse( + UniversityResponse.from(university), + page.getTotalElements(), + page.getTotalPages(), + page.getNumber() + 1, + createCategories(categoryCounts), + createClubs(page.getContent(), memberCounts) + ); + } + + public static WebsiteClubsResponse recent(List clubs, Map memberCounts) { + return new WebsiteClubsResponse( + null, + (long)clubs.size(), + 1, + 1, + List.of(), + createClubs(clubs, memberCounts) + ); + } + + private static List createCategories(Map categoryCounts) { + return Arrays.stream(ClubCategory.values()) + .map(category -> CategoryCountResponse.of(category, categoryCounts.getOrDefault(category, 0L))) + .toList(); + } + + private static List createClubs(List clubs, Map memberCounts) { + return clubs.stream() + .map(club -> ClubResponse.of(club, memberCounts.getOrDefault(club.getId(), 0L))) + .toList(); + } +} diff --git a/src/main/java/gg/agit/konect/domain/website/dto/WebsiteHomeResponse.java b/src/main/java/gg/agit/konect/domain/website/dto/WebsiteHomeResponse.java new file mode 100644 index 000000000..8de1d2616 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/website/dto/WebsiteHomeResponse.java @@ -0,0 +1,67 @@ +package gg.agit.konect.domain.website.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import gg.agit.konect.domain.university.enums.UniversityRegion; +import gg.agit.konect.domain.website.model.WebsiteUniversitySummary; +import io.swagger.v3.oas.annotations.media.Schema; + +public record WebsiteHomeResponse( + @Schema(description = "검색 결과 대학 수", example = "28", requiredMode = REQUIRED) + Integer totalUniversityCount, + + @Schema(description = "대학 목록", requiredMode = REQUIRED) + List universities +) { + + @Schema(name = "WebsiteHomeUniversityResponse") + public record UniversityResponse( + @Schema(description = "대학 고유 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "대학명", example = "한국기술교육대학교", requiredMode = REQUIRED) + String name, + + @Schema(description = "캠퍼스명", example = "본교", requiredMode = REQUIRED) + String campusName, + + @Schema(description = "지역 코드", example = "CHUNGCHEONG", requiredMode = REQUIRED) + UniversityRegion region, + + @Schema(description = "지역명", example = "충청도", requiredMode = REQUIRED) + String regionName, + + @Schema( + description = "대학 로고 이미지 URL", + example = "https://example.com/koreatech-logo.png", + requiredMode = REQUIRED + ) + String imageUrl, + + @Schema(description = "등록 동아리 수", example = "31", requiredMode = REQUIRED) + Long clubCount + ) { + public static UniversityResponse from(WebsiteUniversitySummary summary) { + return new UniversityResponse( + summary.id(), + summary.name(), + summary.campusName(), + summary.region(), + summary.regionName(), + summary.imageUrl(), + summary.clubCount() + ); + } + } + + public static WebsiteHomeResponse from(List summaries) { + return new WebsiteHomeResponse( + summaries.size(), + summaries.stream() + .map(UniversityResponse::from) + .toList() + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/website/model/WebsiteUniversitySummary.java b/src/main/java/gg/agit/konect/domain/website/model/WebsiteUniversitySummary.java new file mode 100644 index 000000000..f97f23af9 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/website/model/WebsiteUniversitySummary.java @@ -0,0 +1,14 @@ +package gg.agit.konect.domain.website.model; + +import gg.agit.konect.domain.university.enums.UniversityRegion; + +public record WebsiteUniversitySummary( + Integer id, + String name, + String campusName, + UniversityRegion region, + String regionName, + String imageUrl, + Long clubCount +) { +} diff --git a/src/main/java/gg/agit/konect/domain/website/repository/WebsiteQueryRepository.java b/src/main/java/gg/agit/konect/domain/website/repository/WebsiteQueryRepository.java new file mode 100644 index 000000000..ea142261b --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/website/repository/WebsiteQueryRepository.java @@ -0,0 +1,209 @@ +package gg.agit.konect.domain.website.repository; + +import static gg.agit.konect.domain.club.model.QClub.club; +import static gg.agit.konect.domain.club.model.QClubMember.clubMember; +import static gg.agit.konect.domain.club.model.QClubRecruitment.clubRecruitment; +import static gg.agit.konect.domain.university.model.QUniversity.university; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Repository; + +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.Tuple; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.NumberExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import gg.agit.konect.domain.club.enums.ClubCategory; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.university.enums.UniversityRegion; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.website.model.WebsiteUniversitySummary; +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class WebsiteQueryRepository { + + private final JPAQueryFactory jpaQueryFactory; + + public List findUniversitySummaries(String query, UniversityRegion region) { + BooleanBuilder condition = new BooleanBuilder(); + addUniversitySearchCondition(condition, query); + addUniversityRegionCondition(condition, region); + NumberExpression clubCount = club.id.countDistinct(); + + List rows = jpaQueryFactory + .select( + university.id, + university.koreanName, + university.campus, + university.region, + university.imageUrl, + clubCount + ) + .from(university) + .leftJoin(club).on(club.university.id.eq(university.id)) + .where(condition) + .groupBy( + university.id, + university.koreanName, + university.campus, + university.region, + university.imageUrl + ) + .orderBy(university.koreanName.asc(), university.campus.asc()) + .fetch(); + + return rows.stream() + .map(row -> new WebsiteUniversitySummary( + row.get(university.id), + row.get(university.koreanName), + row.get(university.campus).getDisplayName(), + row.get(university.region), + row.get(university.region).getDisplayName(), + row.get(university.imageUrl), + row.get(clubCount) + )) + .toList(); + } + + public Optional findUniversity(Integer universityId) { + return Optional.ofNullable(jpaQueryFactory + .selectFrom(university) + .where(university.id.eq(universityId)) + .fetchOne()); + } + + public Page findClubs( + Integer universityId, + String query, + ClubCategory category, + PageRequest pageable + ) { + BooleanBuilder condition = createClubCondition(universityId, query, category); + + List clubs = jpaQueryFactory + .selectFrom(club) + .join(club.university, university).fetchJoin() + .leftJoin(club.clubRecruitment, clubRecruitment).fetchJoin() + .where(condition) + .orderBy(club.name.asc(), club.id.asc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = jpaQueryFactory + .select(club.count()) + .from(club) + .where(condition) + .fetchOne(); + + return new PageImpl<>(clubs, pageable, total == null ? 0 : total); + } + + public Map countClubCategories(Integer universityId, String query) { + BooleanBuilder condition = createClubCondition(universityId, query, null); + NumberExpression clubCount = club.count(); + + List rows = jpaQueryFactory + .select(club.clubCategory, clubCount) + .from(club) + .where(condition) + .groupBy(club.clubCategory) + .fetch(); + + Map categoryCounts = new LinkedHashMap<>(); + rows.forEach(row -> categoryCounts.put(row.get(club.clubCategory), row.get(clubCount))); + return categoryCounts; + } + + public Optional findClub(Integer clubId) { + return Optional.ofNullable(jpaQueryFactory + .selectFrom(club) + .join(club.university, university).fetchJoin() + .leftJoin(club.clubRecruitment, clubRecruitment).fetchJoin() + .where(club.id.eq(clubId)) + .fetchOne()); + } + + public List findClubs(List clubIds) { + if (clubIds.isEmpty()) { + return List.of(); + } + + return jpaQueryFactory + .selectFrom(club) + .join(club.university, university).fetchJoin() + .leftJoin(club.clubRecruitment, clubRecruitment).fetchJoin() + .where(club.id.in(clubIds)) + .fetch(); + } + + public Map countMembersByClubIds(List clubIds) { + if (clubIds.isEmpty()) { + return Map.of(); + } + NumberExpression memberCount = clubMember.count(); + + List rows = jpaQueryFactory + .select(clubMember.club.id, memberCount) + .from(clubMember) + .where( + clubMember.club.id.in(clubIds), + clubMember.user.deletedAt.isNull() + ) + .groupBy(clubMember.club.id) + .fetch(); + + Map memberCounts = new LinkedHashMap<>(); + rows.forEach(row -> memberCounts.put(row.get(clubMember.club.id), row.get(memberCount))); + return memberCounts; + } + + private BooleanBuilder createClubCondition(Integer universityId, String query, ClubCategory category) { + BooleanBuilder condition = new BooleanBuilder(); + + condition.and(club.university.id.eq(universityId)); + addClubSearchCondition(condition, query); + if (category != null) { + condition.and(club.clubCategory.eq(category)); + } + + return condition; + } + + private void addUniversitySearchCondition(BooleanBuilder condition, String query) { + if (query == null || query.isBlank()) { + return; + } + + String normalizedQuery = query.trim().toLowerCase(); + condition.and(university.koreanName.lower().contains(normalizedQuery)); + } + + private void addUniversityRegionCondition(BooleanBuilder condition, UniversityRegion region) { + if (region == null) { + return; + } + + condition.and(university.region.eq(region)); + } + + private void addClubSearchCondition(BooleanBuilder condition, String query) { + if (query == null || query.isBlank()) { + return; + } + + String normalizedQuery = query.trim().toLowerCase(); + BooleanExpression nameContains = club.name.lower().contains(normalizedQuery); + condition.and(nameContains); + } +} diff --git a/src/main/java/gg/agit/konect/domain/website/service/WebsiteService.java b/src/main/java/gg/agit/konect/domain/website/service/WebsiteService.java new file mode 100644 index 000000000..69b384658 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/website/service/WebsiteService.java @@ -0,0 +1,94 @@ +package gg.agit.konect.domain.website.service; + +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CLUB; +import static gg.agit.konect.global.code.ApiResponseCode.UNIVERSITY_NOT_FOUND; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +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.club.model.Club; +import gg.agit.konect.domain.university.enums.UniversityRegion; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.website.dto.WebsiteClubDetailResponse; +import gg.agit.konect.domain.website.dto.WebsiteClubListCondition; +import gg.agit.konect.domain.website.dto.WebsiteClubsResponse; +import gg.agit.konect.domain.website.dto.WebsiteHomeResponse; +import gg.agit.konect.domain.website.model.WebsiteUniversitySummary; +import gg.agit.konect.domain.website.repository.WebsiteQueryRepository; +import gg.agit.konect.global.exception.CustomException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class WebsiteService { + + private final WebsiteQueryRepository websiteQueryRepository; + + public WebsiteHomeResponse getHome(String query, UniversityRegion region) { + List summaries = websiteQueryRepository.findUniversitySummaries(query, region); + return WebsiteHomeResponse.from(summaries); + } + + public WebsiteClubsResponse getUniversityClubs(Integer universityId, WebsiteClubListCondition condition) { + University university = websiteQueryRepository.findUniversity(universityId) + .orElseThrow(() -> CustomException.of(UNIVERSITY_NOT_FOUND)); + PageRequest pageable = PageRequest.of(condition.page() - 1, condition.limit()); + + Page clubs = websiteQueryRepository.findClubs( + universityId, + condition.query(), + condition.category(), + pageable + ); + List clubIds = clubs.getContent().stream() + .map(Club::getId) + .toList(); + + return WebsiteClubsResponse.of( + university, + clubs, + websiteQueryRepository.countMembersByClubIds(clubIds), + websiteQueryRepository.countClubCategories(universityId, condition.query()) + ); + } + + public WebsiteClubDetailResponse getClubDetail(Integer clubId) { + Club club = websiteQueryRepository.findClub(clubId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CLUB)); + Long memberCount = websiteQueryRepository.countMembersByClubIds(List.of(clubId)).getOrDefault(clubId, 0L); + + return WebsiteClubDetailResponse.of(club, memberCount); + } + + public WebsiteClubsResponse getRecentClubs(List clubIds) { + List distinctClubIds = clubIds.stream() + .distinct() + .toList(); + List clubs = websiteQueryRepository.findClubs(distinctClubIds); + Map order = createOrder(clubIds); + + List sortedClubs = clubs.stream() + .sorted(Comparator.comparingInt(club -> order.getOrDefault(club.getId(), Integer.MAX_VALUE))) + .toList(); + + return WebsiteClubsResponse.recent( + sortedClubs, + websiteQueryRepository.countMembersByClubIds(distinctClubIds) + ); + } + + private Map createOrder(List clubIds) { + Map order = new java.util.LinkedHashMap<>(); + for (int i = 0; i < clubIds.size(); i++) { + order.putIfAbsent(clubIds.get(i), i); + } + return order; + } +} 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 b8530ba10..e1273f77a 100644 --- a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java +++ b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java @@ -25,6 +25,7 @@ public enum ApiResponseCode { CANNOT_KICK_SELF(HttpStatus.BAD_REQUEST, "자기 자신을 강퇴할 수 없습니다."), CANNOT_KICK_ROOM_OWNER(HttpStatus.BAD_REQUEST, "방장은 강퇴할 수 없습니다."), CANNOT_KICK_IN_NON_GROUP_ROOM(HttpStatus.BAD_REQUEST, "그룹 채팅방에서만 강퇴할 수 있습니다."), + CANNOT_INVITE_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, "동아리 회장인 경우 회장을 양도하고 탈퇴해야 합니다."), diff --git a/src/main/java/gg/agit/konect/global/config/AsyncConfig.java b/src/main/java/gg/agit/konect/global/config/AsyncConfig.java index f88deb3ef..50f54e027 100644 --- a/src/main/java/gg/agit/konect/global/config/AsyncConfig.java +++ b/src/main/java/gg/agit/konect/global/config/AsyncConfig.java @@ -11,6 +11,7 @@ import org.springframework.scheduling.annotation.AsyncConfigurer; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import gg.agit.konect.global.logging.MdcTaskDecorator; import lombok.extern.slf4j.Slf4j; @Slf4j @@ -44,6 +45,7 @@ public Executor getAsyncExecutor() { executor.setMaxPoolSize(DEFAULT_MAX_POOL_SIZE); executor.setQueueCapacity(DEFAULT_QUEUE_CAPACITY); executor.setThreadNamePrefix("async-default-"); + executor.setTaskDecorator(new MdcTaskDecorator()); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(DEFAULT_AWAIT_TERMINATION_SECONDS); @@ -63,6 +65,7 @@ public Executor sheetSyncTaskExecutor() { executor.setMaxPoolSize(SHEET_SYNC_MAX_POOL_SIZE); executor.setQueueCapacity(SHEET_SYNC_QUEUE_CAPACITY); executor.setThreadNamePrefix("sheet-sync-"); + executor.setTaskDecorator(new MdcTaskDecorator()); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(SHEET_SYNC_AWAIT_TERMINATION_SECONDS); @@ -77,6 +80,7 @@ public Executor notificationTaskExecutor() { executor.setMaxPoolSize(NOTIFICATION_MAX_POOL_SIZE); executor.setQueueCapacity(NOTIFICATION_QUEUE_CAPACITY); executor.setThreadNamePrefix("notification-"); + executor.setTaskDecorator(new MdcTaskDecorator()); executor.setRejectedExecutionHandler((runnable, pool) -> { log.warn("알림 스레드풀 포화로 작업이 거절되었습니다. poolSize={}, activeCount={}, queueSize={}", pool.getPoolSize(), pool.getActiveCount(), pool.getQueue().size()); @@ -95,6 +99,7 @@ public Executor slackTaskExecutor() { executor.setMaxPoolSize(SLACK_MAX_POOL_SIZE); executor.setQueueCapacity(SLACK_QUEUE_CAPACITY); executor.setThreadNamePrefix("slack-"); + executor.setTaskDecorator(new MdcTaskDecorator()); executor.setRejectedExecutionHandler((runnable, pool) -> { log.warn("Slack 스레드풀 포화로 작업이 거절되었습니다. poolSize={}, activeCount={}, queueSize={}", pool.getPoolSize(), pool.getActiveCount(), pool.getQueue().size()); 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 814b02cfe..c6c154e5f 100644 --- a/src/main/java/gg/agit/konect/global/config/SecurityPaths.java +++ b/src/main/java/gg/agit/konect/global/config/SecurityPaths.java @@ -11,7 +11,8 @@ public final class SecurityPaths { "/swagger-resources/**", "/error", "/slack/events", - "/auth/oauth/google/drive/callback" + "/auth/oauth/google/drive/callback", + "/konect/**" }; public static final String[] DENY_PATHS = {}; diff --git a/src/main/java/gg/agit/konect/global/config/SwaggerConfig.java b/src/main/java/gg/agit/konect/global/config/SwaggerConfig.java index d26ce1129..e6b9986e0 100644 --- a/src/main/java/gg/agit/konect/global/config/SwaggerConfig.java +++ b/src/main/java/gg/agit/konect/global/config/SwaggerConfig.java @@ -5,6 +5,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import org.springdoc.core.models.GroupedOpenApi; @@ -16,6 +17,7 @@ import io.swagger.v3.oas.models.servers.Server; @Configuration +@Profile("!prod") public class SwaggerConfig { private final String serverUrl; @@ -58,8 +60,8 @@ public GroupedOpenApi publicApi() { .addOpenApiCustomizer(openApi -> openApi.setTags( openApi.getTags() != null ? openApi.getTags().stream() - .sorted((a, b) -> a.getName().compareTo(b.getName())) - .toList() + .sorted((a, b) -> a.getName().compareTo(b.getName())) + .toList() : null )) .build(); diff --git a/src/main/java/gg/agit/konect/global/config/SwaggerUiResourceController.java b/src/main/java/gg/agit/konect/global/config/SwaggerUiResourceController.java index 3253d9472..247b69b19 100644 --- a/src/main/java/gg/agit/konect/global/config/SwaggerUiResourceController.java +++ b/src/main/java/gg/agit/konect/global/config/SwaggerUiResourceController.java @@ -2,6 +2,7 @@ import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.Resource; +import org.springframework.context.annotation.Profile; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -11,6 +12,7 @@ @Hidden @RestController +@Profile("!prod") public class SwaggerUiResourceController { private static final String SWAGGER_INITIALIZER_PATH = "static/swagger-ui/swagger-initializer.js"; 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 42d6ba717..e38c9367e 100644 --- a/src/main/java/gg/agit/konect/global/config/WebConfig.java +++ b/src/main/java/gg/agit/konect/global/config/WebConfig.java @@ -31,7 +31,7 @@ public void addCorsMappings(CorsRegistry registry) { .allowedOrigins(corsProperties.allowedOrigins().toArray(new String[0])) .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") .allowedHeaders("*") - .exposedHeaders("Authorization") + .exposedHeaders("Authorization", "X-Request-ID") .allowCredentials(true) .maxAge(CORS_PREFLIGHT_MAX_AGE_SECONDS); } 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 f0df6e537..992ec5fb1 100644 --- a/src/main/java/gg/agit/konect/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/gg/agit/konect/global/exception/GlobalExceptionHandler.java @@ -1,15 +1,19 @@ package gg.agit.konect.global.exception; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; import java.time.DateTimeException; import java.util.Enumeration; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.StringJoiner; import java.util.UUID; import org.apache.catalina.connector.ClientAbortException; import org.slf4j.Logger; +import org.slf4j.MDC; import org.springframework.web.multipart.MaxUploadSizeExceededException; import org.slf4j.LoggerFactory; import org.springframework.http.HttpHeaders; @@ -41,6 +45,22 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { private static final Logger RUNTIME_ERROR_LOGGER = LoggerFactory.getLogger("runtime.error"); + private static final String REQUEST_ID_MDC_KEY = "requestId"; + private static final String MASKED_HEADER_VALUE = "***"; + private static final List SENSITIVE_HEADER_NAMES = List.of( + "authorization", + "cookie", + "proxy-authorization", + "set-cookie" + ); + private static final List SENSITIVE_QUERY_PARAMETER_NAMES = List.of( + "access_token", + "client_secret", + "code", + "password", + "refresh_token", + "token" + ); @ExceptionHandler(CustomException.class) public ResponseEntity handleCustomException( @@ -185,28 +205,39 @@ protected ResponseEntity handleHttpMessageNotReadable( @ExceptionHandler(Exception.class) public ResponseEntity handleException(HttpServletRequest request, Exception e) { - StackTraceElement origin = e.getStackTrace()[0]; - String uri = String.format("%s %s", request.getMethod(), request.getRequestURI()); String exception = e.getClass().getSimpleName(); - String location = String.format("%s:%d", origin.getFileName(), origin.getLineNumber()); + String location = getExceptionLocation(e); String message = e.getMessage(); + String requestId = getRequestId(); String slackMessage = String.format( """ + Request ID: `%s` URI: `%s` Location: `%s` Exception: `%s` ```%s``` """, - uri, location, exception, message + requestId, uri, location, exception, message ); RUNTIME_ERROR_LOGGER.error(slackMessage); + requestDebugLogging(request, requestId); return buildErrorResponse(ApiResponseCode.UNEXPECTED_SERVER_ERROR); } + private String getExceptionLocation(Exception e) { + StackTraceElement[] stackTrace = e.getStackTrace(); + if (stackTrace == null || stackTrace.length == 0) { + return "unknown:0"; + } + + StackTraceElement origin = stackTrace[0]; + return String.format("%s:%d", origin.getFileName(), origin.getLineNumber()); + } + private ResponseEntity buildErrorResponse(ApiResponseCode errorCode) { String errorTraceId = UUID.randomUUID().toString(); @@ -253,28 +284,90 @@ private void requestLogging( String errorTraceId ) { log.warn("[{}] {} | errorTraceId={}", httpStatus, errorMessage, errorTraceId); - log.debug("Request: {} {}", request.getMethod(), request.getRequestURI()); - log.debug("Headers: {}", getHeaders(request)); - log.debug("Query String: {}", getQueryString(request)); - log.debug("Body: {}", getRequestBody(request)); + requestDebugLogging(request, getRequestId()); + } + + private void requestDebugLogging(HttpServletRequest request, String requestId) { + if (!log.isDebugEnabled()) { + return; + } + log.debug("Request [requestId: {}]: {} {}", requestId, request.getMethod(), request.getRequestURI()); + log.debug("Headers [requestId: {}]: {}", requestId, getLoggableHeaders(request)); + log.debug("Query String [requestId: {}]: {}", requestId, getQueryString(request)); + log.debug("Body [requestId: {}]: {}", requestId, getRequestBody(request)); } - private Map getHeaders(HttpServletRequest request) { + private String getRequestId() { + String requestId = MDC.get(REQUEST_ID_MDC_KEY); + if (requestId == null) { + return " - "; + } + return requestId; + } + + private Map getLoggableHeaders(HttpServletRequest request) { Map headerMap = new HashMap<>(); Enumeration headerArray = request.getHeaderNames(); while (headerArray.hasMoreElements()) { String headerName = headerArray.nextElement(); - headerMap.put(headerName, request.getHeader(headerName)); + headerMap.put(headerName, getLoggableHeaderValue(request, headerName)); } return headerMap; } + private String getLoggableHeaderValue(HttpServletRequest request, String headerName) { + if (isSensitiveHeader(headerName)) { + return MASKED_HEADER_VALUE; + } + return request.getHeader(headerName); + } + + private boolean isSensitiveHeader(String headerName) { + return SENSITIVE_HEADER_NAMES.stream() + .anyMatch(sensitiveHeaderName -> sensitiveHeaderName.equalsIgnoreCase(headerName)); + } + private String getQueryString(HttpServletRequest httpRequest) { String queryString = httpRequest.getQueryString(); if (queryString == null) { return " - "; } - return queryString; + return getLoggableQueryString(queryString); + } + + private String getLoggableQueryString(String queryString) { + StringJoiner loggableQueryString = new StringJoiner("&"); + for (String parameter : queryString.split("&", -1)) { + loggableQueryString.add(getLoggableQueryParameter(parameter)); + } + return loggableQueryString.toString(); + } + + private String getLoggableQueryParameter(String parameter) { + int delimiterIndex = parameter.indexOf('='); + String parameterName = delimiterIndex >= 0 + ? parameter.substring(0, delimiterIndex) + : parameter; + + if (!isSensitiveQueryParameter(parameterName)) { + return parameter; + } + + return parameterName + "=" + MASKED_HEADER_VALUE; + } + + private boolean isSensitiveQueryParameter(String parameterName) { + String decodedParameterName = decodeQueryParameterName(parameterName); + return SENSITIVE_QUERY_PARAMETER_NAMES.stream() + .anyMatch(sensitiveParameterName -> sensitiveParameterName.equalsIgnoreCase(decodedParameterName)); + } + + private String decodeQueryParameterName(String parameterName) { + try { + return URLDecoder.decode(parameterName, StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + return parameterName; + } } private String getRequestBody(HttpServletRequest request) { diff --git a/src/main/java/gg/agit/konect/global/logging/MdcTaskDecorator.java b/src/main/java/gg/agit/konect/global/logging/MdcTaskDecorator.java new file mode 100644 index 000000000..f63b3830f --- /dev/null +++ b/src/main/java/gg/agit/konect/global/logging/MdcTaskDecorator.java @@ -0,0 +1,33 @@ +package gg.agit.konect.global.logging; + +import java.util.Map; + +import org.slf4j.MDC; +import org.springframework.core.task.TaskDecorator; + +public class MdcTaskDecorator implements TaskDecorator { + + @Override + public Runnable decorate(Runnable runnable) { + Map callerContext = MDC.getCopyOfContextMap(); + + return () -> { + Map workerContext = MDC.getCopyOfContextMap(); + + try { + if (callerContext == null) { + MDC.clear(); + } else { + MDC.setContextMap(callerContext); + } + runnable.run(); + } finally { + if (workerContext == null) { + MDC.clear(); + } else { + MDC.setContextMap(workerContext); + } + } + }; + } +} diff --git a/src/main/java/gg/agit/konect/global/logging/RequestLoggingFilter.java b/src/main/java/gg/agit/konect/global/logging/RequestLoggingFilter.java index 24faaf307..d676ab8cd 100644 --- a/src/main/java/gg/agit/konect/global/logging/RequestLoggingFilter.java +++ b/src/main/java/gg/agit/konect/global/logging/RequestLoggingFilter.java @@ -3,6 +3,7 @@ import java.io.IOException; import java.util.Objects; import java.util.UUID; +import java.util.regex.Pattern; import org.slf4j.MDC; import org.springframework.beans.factory.ObjectProvider; @@ -15,7 +16,6 @@ import org.springframework.web.cors.CorsUtils; import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.util.ContentCachingRequestWrapper; -import org.springframework.web.util.ContentCachingResponseWrapper; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -32,6 +32,10 @@ public class RequestLoggingFilter extends OncePerRequestFilter { private static final String REQUEST_ID = "requestId"; private static final String REQUEST_ID_HEADER = "X-Request-ID"; + private static final int MAX_REQUEST_ID_LENGTH = 128; + // 응답 헤더와 로그에 그대로 남는 값이므로 제어 문자와 과도한 길이는 허용하지 않는다. + private static final Pattern REQUEST_ID_PATTERN = Pattern.compile( + "[A-Za-z0-9._-]{1," + MAX_REQUEST_ID_LENGTH + "}"); private final ObjectProvider pathMatcherProvider; private final LoggingProperties properties; @@ -45,8 +49,9 @@ public void doFilterInternal(HttpServletRequest request, HttpServletResponse res return; } - var cachedRequest = new ContentCachingRequestWrapper(request); - var cachedResponse = new ContentCachingResponseWrapper(response); + HttpServletRequest wrappedRequest = request instanceof ContentCachingRequestWrapper + ? request + : new ContentCachingRequestWrapper(request); StopWatch stopWatch = new StopWatch(); String requestId = getRequestId(httpRequest); String method = httpRequest.getMethod(); @@ -54,15 +59,15 @@ public void doFilterInternal(HttpServletRequest request, HttpServletResponse res try { MDC.put(REQUEST_ID, requestId); + response.setHeader(REQUEST_ID_HEADER, requestId); stopWatch.start(); log.info("request start [requestId: {}, uri: {} {}]", requestId, method, uri); - chain.doFilter(cachedRequest, cachedResponse); + chain.doFilter(wrappedRequest, response); } finally { stopWatch.stop(); log.info("request end [requestId: {}, uri: {} {}, time: {}ms, status: {}]", - requestId, method, uri, stopWatch.getTotalTimeMillis(), cachedResponse.getStatus()); + requestId, method, uri, stopWatch.getTotalTimeMillis(), response.getStatus()); MDC.remove(REQUEST_ID); - cachedResponse.copyBodyToResponse(); } } @@ -80,8 +85,16 @@ private boolean isIgnoredUrl(HttpServletRequest request) { private String getRequestId(HttpServletRequest httpRequest) { String requestId = httpRequest.getHeader(REQUEST_ID_HEADER); if (ObjectUtils.isEmpty(requestId)) { - return UUID.randomUUID().toString().replace("-", ""); + return generateRequestId(); } - return requestId; + String trimmedRequestId = requestId.trim(); + if (!REQUEST_ID_PATTERN.matcher(trimmedRequestId).matches()) { + return generateRequestId(); + } + return trimmedRequestId; + } + + private String generateRequestId() { + return UUID.randomUUID().toString().replace("-", ""); } } diff --git a/src/main/java/gg/agit/konect/infrastructure/claude/client/ClaudeClient.java b/src/main/java/gg/agit/konect/infrastructure/claude/client/ClaudeClient.java index ff88f189b..d4bd26758 100644 --- a/src/main/java/gg/agit/konect/infrastructure/claude/client/ClaudeClient.java +++ b/src/main/java/gg/agit/konect/infrastructure/claude/client/ClaudeClient.java @@ -31,30 +31,26 @@ public class ClaudeClient { private static final String SYSTEM_PROMPT = """ 당신은 KONECT 서비스의 데이터 분석 AI 에이전트입니다. + ## 필수 원칙 + DB를 조회할 때는 먼저 database_schema 도구로 캐시된 실제 테이블 목록, 테이블 comment, + 컬럼 구조를 확인하고, 존재하는 테이블과 컬럼만 사용한다. + 시스템 프롬프트의 과거 지식보다 database_schema 도구 결과를 우선 신뢰한다. + ## 역할 사용자의 질문을 분석하고, 데이터베이스에서 필요한 데이터를 조회하여 답변합니다. ## 사용 가능한 도구 - 1. list_tables: 데이터베이스의 모든 테이블 목록 조회 - 2. describe_table: 특정 테이블의 컬럼 구조 조회 - 3. query: SQL SELECT 쿼리 실행 (읽기 전용) + 1. database_schema: 캐시된 실제 DB 스키마, 테이블 comment, 컬럼 구조 조회 + 2. list_tables: database_schema 조회 실패 또는 캐시된 목록에 확신이 없을 때만 테이블 목록 재조회 + 3. describe_table: 특정 테이블의 최신 컬럼 구조를 재확인해야 할 때만 사용 + 4. query: SQL SELECT 쿼리 실행 (읽기 전용) ## 작업 방식 - 1. 질문과 관련된 테이블이 확실하지 않으면 반드시 list_tables로 먼저 확인 - 2. 테이블 구조가 필요하면 describe_table로 컬럼 정보 확인 + 1. 먼저 database_schema로 현재 DB 스키마와 테이블 comment 확인 + 2. database_schema가 실패했거나 특정 테이블 구조가 더 필요할 때만 list_tables 또는 describe_table 사용 3. 적절한 SQL 쿼리를 작성하여 데이터 조회 4. 결과를 바탕으로 친절하고 자연스럽게 답변 - ## 주요 테이블 힌트 (예시, 전체 목록은 list_tables로 확인) - - users: 사용자 정보 (deleted_at IS NULL = 활성 사용자) - - club: 동아리 정보 - - club_member: 동아리 멤버 (role: PRESIDENT, VICE_PRESIDENT, MANAGER, MEMBER) - - club_recruitment: 모집 공고 - - club_apply: 동아리 지원 - - university_schedule: 학사 일정 - - council_notice: 학생회 공지사항 - - study_time_*: 공부 시간 관련 테이블 - ## 응답 규칙 - 반드시 한국어로 응답 - 답변은 질문한 것에 대해서만 할 것 @@ -63,6 +59,15 @@ public class ClaudeClient { - 데이터베이스에 정말 없는 정보만 정중히 거절 """; + private static final Map DATABASE_SCHEMA_TOOL = Map.of( + "name", "database_schema", + "description", "캐시된 실제 DB 스키마 요약을 조회합니다. 테이블 comment와 컬럼 구조를 포함합니다.", + "input_schema", Map.of( + "type", "object", + "properties", Map.of() + ) + ); + private static final Map QUERY_TOOL = Map.of( "name", "query", "description", "MySQL 데이터베이스에 SELECT 쿼리를 실행합니다. 읽기 전용입니다.", @@ -103,21 +108,24 @@ public class ClaudeClient { ); private static final List> ALL_TOOLS = List.of( - QUERY_TOOL, LIST_TABLES_TOOL, DESCRIBE_TABLE_TOOL + DATABASE_SCHEMA_TOOL, QUERY_TOOL, LIST_TABLES_TOOL, DESCRIBE_TABLE_TOOL ); private final RestClient restClient; private final ClaudeProperties claudeProperties; private final McpClient mcpClient; + private final DatabaseSchemaCache databaseSchemaCache; private final ObjectMapper objectMapper; public ClaudeClient(RestClient.Builder restClientBuilder, ClaudeProperties claudeProperties, McpClient mcpClient, + DatabaseSchemaCache databaseSchemaCache, ObjectMapper objectMapper) { this.restClient = restClientBuilder.build(); this.claudeProperties = claudeProperties; this.mcpClient = mcpClient; + this.databaseSchemaCache = databaseSchemaCache; this.objectMapper = objectMapper; } @@ -253,6 +261,10 @@ private List> processToolCalls(JsonNode content) { private String executeToolCall(String toolName, JsonNode input) { try { return switch (toolName) { + case "database_schema" -> { + log.debug("Loading cached database schema"); + yield databaseSchemaCache.getSchemaSummary(); + } case "query" -> { String sql = input.path("sql").asText(); log.debug("Executing SQL query via MCP"); diff --git a/src/main/java/gg/agit/konect/infrastructure/claude/client/DatabaseSchemaCache.java b/src/main/java/gg/agit/konect/infrastructure/claude/client/DatabaseSchemaCache.java new file mode 100644 index 000000000..c7f5f98f0 --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/claude/client/DatabaseSchemaCache.java @@ -0,0 +1,171 @@ +package gg.agit.konect.infrastructure.claude.client; + +import java.time.Duration; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.dao.DataAccessException; +import org.springframework.dao.DataRetrievalFailureException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class DatabaseSchemaCache { + + private static final Duration FAILURE_RETRY_INTERVAL = Duration.ofSeconds(30); + private static final String FALLBACK_SCHEMA = + "DB 스키마 요약 조회에 실패했습니다. list_tables와 describe_table 도구로 다시 확인하세요."; + private static final String TABLE_SCHEMA_SQL = """ + SELECT table_name, table_comment + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_type = 'BASE TABLE' + AND table_name <> 'flyway_schema_history' + ORDER BY table_name + """; + private static final String COLUMN_SCHEMA_SQL = """ + SELECT table_name, + column_name, + column_type, + is_nullable, + column_key, + column_comment + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name <> 'flyway_schema_history' + ORDER BY table_name, ordinal_position + """; + + private final JdbcTemplate jdbcTemplate; + private volatile String cachedSchema; + private volatile Instant retrySchemaLoadAt = Instant.EPOCH; + + public DatabaseSchemaCache(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public String getSchemaSummary() { + String currentSchema = cachedSchema; + if (currentSchema != null) { + return currentSchema; + } + Instant now = Instant.now(); + if (now.isBefore(retrySchemaLoadAt)) { + return FALLBACK_SCHEMA; + } + + synchronized (this) { + if (cachedSchema != null) { + return cachedSchema; + } + now = Instant.now(); + if (now.isBefore(retrySchemaLoadAt)) { + return FALLBACK_SCHEMA; + } + + try { + cachedSchema = loadSchemaSummary(); + return cachedSchema; + } catch (DataAccessException e) { + log.error("Failed to load database schema summary", e); + retrySchemaLoadAt = now.plus(FAILURE_RETRY_INTERVAL); + return FALLBACK_SCHEMA; + } + } + } + + private String loadSchemaSummary() { + List tables = jdbcTemplate.query( + TABLE_SCHEMA_SQL, + (rs, rowNum) -> new TableSchema( + rs.getString("table_name"), + nullSafeTrim(rs.getString("table_comment")) + ) + ); + + if (tables.isEmpty()) { + log.warn("Database schema cache loaded an empty schema summary"); + throw new DataRetrievalFailureException("Database schema summary is empty"); + } + + List columns = jdbcTemplate.query( + COLUMN_SCHEMA_SQL, + (rs, rowNum) -> new ColumnSchema( + rs.getString("table_name"), + rs.getString("column_name"), + rs.getString("column_type"), + nullSafeTrim(rs.getString("is_nullable")), + nullSafeTrim(rs.getString("column_key")), + nullSafeTrim(rs.getString("column_comment")) + ) + ); + + Map columnsByTable = new LinkedHashMap<>(); + for (ColumnSchema column : columns) { + StringBuilder tableColumns = columnsByTable.computeIfAbsent( + column.tableName(), + ignored -> new StringBuilder() + ); + tableColumns + .append(" - ") + .append(column.columnName()) + .append(" ") + .append(column.columnType()); + + if ("NO".equals(column.isNullable())) { + tableColumns.append(" NOT NULL"); + } + if (!column.columnKey().isBlank()) { + tableColumns.append(" ").append(column.columnKey()); + } + if (!column.comment().isBlank()) { + tableColumns.append(": ").append(column.comment()); + } + tableColumns.append('\n'); + } + + StringBuilder summary = new StringBuilder(); + summary.append("현재 DB 스키마 요약입니다. 테이블 comment를 우선 신뢰하고, 존재하는 테이블/컬럼만 사용하세요.\n"); + for (TableSchema table : tables) { + summary.append("- ") + .append(table.tableName()) + .append(": ") + .append(table.comment().isBlank() ? "설명 없음" : table.comment()) + .append('\n'); + StringBuilder tableColumns = columnsByTable.get(table.tableName()); + if (tableColumns != null) { + summary.append(tableColumns); + } + } + + return summary.toString(); + } + + private String nullSafeTrim(String value) { + if (value == null || value.isBlank()) { + return ""; + } + return value.trim(); + } + + private record TableSchema( + String tableName, + String comment + ) { + } + + private record ColumnSchema( + String tableName, + String columnName, + String columnType, + String isNullable, + String columnKey, + String comment + ) { + } +} diff --git a/src/main/resources/application-monitoring.yml b/src/main/resources/application-monitoring.yml deleted file mode 100644 index 4ec70381f..000000000 --- a/src/main/resources/application-monitoring.yml +++ /dev/null @@ -1,28 +0,0 @@ -logging: - ignored-url-patterns: - - /**/api-docs/** - - /**/swagger-ui/** - - /error - - /favicon.ico - - /actuator/** - - /notifications/inbox/stream - -management: - endpoints: - web: - exposure: - include: health, info, prometheus - - endpoint: - health: - show-details: never - - metrics: - distribution: - percentiles-histogram: - http: - server: - requests: true - - tags: - application: konect-backend diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 000000000..7b9828578 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,5 @@ +springdoc: + api-docs: + enabled: false + swagger-ui: + enabled: false diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c81f1d98e..278ad980e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,3 +1,12 @@ +logging: + ignored-url-patterns: + - /**/api-docs/** + - /**/swagger-ui/** + - /error + - /favicon.ico + - /actuator/** + - /notifications/inbox/stream + spring: application: name: konect-backend @@ -7,7 +16,6 @@ spring: import: - classpath:application-db.yml - classpath:application-infrastructure.yml - - classpath:application-monitoring.yml - classpath:application-security.yml - optional:file:.env.${spring.profiles.active}[.properties] servlet: diff --git a/src/main/resources/db/migration/V70__backfill_chat_room_last_message_metadata.sql b/src/main/resources/db/migration/V70__backfill_chat_room_last_message_metadata.sql new file mode 100644 index 000000000..3fb3d0cdb --- /dev/null +++ b/src/main/resources/db/migration/V70__backfill_chat_room_last_message_metadata.sql @@ -0,0 +1,24 @@ +-- 채팅방 목록/관리 쿼리는 chat_room.last_message_*를 직접 사용하므로 +-- 기존 메시지 이력이 있는 방도 최신 메시지 메타데이터를 다시 맞춰 준다. +-- MAX(created_at)으로 최신 메시지를 고르고, 같은 시각이면 id로 타이브레이크한다. +UPDATE chat_room cr +LEFT JOIN ( + SELECT + cm1.chat_room_id, + cm1.content, + cm1.created_at + FROM chat_message cm1 + JOIN ( + SELECT chat_room_id, MAX(created_at) AS max_created_at + FROM chat_message + GROUP BY chat_room_id + ) cm2 ON cm2.chat_room_id = cm1.chat_room_id AND cm2.max_created_at = cm1.created_at + WHERE cm1.id = ( + SELECT MAX(id) + FROM chat_message + WHERE chat_room_id = cm1.chat_room_id + AND created_at = cm2.max_created_at + ) +) latest_msg ON latest_msg.chat_room_id = cr.id +SET cr.last_message_content = latest_msg.content, + cr.last_message_sent_at = latest_msg.created_at; diff --git a/src/main/resources/db/migration/V71__add_table_comments_for_ai_schema.sql b/src/main/resources/db/migration/V71__add_table_comments_for_ai_schema.sql new file mode 100644 index 000000000..d3c96824f --- /dev/null +++ b/src/main/resources/db/migration/V71__add_table_comments_for_ai_schema.sql @@ -0,0 +1,104 @@ +ALTER TABLE `advertisement` + COMMENT = '앱 광고 배너와 노출 기간 정보'; + +ALTER TABLE `bank` + COMMENT = '회비 납부 등에 사용할 은행 코드와 은행명'; + +ALTER TABLE `chat_message` + COMMENT = '채팅방 메시지 내역'; + +ALTER TABLE `chat_room` + COMMENT = '1대1, 동아리 단체, 시스템 관리자 채팅방'; + +ALTER TABLE `chat_room_member` + COMMENT = '채팅방 참여자, 읽음 시점, 퇴장/숨김 상태'; + +ALTER TABLE `club` + COMMENT = '대학교별 동아리 기본 정보'; + +ALTER TABLE `club_apply` + COMMENT = '사용자의 동아리 지원 신청'; + +ALTER TABLE `club_apply_answer` + COMMENT = '동아리 지원서 질문별 답변'; + +ALTER TABLE `club_apply_question` + COMMENT = '동아리 모집 지원서 질문'; + +ALTER TABLE `club_member` + COMMENT = '동아리 소속 회원과 역할'; + +ALTER TABLE `club_pre_member` + COMMENT = '동아리 사전 등록 회원'; + +ALTER TABLE `club_recruitment` + COMMENT = '동아리 모집 공고와 모집 기간'; + +ALTER TABLE `club_recruitment_image` + COMMENT = '동아리 모집 공고 이미지'; + +ALTER TABLE `council` + COMMENT = '대학교 학생회 정보'; + +ALTER TABLE `council_notice` + COMMENT = '학생회 공지사항'; + +ALTER TABLE `council_notice_read_history` + COMMENT = '학생회 공지사항 사용자별 읽음 기록'; + +ALTER TABLE `group_chat_message` + COMMENT = '레거시 그룹 채팅 메시지'; + +ALTER TABLE `group_chat_read_status` + COMMENT = '레거시 그룹 채팅 읽음 상태'; + +ALTER TABLE `group_chat_room` + COMMENT = '레거시 그룹 채팅방'; + +ALTER TABLE `notification_device_token` + COMMENT = '사용자별 푸시 알림 디바이스 토큰'; + +ALTER TABLE `notification_inbox` + COMMENT = '사용자 알림함에 저장되는 알림 내역'; + +ALTER TABLE `notification_mute_setting` + COMMENT = '사용자별 알림 뮤트 설정'; + +ALTER TABLE `ranking_type` + COMMENT = '순공 랭킹 타입, CLUB/STUDENT_NUMBER/PERSONAL'; + +ALTER TABLE `schedule` + COMMENT = '서비스 일반 일정'; + +ALTER TABLE `study_time_daily` + COMMENT = '사용자별 일별 누적 순공 시간, study_date 기준'; + +ALTER TABLE `study_time_monthly` + COMMENT = '사용자별 월별 누적 순공 시간, study_month는 월의 첫날'; + +ALTER TABLE `study_time_ranking` + COMMENT = '순공 랭킹 집계, 타입과 대학별 대상의 일간/월간 시간'; + +ALTER TABLE `study_time_total` + COMMENT = '사용자별 전체 누적 순공 시간'; + +ALTER TABLE `study_timer` + COMMENT = '현재 순공 타이머가 실행 중인 사용자 세션'; + +ALTER TABLE `university` + COMMENT = '대학교와 캠퍼스 정보'; + +ALTER TABLE `university_schedule` + COMMENT = '대학교 학사 일정'; + +ALTER TABLE `unregistered_user` + COMMENT = '가입 절차를 완료하지 않은 OAuth 사용자'; + +ALTER TABLE `user_oauth_account` + COMMENT = '사용자별 OAuth 제공자 계정과 리프레시 토큰'; + +ALTER TABLE `users` + COMMENT = '서비스 사용자 계정, deleted_at이 NULL이면 활성 사용자'; + +ALTER TABLE `version` + COMMENT = '앱 버전 관리 정보'; diff --git a/src/main/resources/db/migration/V72__add_region_to_university.sql b/src/main/resources/db/migration/V72__add_region_to_university.sql new file mode 100644 index 000000000..ddee0600b --- /dev/null +++ b/src/main/resources/db/migration/V72__add_region_to_university.sql @@ -0,0 +1,2 @@ +ALTER TABLE university + ADD COLUMN region VARCHAR(50) NOT NULL DEFAULT 'UNKNOWN' AFTER campus; diff --git a/src/main/resources/db/migration/V73__add_image_url_to_university.sql b/src/main/resources/db/migration/V73__add_image_url_to_university.sql new file mode 100644 index 000000000..5e76ee148 --- /dev/null +++ b/src/main/resources/db/migration/V73__add_image_url_to_university.sql @@ -0,0 +1,9 @@ +ALTER TABLE university + ADD COLUMN image_url VARCHAR(255) NULL AFTER region; + +UPDATE university +SET image_url = 'https://stage-static.koreatech.in/konect/user/university_logo_sample.png' +WHERE image_url IS NULL; + +ALTER TABLE university + MODIFY COLUMN image_url VARCHAR(255) NOT NULL AFTER region; diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index c04ca5810..688f2a925 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -1,5 +1,5 @@ - + @@ -9,7 +9,7 @@ - [%d{yyyy-MM-dd'T'HH:mm:ss.SSS}] %clr(%-5level) %clr(${PID:-}){magenta} %clr(---){faint} %clr([%15.15thread]){faint} %clr([trace=%X{trace_id:-}%X{traceId:-} span=%X{span_id:-}%X{spanId:-} request=%X{requestId:-}]){yellow} %clr(%-40.40logger{36}){cyan} : %msg%n + [%d{yyyy-MM-dd'T'HH:mm:ss.SSS}] %clr(%-5level) %clr(${PID:-}){magenta} %clr(---){faint} %clr([%15.15thread]){faint} %clr([trace=%X{dd.trace_id:-} span=%X{dd.span_id:-} request=%X{requestId:-}]){yellow} %clr(%-40.40logger{36}){cyan} : %msg%n @@ -17,24 +17,24 @@ ${LOG_PATH}/konect-backend.log - ${LOG_PATH}/konect-backend.%d{yyyy-MM-dd}.log + ${LOG_PATH}/konect-backend.%d{yyyy-MM-dd}.log.gz 30 3GB - [%d{yyyy-MM-dd'T'HH:mm:ss.SSS}] %-5level ${PID:-} --- [%15.15thread] [trace=%X{trace_id:-}%X{traceId:-} span=%X{span_id:-}%X{spanId:-} request=%X{requestId:-}] %-40.40logger{36} : %msg%n + [%d{yyyy-MM-dd'T'HH:mm:ss.SSS}] %-5level ${PID:-} --- [%15.15thread] [trace=%X{dd.trace_id:-} span=%X{dd.span_id:-} request=%X{requestId:-}] %-40.40logger{36} : %msg%n ${LOG_PATH}/scheduler.log - ${LOG_PATH}/scheduler.%d{yyyy-MM-dd}.log + ${LOG_PATH}/scheduler.%d{yyyy-MM-dd}.log.gz 7 1GB - [%d{yyyy-MM-dd'T'HH:mm:ss.SSS}] %-5level [%thread] : %msg%n + [%d{yyyy-MM-dd'T'HH:mm:ss.SSS}] %-5level [%thread] [trace=%X{dd.trace_id:-} span=%X{dd.span_id:-} request=%X{requestId:-}] : %msg%n diff --git a/src/test/java/gg/agit/konect/domain/club/service/ClubMemberSheetServicePackagePrivateTest.java b/src/test/java/gg/agit/konect/domain/club/service/ClubMemberSheetServicePackagePrivateTest.java new file mode 100644 index 000000000..3aefcb12e --- /dev/null +++ b/src/test/java/gg/agit/konect/domain/club/service/ClubMemberSheetServicePackagePrivateTest.java @@ -0,0 +1,74 @@ +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.verifyNoInteractions; + +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.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.ServiceTestSupport; + +class ClubMemberSheetServicePackagePrivateTest 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 ClubSheetRegistrationService clubSheetRegistrationService; + + @InjectMocks + private ClubMemberSheetService clubMemberSheetService; + + @Test + @DisplayName("분석 결과를 받은 updateSheetId도 동아리가 없으면 권한 검증 전에 실패한다") + void updateSheetIdWithAnalysisThrowsNotFoundClubBeforePermissionCheck() { + // given + Integer clubId = 1; + Integer requesterId = 2; + String spreadsheetId = "spreadsheet-id"; + SheetHeaderMapper.SheetAnalysisResult analysisResult = new SheetHeaderMapper.SheetAnalysisResult( + null, + null, + null + ); + + given(clubRepository.existsById(clubId)).willReturn(false); + + // when & then + assertThatThrownBy(() -> clubMemberSheetService.updateSheetId( + clubId, + requesterId, + spreadsheetId, + analysisResult + )) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()).isEqualTo( + ApiResponseCode.NOT_FOUND_CLUB)); + + verifyNoInteractions(clubPermissionValidator, clubSheetRegistrationService); + } +} 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 index b7a479826..753fa4951 100644 --- a/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java @@ -7,9 +7,12 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; import java.util.Map; +import com.fasterxml.jackson.databind.JsonNode; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -21,6 +24,7 @@ import org.springframework.test.context.jdbc.SqlConfig; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.transaction.TestTransaction; +import org.springframework.test.web.servlet.MvcResult; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; @@ -28,6 +32,7 @@ import gg.agit.konect.domain.chat.dto.ChatMessageSendRequest; import gg.agit.konect.domain.chat.dto.ChatRoomCreateRequest; +import gg.agit.konect.domain.chat.dto.ChatRoomMembersInviteRequest; import gg.agit.konect.domain.chat.dto.ChatRoomNameUpdateRequest; import gg.agit.konect.domain.chat.enums.ChatType; import gg.agit.konect.domain.chat.model.ChatMessage; @@ -219,6 +224,28 @@ private boolean isDirectRoomBetween(Integer roomId, Integer firstUserId, Integer && roomMembers.stream().anyMatch(member -> member.getUserId().equals(secondUserId)); } + private int parseChatRoomId(MvcResult result) throws Exception { + String responseBody = result.getResponse().getContentAsString(); + return objectMapper.readTree(responseBody).get("chatRoomId").asInt(); + } + + private List extractRoomIds(MvcResult result) throws Exception { + String responseBody = result.getResponse().getContentAsString(); + JsonNode root = objectMapper.readTree(responseBody); + JsonNode rooms = root.get("rooms"); + List roomIds = new ArrayList<>(); + if (rooms != null && rooms.isArray()) { + for (JsonNode room : rooms) { + JsonNode roomIdNode = + room.has("chatRoomId") ? room.get("chatRoomId") : room.get("roomId"); + if (roomIdNode != null) { + roomIds.add(roomIdNode.asInt()); + } + } + } + return roomIds; + } + @Nested @DisplayName("POST /chats/rooms - 일반 채팅방 생성") class CreateDirectChatRoom { @@ -386,6 +413,34 @@ void adminCanReadInquiryRoomMessagesWithoutMembership() throws Exception { .containsExactlyInAnyOrder(SYSTEM_ADMIN_ID, normalUser.getId()); } + @Test + @DisplayName("관리자는 멤버가 아니어도 문의방 멤버 목록을 조회할 수 있다") + void adminCanReadInquiryRoomMembersWithoutMembership() throws Exception { + User anotherAdmin = persist(UserFixture.createAdmin(university)); + clearPersistenceContext(); + + mockLoginUser(normalUser.getId()); + int chatRoomId = parseChatRoomId( + performPost("/chats/rooms/admin") + .andExpect(status().isOk()) + .andReturn() + ); + + clearPersistenceContext(); + + mockLoginUser(anotherAdmin.getId()); + performGet("/chats/rooms/" + chatRoomId + "/members") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.members.length()").value(2)) + .andExpect(jsonPath("$.members[*].userId").value( + org.hamcrest.Matchers.containsInAnyOrder(SYSTEM_ADMIN_ID, normalUser.getId()) + )); + + assertThat(chatRoomMemberRepository.findByChatRoomId(chatRoomId)) + .extracting(ChatRoomMember::getUserId) + .containsExactlyInAnyOrder(SYSTEM_ADMIN_ID, normalUser.getId()); + } + @Test @DisplayName("어드민이 나간 문의 채팅방에 사용자가 새 메시지를 보내 어드민 목록에 다시 노출된다") @Transactional(propagation = Propagation.REQUIRES_NEW) @@ -444,28 +499,6 @@ void adminLeftInquiryRoomReappearsWhenUserSendsNewMessage() throws Exception { assertThat(extractRoomIds(adminRoomsAfterNewMessage)).contains(chatRoomId); } - private int parseChatRoomId(org.springframework.test.web.servlet.MvcResult result) throws Exception { - String responseBody = result.getResponse().getContentAsString(); - return objectMapper.readTree(responseBody).get("chatRoomId").asInt(); - } - - private List extractRoomIds(org.springframework.test.web.servlet.MvcResult result) throws Exception { - String responseBody = result.getResponse().getContentAsString(); - com.fasterxml.jackson.databind.JsonNode root = objectMapper.readTree(responseBody); - com.fasterxml.jackson.databind.JsonNode rooms = root.get("rooms"); - List roomIds = new java.util.ArrayList<>(); - if (rooms != null && rooms.isArray()) { - for (com.fasterxml.jackson.databind.JsonNode room : rooms) { - // roomId 또는 chatRoomId 필드 확인 - com.fasterxml.jackson.databind.JsonNode roomIdNode = - room.has("chatRoomId") ? room.get("chatRoomId") : room.get("roomId"); - if (roomIdNode != null) { - roomIds.add(roomIdNode.asInt()); - } - } - } - return roomIds; - } } @Nested @@ -718,6 +751,35 @@ void sendMessageSuccess() throws Exception { .containsExactly("안녕하세요"); } + @Test + @DisplayName("메시지를 전송하면 chat_room last message 메타데이터도 함께 갱신된다") + @Sql( + statements = CHAT_TEST_DATA_CLEANUP_SQL, + config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD + ) + void sendMessageUpdatesChatRoomLastMessageColumns() throws Exception { + // given + ChatRoom chatRoom = createDirectChatRoom(normalUser, targetUser); + mockLoginUser(normalUser.getId()); + + // when + performPost("/chats/rooms/" + chatRoom.getId() + "/messages", new ChatMessageSendRequest("메타데이터 확인")) + .andExpect(status().isOk()); + + // then + TestTransaction.flagForCommit(); + TestTransaction.end(); + + transactionTemplate.execute(status -> { + clearPersistenceContext(); + ChatRoom updatedRoom = chatRoomRepository.findById(chatRoom.getId()).orElseThrow(); + assertThat(updatedRoom.getLastMessageContent()).isEqualTo("메타데이터 확인"); + assertThat(updatedRoom.getLastMessageSentAt()).isNotNull(); + return null; + }); + } + @Test @DisplayName("관리자가 문의방에 답변하면 실제 문의 사용자에게 알림을 보낸다") void adminReplySendsNotificationToInquiryUser() throws Exception { @@ -910,6 +972,15 @@ void leaveDirectChatRoomAndShowOnlyNewMessages() throws Exception { performPost("/chats/rooms/" + chatRoom.getId() + "/messages", new ChatMessageSendRequest("다시 안녕")) .andExpect(status().isOk()); + transactionTemplate.execute(status -> { + clearPersistenceContext(); + ChatRoomMember restoredMember = chatRoomMemberRepository + .findByChatRoomIdAndUserId(chatRoom.getId(), normalUser.getId()) + .orElseThrow(); + assertThat(restoredMember.hasLeft()).isFalse(); + return null; + }); + mockLoginUser(normalUser.getId()); performGet("/chats/rooms") .andExpect(status().isOk()) @@ -1208,6 +1279,40 @@ void searchChatsMatchesDefaultNameEvenWithCustomRoomName() throws Exception { .andExpect(jsonPath("$.roomMatches.rooms[0].roomName").value("내 메모")); } + @Test + @DisplayName("관리자는 멤버가 아니어도 사용자 문의방을 검색할 수 있다") + void searchChatsIncludesInquiryRoomForAdminWithoutMembership() throws Exception { + // given + User anotherAdmin = persist(UserFixture.createAdmin(university)); + clearPersistenceContext(); + + mockLoginUser(normalUser.getId()); + int chatRoomId = parseChatRoomId( + performPost("/chats/rooms/admin") + .andExpect(status().isOk()) + .andReturn() + ); + performPost("/chats/rooms/" + chatRoomId + "/messages", + new ChatMessageSendRequest("관리자 검색 문의")) + .andExpect(status().isOk()); + + clearPersistenceContext(); + mockLoginUser(anotherAdmin.getId()); + + // 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(chatRoomId)) + .andExpect(jsonPath("$.roomMatches.rooms[0].roomName").value("일반유저")) + .andExpect(jsonPath("$.messageMatches.totalCount").value(0)); + + assertThat(chatRoomMemberRepository.findByChatRoomId(chatRoomId)) + .extracting(ChatRoomMember::getUserId) + .containsExactlyInAnyOrder(SYSTEM_ADMIN_ID, normalUser.getId()); + } + @Test @DisplayName("채팅방 검색 결과에 페이지네이션을 적용한다") void searchChatsAppliesPaginationToRoomMatches() throws Exception { @@ -1501,6 +1606,166 @@ void canSendMessageToCreatedGroupChatRoom() throws Exception { } } + @Nested + @DisplayName("POST /chats/rooms/{chatRoomId}/members - 그룹 채팅방 멤버 초대") + class InviteMembers { + + private User ownerUser; + private User memberUser; + private User newMemberA; + private User newMemberB; + private ChatRoom groupRoom; + + @BeforeEach + void setUpInviteFixture() { + ownerUser = createUser("초대방장", "2021136101"); + memberUser = createUser("초대멤버", "2021136102"); + newMemberA = createUser("새멤버A", "2021136103"); + newMemberB = createUser("새멤버B", "2021136104"); + groupRoom = createGroupChatRoomWithOwner(ownerUser, memberUser); + clearPersistenceContext(); + } + + @Test + @DisplayName("GROUP active 멤버가 여러 명을 초대한다") + void inviteMembersSuccess() throws Exception { + mockLoginUser(memberUser.getId()); + + performPost( + "/chats/rooms/" + groupRoom.getId() + "/members", + new ChatRoomMembersInviteRequest(List.of(newMemberA.getId(), newMemberB.getId())) + ) + .andExpect(status().isNoContent()); + + clearPersistenceContext(); + assertThat(chatRoomMemberRepository.findByChatRoomId(groupRoom.getId())) + .extracting(ChatRoomMember::getUserId) + .containsExactlyInAnyOrder( + ownerUser.getId(), + memberUser.getId(), + newMemberA.getId(), + newMemberB.getId() + ); + } + + @Test + @DisplayName("방장이 아닌 active 멤버도 초대할 수 있다") + void nonOwnerMemberCanInviteMembers() throws Exception { + mockLoginUser(memberUser.getId()); + + performPost( + "/chats/rooms/" + groupRoom.getId() + "/members", + new ChatRoomMembersInviteRequest(List.of(newMemberA.getId())) + ) + .andExpect(status().isNoContent()); + + clearPersistenceContext(); + ChatRoomMember invitedMember = chatRoomMemberRepository + .findByChatRoomIdAndUserId(groupRoom.getId(), newMemberA.getId()) + .orElseThrow(); + assertThat(invitedMember.isOwner()).isFalse(); + } + + @Test + @DisplayName("채팅방 멤버가 아니면 초대할 수 없다") + void inviteMembersByOutsiderFails() throws Exception { + User outsider = createUser("외부사용자", "2021136105"); + mockLoginUser(outsider.getId()); + + performPost( + "/chats/rooms/" + groupRoom.getId() + "/members", + new ChatRoomMembersInviteRequest(List.of(newMemberA.getId())) + ) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); + } + + @Test + @DisplayName("DIRECT 채팅방에는 멤버를 초대할 수 없다") + void inviteMembersToDirectRoomFails() throws Exception { + ChatRoom directRoom = createDirectChatRoom(ownerUser, memberUser); + mockLoginUser(ownerUser.getId()); + + performPost( + "/chats/rooms/" + directRoom.getId() + "/members", + new ChatRoomMembersInviteRequest(List.of(newMemberA.getId())) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("CANNOT_INVITE_IN_NON_GROUP_ROOM")); + } + + @Test + @DisplayName("CLUB_GROUP 채팅방에는 멤버를 초대할 수 없다") + void inviteMembersToClubGroupRoomFails() throws Exception { + Club inviteClub = persist(ClubFixture.create(university, "초대 테스트 동아리")); + ChatRoom clubGroupRoom = persist(ChatRoom.clubGroupOf(inviteClub)); + addRoomMember(clubGroupRoom, ownerUser); + mockLoginUser(ownerUser.getId()); + + performPost( + "/chats/rooms/" + clubGroupRoom.getId() + "/members", + new ChatRoomMembersInviteRequest(List.of(newMemberA.getId())) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("CANNOT_INVITE_IN_NON_GROUP_ROOM")); + } + + @Test + @DisplayName("존재하지 않는 userId가 포함되면 전체 요청을 실패시킨다") + void inviteMembersWithMissingUserFails() throws Exception { + mockLoginUser(memberUser.getId()); + + performPost( + "/chats/rooms/" + groupRoom.getId() + "/members", + new ChatRoomMembersInviteRequest(List.of(newMemberA.getId(), 99999)) + ) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("NOT_FOUND_USER")); + + clearPersistenceContext(); + assertThat(chatRoomMemberRepository.findByChatRoomIdAndUserId( + groupRoom.getId(), newMemberA.getId() + )).isEmpty(); + } + + @Test + @DisplayName("userIds에 null이 포함되면 validation 에러를 반환한다") + void inviteMembersWithNullUserIdFails() throws Exception { + mockLoginUser(memberUser.getId()); + List userIds = new ArrayList<>(); + userIds.add(null); + + performPost( + "/chats/rooms/" + groupRoom.getId() + "/members", + new ChatRoomMembersInviteRequest(userIds) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_REQUEST_BODY")); + } + + @Test + @DisplayName("이미 참여 중인 멤버와 자기 자신과 중복 userId는 무시한다") + void inviteMembersIgnoresExistingSelfAndDuplicateUsers() throws Exception { + mockLoginUser(memberUser.getId()); + + performPost( + "/chats/rooms/" + groupRoom.getId() + "/members", + new ChatRoomMembersInviteRequest(List.of( + memberUser.getId(), + ownerUser.getId(), + newMemberA.getId(), + newMemberA.getId() + )) + ) + .andExpect(status().isNoContent()); + + clearPersistenceContext(); + assertThat(chatRoomMemberRepository.findByChatRoomId(groupRoom.getId())) + .extracting(ChatRoomMember::getUserId) + .containsExactlyInAnyOrder(ownerUser.getId(), memberUser.getId(), newMemberA.getId()); + } + } + @Nested @DisplayName("DELETE /chats/rooms/{chatRoomId}/members/{targetUserId} - 멤버 강퇴") class KickMember { @@ -2002,6 +2267,33 @@ void toggleMuteByKickedMemberReturnsForbidden() throws Exception { .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); } + + @Test + @DisplayName("다른 관리자는 문의방 멤버가 아니어도 뮤트 설정을 변경할 수 있다") + void anotherAdminCanToggleMuteInInquiryRoomWithoutMembership() throws Exception { + User anotherAdmin = persist(UserFixture.createAdmin(university)); + clearPersistenceContext(); + + mockLoginUser(normalUser.getId()); + int chatRoomId = parseChatRoomId( + performPost("/chats/rooms/admin") + .andExpect(status().isOk()) + .andReturn() + ); + + mockLoginUser(anotherAdmin.getId()); + performPost("/chats/rooms/" + chatRoomId + "/mute") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isMuted").value(true)); + + clearPersistenceContext(); + + assertThat(notificationMuteSettingRepository.findByTargetTypeAndTargetIdAndUserId( + NotificationTargetType.CHAT_ROOM, + chatRoomId, + anotherAdmin.getId() + )).isPresent(); + } } @Nested @@ -2044,5 +2336,22 @@ void searchChatsWithSingleCharacterKeywordReturnsOk() throws Exception { performGet("/chats/rooms/search?keyword=a&page=1&limit=20") .andExpect(status().isOk()); } + + @Test + @DisplayName("일반 group 채팅방 메시지는 검색 대상에 포함되지 않는다") + void searchChatsExcludesOpenGroupRoomMessages() throws Exception { + User groupMember = createUser("그룹멤버", "2021136014"); + ChatRoom groupRoom = createGroupChatRoomWithOwner(normalUser, groupMember); + persistChatMessage(groupRoom, groupMember, "그룹전용키워드"); + clearPersistenceContext(); + + mockLoginUser(normalUser.getId()); + + performGet("/chats/rooms/search?keyword=그룹전용키워드&page=1&limit=20") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.roomMatches.totalCount").value(0)) + .andExpect(jsonPath("$.messageMatches.totalCount").value(0)) + .andExpect(jsonPath("$.messageMatches.currentCount").value(0)); + } } } diff --git a/src/test/java/gg/agit/konect/integration/domain/notice/NoticeApiTest.java b/src/test/java/gg/agit/konect/integration/domain/notice/NoticeApiTest.java index e2edd977b..511bc92c3 100644 --- a/src/test/java/gg/agit/konect/integration/domain/notice/NoticeApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/notice/NoticeApiTest.java @@ -1,5 +1,6 @@ package gg.agit.konect.integration.domain.notice; +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; @@ -91,6 +92,23 @@ void getNoticesExcludesOtherUniversityNotices() throws Exception { .andExpect(jsonPath("$.councilNotices", hasSize(1))) .andExpect(jsonPath("$.councilNotices[0].title").value("우리 대학 공지")); } + + @Test + @DisplayName("공지 목록 조회는 읽음 이력을 생성하지 않는다") + void getNoticesDoesNotCreateReadHistory() throws Exception { + // given + Council council = persist(CouncilFixture.create(university)); + CouncilNotice notice = persist(CouncilNoticeFixture.create(council)); + clearPersistenceContext(); + + // when + performGet(NOTICES_ENDPOINT + "?page=1&limit=10") + .andExpect(status().isOk()); + + // then + Number readHistoryCount = countReadHistory(user.getId(), notice.getId()); + assertThat(readHistoryCount.longValue()).isZero(); + } } @Nested @@ -127,16 +145,8 @@ void getNoticeDoesNotDuplicateReadHistory() throws Exception { .andExpect(status().isOk()); // then - Number readHistoryCount = (Number)entityManager.createNativeQuery(""" - select count(*) - from council_notice_read_history - where user_id = ? and council_notice_id = ? - """) - .setParameter(1, user.getId()) - .setParameter(2, notice.getId()) - .getSingleResult(); - - org.assertj.core.api.Assertions.assertThat(readHistoryCount.longValue()).isEqualTo(1L); + Number readHistoryCount = countReadHistory(user.getId(), notice.getId()); + assertThat(readHistoryCount.longValue()).isEqualTo(1L); } @Test @@ -292,4 +302,15 @@ insert into council ( .setParameter(11, universityId) .executeUpdate(); } + + private Number countReadHistory(Integer userId, Integer noticeId) { + return (Number)entityManager.createNativeQuery(""" + select count(*) + from council_notice_read_history + where user_id = ? and council_notice_id = ? + """) + .setParameter(1, userId) + .setParameter(2, noticeId) + .getSingleResult(); + } } diff --git a/src/test/java/gg/agit/konect/integration/domain/schedule/ScheduleApiTest.java b/src/test/java/gg/agit/konect/integration/domain/schedule/ScheduleApiTest.java index d5354e3b9..ea1241870 100644 --- a/src/test/java/gg/agit/konect/integration/domain/schedule/ScheduleApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/schedule/ScheduleApiTest.java @@ -1,15 +1,18 @@ package gg.agit.konect.integration.domain.schedule; import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.nullValue; 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; import org.junit.jupiter.api.Test; +import org.springframework.util.LinkedMultiValueMap; import gg.agit.konect.domain.schedule.model.Schedule; import gg.agit.konect.domain.university.model.University; @@ -129,6 +132,38 @@ void otherUniversitySchedulesNotIncluded() throws Exception { .andExpect(jsonPath("$.schedules", hasSize(1))) .andExpect(jsonPath("$.schedules[0].title").value("우리대학 일정")); } + + @Test + @DisplayName("다가오는 일정은 시작 전 일정만 D-Day를 반환한다") + void getUpcomingSchedulesReturnsDDayOnlyBeforeStartDate() throws Exception { + // given + LocalDate today = LocalDate.now(); + Schedule ongoingSchedule = persist(ScheduleFixture.createUniversity( + "진행 중 일정", + today.minusDays(1).atStartOfDay(), + today.plusDays(DAYS_10).atStartOfDay() + )); + Schedule futureSchedule = persist(ScheduleFixture.createUniversity( + "미래 시작 일정", + today.plusDays(DAYS_30).atStartOfDay(), + today.plusDays(DAYS_35).atStartOfDay() + )); + + persist(ScheduleFixture.createUniversitySchedule(ongoingSchedule, university)); + persist(ScheduleFixture.createUniversitySchedule(futureSchedule, university)); + clearPersistenceContext(); + + mockLoginUser(user.getId()); + + // when & then + performGet("/schedules/upcoming") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.schedules", hasSize(2))) + .andExpect(jsonPath("$.schedules[0].title").value("진행 중 일정")) + .andExpect(jsonPath("$.schedules[0].dDay").value(nullValue())) + .andExpect(jsonPath("$.schedules[1].title").value("미래 시작 일정")) + .andExpect(jsonPath("$.schedules[1].dDay").value(DAYS_30)); + } } @Nested @@ -189,6 +224,40 @@ void getSchedulesWithQuery() throws Exception { .andExpect(jsonPath("$.schedules[0].title").value("수강신청 기간")); } + @Test + @DisplayName("검색어는 앞뒤 공백과 대소문자를 무시한다") + void getSchedulesWithTrimmedCaseInsensitiveQuery() throws Exception { + // given + LocalDateTime marchStart = LocalDateTime.of(TEST_YEAR, MARCH, 1, 0, 0); + + Schedule examSchedule = persist(ScheduleFixture.createUniversity( + "Final EXAM", + marchStart.plusDays(1), + marchStart.plusDays(EXPECTED_SCHEDULE_COUNT) + )); + Schedule registrationSchedule = persist(ScheduleFixture.createUniversity( + "수강신청 기간", + marchStart.plusDays(DAYS_10), + marchStart.plusDays(DAYS_15) + )); + + persist(ScheduleFixture.createUniversitySchedule(examSchedule, university)); + persist(ScheduleFixture.createUniversitySchedule(registrationSchedule, university)); + clearPersistenceContext(); + + mockLoginUser(user.getId()); + LinkedMultiValueMap params = new LinkedMultiValueMap<>(); + params.add("year", String.valueOf(TEST_YEAR)); + params.add("month", String.valueOf(MARCH)); + params.add("query", " exam "); + + // when & then + performGet("/schedules", params) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.schedules", hasSize(1))) + .andExpect(jsonPath("$.schedules[0].title").value("Final EXAM")); + } + @Test @DisplayName("월을 걸치는 일정도 조회된다") void getSchedulesSpanningMonths() throws Exception { 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 deleted file mode 100644 index e69de29bb..000000000 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 index dbb7c6370..dd5e6b5fe 100644 --- a/src/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java @@ -79,6 +79,26 @@ void uploadImageSuccess() throws Exception { assertThat(requestCaptor.getValue().contentType()).isEqualTo("image/png"); } + @Test + @DisplayName("대학교 이미지를 업로드하면 university 경로에 저장한다") + void uploadUniversityImageSuccess() throws Exception { + // given + byte[] pngBytes = createPngBytes(8, 8); + MockMultipartFile file = imageFile("university.png", "image/png", pngBytes); + + // when + MvcResult result = uploadImage(file, UploadTarget.UNIVERSITY) + .andExpect(status().isOk()) + .andReturn(); + + // then + String responseBody = result.getResponse().getContentAsString(); + String key = JsonPath.read(responseBody, "$.key"); + + assertThat(key).startsWith("test/university/"); + assertThat(key).endsWith(".png"); + } + @Test @DisplayName("jpeg 이미지를 업로드하면 원본 형태로 저장한다") void uploadJpegImageSuccess() throws Exception { 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 index 7339b0098..9d35713fc 100644 --- a/src/test/java/gg/agit/konect/integration/domain/user/UserSignupApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/user/UserSignupApiTest.java @@ -10,6 +10,11 @@ import java.time.Duration; +import gg.agit.konect.domain.chat.enums.ChatType; +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.enums.ClubPosition; import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.ClubMember; @@ -21,6 +26,7 @@ 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.event.UserRegisteredEvent; import gg.agit.konect.domain.user.service.RefreshTokenService; import gg.agit.konect.domain.user.service.SignupTokenService; import gg.agit.konect.domain.user.repository.UnRegisteredUserRepository; @@ -40,6 +46,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.event.ApplicationEvents; +import org.springframework.test.context.event.RecordApplicationEvents; import org.springframework.test.web.servlet.ResultActions; import java.util.List; @@ -47,6 +55,7 @@ import jakarta.servlet.http.Cookie; @DisplayName("회원가입 API 테스트") +@RecordApplicationEvents class UserSignupApiTest extends IntegrationTestSupport { @Autowired @@ -61,6 +70,15 @@ class UserSignupApiTest extends IntegrationTestSupport { @Autowired private ClubMemberRepository clubMemberRepository; + @Autowired + private ChatRoomRepository chatRoomRepository; + + @Autowired + private ChatRoomMemberRepository chatRoomMemberRepository; + + @Autowired + private ApplicationEvents applicationEvents; + @MockitoBean private SignupTokenService signupTokenService; @@ -119,6 +137,8 @@ void signupSuccess() throws Exception { assertThat(savedUser).isNotNull(); assertThat(savedUser.getName()).isEqualTo("홍길동"); assertThat(savedUser.getEmail()).isEqualTo(email); + assertThat(applicationEvents.stream(UserRegisteredEvent.class)) + .contains(UserRegisteredEvent.from(email, Provider.GOOGLE.name())); assertSignupTokenConsumedOnce(); } @@ -162,6 +182,10 @@ void signupWithPreMemberAutoJoinsClub() throws Exception { ClubMember clubMember = clubMemberRepository.getByClubIdAndUserId(club.getId(), savedUser.getId()); assertThat(clubMember.getClubPosition()).isEqualTo(ClubPosition.MEMBER); + ChatRoom clubRoom = chatRoomRepository.findByClubId(club.getId()).orElseThrow(); + assertThat(chatRoomMemberRepository.existsByChatRoomIdAndUserId(clubRoom.getId(), savedUser.getId())) + .isTrue(); + // PreMember는 삭제되었는지 확인 List remainingPreMembers = clubPreMemberRepository.findAllByClubId(club.getId()); assertThat(remainingPreMembers).isEmpty(); @@ -187,6 +211,9 @@ void signupWithPreMemberPresidentReplacesExistingPresident() throws Exception { .clubPosition(ClubPosition.PRESIDENT) .build(); persist(preMemberPresident); + ChatRoom existingClubRoom = persist(ChatRoom.clubGroupOf(club)); + User managedExistingPresident = entityManager.find(User.class, existingPresident.getId()); + persist(ChatRoomMember.of(existingClubRoom, managedExistingPresident, existingClubRoom.getCreatedAt())); clearPersistenceContext(); // 기존 회장이 존재하는지 확인 @@ -212,6 +239,50 @@ void signupWithPreMemberPresidentReplacesExistingPresident() throws Exception { assertThat(clubMemberRepository.findPresidentByClubId(club.getId())).isPresent(); assertThat(clubMemberRepository.findPresidentByClubId(club.getId()).get().getUser().getId()) .isEqualTo(savedUser.getId()); + ChatRoom clubRoom = chatRoomRepository.findByClubId(club.getId()).orElseThrow(); + assertThat(chatRoomMemberRepository.existsByChatRoomIdAndUserId(clubRoom.getId(), savedUser.getId())) + .isTrue(); + assertThat(chatRoomMemberRepository.existsByChatRoomIdAndUserId( + clubRoom.getId(), + existingPresident.getId() + )) + .isFalse(); + assertSignupTokenConsumedOnce(); + } + + @Test + @DisplayName("회원가입 시 운영자가 있으면 환영 direct 메시지와 마지막 메시지를 저장한다") + void signupSendsWelcomeMessageWhenAdminExists() throws Exception { + // given + User admin = persist(UserFixture.createAdmin(university)); + String email = "welcome@koreatech.ac.kr"; + String studentNumber = "2021136010"; + + UnRegisteredUser unRegisteredUser = UnRegisteredUserFixture.createGoogle(email); + persist(unRegisteredUser); + clearPersistenceContext(); + + SignupRequest request = new SignupRequest("환영대상", university.getId(), studentNumber, true); + stubSignupTokenClaims(email); + + // when + performSignup(request) + .andExpect(status().isOk()); + + // then + clearPersistenceContext(); + User savedUser = findSavedUser(studentNumber); + assertThat(savedUser).isNotNull(); + + ChatRoom welcomeRoom = chatRoomRepository.findByTwoUsers(admin.getId(), savedUser.getId(), ChatType.DIRECT) + .orElseThrow(); + assertThat(welcomeRoom.getLastMessageContent()) + .isEqualTo("KONECT에 오신 것을 환영합니다. 궁금한 점이 있으면 언제든 문의해 주세요."); + assertThat(welcomeRoom.getLastMessageSentAt()).isNotNull(); + assertThat(chatRoomMemberRepository.existsByChatRoomIdAndUserId(welcomeRoom.getId(), admin.getId())) + .isTrue(); + assertThat(chatRoomMemberRepository.existsByChatRoomIdAndUserId(welcomeRoom.getId(), savedUser.getId())) + .isTrue(); assertSignupTokenConsumedOnce(); } 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 index 5c93d3cff..a41bb68b3 100644 --- a/src/test/java/gg/agit/konect/integration/domain/user/UserWithdrawApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/user/UserWithdrawApiTest.java @@ -1,13 +1,18 @@ package gg.agit.konect.integration.domain.user; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; 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.enums.Provider; +import gg.agit.konect.domain.user.event.UserWithdrawnEvent; import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.model.UserOAuthAccount; import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.infrastructure.oauth.AppleTokenRevocationService; import gg.agit.konect.support.IntegrationTestSupport; import gg.agit.konect.support.fixture.ClubFixture; import gg.agit.konect.support.fixture.ClubMemberFixture; @@ -19,10 +24,14 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.event.ApplicationEvents; +import org.springframework.test.context.event.RecordApplicationEvents; import java.time.LocalDateTime; @DisplayName("회원 탈퇴 API 테스트") +@RecordApplicationEvents class UserWithdrawApiTest extends IntegrationTestSupport { @Autowired @@ -31,6 +40,12 @@ class UserWithdrawApiTest extends IntegrationTestSupport { @Autowired private ClubMemberRepository clubMemberRepository; + @Autowired + private ApplicationEvents applicationEvents; + + @MockitoBean + private AppleTokenRevocationService appleTokenRevocationService; + private University university; private Club club; @@ -84,6 +99,32 @@ void withdrawWithoutClubMembershipSuccess() throws Exception { assertThat(withdrawnUser.getDeletedAt()).isNotNull(); } + @Test + @DisplayName("Apple OAuth 계정이 있으면 탈퇴 시 토큰을 revoke하고 탈퇴 이벤트를 발행한다") + void withdrawWithAppleOAuthRevokesTokenAndPublishesEvent() throws Exception { + // given + User user = persist(UserFixture.createUser(university, "애플회원", "2021136010")); + persist(UserOAuthAccount.of( + user, + Provider.APPLE, + "apple-provider-id", + "apple@konect.test", + "apple-refresh-token" + )); + clearPersistenceContext(); + + mockLoginUser(user.getId()); + + // when + performDelete("/users/withdraw") + .andExpect(status().isNoContent()); + + // then + verify(appleTokenRevocationService).revoke("apple-refresh-token"); + assertThat(applicationEvents.stream(UserWithdrawnEvent.class)) + .contains(UserWithdrawnEvent.from(user.getEmail(), Provider.APPLE.name())); + } + @Test @DisplayName("회장은 탈퇴할 수 없다") void withdrawAsPresidentFails() throws Exception { diff --git a/src/test/java/gg/agit/konect/integration/domain/website/WebsiteApiTest.java b/src/test/java/gg/agit/konect/integration/domain/website/WebsiteApiTest.java new file mode 100644 index 000000000..ab7a54b5c --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/domain/website/WebsiteApiTest.java @@ -0,0 +1,228 @@ +package gg.agit.konect.integration.domain.website; + +import static org.hamcrest.Matchers.hasSize; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +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.stream.Collectors; +import java.util.stream.IntStream; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import gg.agit.konect.domain.club.enums.ClubCategory; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.university.enums.Campus; +import gg.agit.konect.domain.university.enums.UniversityRegion; +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.ClubMemberFixture; +import gg.agit.konect.support.fixture.ClubRecruitmentFixture; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +class WebsiteApiTest extends IntegrationTestSupport { + + @Nested + @DisplayName("GET /konect/home - 웹사이트 메인") + class GetHome { + + @Test + @DisplayName("로그인 없이 대학 목록과 등록 동아리 수를 조회한다") + void getHomeWithoutLogin() throws Exception { + // given + University koreatech = persist(UniversityFixture.create( + "한국기술교육대학교", + Campus.MAIN, + UniversityRegion.CHUNGCHEONG, + "https://example.com/koreatech-logo.png" + )); + University seoul = persist(UniversityFixture.create( + "서울대학교", + Campus.MAIN, + UniversityRegion.SEOUL + )); + persist(createClub(koreatech, "BCSD Lab", ClubCategory.ACADEMIC)); + persist(createClub(koreatech, "COK", ClubCategory.SPORTS)); + persist(createClub(seoul, "서울 동아리", ClubCategory.HOBBY)); + clearPersistenceContext(); + + // when & then + performGet("/konect/home?query=한국®ion=CHUNGCHEONG") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalUniversityCount").value(1)) + .andExpect(jsonPath("$.universities[0].name").value("한국기술교육대학교")) + .andExpect(jsonPath("$.universities[0].campusName").value("본교")) + .andExpect(jsonPath("$.universities[0].region").value("CHUNGCHEONG")) + .andExpect(jsonPath("$.universities[0].regionName").value("충청도")) + .andExpect(jsonPath("$.universities[0].imageUrl").value("https://example.com/koreatech-logo.png")) + .andExpect(jsonPath("$.universities[0].clubCount").value(2)); + + verify(loginCheckInterceptor, never()).preHandle(any(), any(), any()); + verify(authorizationInterceptor, never()).preHandle(any(), any(), any()); + } + } + + @Nested + @DisplayName("GET /konect/universities/{universityId}/clubs - 대학별 동아리") + class GetUniversityClubs { + + @Test + @DisplayName("검색어와 분과로 동아리 목록을 조회하고 분과별 개수를 함께 반환한다") + void getUniversityClubsWithFilters() throws Exception { + // given + University university = persist(UniversityFixture.create( + "한국기술교육대학교", + Campus.MAIN, + UniversityRegion.CHUNGCHEONG, + "https://example.com/koreatech-logo.png" + )); + Club bcsd = persist(createClub(university, "BCSD Lab", ClubCategory.ACADEMIC)); + Club study = persist(createClub(university, "경영전략연구회", ClubCategory.ACADEMIC)); + persist(createClub(university, "ZEST", ClubCategory.PERFORMANCE)); + persistMember(bcsd, "회원1", "2024000001"); + persistMember(bcsd, "회원2", "2024000002"); + withdraw(persistMember(bcsd, "탈퇴회원", "2024000004")); + persistMember(study, "회원3", "2024000003"); + clearPersistenceContext(); + + // when & then + performGet("/konect/universities/" + university.getId() + + "/clubs?page=1&limit=10&query=BCSD&category=ACADEMIC") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.university.name").value("한국기술교육대학교")) + .andExpect(jsonPath("$.university.imageUrl").value("https://example.com/koreatech-logo.png")) + .andExpect(jsonPath("$.totalCount").value(1)) + .andExpect(jsonPath("$.clubs", hasSize(1))) + .andExpect(jsonPath("$.clubs[0].name").value("BCSD Lab")) + .andExpect(jsonPath("$.clubs[0].memberCount").value(2)) + .andExpect(jsonPath("$.categories[0].category").value("ACADEMIC")) + .andExpect(jsonPath("$.categories[0].count").value(1)); + } + + @Test + @DisplayName("존재하지 않는 대학이면 404를 반환한다") + void getUniversityClubsNotFound() throws Exception { + // when & then + performGet("/konect/universities/99999/clubs") + .andExpect(status().isNotFound()); + } + } + + @Nested + @DisplayName("GET /konect/clubs/{clubId} - 동아리 상세") + class GetClubDetail { + + @Test + @DisplayName("동아리 상세 소개와 모집 정보를 조회한다") + void getClubDetailSuccess() throws Exception { + // given + University university = persist(UniversityFixture.create( + "한국기술교육대학교", + Campus.MAIN, + UniversityRegion.CHUNGCHEONG + )); + Club club = persist(createClub(university, "ZEST", ClubCategory.PERFORMANCE)); + persist(ClubRecruitmentFixture.createAlwaysRecruiting(club)); + persistMember(club, "회장", "2024000004"); + clearPersistenceContext(); + + // when & then + performGet("/konect/clubs/" + club.getId()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("ZEST")) + .andExpect(jsonPath("$.categoryName").value("공연")) + .andExpect(jsonPath("$.university.name").value("한국기술교육대학교")) + .andExpect(jsonPath("$.university.region").value("CHUNGCHEONG")) + .andExpect(jsonPath("$.memberCount").value(1)) + .andExpect(jsonPath("$.recruitment.isAlwaysRecruiting").value(true)) + .andExpect(jsonPath("$.recruitment.content").value("상시 모집 공고 내용입니다.")); + } + } + + @Nested + @DisplayName("GET /konect/clubs/recent - 최근 본 동아리") + class GetRecentClubs { + + @Test + @DisplayName("요청한 동아리 ID 순서대로 카드 정보를 반환한다") + void getRecentClubsKeepsRequestOrder() throws Exception { + // given + University university = persist(UniversityFixture.create( + "한국기술교육대학교", + Campus.MAIN, + UniversityRegion.CHUNGCHEONG + )); + Club first = persist(createClub(university, "첫 번째", ClubCategory.ACADEMIC)); + Club second = persist(createClub(university, "두 번째", ClubCategory.SPORTS)); + clearPersistenceContext(); + + // when & then + performGet("/konect/clubs/recent?clubIds=" + second.getId() + "," + first.getId()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.clubs", hasSize(2))) + .andExpect(jsonPath("$.clubs[0].name").value("두 번째")) + .andExpect(jsonPath("$.clubs[1].name").value("첫 번째")); + } + + @Test + @DisplayName("최근 본 동아리 ID가 100개를 초과하면 400을 반환한다") + void getRecentClubsRejectsTooManyClubIds() throws Exception { + // given + String clubIds = IntStream.rangeClosed(1, 101) + .mapToObj(String::valueOf) + .collect(Collectors.joining(",")); + + // when & then + performGet("/konect/clubs/recent?clubIds=" + clubIds) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("최근 본 동아리 ID가 비어 있으면 400을 반환한다") + void getRecentClubsRejectsEmptyClubIds() throws Exception { + // when & then + performGet("/konect/clubs/recent?clubIds=") + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("최근 본 동아리 ID가 없으면 400을 반환한다") + void getRecentClubsRejectsMissingClubIds() throws Exception { + // when & then + performGet("/konect/clubs/recent") + .andExpect(status().isBadRequest()); + } + } + + private Club createClub(University university, String name, ClubCategory category) { + return Club.builder() + .university(university) + .name(name) + .description("한 줄 소개") + .introduce("상세 소개입니다.") + .imageUrl("https://example.com/" + name + ".png") + .location("학생회관 101호") + .clubCategory(category) + .isRecruitmentEnabled(false) + .isApplicationEnabled(false) + .isFeeRequired(false) + .build(); + } + + private User persistMember(Club club, String name, String studentNumber) { + User user = persist(UserFixture.createUser(club.getUniversity(), name, studentNumber)); + persist(ClubMemberFixture.createMember(club, user)); + return user; + } + + private void withdraw(User user) { + user.withdraw(LocalDateTime.now()); + } +} diff --git a/src/test/java/gg/agit/konect/support/fixture/RankingTypeFixture.java b/src/test/java/gg/agit/konect/support/fixture/RankingTypeFixture.java new file mode 100644 index 000000000..e120a3300 --- /dev/null +++ b/src/test/java/gg/agit/konect/support/fixture/RankingTypeFixture.java @@ -0,0 +1,17 @@ +package gg.agit.konect.support.fixture; + +import org.springframework.test.util.ReflectionTestUtils; + +import gg.agit.konect.domain.studytime.model.RankingType; + +public class RankingTypeFixture { + + public static RankingType createWithId(Integer id) { + RankingType rankingType = new TestRankingType(); + ReflectionTestUtils.setField(rankingType, "id", id); + return rankingType; + } + + private static class TestRankingType extends RankingType { + } +} diff --git a/src/test/java/gg/agit/konect/support/fixture/StudyTimerFixture.java b/src/test/java/gg/agit/konect/support/fixture/StudyTimerFixture.java new file mode 100644 index 000000000..4e35bcacc --- /dev/null +++ b/src/test/java/gg/agit/konect/support/fixture/StudyTimerFixture.java @@ -0,0 +1,18 @@ +package gg.agit.konect.support.fixture; + +import java.time.LocalDateTime; + +import org.springframework.test.util.ReflectionTestUtils; + +import gg.agit.konect.domain.studytime.model.StudyTimer; +import gg.agit.konect.domain.user.model.User; + +public class StudyTimerFixture { + + public static StudyTimer createStartedTimer(User user, LocalDateTime startedAt) { + StudyTimer studyTimer = StudyTimer.of(user, startedAt); + ReflectionTestUtils.setField(studyTimer, "createdAt", startedAt); + ReflectionTestUtils.setField(studyTimer, "updatedAt", startedAt); + return studyTimer; + } +} diff --git a/src/test/java/gg/agit/konect/support/fixture/UniversityFixture.java b/src/test/java/gg/agit/konect/support/fixture/UniversityFixture.java index 1b6e14593..192ae5f18 100644 --- a/src/test/java/gg/agit/konect/support/fixture/UniversityFixture.java +++ b/src/test/java/gg/agit/konect/support/fixture/UniversityFixture.java @@ -3,18 +3,32 @@ import org.springframework.test.util.ReflectionTestUtils; import gg.agit.konect.domain.university.enums.Campus; +import gg.agit.konect.domain.university.enums.UniversityRegion; import gg.agit.konect.domain.university.model.University; public class UniversityFixture { + private static final String DEFAULT_IMAGE_URL = + "https://stage-static.koreatech.in/konect/user/university_logo_sample.png"; + public static University create() { return create("한국기술교육대학교", Campus.MAIN); } public static University create(String koreanName, Campus campus) { + return create(koreanName, campus, UniversityRegion.CHUNGCHEONG); + } + + public static University create(String koreanName, Campus campus, UniversityRegion region) { + return create(koreanName, campus, region, DEFAULT_IMAGE_URL); + } + + public static University create(String koreanName, Campus campus, UniversityRegion region, String imageUrl) { return University.builder() .koreanName(koreanName) .campus(campus) + .region(region) + .imageUrl(imageUrl) .build(); } diff --git a/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatDirectRoomAccessServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatDirectRoomAccessServiceTest.java new file mode 100644 index 000000000..ddff5a46e --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatDirectRoomAccessServiceTest.java @@ -0,0 +1,125 @@ +package gg.agit.konect.unit.domain.chat.service; + +import static gg.agit.konect.global.code.ApiResponseCode.FORBIDDEN_CHAT_ROOM_ACCESS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +import java.time.LocalDateTime; +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 org.springframework.test.util.ReflectionTestUtils; + +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.service.ChatDirectRoomAccessService; +import gg.agit.konect.domain.user.enums.UserRole; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +class ChatDirectRoomAccessServiceTest extends ServiceTestSupport { + + private static final LocalDateTime BASE_TIME = LocalDateTime.of(2026, 4, 27, 10, 0); + + @Mock + private ChatRoomMemberRepository chatRoomMemberRepository; + + @InjectMocks + private ChatDirectRoomAccessService chatDirectRoomAccessService; + + @Test + @DisplayName("접근 가능한 direct room 멤버를 반환한다") + void getAccessibleMemberReturnsMember() { + User user = user(10); + ChatRoom room = room(1); + ChatRoomMember member = member(room, user); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId())) + .willReturn(Optional.of(member)); + + ChatRoomMember result = chatDirectRoomAccessService.getAccessibleMember(room, user); + + assertThat(result).isSameAs(member); + } + + @Test + @DisplayName("나간 direct room에 새 메시지가 있으면 접근 시 나간 상태를 해제한다") + void getAccessibleMemberRestoresLeftMemberWhenVisibleMessageExists() { + User user = user(10); + ChatRoom room = room(1); + room.updateLastMessage("새 메시지", BASE_TIME.plusHours(2)); + ChatRoomMember member = member(room, user); + member.leaveDirectRoom(BASE_TIME.plusHours(1)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId())) + .willReturn(Optional.of(member)); + + chatDirectRoomAccessService.getAccessibleMember(room, user); + + assertThat(member.hasLeft()).isFalse(); + } + + @Test + @DisplayName("나간 direct room에 새 메시지가 없으면 접근을 거부한다") + void getAccessibleMemberRejectsLeftMemberWithoutVisibleMessage() { + User user = user(10); + ChatRoom room = room(1); + ChatRoomMember member = member(room, user); + member.leaveDirectRoom(BASE_TIME.plusHours(1)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId())) + .willReturn(Optional.of(member)); + + assertThatThrownBy(() -> chatDirectRoomAccessService.getAccessibleMember(room, user)) + .isInstanceOf(CustomException.class) + .satisfies(exception -> + assertThat(((CustomException)exception).getErrorCode()).isEqualTo(FORBIDDEN_CHAT_ROOM_ACCESS)); + } + + @Test + @DisplayName("접근 준비는 복원 전 visibleMessageFrom을 반환한다") + void prepareAccessAndGetVisibleMessageFromReturnsPreviousVisibilityBoundary() { + User user = user(10); + ChatRoom room = room(1); + room.updateLastMessage("새 메시지", BASE_TIME.plusHours(2)); + ChatRoomMember member = member(room, user); + LocalDateTime visibleMessageFrom = BASE_TIME.plusHours(1); + ReflectionTestUtils.setField(member, "leftAt", visibleMessageFrom); + ReflectionTestUtils.setField(member, "visibleMessageFrom", visibleMessageFrom); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId())) + .willReturn(Optional.of(member)); + + LocalDateTime result = chatDirectRoomAccessService.prepareAccessAndGetVisibleMessageFrom(room, user); + + assertThat(result).isEqualTo(visibleMessageFrom); + assertThat(member.hasLeft()).isFalse(); + } + + private User user(Integer id) { + return UserFixture.createUserWithId( + UniversityFixture.createWithId(1), + id, + "사용자" + id, + "2024" + String.format("%04d", id), + UserRole.USER + ); + } + + private ChatRoom room(Integer id) { + ChatRoom room = ChatRoom.directOf(); + ReflectionTestUtils.setField(room, "id", id); + ReflectionTestUtils.setField(room, "createdAt", BASE_TIME); + return room; + } + + private ChatRoomMember member(ChatRoom room, User user) { + ChatRoomMember member = ChatRoomMember.of(room, user, BASE_TIME); + ReflectionTestUtils.setField(member, "createdAt", BASE_TIME); + return member; + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatInviteServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatInviteServiceTest.java new file mode 100644 index 000000000..c1c7bf5f5 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatInviteServiceTest.java @@ -0,0 +1,144 @@ +package gg.agit.konect.unit.domain.chat.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import gg.agit.konect.domain.chat.dto.ChatInvitableUsersResponse; +import gg.agit.konect.domain.chat.enums.ChatInviteSortBy; +import gg.agit.konect.domain.chat.repository.ChatInviteQueryRepository; +import gg.agit.konect.domain.chat.service.ChatInviteService; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.ClubMember; +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.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 ChatInviteServiceTest extends ServiceTestSupport { + + @Mock + private ChatInviteQueryRepository chatInviteQueryRepository; + + @Mock + private UserRepository userRepository; + + @Test + @DisplayName("이름 정렬은 초대 후보를 단일 사용자 목록으로 변환한다") + void getInvitableUsersReturnsNameSortedUsers() { + // given + Integer userId = 10; + User requester = createUser(userId, "요청자"); + User candidate = createUser(20, "후보"); + PageRequest pageRequest = PageRequest.of(0, 20); + ChatInviteService service = new ChatInviteService(chatInviteQueryRepository, userRepository); + + given(userRepository.getById(userId)).willReturn(requester); + given(chatInviteQueryRepository.findInvitableUsers(userId, "후보", pageRequest)) + .willReturn(new PageImpl<>(List.of(candidate), pageRequest, 1)); + + // when + ChatInvitableUsersResponse response = service.getInvitableUsers( + userId, + "후보", + ChatInviteSortBy.NAME, + 1, + 20 + ); + + // then + assertThat(response.totalCount()).isEqualTo(1L); + assertThat(response.currentCount()).isEqualTo(1); + assertThat(response.totalPage()).isEqualTo(1); + assertThat(response.currentPage()).isEqualTo(1); + assertThat(response.sortBy()).isEqualTo(ChatInviteSortBy.NAME); + assertThat(response.grouped()).isFalse(); + assertThat(response.users()) + .extracting(ChatInvitableUsersResponse.InvitableUser::userId) + .containsExactly(candidate.getId()); + assertThat(response.sections()).isEmpty(); + } + + @Test + @DisplayName("동아리 정렬은 현재 페이지 후보만 대표 동아리 섹션으로 묶는다") + void getInvitableUsersReturnsClubSections() { + // given + Integer userId = 10; + User requester = createUser(userId, "요청자"); + User bcsdUser = createUser(20, "BCSD 후보"); + User dualSharedUser = createUser(30, "복수 공유 후보"); + User etcUser = createUser(40, "기타 후보"); + Club bcsd = ClubFixture.createWithId(UniversityFixture.create(), 1, "BCSD"); + Club seminar = ClubFixture.createWithId(UniversityFixture.create(), 2, "Seminar"); + ClubMember bcsdMembership = ClubMemberFixture.createMember(bcsd, bcsdUser); + ClubMember dualBcsdMembership = ClubMemberFixture.createMember(bcsd, dualSharedUser); + ClubMember dualSeminarMembership = ClubMemberFixture.createMember(seminar, dualSharedUser); + PageRequest pageRequest = PageRequest.of(0, 20); + ChatInviteService service = new ChatInviteService(chatInviteQueryRepository, userRepository); + + given(userRepository.getById(userId)).willReturn(requester); + given(chatInviteQueryRepository.findInvitableUserIdsGroupedByClub(userId, null, pageRequest)) + .willReturn(new PageImpl<>( + List.of(bcsdUser.getId(), dualSharedUser.getId(), etcUser.getId()), + pageRequest, + 3 + )); + given(userRepository.findAllByIdIn(List.of(bcsdUser.getId(), dualSharedUser.getId(), etcUser.getId()))) + .willReturn(List.of(etcUser, dualSharedUser, bcsdUser)); + given(chatInviteQueryRepository.findSharedClubMemberships( + userId, + List.of(bcsdUser.getId(), dualSharedUser.getId(), etcUser.getId()) + )) + .willReturn(List.of(bcsdMembership, dualBcsdMembership, dualSeminarMembership)); + + // when + ChatInvitableUsersResponse response = service.getInvitableUsers( + userId, + null, + ChatInviteSortBy.CLUB, + 1, + 20 + ); + + // then + assertThat(response.totalCount()).isEqualTo(3L); + assertThat(response.currentCount()).isEqualTo(3); + assertThat(response.totalPage()).isEqualTo(1); + assertThat(response.currentPage()).isEqualTo(1); + assertThat(response.sortBy()).isEqualTo(ChatInviteSortBy.CLUB); + assertThat(response.grouped()).isTrue(); + assertThat(response.users()).isEmpty(); + assertThat(response.sections()) + .extracting(ChatInvitableUsersResponse.InvitableSection::clubName) + .containsExactly("BCSD", "기타"); + assertThat(response.sections().get(0).users()) + .extracting(ChatInvitableUsersResponse.InvitableUser::userId) + .containsExactly(bcsdUser.getId(), dualSharedUser.getId()); + assertThat(response.sections().get(1).users()) + .extracting(ChatInvitableUsersResponse.InvitableUser::userId) + .containsExactly(etcUser.getId()); + verify(userRepository).findAllByIdIn(List.of(bcsdUser.getId(), dualSharedUser.getId(), etcUser.getId())); + } + + private User createUser(Integer id, String name) { + return UserFixture.createUserWithId( + UniversityFixture.create(), + id, + name, + "2024" + String.format("%04d", id), + UserRole.USER + ); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatMessagePageResolverTest.java b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatMessagePageResolverTest.java new file mode 100644 index 000000000..57e4d0e96 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatMessagePageResolverTest.java @@ -0,0 +1,310 @@ +package gg.agit.konect.unit.domain.chat.service; + +import static gg.agit.konect.domain.chat.service.ChatRoomMembershipService.SYSTEM_ADMIN_ID; +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CHAT_ROOM; +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CLUB_MEMBER; +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.never; +import static org.mockito.Mockito.verify; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.assertj.core.api.ThrowableAssert.ThrowingCallable; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.test.util.ReflectionTestUtils; + +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.service.ChatMessagePageResolver; +import gg.agit.konect.domain.chat.service.ChatRoomSystemAdminService; +import gg.agit.konect.domain.club.model.Club; +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.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +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 ChatMessagePageResolverTest extends ServiceTestSupport { + + private static final LocalDateTime BASE_TIME = LocalDateTime.of(2026, 4, 27, 10, 0); + private static final LocalDateTime TARGET_TIME = LocalDateTime.of(2026, 4, 27, 14, 0); + + @Mock + private ChatMessageRepository chatMessageRepository; + + @Mock + private ChatRoomMemberRepository chatRoomMemberRepository; + + @Mock + private ClubMemberRepository clubMemberRepository; + + @Mock + private ChatRoomSystemAdminService chatRoomSystemAdminService; + + @InjectMocks + private ChatMessagePageResolver chatMessagePageResolver; + + @Test + @DisplayName("group room 접근 권한 확인 후 messageId 페이지를 계산한다") + void resolvePageForGroupRoomMessage() { + User user = user(10, UserRole.USER); + ChatRoom room = room(1, ChatType.GROUP); + ChatMessage message = message(50, room, user, TARGET_TIME); + stubActiveMember(room, user); + stubTargetMessage(message); + given(chatMessageRepository.countNewerMessagesByChatRoomId(room.getId(), message.getId(), TARGET_TIME, null)) + .willReturn(25L); + + int page = chatMessagePageResolver.resolvePageForMessage(room.getId(), message.getId(), room, user, 20); + + assertThat(page).isEqualTo(2); + } + + @Test + @DisplayName("group room 비회원이면 messageId 조회 전에 404를 던진다") + void resolvePageForGroupRoomRejectsNonMemberBeforeMessageLookup() { + User user = user(99, UserRole.USER); + ChatRoom room = room(1, ChatType.GROUP); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId())) + .willReturn(Optional.empty()); + + assertNotFound(() -> chatMessagePageResolver.resolvePageForMessage(room.getId(), 50, room, user, 20)); + verify(chatMessageRepository, never()).findByIdWithChatRoom(50); + } + + @Test + @DisplayName("group room에서 나간 멤버를 404로 거부한다") + void resolvePageForGroupRoomRejectsLeftMember() { + User user = user(10, UserRole.USER); + ChatRoom room = room(1, ChatType.GROUP); + ChatRoomMember member = member(room, user); + ReflectionTestUtils.setField(member, "leftAt", BASE_TIME.plusHours(1)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId())) + .willReturn(Optional.of(member)); + + assertNotFound(() -> chatMessagePageResolver.resolvePageForMessage(room.getId(), 50, room, user, 20)); + verify(chatMessageRepository, never()).findByIdWithChatRoom(50); + } + + @Test + @DisplayName("direct room은 visibleMessageFrom 이후 메시지만 페이지 계산한다") + void resolvePageForDirectRoomUsesVisibleMessageFrom() { + User user = user(10, UserRole.USER); + ChatRoom room = room(1, ChatType.DIRECT); + LocalDateTime visibleMessageFrom = BASE_TIME.plusHours(2); + ChatMessage message = message(50, room, user(20, UserRole.USER), BASE_TIME.plusHours(3)); + stubDirectMember(room, user, visibleMessageFrom); + stubTargetMessage(message); + given(chatMessageRepository.countNewerMessagesByChatRoomId( + room.getId(), message.getId(), message.getCreatedAt(), visibleMessageFrom + )).willReturn(0L); + + int page = chatMessagePageResolver.resolvePageForMessage(room.getId(), message.getId(), room, user, 20); + + assertThat(page).isEqualTo(1); + } + + @Test + @DisplayName("direct room의 visibleMessageFrom 이전 messageId를 404로 숨긴다") + void resolvePageForDirectRoomRejectsHiddenMessage() { + User user = user(10, UserRole.USER); + ChatRoom room = room(1, ChatType.DIRECT); + LocalDateTime visibleMessageFrom = BASE_TIME.plusHours(2); + ChatMessage message = message(50, room, user(20, UserRole.USER), BASE_TIME.plusHours(1)); + stubDirectMember(room, user, visibleMessageFrom); + stubTargetMessage(message); + + assertNotFound(() -> chatMessagePageResolver.resolvePageForMessage(room.getId(), 50, room, user, 20)); + verify(chatMessageRepository, never()).countNewerMessagesByChatRoomId( + room.getId(), message.getId(), message.getCreatedAt(), visibleMessageFrom + ); + } + + @Test + @DisplayName("direct room 비회원 일반 사용자를 404로 거부한다") + void resolvePageForDirectRoomRejectsNonMemberUser() { + User user = user(10, UserRole.USER); + ChatRoom room = room(1, ChatType.DIRECT); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId())) + .willReturn(Optional.empty()); + + assertNotFound(() -> chatMessagePageResolver.resolvePageForMessage(room.getId(), 50, room, user, 20)); + verify(chatMessageRepository, never()).findByIdWithChatRoom(50); + } + + @Test + @DisplayName("admin의 system admin 방 조회는 SYSTEM_ADMIN 가시 범위를 사용한다") + void resolvePageForAdminSystemRoomUsesSystemAdminVisibility() { + User admin = user(99, UserRole.ADMIN); + ChatRoom room = room(1, ChatType.DIRECT); + LocalDateTime visibleMessageFrom = BASE_TIME.plusHours(2); + ChatMessage message = message(50, room, admin, BASE_TIME.plusHours(3)); + stubSystemAdminRoom(room); + stubSystemAdminMember(room, visibleMessageFrom); + stubTargetMessage(message); + given(chatMessageRepository.countNewerMessagesByChatRoomId( + room.getId(), message.getId(), message.getCreatedAt(), visibleMessageFrom + )).willReturn(0L); + + int page = chatMessagePageResolver.resolvePageForMessage(room.getId(), message.getId(), room, admin, 20); + + assertThat(page).isEqualTo(1); + } + + @Test + @DisplayName("system admin 멤버가 없으면 admin 조회 가시 범위를 null로 계산한다") + void resolvePageForAdminSystemRoomAllowsMissingSystemAdminMember() { + User admin = user(99, UserRole.ADMIN); + ChatRoom room = room(1, ChatType.DIRECT); + ChatMessage message = message(50, room, admin, BASE_TIME.plusHours(3)); + stubSystemAdminRoom(room); + given(chatRoomMemberRepository.findByChatRoomId(room.getId())).willReturn(List.of()); + stubTargetMessage(message); + given(chatMessageRepository.countNewerMessagesByChatRoomId( + room.getId(), message.getId(), message.getCreatedAt(), null + )).willReturn(0L); + + int page = chatMessagePageResolver.resolvePageForMessage(room.getId(), message.getId(), room, admin, 20); + + assertThat(page).isEqualTo(1); + } + + @Test + @DisplayName("club group 동아리 멤버십 없음만 404로 변환한다") + void resolvePageForClubRoomConvertsMissingClubMemberToNotFoundRoom() { + User user = user(10, UserRole.USER); + ChatRoom room = clubRoom(1); + given(clubMemberRepository.getByClubIdAndUserId(room.getClub().getId(), user.getId())) + .willThrow(CustomException.of(NOT_FOUND_CLUB_MEMBER)); + + assertNotFound(() -> chatMessagePageResolver.resolvePageForMessage(room.getId(), 50, room, user, 20)); + verify(chatMessageRepository, never()).findByIdWithChatRoom(50); + } + + @Test + @DisplayName("club group 멤버십 조회 예외가 404가 아니면 그대로 전파한다") + void resolvePageForClubRoomPropagatesNonMembershipException() { + User user = user(10, UserRole.USER); + ChatRoom room = clubRoom(1); + CustomException exception = CustomException.of(ApiResponseCode.NOT_FOUND_USER); + given(clubMemberRepository.getByClubIdAndUserId(room.getClub().getId(), user.getId())) + .willThrow(exception); + + assertThatThrownBy(() -> chatMessagePageResolver.resolvePageForMessage(room.getId(), 50, room, user, 20)) + .isSameAs(exception); + verify(chatMessageRepository, never()).findByIdWithChatRoom(50); + } + + @Test + @DisplayName("club group 접근 가능 사용자의 messageId 페이지를 계산한다") + void resolvePageForClubRoomMessage() { + User user = user(10, UserRole.USER); + ChatRoom room = clubRoom(1); + ChatMessage message = message(50, room, user, TARGET_TIME); + given(clubMemberRepository.getByClubIdAndUserId(room.getClub().getId(), user.getId())) + .willReturn(ClubMemberFixture.createMember(room.getClub(), user)); + stubTargetMessage(message); + given(chatMessageRepository.countNewerMessagesByChatRoomId(room.getId(), message.getId(), TARGET_TIME, null)) + .willReturn(0L); + + int page = chatMessagePageResolver.resolvePageForMessage(room.getId(), message.getId(), room, user, 20); + + assertThat(page).isEqualTo(1); + } + + @Test + @DisplayName("다른 방의 messageId를 404로 숨긴다") + void resolvePageForMessageRejectsMessageFromOtherRoom() { + User user = user(10, UserRole.USER); + ChatRoom room = room(1, ChatType.GROUP); + ChatMessage message = message(50, room(2, ChatType.GROUP), user, TARGET_TIME); + stubActiveMember(room, user); + stubTargetMessage(message); + + assertNotFound(() -> chatMessagePageResolver.resolvePageForMessage(room.getId(), 50, room, user, 20)); + } + + private void stubActiveMember(ChatRoom room, User user) { + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId())) + .willReturn(Optional.of(member(room, user))); + } + + private void stubDirectMember(ChatRoom room, User user, LocalDateTime visibleMessageFrom) { + ChatRoomMember member = member(room, user); + ReflectionTestUtils.setField(member, "visibleMessageFrom", visibleMessageFrom); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId())) + .willReturn(Optional.of(member)); + } + + private void stubSystemAdminRoom(ChatRoom room) { + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), 99)).willReturn(Optional.empty()); + given(chatRoomSystemAdminService.isSystemAdminRoom(room.getId())).willReturn(true); + } + + private void stubSystemAdminMember(ChatRoom room, LocalDateTime visibleMessageFrom) { + ChatRoomMember member = member(room, user(SYSTEM_ADMIN_ID, UserRole.ADMIN)); + ReflectionTestUtils.setField(member, "visibleMessageFrom", visibleMessageFrom); + given(chatRoomMemberRepository.findByChatRoomId(room.getId())).willReturn(List.of(member)); + given(chatRoomSystemAdminService.findSystemAdminMember(List.of(member))).willReturn(member); + } + + private void stubTargetMessage(ChatMessage message) { + given(chatMessageRepository.findByIdWithChatRoom(message.getId())).willReturn(Optional.of(message)); + } + + private User user(Integer id, UserRole role) { + return UserFixture.createUserWithId(UniversityFixture.createWithId(1), id, "사용자" + id, + "2024" + String.format("%04d", id), role); + } + + private ChatRoom room(Integer id, ChatType type) { + ChatRoom room = type == ChatType.DIRECT ? ChatRoom.directOf() : ChatRoom.groupOf(); + ReflectionTestUtils.setField(room, "id", id); + ReflectionTestUtils.setField(room, "createdAt", BASE_TIME); + return room; + } + + private ChatRoom clubRoom(Integer id) { + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), 77, "BCSD"); + ChatRoom room = ChatRoom.clubGroupOf(club); + ReflectionTestUtils.setField(room, "id", id); + ReflectionTestUtils.setField(room, "createdAt", BASE_TIME); + return room; + } + + private ChatRoomMember member(ChatRoom room, User user) { + ChatRoomMember member = ChatRoomMember.of(room, user, BASE_TIME); + ReflectionTestUtils.setField(member, "createdAt", BASE_TIME); + return member; + } + + private ChatMessage message(Integer id, ChatRoom room, User sender, LocalDateTime createdAt) { + ChatMessage message = ChatMessage.of(room, sender, "메시지"); + ReflectionTestUtils.setField(message, "id", id); + ReflectionTestUtils.setField(message, "createdAt", createdAt); + return message; + } + + private void assertNotFound(ThrowingCallable callable) { + assertThatThrownBy(callable) + .isInstanceOf(CustomException.class) + .satisfies(exception -> + assertThat(((CustomException)exception).getErrorCode()).isEqualTo(NOT_FOUND_CHAT_ROOM)); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatMessageReadServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatMessageReadServiceTest.java new file mode 100644 index 000000000..be1707690 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatMessageReadServiceTest.java @@ -0,0 +1,180 @@ +package gg.agit.konect.unit.domain.chat.service; + +import static gg.agit.konect.domain.chat.service.ChatRoomMembershipService.SYSTEM_ADMIN_ID; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.BDDMockito.given; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.util.ReflectionTestUtils; + +import gg.agit.konect.domain.chat.dto.ChatMessageDetailResponse; +import gg.agit.konect.domain.chat.dto.ChatMessagePageResponse; +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.service.ChatDirectRoomAccessService; +import gg.agit.konect.domain.chat.service.ChatMessageReadService; +import gg.agit.konect.domain.chat.service.ChatRoomSystemAdminService; +import gg.agit.konect.domain.user.enums.UserRole; +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.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +class ChatMessageReadServiceTest extends ServiceTestSupport { + + @Mock + private ChatMessageRepository chatMessageRepository; + + @Mock + private ChatRoomMemberRepository chatRoomMemberRepository; + + @Mock + private ChatRoomSystemAdminService chatRoomSystemAdminService; + + @Mock + private ChatDirectRoomAccessService chatDirectRoomAccessService; + + private ChatMessageReadService chatMessageReadService; + + @BeforeEach + void setUp() { + chatMessageReadService = new ChatMessageReadService( + chatMessageRepository, + chatRoomMemberRepository, + chatRoomSystemAdminService, + chatDirectRoomAccessService + ); + } + + @Test + @DisplayName("어드민은 SYSTEM_ADMIN 문의방에서 다른 어드민 메시지도 내 메시지로 조회한다") + void getAdminSystemDirectChatRoomMessagesMarksOtherAdminMessagesAsMine() { + // given + User viewerAdmin = createUser(99, "viewer admin", UserRole.ADMIN); + User otherAdmin = createUser(88, "other admin", UserRole.ADMIN); + User systemAdmin = createUser(SYSTEM_ADMIN_ID, "system admin", UserRole.ADMIN); + User normalUser = createUser(20, "normal user", UserRole.USER); + + ChatRoom room = createRoom(1, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember systemAdminMember = createRoomMember(room, systemAdmin, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember normalUserMember = createRoomMember(room, normalUser, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatMessage otherAdminMessage = createMessage( + 100, + room, + otherAdmin, + "reply", + LocalDateTime.of(2026, 4, 11, 10, 1) + ); + PageRequest pageable = PageRequest.of(0, 20); + + given(chatRoomMemberRepository.findByChatRoomId(room.getId())) + .willReturn(List.of(systemAdminMember, normalUserMember)); + given(chatRoomSystemAdminService.findSystemAdminMember(List.of(systemAdminMember, normalUserMember))) + .willReturn(systemAdminMember); + given(chatMessageRepository.findByChatRoomId(eq(room.getId()), nullable(LocalDateTime.class), eq(pageable))) + .willReturn(new PageImpl<>(List.of(otherAdminMessage), pageable, 1)); + + // when + ChatMessagePageResponse response = chatMessageReadService.getAdminSystemDirectChatRoomMessages( + viewerAdmin, + room, + 1, + 20, + LocalDateTime.of(2026, 4, 11, 10, 2) + ); + + // then + ChatMessageDetailResponse message = response.messages().getFirst(); + assertThat(message.senderId()).isEqualTo(otherAdmin.getId()); + assertThat(message.isMine()).isTrue(); + assertThat(message.isRead()).isTrue(); + } + + @Test + @DisplayName("일반 사용자는 direct 방의 어드민 메시지를 상대 메시지로 조회한다") + void getDirectChatRoomMessagesKeepsAdminMessagesAsOpponentForNormalUser() { + // given + User normalUser = createUser(20, "normal user", UserRole.USER); + User systemAdmin = createUser(SYSTEM_ADMIN_ID, "system admin", UserRole.ADMIN); + User otherAdmin = createUser(88, "other admin", UserRole.ADMIN); + + ChatRoom room = createRoom(1, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember systemAdminMember = createRoomMember(room, systemAdmin, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember normalUserMember = createRoomMember(room, normalUser, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatMessage otherAdminMessage = createMessage( + 100, + room, + otherAdmin, + "reply", + LocalDateTime.of(2026, 4, 11, 10, 1) + ); + PageRequest pageable = PageRequest.of(0, 20); + + given(chatRoomMemberRepository.findByChatRoomId(room.getId())) + .willReturn(List.of(systemAdminMember, normalUserMember)); + given(chatDirectRoomAccessService.prepareAccessAndGetVisibleMessageFrom(room, normalUser)) + .willReturn(null); + given(chatMessageRepository.findByChatRoomId(eq(room.getId()), nullable(LocalDateTime.class), eq(pageable))) + .willReturn(new PageImpl<>(List.of(otherAdminMessage), pageable, 1)); + + // when + ChatMessagePageResponse response = chatMessageReadService.getDirectChatRoomMessages( + normalUser, + room, + 1, + 20, + LocalDateTime.of(2026, 4, 11, 10, 2) + ); + + // then + ChatMessageDetailResponse message = response.messages().getFirst(); + assertThat(message.senderId()).isEqualTo(SYSTEM_ADMIN_ID); + assertThat(message.isMine()).isFalse(); + assertThat(message.isRead()).isTrue(); + } + + private User createUser(Integer id, String name, UserRole role) { + return UserFixture.createUserWithId(UniversityFixture.createWithId(1), id, name, + "2024" + String.format("%04d", id), role); + } + + private ChatRoom createRoom(Integer id, ChatType type, LocalDateTime createdAt) { + ChatRoom room = switch (type) { + case DIRECT -> ChatRoom.directOf(); + case GROUP -> ChatRoom.groupOf(); + case CLUB_GROUP -> ChatRoom.clubGroupOf(ClubFixture.createWithId(UniversityFixture.createWithId(1), 77)); + default -> throw new IllegalArgumentException("Unsupported ChatType: " + type); + }; + ReflectionTestUtils.setField(room, "id", id); + ReflectionTestUtils.setField(room, "createdAt", createdAt); + return room; + } + + private ChatRoomMember createRoomMember(ChatRoom room, User user, LocalDateTime lastReadAt) { + ChatRoomMember member = ChatRoomMember.of(room, user, lastReadAt); + ReflectionTestUtils.setField(member, "createdAt", lastReadAt); + return member; + } + + private ChatMessage createMessage(Integer id, ChatRoom room, User sender, String content, LocalDateTime createdAt) { + ChatMessage message = ChatMessage.of(room, sender, content); + ReflectionTestUtils.setField(message, "id", id); + ReflectionTestUtils.setField(message, "createdAt", createdAt); + return message; + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomMemberCommandServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomMemberCommandServiceTest.java new file mode 100644 index 000000000..ac6dac046 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomMemberCommandServiceTest.java @@ -0,0 +1,195 @@ +package gg.agit.konect.unit.domain.chat.service; + +import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_INVITE_IN_NON_GROUP_ROOM; +import static gg.agit.konect.global.code.ApiResponseCode.FORBIDDEN_CHAT_ROOM_ACCESS; +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_USER; +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.times; +import static org.mockito.Mockito.verify; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.assertj.core.api.ThrowableAssert.ThrowingCallable; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.test.util.ReflectionTestUtils; + +import gg.agit.konect.domain.chat.enums.ChatType; +import gg.agit.konect.domain.chat.model.ChatRoom; +import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; +import gg.agit.konect.domain.chat.repository.ChatRoomRepository; +import gg.agit.konect.domain.chat.service.ChatRoomMemberCommandService; +import gg.agit.konect.domain.chat.service.ChatRoomMembershipService; +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.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.ClubFixture; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +class ChatRoomMemberCommandServiceTest extends ServiceTestSupport { + + @Mock + private ChatRoomRepository chatRoomRepository; + + @Mock + private ChatRoomMemberRepository chatRoomMemberRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private ChatRoomMembershipService chatRoomMembershipService; + + @InjectMocks + private ChatRoomMemberCommandService chatRoomMemberCommandService; + + @Test + @DisplayName("초대 요청은 중복 userId와 요청자 자신과 기존 멤버를 제외하고 새 멤버만 추가한다") + void inviteMembersAddsOnlyNewUsers() { + // given + Integer roomId = 10; + Integer requesterId = 100; + User requester = createUser(requesterId, "요청자"); + User existingMember = createUser(200, "기존멤버"); + User newMember = createUser(300, "새멤버"); + ChatRoom room = createRoom(roomId, ChatType.GROUP); + + given(chatRoomRepository.findById(roomId)).willReturn(Optional.of(room)); + given(chatRoomMemberRepository.existsActiveByChatRoomIdAndUserId(roomId, requesterId)).willReturn(true); + given(userRepository.findAllByIdIn(List.of(requesterId, existingMember.getId(), newMember.getId()))) + .willReturn(List.of(requester, existingMember, newMember)); + given(chatRoomMemberRepository.findActiveUserIdsByChatRoomIdAndUserIdIn( + roomId, + List.of(requesterId, existingMember.getId(), newMember.getId()) + )).willReturn(List.of(requesterId, existingMember.getId())); + + // when + chatRoomMemberCommandService.inviteMembers( + requesterId, + roomId, + List.of(requesterId, existingMember.getId(), newMember.getId(), newMember.getId()) + ); + + // then + verify(chatRoomMembershipService, times(1)) + .ensureMember(eq(room), eq(newMember), any(LocalDateTime.class)); + verify(chatRoomMembershipService, never()).ensureMember(eq(room), eq(requester), any(LocalDateTime.class)); + verify(chatRoomMembershipService, never()).ensureMember(eq(room), eq(existingMember), any(LocalDateTime.class)); + } + + @Test + @DisplayName("일반 GROUP이 아니면 초대할 수 없다") + void inviteMembersToNonGroupRoomFails() { + // given + Integer roomId = 10; + ChatRoom room = createRoom(roomId, ChatType.DIRECT); + given(chatRoomRepository.findById(roomId)).willReturn(Optional.of(room)); + + // when & then + assertErrorCode( + () -> chatRoomMemberCommandService.inviteMembers(100, roomId, List.of(200)), + CANNOT_INVITE_IN_NON_GROUP_ROOM + ); + + verify(userRepository, never()).findAllByIdIn(any()); + } + + @Test + @DisplayName("CLUB_GROUP 채팅방에는 초대할 수 없다") + void inviteMembersToClubGroupRoomFails() { + // given + Integer roomId = 10; + ChatRoom room = createRoom(roomId, ChatType.CLUB_GROUP); + given(chatRoomRepository.findById(roomId)).willReturn(Optional.of(room)); + + // when & then + assertErrorCode( + () -> chatRoomMemberCommandService.inviteMembers(100, roomId, List.of(200)), + CANNOT_INVITE_IN_NON_GROUP_ROOM + ); + + verify(userRepository, never()).findAllByIdIn(any()); + } + + @Test + @DisplayName("요청자가 active 멤버가 아니면 초대할 수 없다") + void inviteMembersByInactiveRequesterFails() { + // given + Integer roomId = 10; + Integer requesterId = 100; + ChatRoom room = createRoom(roomId, ChatType.GROUP); + given(chatRoomRepository.findById(roomId)).willReturn(Optional.of(room)); + given(chatRoomMemberRepository.existsActiveByChatRoomIdAndUserId(roomId, requesterId)).willReturn(false); + + // when & then + assertErrorCode( + () -> chatRoomMemberCommandService.inviteMembers(requesterId, roomId, List.of(200)), + FORBIDDEN_CHAT_ROOM_ACCESS + ); + + verify(userRepository, never()).findAllByIdIn(any()); + } + + @Test + @DisplayName("존재하지 않는 사용자가 있으면 전체 요청을 실패시킨다") + void inviteMembersWithMissingUserFails() { + // given + Integer roomId = 10; + Integer requesterId = 100; + ChatRoom room = createRoom(roomId, ChatType.GROUP); + User foundUser = createUser(200, "존재사용자"); + given(chatRoomRepository.findById(roomId)).willReturn(Optional.of(room)); + given(chatRoomMemberRepository.existsActiveByChatRoomIdAndUserId(roomId, requesterId)).willReturn(true); + given(userRepository.findAllByIdIn(List.of(foundUser.getId(), 99999))) + .willReturn(List.of(foundUser)); + + // when & then + assertErrorCode( + () -> chatRoomMemberCommandService.inviteMembers(requesterId, roomId, List.of(foundUser.getId(), 99999)), + NOT_FOUND_USER + ); + + verify(chatRoomMembershipService, never()).ensureMember(any(), any(), any()); + } + + private ChatRoom createRoom(Integer id, ChatType type) { + ChatRoom room = switch (type) { + case DIRECT -> ChatRoom.directOf(); + case GROUP -> ChatRoom.groupOf(); + case CLUB_GROUP -> ChatRoom.clubGroupOf( + ClubFixture.createWithId(UniversityFixture.createWithId(1), 77, "BCSD") + ); + }; + ReflectionTestUtils.setField(room, "id", id); + return room; + } + + private User createUser(Integer id, String name) { + return UserFixture.createUserWithId( + UniversityFixture.create(), + id, + name, + "2024" + String.format("%04d", id), + UserRole.USER + ); + } + + private void assertErrorCode(ThrowingCallable callable, ApiResponseCode errorCode) { + assertThatThrownBy(callable) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()).isEqualTo(errorCode)); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomMembershipServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomMembershipServiceTest.java new file mode 100644 index 000000000..41afc706d --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomMembershipServiceTest.java @@ -0,0 +1,538 @@ +package gg.agit.konect.unit.domain.chat.service; + +import static gg.agit.konect.domain.chat.service.ChatRoomMembershipService.SYSTEM_ADMIN_ID; +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 org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.assertj.core.api.ThrowableAssert.ThrowingCallable; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.test.util.ReflectionTestUtils; + +import gg.agit.konect.domain.chat.dto.ChatRoomMemberResponse; +import gg.agit.konect.domain.chat.dto.ChatRoomMembersResponse; +import gg.agit.konect.domain.chat.enums.ChatType; +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.chat.service.ChatRoomMembershipService; +import gg.agit.konect.domain.chat.service.ChatRoomSystemAdminService; +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.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +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 ChatRoomMembershipServiceTest extends ServiceTestSupport { + + @Mock + private ChatRoomRepository chatRoomRepository; + + @Mock + private ChatRoomMemberRepository chatRoomMemberRepository; + + @Mock + private ClubMemberRepository clubMemberRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private ChatRoomSystemAdminService chatRoomSystemAdminService; + + @InjectMocks + private ChatRoomMembershipService chatRoomMembershipService; + + @Test + @DisplayName("채팅방 멤버 목록 조회 성공") + void getChatRoomMembers_success() { + // given + Integer chatRoomId = 1; + Integer currentUserId = 100; + + User user1 = createUser(currentUserId, "User1", UserRole.USER); + User user2 = createUser(200, "User2", UserRole.USER); + ChatRoom chatRoom = createRoom(chatRoomId, ChatType.GROUP, LocalDateTime.now()); + ChatRoomMember member1 = createRoomMember(chatRoom, user1, true, LocalDateTime.now()); + ChatRoomMember member2 = createRoomMember(chatRoom, user2, false, LocalDateTime.now()); + + given(userRepository.findById(currentUserId)).willReturn(Optional.of(user1)); + given(chatRoomRepository.findById(chatRoomId)).willReturn(Optional.of(chatRoom)); + given(chatRoomMemberRepository.existsActiveByChatRoomIdAndUserId(chatRoomId, currentUserId)).willReturn(true); + given(chatRoomMemberRepository.findActiveMembersByChatRoomId(chatRoomId)).willReturn(List.of(member1, member2)); + + // when + ChatRoomMembersResponse response = chatRoomMembershipService.getChatRoomMembers(chatRoomId, currentUserId); + + // then + assertThat(response.members()).hasSize(2); + + ChatRoomMemberResponse firstMember = response.members().get(0); + assertThat(firstMember.userId()).isEqualTo(100); + assertThat(firstMember.name()).isEqualTo("User1"); + assertThat(firstMember.isOwner()).isTrue(); + + ChatRoomMemberResponse secondMember = response.members().get(1); + assertThat(secondMember.userId()).isEqualTo(200); + assertThat(secondMember.name()).isEqualTo("User2"); + assertThat(secondMember.isOwner()).isFalse(); + } + + @Test + @DisplayName("비멤버가 조회 시도 시 FORBIDDEN 예외 발생") + void getChatRoomMembers_forbiddenWhenNotMember() { + // given + Integer chatRoomId = 1; + Integer currentUserId = 100; + User user = createUser(currentUserId, "User1", UserRole.USER); + ChatRoom chatRoom = createRoom(chatRoomId, ChatType.GROUP, LocalDateTime.now()); + + given(userRepository.findById(currentUserId)).willReturn(Optional.of(user)); + given(chatRoomRepository.findById(chatRoomId)).willReturn(Optional.of(chatRoom)); + given(chatRoomMemberRepository.existsActiveByChatRoomIdAndUserId(chatRoomId, currentUserId)).willReturn(false); + + // when & then + assertErrorCode( + () -> chatRoomMembershipService.getChatRoomMembers(chatRoomId, currentUserId), + FORBIDDEN_CHAT_ROOM_ACCESS + ); + } + + @Test + @DisplayName("나간 멤버가 조회 시도 시 FORBIDDEN 예외 발생") + void getChatRoomMembers_forbiddenWhenLeftMember() { + // given + Integer chatRoomId = 1; + Integer currentUserId = 100; + User user = createUser(currentUserId, "User1", UserRole.USER); + ChatRoom chatRoom = createRoom(chatRoomId, ChatType.GROUP, LocalDateTime.now()); + + given(userRepository.findById(currentUserId)).willReturn(Optional.of(user)); + given(chatRoomRepository.findById(chatRoomId)).willReturn(Optional.of(chatRoom)); + given(chatRoomMemberRepository.existsActiveByChatRoomIdAndUserId(chatRoomId, currentUserId)).willReturn(false); + + // when & then + assertErrorCode( + () -> chatRoomMembershipService.getChatRoomMembers(chatRoomId, currentUserId), + FORBIDDEN_CHAT_ROOM_ACCESS + ); + } + + @Test + @DisplayName("나간 멤버는 조회되지 않음") + void getChatRoomMembers_excludesLeftMembers() { + // given + Integer chatRoomId = 1; + Integer currentUserId = 100; + + User user1 = createUser(currentUserId, "User1", UserRole.USER); + User user2 = createUser(200, "User2", UserRole.USER); + ChatRoom chatRoom = createRoom(chatRoomId, ChatType.GROUP, LocalDateTime.now()); + ChatRoomMember member1 = createRoomMember(chatRoom, user1, false, LocalDateTime.now()); + + given(userRepository.findById(currentUserId)).willReturn(Optional.of(user1)); + given(chatRoomRepository.findById(chatRoomId)).willReturn(Optional.of(chatRoom)); + given(chatRoomMemberRepository.existsActiveByChatRoomIdAndUserId(chatRoomId, currentUserId)).willReturn(true); + given(chatRoomMemberRepository.findActiveMembersByChatRoomId(chatRoomId)).willReturn(List.of(member1)); + + // when + ChatRoomMembersResponse response = chatRoomMembershipService.getChatRoomMembers(chatRoomId, currentUserId); + + // then + assertThat(response.members()).hasSize(1); + assertThat(response.members().get(0).userId()).isEqualTo(100); + assertThat(response.members().get(0).name()).isEqualTo("User1"); + } + + @Test + @DisplayName("addClubMember는 기존 클럽 채팅방이 없으면 생성하고 멤버를 저장한다") + void addClubMemberCreatesClubRoomAndSavesMember() { + // given + Club club = createClub(10); + User user = createUser(20, "동아리원", UserRole.USER); + ClubMember clubMember = createClubMember(club, user, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoom savedRoom = createRoom(30, ChatType.CLUB_GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(chatRoomRepository.findByClubId(club.getId())).willReturn(Optional.empty()); + given(chatRoomRepository.save(any(ChatRoom.class))).willReturn(savedRoom); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(savedRoom.getId(), user.getId())) + .willReturn(Optional.empty()); + + // when + chatRoomMembershipService.addClubMember(clubMember); + + // then + verify(chatRoomRepository).save(any(ChatRoom.class)); + verify(chatRoomMemberRepository).save(any(ChatRoomMember.class)); + } + + @Test + @DisplayName("addClubMember는 채팅방 동시 생성 중복 예외가 나면 재조회한 방에 멤버를 추가한다") + void addClubMemberReloadsRoomWhenClubRoomCreationHitsDuplicate() { + // given + Club club = createClub(10); + User user = createUser(20, "동아리원", UserRole.USER); + ClubMember clubMember = createClubMember(club, user, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoom existingRoom = createRoom(31, ChatType.CLUB_GROUP, LocalDateTime.of(2026, 4, 11, 10, 1)); + + given(chatRoomRepository.findByClubId(club.getId())) + .willReturn(Optional.empty()) + .willReturn(Optional.of(existingRoom)); + given(chatRoomRepository.save(any(ChatRoom.class))) + .willThrow(new DuplicateKeyException("duplicate room")); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(existingRoom.getId(), user.getId())) + .willReturn(Optional.empty()); + + // when + chatRoomMembershipService.addClubMember(clubMember); + + // then + verify(chatRoomRepository).save(any(ChatRoom.class)); + verify(chatRoomRepository, times(2)).findByClubId(club.getId()); + verify(chatRoomMemberRepository).save(any(ChatRoomMember.class)); + } + + @Test + @DisplayName("addClubMember는 이미 멤버가 있으면 더 최신 createdAt일 때만 lastReadAt을 갱신한다") + void addClubMemberUpdatesLastReadAtOnlyWhenBaselineIsNewer() { + // given + Club club = createClub(10); + User user = createUser(20, "동아리원", UserRole.USER); + ChatRoom room = createRoom(30, ChatType.CLUB_GROUP, LocalDateTime.of(2026, 4, 11, 9, 0)); + ClubMember clubMember = createClubMember(club, user, LocalDateTime.of(2026, 4, 11, 11, 0)); + ChatRoomMember existingMember = createRoomMember(room, user, false, LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(chatRoomRepository.findByClubId(club.getId())).willReturn(Optional.of(room)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId())) + .willReturn(Optional.of(existingMember)); + + // when + chatRoomMembershipService.addClubMember(clubMember); + + // then + assertThat(existingMember.getLastReadAt()).isEqualTo(clubMember.getCreatedAt()); + verify(chatRoomMemberRepository, never()).save(any(ChatRoomMember.class)); + } + + @Test + @DisplayName("addClubMember는 이미 더 최신 lastReadAt이 있으면 저장하지 않고 유지한다") + void addClubMemberKeepsLastReadAtWhenExistingMemberIsNewer() { + // given + Club club = createClub(10); + User user = createUser(20, "동아리원", UserRole.USER); + ChatRoom room = createRoom(30, ChatType.CLUB_GROUP, LocalDateTime.of(2026, 4, 11, 9, 0)); + ClubMember clubMember = createClubMember(club, user, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember existingMember = createRoomMember(room, user, false, LocalDateTime.of(2026, 4, 11, 11, 0)); + + given(chatRoomRepository.findByClubId(club.getId())).willReturn(Optional.of(room)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId())) + .willReturn(Optional.of(existingMember)); + + // when + chatRoomMembershipService.addClubMember(clubMember); + + // then + assertThat(existingMember.getLastReadAt()).isEqualTo(LocalDateTime.of(2026, 4, 11, 11, 0)); + verify(chatRoomMemberRepository, never()).save(any(ChatRoomMember.class)); + } + + @Test + @DisplayName("addDirectMembers는 joinedAt이 null이면 즉시 실패한다") + void addDirectMembersFailsWhenJoinedAtIsNull() { + // given + ChatRoom room = createRoom(10, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + User firstUser = createUser(20, "첫번째", UserRole.USER); + User secondUser = createUser(30, "두번째", UserRole.USER); + + // when & then + assertThatThrownBy(() -> chatRoomMembershipService.addDirectMembers(room, firstUser, secondUser, null)) + .isInstanceOf(NullPointerException.class); + verify(chatRoomMemberRepository, never()).save(any(ChatRoomMember.class)); + } + + @Test + @DisplayName("addDirectMembers는 멤버 저장 중 중복 예외가 나면 무시하고 나머지 멤버를 계속 처리한다") + void addDirectMembersIgnoresDuplicateMemberSave() { + // given + ChatRoom room = createRoom(10, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + User firstUser = createUser(20, "첫번째", UserRole.USER); + User secondUser = createUser(30, "두번째", UserRole.USER); + LocalDateTime joinedAt = LocalDateTime.of(2026, 4, 11, 10, 5); + + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), firstUser.getId())) + .willReturn(Optional.empty()); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), secondUser.getId())) + .willReturn(Optional.empty()); + given(chatRoomMemberRepository.save(any(ChatRoomMember.class))) + .willThrow(new DuplicateKeyException("duplicate member")) + .willReturn(createRoomMember(room, secondUser, false, joinedAt)); + + // when + chatRoomMembershipService.addDirectMembers(room, firstUser, secondUser, joinedAt); + + // then + verify(chatRoomMemberRepository, times(2)).save(any(ChatRoomMember.class)); + } + + @Test + @DisplayName("removeClubMember는 클럽 채팅방이 있을 때만 멤버를 삭제한다") + void removeClubMemberDeletesOnlyWhenClubRoomExists() { + // given + ChatRoom room = createRoom(10, ChatType.CLUB_GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + given(chatRoomRepository.findByClubId(1)).willReturn(Optional.of(room)); + + // when + chatRoomMembershipService.removeClubMember(1, 20); + + // then + verify(chatRoomMemberRepository).deleteByChatRoomIdAndUserId(room.getId(), 20); + } + + @Test + @DisplayName("updateDirectRoomLastReadAt는 system-admin room에서 admin이 읽으면 시스템 관리자 멤버만 갱신한다") + void updateDirectRoomLastReadAtUpdatesSystemAdminForAdminReader() { + // given + int roomId = 10; + User admin = createUser(99, "관리자", UserRole.ADMIN); + ChatRoom room = createRoom(roomId, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + LocalDateTime readAt = LocalDateTime.of(2026, 4, 11, 10, 5); + given(chatRoomSystemAdminService.isSystemAdminRoom(roomId)).willReturn(true); + + // when + chatRoomMembershipService.updateDirectRoomLastReadAt(roomId, admin, readAt, room); + + // then + verify(chatRoomMemberRepository).updateLastReadAtIfOlder(roomId, SYSTEM_ADMIN_ID, readAt); + verify(chatRoomMemberRepository, never()).existsByChatRoomIdAndUserId(roomId, admin.getId()); + verify(chatRoomMemberRepository, never()).updateLastReadAtIfOlder(roomId, admin.getId(), readAt); + } + + @Test + @DisplayName("updateDirectRoomLastReadAt는 일반 멤버가 직접 채팅방에 속하면 본인 lastReadAt을 갱신한다") + void updateDirectRoomLastReadAtUpdatesReaderWhenMemberExists() { + // given + int roomId = 10; + User user = createUser(20, "사용자", UserRole.USER); + ChatRoom room = createRoom(roomId, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + LocalDateTime readAt = LocalDateTime.of(2026, 4, 11, 10, 5); + given(chatRoomMemberRepository.existsByChatRoomIdAndUserId(roomId, user.getId())).willReturn(true); + + // when + chatRoomMembershipService.updateDirectRoomLastReadAt(roomId, user, readAt, room); + + // then + verify(chatRoomMemberRepository).updateLastReadAtIfOlder(roomId, user.getId(), readAt); + } + + @Test + @DisplayName("updateDirectRoomLastReadAt는 일반 사용자가 멤버가 아니면 접근을 거부한다") + void updateDirectRoomLastReadAtRejectsNonMemberUser() { + // given + int roomId = 10; + User user = createUser(20, "사용자", UserRole.USER); + ChatRoom room = createRoom(roomId, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + LocalDateTime readAt = LocalDateTime.of(2026, 4, 11, 10, 5); + given(chatRoomMemberRepository.existsByChatRoomIdAndUserId(roomId, user.getId())).willReturn(false); + + // when & then + assertErrorCode( + () -> chatRoomMembershipService.updateDirectRoomLastReadAt(roomId, user, readAt, room), + FORBIDDEN_CHAT_ROOM_ACCESS + ); + verify(chatRoomMemberRepository, never()).updateLastReadAtIfOlder(roomId, user.getId(), readAt); + } + + @Test + @DisplayName("updateDirectRoomLastReadAt는 admin이 system-admin room 비멤버여도 본인 멤버를 생성하지 않는다") + void updateDirectRoomLastReadAtSkipsAdminMembershipCreationInSystemAdminRoom() { + // given + int roomId = 10; + User admin = createUser(99, "관리자", UserRole.ADMIN); + ChatRoom room = createRoom(roomId, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + LocalDateTime readAt = LocalDateTime.of(2026, 4, 11, 10, 5); + given(chatRoomSystemAdminService.isSystemAdminRoom(roomId)).willReturn(true); + + // when + chatRoomMembershipService.updateDirectRoomLastReadAt(roomId, admin, readAt, room); + + // then + verify(chatRoomMemberRepository, never()).save(any(ChatRoomMember.class)); + } + + @Test + @DisplayName("ensureClubRoomMember는 group room이 아니거나 club이 없으면 채팅방을 찾을 수 없다고 본다") + void ensureClubRoomMemberRejectsNonClubGroupRoom() { + // given + ChatRoom directRoom = createRoom(10, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + given(chatRoomRepository.findById(directRoom.getId())).willReturn(Optional.of(directRoom)); + + // when & then + assertErrorCode(() -> chatRoomMembershipService.ensureClubRoomMember(directRoom.getId(), 20), + NOT_FOUND_CHAT_ROOM); + verify(clubMemberRepository, never()).getByClubIdAndUserId(any(), any()); + } + + @Test + @DisplayName("ensureClubRoomMember는 club 멤버 createdAt 기준으로 채팅방 멤버를 보장한다") + void ensureClubRoomMemberCreatesOrUpdatesMemberFromClubMemberBaseline() { + // given + Club club = createClub(10); + ChatRoom room = createRoom(30, ChatType.CLUB_GROUP, LocalDateTime.of(2026, 4, 11, 9, 0)); + ReflectionTestUtils.setField(room, "club", club); + User user = createUser(20, "동아리원", UserRole.USER); + ClubMember clubMember = createClubMember(club, user, LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(chatRoomRepository.findById(room.getId())).willReturn(Optional.of(room)); + given(clubMemberRepository.getByClubIdAndUserId(club.getId(), user.getId())).willReturn(clubMember); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId())) + .willReturn(Optional.empty()); + + // when + chatRoomMembershipService.ensureClubRoomMember(room.getId(), user.getId()); + + // then + verify(chatRoomMemberRepository).save(any(ChatRoomMember.class)); + } + + @Test + @DisplayName("ensureClubRoomMember는 이미 조회한 club group room을 재조회하지 않고 멤버를 보장한다") + void ensureClubRoomMemberUsesProvidedRoomWithoutRefetch() { + // given + Club club = createClub(10); + ChatRoom room = createRoom(30, ChatType.CLUB_GROUP, LocalDateTime.of(2026, 4, 11, 9, 0)); + ReflectionTestUtils.setField(room, "club", club); + User user = createUser(20, "동아리원", UserRole.USER); + ClubMember clubMember = createClubMember(club, user, LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(clubMemberRepository.getByClubIdAndUserId(club.getId(), user.getId())).willReturn(clubMember); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId())) + .willReturn(Optional.empty()); + + // when + chatRoomMembershipService.ensureClubRoomMember(room, user.getId()); + + // then + verify(chatRoomRepository, never()).findById(any()); + verify(chatRoomMemberRepository).save(any(ChatRoomMember.class)); + } + + @Test + @DisplayName("updateLastReadAt는 저장된 값이 더 오래된 경우에만 갱신 쿼리를 위임한다") + void updateLastReadAtDelegatesConditionalUpdate() { + // when + LocalDateTime readAt = LocalDateTime.of(2026, 4, 11, 10, 0); + chatRoomMembershipService.updateLastReadAt(10, 20, readAt); + + // then + verify(chatRoomMemberRepository).updateLastReadAtIfOlder(10, 20, readAt); + } + + @Test + @DisplayName("중복이 아닌 DataIntegrityViolationException은 삼키지 않고 다시 던진다") + void addClubMemberRethrowsNonDuplicateIntegrityViolation() { + // given + Club club = createClub(10); + User user = createUser(20, "동아리원", UserRole.USER); + ClubMember clubMember = createClubMember(club, user, LocalDateTime.of(2026, 4, 11, 10, 0)); + DataIntegrityViolationException exception = new DataIntegrityViolationException("other constraint"); + + given(chatRoomRepository.findByClubId(club.getId())).willReturn(Optional.empty()); + given(chatRoomRepository.save(any(ChatRoom.class))).willThrow(exception); + + // when & then + assertThatThrownBy(() -> chatRoomMembershipService.addClubMember(clubMember)) + .isSameAs(exception); + } + + @Test + @DisplayName("root cause 메시지에 duplicate key가 있으면 채팅방 멤버 저장 중복도 무시한다") + void addDirectMembersIgnoresDuplicateByRootCauseMessage() { + // given + ChatRoom room = createRoom(10, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + User firstUser = createUser(20, "첫번째", UserRole.USER); + User secondUser = createUser(30, "두번째", UserRole.USER); + LocalDateTime joinedAt = LocalDateTime.of(2026, 4, 11, 10, 5); + DataIntegrityViolationException duplicateLikeException = new DataIntegrityViolationException( + "constraint violated", + new RuntimeException("duplicate key value violates unique constraint") + ); + + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), firstUser.getId())) + .willReturn(Optional.empty()); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), secondUser.getId())) + .willReturn(Optional.empty()); + given(chatRoomMemberRepository.save(any(ChatRoomMember.class))) + .willThrow(duplicateLikeException) + .willReturn(createRoomMember(room, secondUser, false, joinedAt)); + + // when + chatRoomMembershipService.addDirectMembers(room, firstUser, secondUser, joinedAt); + + // then + verify(chatRoomMemberRepository, times(2)).save(any(ChatRoomMember.class)); + } + + private Club createClub(Integer clubId) { + return ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId, "BCSD"); + } + + private User createUser(Integer userId, String name, UserRole role) { + return UserFixture.createUserWithId(userId, name, role); + } + + private ClubMember createClubMember(Club club, User user, LocalDateTime createdAt) { + ClubMember clubMember = ClubMemberFixture.createMember(club, user); + ReflectionTestUtils.setField(clubMember, "createdAt", createdAt); + return clubMember; + } + + private ChatRoom createRoom(Integer id, ChatType type, LocalDateTime createdAt) { + ChatRoom room = switch (type) { + case DIRECT -> ChatRoom.directOf(); + case GROUP -> ChatRoom.groupOf(); + case CLUB_GROUP -> ChatRoom.clubGroupOf(createClub(77)); + default -> throw new IllegalArgumentException("Unsupported ChatType: " + type); + }; + ReflectionTestUtils.setField(room, "id", id); + ReflectionTestUtils.setField(room, "createdAt", createdAt); + return room; + } + + private ChatRoomMember createRoomMember(ChatRoom room, User user, boolean isOwner, LocalDateTime lastReadAt) { + ChatRoomMember member = isOwner + ? ChatRoomMember.ofOwner(room, user, lastReadAt) + : ChatRoomMember.of(room, user, lastReadAt); + ReflectionTestUtils.setField(member, "createdAt", lastReadAt); + return member; + } + + private void assertErrorCode(ThrowingCallable callable, ApiResponseCode errorCode) { + assertThatThrownBy(callable) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()).isEqualTo(errorCode)); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomSettingsServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomSettingsServiceTest.java new file mode 100644 index 000000000..1bec16abd --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomSettingsServiceTest.java @@ -0,0 +1,94 @@ +package gg.agit.konect.unit.domain.chat.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; + +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 gg.agit.konect.domain.chat.dto.ChatRoomSummaryResponse; +import gg.agit.konect.domain.chat.enums.ChatType; +import gg.agit.konect.domain.chat.model.ChatRoomMember; +import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; +import gg.agit.konect.domain.chat.service.ChatRoomSettingsService; +import gg.agit.konect.domain.notification.enums.NotificationTargetType; +import gg.agit.konect.domain.notification.model.NotificationMuteSetting; +import gg.agit.konect.domain.notification.repository.NotificationMuteSettingRepository; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.support.ServiceTestSupport; + +class ChatRoomSettingsServiceTest extends ServiceTestSupport { + + @Mock + private NotificationMuteSettingRepository notificationMuteSettingRepository; + + @Mock + private ChatRoomMemberRepository chatRoomMemberRepository; + + @InjectMocks + private ChatRoomSettingsService chatRoomSettingsService; + + @Test + @DisplayName("applyUserSettings는 커스텀 방 이름과 뮤트 설정을 목록 응답에 합성한다") + void applyUserSettingsAppliesCustomNameAndMute() { + // given + Integer userId = 10; + ChatRoomSummaryResponse room = createRoomSummary(1, "기본 이름"); + ChatRoomMember member = mock(ChatRoomMember.class); + given(member.getChatRoomId()).willReturn(room.roomId()); + given(member.getCustomRoomName()).willReturn("내 방 이름"); + given(notificationMuteSettingRepository.findByTargetTypeAndTargetIdsAndUserId( + NotificationTargetType.CHAT_ROOM, + List.of(room.roomId()), + userId + )).willReturn(List.of(NotificationMuteSetting.of( + NotificationTargetType.CHAT_ROOM, + room.roomId(), + mock(User.class), + true + ))); + given(chatRoomMemberRepository.findByChatRoomIdsAndUserId(List.of(room.roomId()), userId)) + .willReturn(List.of(member)); + + // when + List result = chatRoomSettingsService.applyUserSettings(List.of(room), userId); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).roomName()).isEqualTo("내 방 이름"); + assertThat(result.get(0).isMuted()).isTrue(); + assertThat(result.get(0).lastMessage()).isEqualTo(room.lastMessage()); + } + + @Test + @DisplayName("applyUserSettings는 빈 목록이면 설정 조회를 생략한다") + void applyUserSettingsSkipsLookupForEmptyRooms() { + // when + List result = chatRoomSettingsService.applyUserSettings(List.of(), 10); + + // then + assertThat(result).isEmpty(); + verifyNoInteractions(notificationMuteSettingRepository, chatRoomMemberRepository); + } + + private ChatRoomSummaryResponse createRoomSummary(Integer roomId, String roomName) { + return new ChatRoomSummaryResponse( + roomId, + ChatType.DIRECT, + roomName, + "https://example.com/image.png", + "마지막 메시지", + LocalDateTime.of(2026, 4, 27, 11, 0), + LocalDateTime.of(2026, 4, 27, 10, 0), + 3, + false + ); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomSummaryServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomSummaryServiceTest.java new file mode 100644 index 000000000..61484b78f --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomSummaryServiceTest.java @@ -0,0 +1,97 @@ +package gg.agit.konect.unit.domain.chat.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +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.chat.dto.ChatRoomSummaryResponse; +import gg.agit.konect.domain.chat.enums.ChatType; +import gg.agit.konect.domain.chat.service.ChatRoomSettingsService; +import gg.agit.konect.domain.chat.service.ChatRoomSummaryService; +import gg.agit.konect.support.ServiceTestSupport; + +class ChatRoomSummaryServiceTest extends ServiceTestSupport { + + @Mock + private ChatRoomSettingsService chatRoomSettingsService; + + @InjectMocks + private ChatRoomSummaryService chatRoomSummaryService; + + @Test + @DisplayName("summarizeChatRooms는 사용자 설정을 적용한 뒤 최신 대화 순으로 정렬한다") + void summarizeChatRoomsAppliesSettingsAndSortsByRecentActivity() { + // given + Integer userId = 10; + ChatRoomSummaryResponse olderRoom = createRoom(1, ChatType.DIRECT, "오래된 방", + LocalDateTime.of(2026, 4, 27, 9, 0), LocalDateTime.of(2026, 4, 27, 8, 0)); + ChatRoomSummaryResponse emptyNewRoom = createRoom(2, ChatType.GROUP, "새 빈 방", + null, LocalDateTime.of(2026, 4, 27, 11, 0)); + ChatRoomSummaryResponse newestRoom = createRoom(3, ChatType.CLUB_GROUP, "최신 방", + LocalDateTime.of(2026, 4, 27, 12, 0), LocalDateTime.of(2026, 4, 27, 7, 0)); + List combinedRooms = List.of(olderRoom, newestRoom, emptyNewRoom); + + given(chatRoomSettingsService.applyUserSettings(combinedRooms, userId)) + .willReturn(combinedRooms); + + // when + List result = chatRoomSummaryService.summarizeChatRooms( + userId, + List.of(olderRoom), + List.of(newestRoom), + List.of(emptyNewRoom) + ); + + // then + assertThat(result).extracting(ChatRoomSummaryResponse::roomId) + .containsExactly(3, 2, 1); + } + + @Test + @DisplayName("getDefaultRoomNameMap은 검색용 기본 방 이름을 보존한다") + void getDefaultRoomNameMapKeepsOriginalRoomNames() { + // given + ChatRoomSummaryResponse directRoom = createRoom(1, ChatType.DIRECT, "상대방", + LocalDateTime.of(2026, 4, 27, 9, 0), LocalDateTime.of(2026, 4, 27, 8, 0)); + ChatRoomSummaryResponse clubRoom = createRoom(2, ChatType.CLUB_GROUP, "동아리", + LocalDateTime.of(2026, 4, 27, 10, 0), LocalDateTime.of(2026, 4, 27, 8, 0)); + + // when + Map result = chatRoomSummaryService.getDefaultRoomNameMap( + List.of(directRoom), + List.of(clubRoom) + ); + + // then + assertThat(result).containsEntry(1, "상대방") + .containsEntry(2, "동아리"); + } + + private ChatRoomSummaryResponse createRoom( + Integer roomId, + ChatType chatType, + String roomName, + LocalDateTime lastSentAt, + LocalDateTime createdAt + ) { + return new ChatRoomSummaryResponse( + roomId, + chatType, + roomName, + null, + null, + lastSentAt, + createdAt, + 0, + false + ); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomSystemAdminServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomSystemAdminServiceTest.java new file mode 100644 index 000000000..dacd4d7cd --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatRoomSystemAdminServiceTest.java @@ -0,0 +1,60 @@ +package gg.agit.konect.unit.domain.chat.service; + +import static gg.agit.konect.domain.chat.service.ChatRoomMembershipService.SYSTEM_ADMIN_ID; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +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 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.service.ChatRoomSystemAdminService; +import gg.agit.konect.domain.user.enums.UserRole; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +class ChatRoomSystemAdminServiceTest extends ServiceTestSupport { + + @Mock + private ChatRoomMemberRepository chatRoomMemberRepository; + + @InjectMocks + private ChatRoomSystemAdminService chatRoomSystemAdminService; + + @Test + @DisplayName("isSystemAdminRoom은 SYSTEM_ADMIN 멤버가 있으면 true를 반환한다") + void isSystemAdminRoomReturnsTrueWhenSystemAdminMemberExists() { + given(chatRoomMemberRepository.existsByChatRoomIdAndUserId(1, SYSTEM_ADMIN_ID)).willReturn(true); + + boolean result = chatRoomSystemAdminService.isSystemAdminRoom(1); + + assertThat(result).isTrue(); + } + + @Test + @DisplayName("findSystemAdminMember는 멤버 목록에서 SYSTEM_ADMIN 멤버를 반환한다") + void findSystemAdminMemberReturnsSystemAdminMember() { + ChatRoom room = ChatRoom.directOf(); + User systemAdmin = UserFixture.createUserWithId( + UniversityFixture.createWithId(1), + SYSTEM_ADMIN_ID, + "시스템관리자", + "20240001", + UserRole.ADMIN + ); + ChatRoomMember member = ChatRoomMember.of(room, systemAdmin, LocalDateTime.now()); + + ChatRoomMember result = chatRoomSystemAdminService.findSystemAdminMember(List.of(member)); + + assertThat(result).isSameAs(member); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatServiceTest.java index 45760b453..6c95646a2 100644 --- a/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatServiceTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatServiceTest.java @@ -12,6 +12,7 @@ import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_USER; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.tuple; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.nullable; @@ -25,10 +26,10 @@ import java.util.Optional; import org.assertj.core.api.ThrowableAssert.ThrowingCallable; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.PageImpl; @@ -43,15 +44,27 @@ import gg.agit.konect.domain.chat.dto.ChatRoomNameUpdateRequest; import gg.agit.konect.domain.chat.dto.ChatRoomResponse; 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.ChatRoomQueryRepository; import gg.agit.konect.domain.chat.repository.ChatRoomRepository; +import gg.agit.konect.domain.chat.service.ChatDirectRoomAccessService; +import gg.agit.konect.domain.chat.service.ChatInviteService; +import gg.agit.konect.domain.chat.service.ChatMessageReadService; +import gg.agit.konect.domain.chat.service.ChatMessagePageResolver; +import gg.agit.konect.domain.chat.service.ChatMessageSendService; import gg.agit.konect.domain.chat.service.ChatPresenceService; +import gg.agit.konect.domain.chat.service.ChatRoomAccessService; +import gg.agit.konect.domain.chat.service.ChatRoomCreationService; +import gg.agit.konect.domain.chat.service.ChatRoomMemberCommandService; import gg.agit.konect.domain.chat.service.ChatRoomMembershipService; +import gg.agit.konect.domain.chat.service.ChatRoomSummaryService; +import gg.agit.konect.domain.chat.service.ChatRoomSystemAdminService; +import gg.agit.konect.domain.chat.service.ChatSearchService; import gg.agit.konect.domain.chat.service.ChatService; import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.ClubMember; @@ -76,6 +89,9 @@ class ChatServiceTest extends ServiceTestSupport { @Mock private ChatRoomRepository chatRoomRepository; + @Mock + private ChatRoomQueryRepository chatRoomQueryRepository; + @Mock private ChatMessageRepository chatMessageRepository; @@ -88,9 +104,6 @@ class ChatServiceTest extends ServiceTestSupport { @Mock private ClubMemberRepository clubMemberRepository; - @Mock - private ChatInviteQueryRepository chatInviteQueryRepository; - @Mock private UserRepository userRepository; @@ -100,15 +113,103 @@ class ChatServiceTest extends ServiceTestSupport { @Mock private ChatRoomMembershipService chatRoomMembershipService; + @Mock + private ChatRoomSummaryService chatRoomSummaryService; + + @Mock + private ChatSearchService chatSearchService; + + @Mock + private ChatInviteService chatInviteService; + + @Mock + private ChatRoomSystemAdminService chatRoomSystemAdminService; + @Mock private NotificationService notificationService; @Mock private ApplicationEventPublisher eventPublisher; - @InjectMocks private ChatService chatService; + @BeforeEach + void setUp() { + ChatDirectRoomAccessService chatDirectRoomAccessService = + new ChatDirectRoomAccessService(chatRoomMemberRepository); + ChatMessagePageResolver chatMessagePageResolver = new ChatMessagePageResolver( + chatMessageRepository, + chatRoomMemberRepository, + clubMemberRepository, + chatRoomSystemAdminService + ); + ChatRoomAccessService chatRoomAccessService = new ChatRoomAccessService( + chatRoomMemberRepository, + userRepository, + chatRoomMembershipService, + chatRoomSystemAdminService, + chatDirectRoomAccessService + ); + ChatRoomMemberCommandService chatRoomMemberCommandService = new ChatRoomMemberCommandService( + chatRoomRepository, + chatRoomMemberRepository, + userRepository, + chatRoomMembershipService + ); + ChatRoomMembershipService chatRoomMembershipForCreation = new ChatRoomMembershipService( + chatRoomRepository, + chatRoomMemberRepository, + clubMemberRepository, + userRepository, + chatRoomSystemAdminService + ); + ChatRoomCreationService chatRoomCreationService = new ChatRoomCreationService( + chatRoomRepository, + chatRoomMemberRepository, + userRepository, + chatRoomMembershipForCreation + ); + ChatMessageSendService chatMessageSendService = new ChatMessageSendService( + chatRoomRepository, + chatMessageRepository, + chatRoomMemberRepository, + clubMemberRepository, + userRepository, + chatRoomSystemAdminService, + chatDirectRoomAccessService, + notificationService, + eventPublisher + ); + ChatMessageReadService chatMessageReadService = new ChatMessageReadService( + chatMessageRepository, + chatRoomMemberRepository, + chatRoomSystemAdminService, + chatDirectRoomAccessService + ); + chatService = new ChatService( + chatRoomRepository, + chatRoomQueryRepository, + chatMessageRepository, + chatRoomMemberRepository, + notificationMuteSettingRepository, + clubMemberRepository, + userRepository, + chatPresenceService, + chatRoomMembershipService, + chatRoomMemberCommandService, + chatRoomSummaryService, + chatSearchService, + chatInviteService, + chatMessageReadService, + chatMessagePageResolver, + chatRoomAccessService, + chatRoomCreationService, + chatRoomSystemAdminService, + chatDirectRoomAccessService, + chatMessageSendService + ); + } + @Test @DisplayName("createOrGetChatRoom은 자기 자신과의 direct room 생성을 거부한다") void createOrGetChatRoomRejectsSelfChat() { @@ -181,11 +282,7 @@ void createOrGetChatRoomUsesSystemAdminRoomForAdminToUser() { .willReturn(Optional.empty()); given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), targetUserId)) .willReturn(Optional.empty()); - given(chatRoomMemberRepository.findRoomMemberIdsByChatRoomIds(List.of(room.getId()))) - .willReturn(List.of( - new Object[] {room.getId(), SYSTEM_ADMIN_ID, room.getCreatedAt()}, - new Object[] {room.getId(), targetUserId, room.getCreatedAt()} - )); + given(chatRoomSystemAdminService.isSystemAdminRoom(room.getId())).willReturn(true); // when ChatRoomResponse response = chatService.createOrGetChatRoom(adminUserId, @@ -416,6 +513,7 @@ void toggleMuteTogglesFromUnmutedToMuted() { // then assertThat(response.isMuted()).isTrue(); assertThat(setting.getIsMuted()).isTrue(); + verify(userRepository, times(1)).getById(userId); verify(notificationMuteSettingRepository).save(setting); } @@ -764,32 +862,104 @@ void getMessagesReturnsAdminSystemRoomMessages() { LocalDateTime.of(2026, 4, 11, 10, 0)); ChatRoomMember targetMember = createRoomMember(systemAdminRoom, targetUser, false, LocalDateTime.of(2026, 4, 11, 10, 0)); - ChatMessage message = createMessage(100, systemAdminRoom, admin, "문의", - LocalDateTime.of(2026, 4, 11, 10, 1)); + ReflectionTestUtils.setField(systemAdminMember, "visibleMessageFrom", + LocalDateTime.of(2026, 4, 11, 10, 5)); + ReflectionTestUtils.setField(targetMember, "visibleMessageFrom", + LocalDateTime.of(2026, 4, 11, 10, 7)); + systemAdminMember.updateLastReadAt(LocalDateTime.of(2026, 4, 11, 10, 7)); + targetMember.updateLastReadAt(LocalDateTime.of(2026, 4, 11, 10, 3)); + ChatMessage adminMessage = createMessage(100, systemAdminRoom, admin, "관리자 답변", + LocalDateTime.of(2026, 4, 11, 10, 6)); + ChatMessage userMessage = createMessage(101, systemAdminRoom, targetUser, "사용자 문의", + LocalDateTime.of(2026, 4, 11, 10, 8)); given(chatRoomRepository.findById(systemAdminRoom.getId())).willReturn(Optional.of(systemAdminRoom)); given(userRepository.getById(adminId)).willReturn(admin); - given(chatRoomMemberRepository.findRoomMemberIdsByChatRoomIds(List.of(systemAdminRoom.getId()))) - .willReturn(List.of( - new Object[] {systemAdminRoom.getId(), SYSTEM_ADMIN_ID, systemAdminRoom.getCreatedAt()}, - new Object[] {systemAdminRoom.getId(), 20, systemAdminRoom.getCreatedAt()} - )); + given(chatRoomSystemAdminService.isSystemAdminRoom(systemAdminRoom.getId())).willReturn(true); + given(chatRoomSystemAdminService.findSystemAdminMember(List.of(systemAdminMember, targetMember))) + .willReturn(systemAdminMember); given(chatRoomMemberRepository.findByChatRoomId(systemAdminRoom.getId())) .willReturn(List.of(systemAdminMember, targetMember)); - given(chatMessageRepository.findByChatRoomId(eq(systemAdminRoom.getId()), nullable(LocalDateTime.class), + given(chatMessageRepository.findByChatRoomId(eq(systemAdminRoom.getId()), + eq(systemAdminMember.getVisibleMessageFrom()), eq(PageRequest.of(0, 20)))) - .willReturn(new PageImpl<>(List.of(message), PageRequest.of(0, 20), 1)); + .willReturn(new PageImpl<>(List.of(adminMessage, userMessage), PageRequest.of(0, 20), 2)); // when ChatMessagePageResponse response = chatService.getMessages(adminId, systemAdminRoom.getId(), 1, 20); // then - assertThat(response.messages()).hasSize(1); + assertThat(response.messages()) + .extracting( + ChatMessageDetailResponse::senderId, + ChatMessageDetailResponse::content, + ChatMessageDetailResponse::unreadCount, + ChatMessageDetailResponse::isMine + ) + .containsExactly( + tuple(adminId, "관리자 답변", 0, true), + tuple(targetUser.getId(), "사용자 문의", 2, false) + ); verify(chatRoomMembershipService).updateLastReadAt(eq(systemAdminRoom.getId()), eq(SYSTEM_ADMIN_ID), any(LocalDateTime.class)); verify(chatPresenceService).recordPresence(systemAdminRoom.getId(), adminId); } + @Test + @DisplayName("getMessages는 일반 사용자의 system admin 방 조회에서 가시 범위와 sender masking을 적용한다") + void getMessagesAppliesVisibilityAndSenderMaskingForUserInSystemAdminRoom() { + // given + Integer userId = 20; + Integer adminId = 99; + User user = createUser(userId, "사용자", UserRole.USER); + User admin = createUser(adminId, "관리자", UserRole.ADMIN); + User systemAdmin = createUser(SYSTEM_ADMIN_ID, "시스템관리자", UserRole.ADMIN); + ChatRoom systemAdminRoom = createRoom(1, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember userMember = createRoomMember(systemAdminRoom, user, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember systemAdminMember = createRoomMember(systemAdminRoom, systemAdmin, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + ReflectionTestUtils.setField(userMember, "visibleMessageFrom", LocalDateTime.of(2026, 4, 11, 10, 5)); + ReflectionTestUtils.setField(systemAdminMember, "visibleMessageFrom", + LocalDateTime.of(2026, 4, 11, 10, 7)); + userMember.updateLastReadAt(LocalDateTime.of(2026, 4, 11, 10, 7)); + systemAdminMember.updateLastReadAt(LocalDateTime.of(2026, 4, 11, 10, 3)); + ChatMessage adminMessage = createMessage(100, systemAdminRoom, admin, "관리자 답변", + LocalDateTime.of(2026, 4, 11, 10, 6)); + ChatMessage userMessage = createMessage(101, systemAdminRoom, user, "사용자 문의", + LocalDateTime.of(2026, 4, 11, 10, 8)); + + given(chatRoomRepository.findById(systemAdminRoom.getId())).willReturn(Optional.of(systemAdminRoom)); + given(userRepository.getById(userId)).willReturn(user); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(systemAdminRoom.getId(), userId)) + .willReturn(Optional.of(userMember)); + given(chatRoomMemberRepository.findByChatRoomId(systemAdminRoom.getId())) + .willReturn(List.of(systemAdminMember, userMember)); + given(chatMessageRepository.findByChatRoomId(eq(systemAdminRoom.getId()), + eq(userMember.getVisibleMessageFrom()), + eq(PageRequest.of(0, 20)))) + .willReturn(new PageImpl<>(List.of(adminMessage, userMessage), PageRequest.of(0, 20), 2)); + + // when + ChatMessagePageResponse response = chatService.getMessages(userId, systemAdminRoom.getId(), 1, 20); + + // then + assertThat(response.messages()) + .extracting( + ChatMessageDetailResponse::senderId, + ChatMessageDetailResponse::content, + ChatMessageDetailResponse::unreadCount, + ChatMessageDetailResponse::isMine + ) + .containsExactly( + tuple(SYSTEM_ADMIN_ID, "관리자 답변", 0, false), + tuple(userId, "사용자 문의", 2, true) + ); + verify(chatRoomMembershipService).updateDirectRoomLastReadAt(eq(systemAdminRoom.getId()), eq(user), + any(LocalDateTime.class), eq(systemAdminRoom)); + verify(chatPresenceService).recordPresence(systemAdminRoom.getId(), userId); + } + // ===== sendMessage ===== @Test @@ -825,6 +995,9 @@ void sendMessageInDirectRoomSavesMessageAndSendsNotification() { given(chatRoomMemberRepository.findByChatRoomId(directRoom.getId())) .willReturn(List.of(senderMember, receiverMember)); given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); + given(chatRoomRepository.updateLastMessageIfLatest( + directRoom.getId(), savedMessage.getId(), savedMessage.getContent(), savedMessage.getCreatedAt() + )).willReturn(1); given(chatRoomMemberRepository.updateLastReadAtIfOlder(eq(directRoom.getId()), eq(senderId), any(LocalDateTime.class))) .willReturn(1); @@ -843,6 +1016,44 @@ void sendMessageInDirectRoomSavesMessageAndSendsNotification() { eq("hello")); } + @Test + @DisplayName("sendMessage는 이미 더 최신 메시지가 있으면 room 마지막 메시지 메타데이터를 덮어쓰지 않는다") + void sendMessageDoesNotOverwriteRoomMetadataWhenNewerMessageAlreadyExists() { + Integer senderId = 10; + Integer receiverId = 20; + User sender = createUser(senderId, "보낸이", UserRole.USER); + User receiver = createUser(receiverId, "받는이", UserRole.USER); + ChatRoom directRoom = createRoom(1, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember senderMember = createRoomMember(directRoom, sender, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember receiverMember = createRoomMember(directRoom, receiver, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatMessage savedMessage = createMessage(100, directRoom, sender, "older", + LocalDateTime.of(2026, 4, 11, 10, 1)); + + ReflectionTestUtils.setField(directRoom, "lastMessageContent", "newer"); + ReflectionTestUtils.setField(directRoom, "lastMessageSentAt", LocalDateTime.of(2026, 4, 11, 10, 2)); + + given(chatRoomRepository.findById(directRoom.getId())).willReturn(Optional.of(directRoom)); + given(userRepository.getById(senderId)).willReturn(sender); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(directRoom.getId(), senderId)) + .willReturn(Optional.of(senderMember)); + given(chatRoomMemberRepository.findByChatRoomId(directRoom.getId())) + .willReturn(List.of(senderMember, receiverMember)); + given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); + given(chatRoomRepository.updateLastMessageIfLatest( + directRoom.getId(), savedMessage.getId(), savedMessage.getContent(), savedMessage.getCreatedAt() + )).willReturn(0); + given(chatRoomMemberRepository.updateLastReadAtIfOlder(eq(directRoom.getId()), eq(senderId), + any(LocalDateTime.class))) + .willReturn(1); + + chatService.sendMessage(senderId, directRoom.getId(), new ChatMessageSendRequest("older")); + + assertThat(directRoom.getLastMessageContent()).isEqualTo("newer"); + assertThat(directRoom.getLastMessageSentAt()).isEqualTo(LocalDateTime.of(2026, 4, 11, 10, 2)); + } + @Test @DisplayName("sendMessage는 group room에서 메시지를 저장하고 그룹 알림을 보낸다") void sendMessageInGroupRoomSavesMessageAndSendsGroupNotification() { @@ -860,6 +1071,9 @@ void sendMessageInGroupRoomSavesMessageAndSendsGroupNotification() { given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), senderId)) .willReturn(Optional.of(senderMember)); given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); + given(chatRoomRepository.updateLastMessageIfLatest( + groupRoom.getId(), savedMessage.getId(), savedMessage.getContent(), savedMessage.getCreatedAt() + )).willReturn(1); given(chatRoomMemberRepository.updateLastReadAtIfOlder(eq(groupRoom.getId()), eq(senderId), any(LocalDateTime.class))) .willReturn(1); @@ -926,6 +1140,9 @@ void sendMessageInClubRoomSavesMessageAndSendsGroupNotification() { given(chatRoomMemberRepository.findByChatRoomIdAndUserId(clubRoom.getId(), senderId)) .willReturn(Optional.of(senderRoomMember)); given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); + given(chatRoomRepository.updateLastMessageIfLatest( + clubRoom.getId(), savedMessage.getId(), savedMessage.getContent(), savedMessage.getCreatedAt() + )).willReturn(1); given(chatRoomMemberRepository.updateLastReadAtIfOlder(eq(clubRoom.getId()), eq(senderId), any(LocalDateTime.class))) .willReturn(1); @@ -945,6 +1162,51 @@ void sendMessageInClubRoomSavesMessageAndSendsGroupNotification() { ); } + @Test + @DisplayName("sendMessage는 일반 사용자가 SYSTEM_ADMIN 방에 보내면 관리자 문의 이벤트를 발행한다") + void sendMessageByUserInSystemAdminRoomPublishesAdminChatEvent() { + // given + Integer senderId = 20; + String content = "문의합니다"; + User sender = createUser(senderId, "사용자", UserRole.USER); + User systemAdmin = createUser(SYSTEM_ADMIN_ID, "시스템관리자", UserRole.ADMIN); + ChatRoom systemAdminRoom = createRoom(1, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember senderMember = createRoomMember(systemAdminRoom, sender, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember systemAdminMember = createRoomMember(systemAdminRoom, systemAdmin, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatMessage savedMessage = createMessage(100, systemAdminRoom, sender, content, + LocalDateTime.of(2026, 4, 11, 10, 1)); + + given(chatRoomRepository.findById(systemAdminRoom.getId())).willReturn(Optional.of(systemAdminRoom)); + given(userRepository.getById(senderId)).willReturn(sender); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(systemAdminRoom.getId(), senderId)) + .willReturn(Optional.of(senderMember)); + given(chatRoomMemberRepository.findByChatRoomId(systemAdminRoom.getId())) + .willReturn(List.of(systemAdminMember, senderMember)); + given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); + given(chatRoomRepository.updateLastMessageIfLatest( + systemAdminRoom.getId(), savedMessage.getId(), savedMessage.getContent(), savedMessage.getCreatedAt() + )).willReturn(1); + given(chatRoomMemberRepository.updateLastReadAtIfOlder(eq(systemAdminRoom.getId()), eq(senderId), + any(LocalDateTime.class))) + .willReturn(1); + + // when + chatService.sendMessage(senderId, systemAdminRoom.getId(), new ChatMessageSendRequest(content)); + + // then + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(AdminChatReceivedEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + assertThat(eventCaptor.getValue()) + .extracting( + AdminChatReceivedEvent::senderId, + AdminChatReceivedEvent::senderName, + AdminChatReceivedEvent::content + ) + .containsExactly(senderId, sender.getName(), content); + } + @Test @DisplayName("sendMessage는 admin이 system admin room에 보내면 멤버십 체크를 건너뛰고 lastReadAt 업데이트도 하지 않는다") void sendMessageAdminBypassesMembershipInSystemAdminRoom() { @@ -964,14 +1226,13 @@ void sendMessageAdminBypassesMembershipInSystemAdminRoom() { given(chatRoomRepository.findById(systemAdminRoom.getId())).willReturn(Optional.of(systemAdminRoom)); given(userRepository.getById(adminId)).willReturn(admin); - given(chatRoomMemberRepository.findRoomMemberIdsByChatRoomIds(List.of(systemAdminRoom.getId()))) - .willReturn(List.of( - new Object[] {systemAdminRoom.getId(), SYSTEM_ADMIN_ID, systemAdminRoom.getCreatedAt()}, - new Object[] {systemAdminRoom.getId(), targetUserId, systemAdminRoom.getCreatedAt()} - )); + given(chatRoomSystemAdminService.isSystemAdminRoom(systemAdminRoom.getId())).willReturn(true); given(chatRoomMemberRepository.findByChatRoomId(systemAdminRoom.getId())) .willReturn(List.of(systemAdminMember, targetMember)); given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); + given(chatRoomRepository.updateLastMessageIfLatest( + systemAdminRoom.getId(), savedMessage.getId(), savedMessage.getContent(), savedMessage.getCreatedAt() + )).willReturn(1); // when ChatMessageDetailResponse response = chatService.sendMessage(adminId, systemAdminRoom.getId(), @@ -988,6 +1249,7 @@ void sendMessageAdminBypassesMembershipInSystemAdminRoom() { // 비관리자에게 알림이 전송되어야 한다 verify(notificationService).sendChatNotification(eq(targetUserId), eq(systemAdminRoom.getId()), eq("관리자"), eq("문의")); + verify(eventPublisher, never()).publishEvent(any(AdminChatReceivedEvent.class)); } // ===== toggleMute additional ===== diff --git a/src/test/java/gg/agit/konect/unit/domain/club/model/ClubTest.java b/src/test/java/gg/agit/konect/unit/domain/club/model/ClubTest.java new file mode 100644 index 000000000..fbedfbf62 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/club/model/ClubTest.java @@ -0,0 +1,47 @@ +package gg.agit.konect.unit.domain.club.model; + +import static gg.agit.konect.global.code.ApiResponseCode.INVALID_REQUEST_BODY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.ClubFixture; +import gg.agit.konect.support.fixture.UniversityFixture; + +class ClubTest extends ServiceTestSupport { + + @Test + @DisplayName("replaceFeeInfo는 네 필드가 모두 비면 기존 회비 정보를 전부 제거한다") + void replaceFeeInfoClearsAllFeeFieldsWhenEveryFieldIsBlank() { + // given + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), 1, "BCSD"); + club.replaceFeeInfo("30000", "국민은행", "123-456-7890", "BCSD"); + + // when + club.replaceFeeInfo(null, null, null, null); + + // then + assertThat(club.getFeeAmount()).isNull(); + assertThat(club.getFeeBank()).isNull(); + assertThat(club.getFeeAccountNumber()).isNull(); + assertThat(club.getFeeAccountHolder()).isNull(); + } + + @Test + @DisplayName("replaceFeeInfo는 일부만 비어 있는 partial 입력을 거부한다") + void replaceFeeInfoRejectsPartialInput() { + // given + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), 1, "BCSD"); + + // when & then + assertThatThrownBy(() -> club.replaceFeeInfo("30000", "국민은행", null, null)) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()) + .isEqualTo(INVALID_REQUEST_BODY)); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberManagementServiceBatchTest.java b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberManagementServiceBatchTest.java index 44d2666b2..a5d7e8114 100644 --- a/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberManagementServiceBatchTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberManagementServiceBatchTest.java @@ -27,6 +27,7 @@ import gg.agit.konect.domain.club.service.ClubMemberManagementService; import gg.agit.konect.domain.club.service.ClubPermissionValidator; import gg.agit.konect.domain.university.enums.Campus; +import gg.agit.konect.domain.university.enums.UniversityRegion; import gg.agit.konect.domain.university.model.University; import gg.agit.konect.domain.user.repository.UserRepository; import gg.agit.konect.global.code.ApiResponseCode; @@ -70,6 +71,7 @@ void setUp() { .id(1) .koreanName("Test University") .campus(Campus.MAIN) + .region(UniversityRegion.CHUNGCHEONG) .build(); club = Club.builder() diff --git a/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberManagementServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberManagementServiceTest.java index b044909c1..0eaf0d6cb 100644 --- a/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberManagementServiceTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberManagementServiceTest.java @@ -476,10 +476,59 @@ void removeMemberWorksNormally() { clubMemberManagementService.removeMember(clubId, targetUserId, requesterId); // then + verify(clubPermissionValidator).validateLeaderAccess(clubId, requesterId); verify(clubMemberRepository).delete(target); verify(chatRoomMembershipService).removeClubMember(clubId, targetUserId); } + @Test + @DisplayName("removeMember는 어드민이 동아리 소속이 아니어도 일반 회원 제거를 수행할 수 있다") + void removeMemberAllowsAdminWithoutClubMembership() { + // given + Integer clubId = 1; + Integer requesterId = 100; + Integer targetUserId = 200; + Club club = createClub(); + User adminUser = UserFixture.createUserWithId(requesterId, "관리자", UserRole.ADMIN); + User targetUser = UserFixture.createUserWithId(targetUserId, "대상", UserRole.USER); + ClubMember target = ClubMemberFixture.createMember(club, targetUser); + + when(clubRepository.getById(clubId)).thenReturn(club); + when(userRepository.getById(requesterId)).thenReturn(adminUser); + when(clubMemberRepository.getByClubIdAndUserId(clubId, targetUserId)).thenReturn(target); + + // when + clubMemberManagementService.removeMember(clubId, targetUserId, requesterId); + + // then + verify(clubPermissionValidator).validateLeaderAccess(clubId, requesterId); + verify(clubMemberRepository).delete(target); + verify(chatRoomMembershipService).removeClubMember(clubId, targetUserId); + } + + @Test + @DisplayName("removeMember는 어드민이라도 운영진 이상 직책은 직접 제거할 수 없다") + void removeMemberRejectsAdminRemovingNonMemberPosition() { + // given + Integer clubId = 1; + Integer requesterId = 100; + Integer targetUserId = 200; + Club club = createClub(); + User adminUser = UserFixture.createUserWithId(requesterId, "관리자", UserRole.ADMIN); + User targetUser = UserFixture.createUserWithId(targetUserId, "운영진", UserRole.USER); + ClubMember target = ClubMemberFixture.createManager(club, targetUser); + + when(clubRepository.getById(clubId)).thenReturn(club); + when(userRepository.getById(requesterId)).thenReturn(adminUser); + when(clubMemberRepository.getByClubIdAndUserId(clubId, targetUserId)).thenReturn(target); + + // when & then + assertErrorCode( + () -> clubMemberManagementService.removeMember(clubId, targetUserId, requesterId), + CANNOT_REMOVE_NON_MEMBER + ); + } + @Test @DisplayName("changeVicePresident는 같은 부회장 재지정 시 아무 변화 없음") void changeVicePresidentHandlesSameVicePresident() { diff --git a/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberSheetServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberSheetServiceTest.java index 663e6eda9..2eb2c9fb1 100644 --- a/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberSheetServiceTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberSheetServiceTest.java @@ -3,28 +3,30 @@ import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CLUB_SHEET_ID; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; -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.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.club.service.ClubMemberSheetService; import gg.agit.konect.domain.club.service.ClubPermissionValidator; +import gg.agit.konect.domain.club.service.ClubSheetRegistrationService; import gg.agit.konect.domain.club.service.SheetHeaderMapper; import gg.agit.konect.domain.club.service.SheetSyncExecutor; +import gg.agit.konect.global.code.ApiResponseCode; import gg.agit.konect.global.exception.CustomException; import gg.agit.konect.support.ServiceTestSupport; import gg.agit.konect.support.fixture.ClubFixture; @@ -51,13 +53,13 @@ class ClubMemberSheetServiceTest extends ServiceTestSupport { private SheetHeaderMapper sheetHeaderMapper; @Mock - private ObjectMapper objectMapper; + private ClubSheetRegistrationService clubSheetRegistrationService; @InjectMocks private ClubMemberSheetService clubMemberSheetService; @Test - @DisplayName("시트 동기화 수에 사전 회원도 포함한다") + @DisplayName("시트 동기화 응답에 사전 회원 수를 포함한다") void syncMembersToSheetIncludesPreMembersInCount() { // given Integer clubId = 1; @@ -87,24 +89,22 @@ void syncMembersToSheetIncludesPreMembersInCount() { } @Test - @DisplayName("updateSheetId는 정상 동작한다") - void updateSheetIdWorksNormally() throws JsonProcessingException { + @DisplayName("시트 ID를 분석한 뒤 등록 서비스에 위임한다") + void updateSheetIdWorksNormally() { // given Integer clubId = 1; Integer requesterId = 2; String spreadsheetUrl = "https://docs.google.com/spreadsheets/d/test-sheet-id/edit"; - Club club = ClubFixture.create(UniversityFixture.create()); ClubSheetIdUpdateRequest request = new ClubSheetIdUpdateRequest(spreadsheetUrl); - gg.agit.konect.domain.club.model.SheetColumnMapping mapping = gg.agit.konect.domain.club.model.SheetColumnMapping.defaultMapping(); + SheetColumnMapping mapping = SheetColumnMapping.defaultMapping(); SheetHeaderMapper.SheetAnalysisResult analysisResult = new SheetHeaderMapper.SheetAnalysisResult( mapping, null, null ); - given(clubRepository.getById(clubId)).willReturn(club); + given(clubRepository.existsById(clubId)).willReturn(true); given(sheetHeaderMapper.analyzeAllSheets("test-sheet-id")).willReturn(analysisResult); - given(objectMapper.writeValueAsString(analysisResult.memberListMapping().toMap())).willReturn("{}"); // when clubMemberSheetService.updateSheetId(clubId, requesterId, request); @@ -112,11 +112,31 @@ void updateSheetIdWorksNormally() throws JsonProcessingException { // then verify(clubPermissionValidator).validateManagerAccess(clubId, requesterId); verify(sheetHeaderMapper).analyzeAllSheets("test-sheet-id"); - assertThat(club.getGoogleSheetId()).isEqualTo("test-sheet-id"); + verify(clubSheetRegistrationService).updateSheetRegistration(clubId, "test-sheet-id", analysisResult); + } + + @Test + @DisplayName("updateSheetId는 동아리가 없으면 외부 분석을 호출하지 않는다") + void updateSheetIdThrowsNotFoundClubBeforeSheetAnalysis() { + // given + Integer clubId = 1; + Integer requesterId = 2; + String spreadsheetUrl = "invalid-sheet-url"; + ClubSheetIdUpdateRequest request = new ClubSheetIdUpdateRequest(spreadsheetUrl); + + given(clubRepository.existsById(clubId)).willReturn(false); + + // when & then + assertThatThrownBy(() -> clubMemberSheetService.updateSheetId(clubId, requesterId, request)) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()).isEqualTo( + ApiResponseCode.NOT_FOUND_CLUB)); + + verifyNoInteractions(clubPermissionValidator, sheetHeaderMapper, clubSheetRegistrationService); } @Test - @DisplayName("syncMembersToSheet는 sheetId가 null인 경우 NOT_FOUND_CLUB_SHEET_ID 예외를 던진다") + @DisplayName("syncMembersToSheet는 sheetId가 null이면 예외를 던진다") void syncMembersToSheetThrowsNotFoundClubSheetIdWhenSheetIdIsNull() { // given Integer clubId = 1; @@ -138,7 +158,7 @@ void syncMembersToSheetThrowsNotFoundClubSheetIdWhenSheetIdIsNull() { } @Test - @DisplayName("syncMembersToSheet는 sheetId가 blank인 경우 NOT_FOUND_CLUB_SHEET_ID 예외를 던진다") + @DisplayName("syncMembersToSheet는 sheetId가 blank이면 예외를 던진다") void syncMembersToSheetThrowsNotFoundClubSheetIdWhenSheetIdIsBlank() { // given Integer clubId = 1; @@ -161,7 +181,7 @@ void syncMembersToSheetThrowsNotFoundClubSheetIdWhenSheetIdIsBlank() { } @Test - @DisplayName("syncMembersToSheet는 빈 동아리(멤버 0명)에 대해 정상 동작한다") + @DisplayName("syncMembersToSheet는 빈 동아리도 정상 처리한다") void syncMembersToSheetHandlesEmptyClub() { // given Integer clubId = 1; @@ -191,13 +211,12 @@ void syncMembersToSheetHandlesEmptyClub() { } @Test - @DisplayName("updateSheetId는 null memberListMapping 분석 결과 시 NullPointerException이 발생한다") - void updateSheetIdThrowsNpeWhenMemberListMappingIsNull() throws JsonProcessingException { + @DisplayName("updateSheetId는 null memberListMapping 분석 결과를 등록 서비스로 전달한다") + void updateSheetIdDelegatesNullMemberListMapping() { // given Integer clubId = 1; Integer requesterId = 2; String spreadsheetUrl = "https://docs.google.com/spreadsheets/d/test-sheet-id/edit"; - Club club = ClubFixture.create(UniversityFixture.create()); ClubSheetIdUpdateRequest request = new ClubSheetIdUpdateRequest(spreadsheetUrl); SheetHeaderMapper.SheetAnalysisResult analysisResult = new SheetHeaderMapper.SheetAnalysisResult( null, @@ -205,11 +224,17 @@ void updateSheetIdThrowsNpeWhenMemberListMappingIsNull() throws JsonProcessingEx null ); - given(clubRepository.getById(clubId)).willReturn(club); + given(clubRepository.existsById(clubId)).willReturn(true); given(sheetHeaderMapper.analyzeAllSheets("test-sheet-id")).willReturn(analysisResult); - // when & then - assertThatThrownBy(() -> clubMemberSheetService.updateSheetId(clubId, requesterId, request)) - .isInstanceOf(NullPointerException.class); + // when + clubMemberSheetService.updateSheetId(clubId, requesterId, request); + + // then + verify(clubSheetRegistrationService).updateSheetRegistration( + eq(clubId), + eq("test-sheet-id"), + eq(analysisResult) + ); } } diff --git a/src/test/java/gg/agit/konect/unit/domain/club/service/ClubPermissionValidatorTest.java b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubPermissionValidatorTest.java new file mode 100644 index 000000000..ec51949c6 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubPermissionValidatorTest.java @@ -0,0 +1,42 @@ +package gg.agit.konect.unit.domain.club.service; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.Mockito.verifyNoInteractions; + +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.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.service.ClubPermissionValidator; +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.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.UserFixture; + +class ClubPermissionValidatorTest extends ServiceTestSupport { + + @Mock + private ClubMemberRepository clubMemberRepository; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private ClubPermissionValidator clubPermissionValidator; + + @Test + @DisplayName("validateLeaderAccess는 동아리 소속이 없는 어드민도 허용한다") + void validateLeaderAccessAllowsAdminWithoutClubMembership() { + // given + Integer clubId = 1; + User admin = UserFixture.createUserWithId(100, "관리자", UserRole.ADMIN); + + // when & then + assertThatCode(() -> clubPermissionValidator.validateLeaderAccess(clubId, admin)) + .doesNotThrowAnyException(); + verifyNoInteractions(clubMemberRepository); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/club/service/ClubRecruitmentServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubRecruitmentServiceTest.java new file mode 100644 index 000000000..496c120b7 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubRecruitmentServiceTest.java @@ -0,0 +1,117 @@ +package gg.agit.konect.unit.domain.club.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +import java.time.LocalDateTime; + +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.dto.ClubRecruitmentResponse; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.ClubRecruitment; +import gg.agit.konect.domain.club.repository.ClubApplyRepository; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubRecruitmentRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.domain.club.service.ClubPermissionValidator; +import gg.agit.konect.domain.club.service.ClubRecruitmentService; +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.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.ClubFixture; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +class ClubRecruitmentServiceTest extends ServiceTestSupport { + + @Mock + private ClubRepository clubRepository; + + @Mock + private ClubRecruitmentRepository clubRecruitmentRepository; + + @Mock + private ClubMemberRepository clubMemberRepository; + + @Mock + private ClubApplyRepository clubApplyRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private ClubPermissionValidator clubPermissionValidator; + + @InjectMocks + private ClubRecruitmentService clubRecruitmentService; + + @Test + @DisplayName("getRecruitment는 현재 회원이면 isApplied=true를 반환한다") + void getRecruitmentMarksAppliedForMember() { + // given + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), 1, "BCSD"); + User user = UserFixture.createUserWithId( + UniversityFixture.createWithId(1), + 10, + "회원", + "20240001", + UserRole.USER + ); + ClubRecruitment recruitment = ClubRecruitment.of( + LocalDateTime.now().minusDays(1), + LocalDateTime.now().plusDays(1), + false, + "모집 공고", + club + ); + + given(clubRepository.getById(1)).willReturn(club); + given(userRepository.getById(10)).willReturn(user); + given(clubRecruitmentRepository.getByClubId(1)).willReturn(recruitment); + given(clubMemberRepository.existsByClubIdAndUserId(1, 10)).willReturn(true); + + // when + ClubRecruitmentResponse response = clubRecruitmentService.getRecruitment(1, 10); + + // then + assertThat(response.isApplied()).isTrue(); + } + + @Test + @DisplayName("getRecruitment는 회원이 아니어도 pending 지원이 있으면 isApplied=true를 반환한다") + void getRecruitmentMarksAppliedForPendingApplicant() { + // given + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), 1, "BCSD"); + User user = UserFixture.createUserWithId( + UniversityFixture.createWithId(1), + 10, + "지원자", + "20240001", + UserRole.USER + ); + ClubRecruitment recruitment = ClubRecruitment.of( + null, + null, + true, + "상시 모집", + club + ); + + given(clubRepository.getById(1)).willReturn(club); + given(userRepository.getById(10)).willReturn(user); + given(clubRecruitmentRepository.getByClubId(1)).willReturn(recruitment); + given(clubMemberRepository.existsByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyRepository.existsPendingByClubIdAndUserId(1, 10)).willReturn(true); + + // when + ClubRecruitmentResponse response = clubRecruitmentService.getRecruitment(1, 10); + + // then + assertThat(response.isApplied()).isTrue(); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/club/service/ClubSheetRegistrationServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubSheetRegistrationServiceTest.java new file mode 100644 index 000000000..046c35b85 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubSheetRegistrationServiceTest.java @@ -0,0 +1,138 @@ +package gg.agit.konect.unit.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.never; +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.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import gg.agit.konect.domain.club.model.SheetColumnMapping; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.domain.club.service.ClubSheetRegistrationService; +import gg.agit.konect.domain.club.service.SheetHeaderMapper; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.ServiceTestSupport; + +class ClubSheetRegistrationServiceTest extends ServiceTestSupport { + + @Mock + private ClubRepository clubRepository; + + @Mock + private ObjectMapper objectMapper; + + @InjectMocks + private ClubSheetRegistrationService clubSheetRegistrationService; + + @Test + @DisplayName("시트 등록 정보를 트랜잭션 서비스에서 저장한다") + void updateSheetRegistrationUpdatesClubSheetInfo() throws Exception { + // given + Integer clubId = 1; + String spreadsheetId = "spreadsheet-id"; + SheetColumnMapping mapping = SheetColumnMapping.defaultMapping(); + SheetHeaderMapper.SheetAnalysisResult analysisResult = new SheetHeaderMapper.SheetAnalysisResult( + mapping, + null, + null + ); + + given(objectMapper.writeValueAsString(mapping.toMap())).willReturn("{}"); + given(clubRepository.updateSheetRegistration(clubId, spreadsheetId, "{}")).willReturn(1); + + // when + clubSheetRegistrationService.updateSheetRegistration(clubId, spreadsheetId, analysisResult); + + // then + verify(clubRepository).updateSheetRegistration(clubId, spreadsheetId, "{}"); + } + + @Test + @DisplayName("회원 목록 매핑이 null이면 시트 정보를 저장하지 않는다") + void updateSheetRegistrationThrowsWhenMemberListMappingIsNull() { + // given + Integer clubId = 1; + String spreadsheetId = "spreadsheet-id"; + SheetHeaderMapper.SheetAnalysisResult analysisResult = new SheetHeaderMapper.SheetAnalysisResult( + null, + null, + null + ); + + // when & then + assertThatThrownBy(() -> clubSheetRegistrationService.updateSheetRegistration( + clubId, + spreadsheetId, + analysisResult + )) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()).isEqualTo( + ApiResponseCode.CLUB_SHEET_ANALYSIS_REQUIRED)); + + verify(clubRepository, never()).updateSheetRegistration(clubId, spreadsheetId, null); + } + + @Test + @DisplayName("매핑 직렬화에 실패하면 시트 정보를 저장하지 않는다") + void updateSheetRegistrationThrowsWhenMappingSerializationFails() throws Exception { + // given + Integer clubId = 1; + String spreadsheetId = "spreadsheet-id"; + SheetColumnMapping mapping = SheetColumnMapping.defaultMapping(); + SheetHeaderMapper.SheetAnalysisResult analysisResult = new SheetHeaderMapper.SheetAnalysisResult( + mapping, + null, + null + ); + + given(objectMapper.writeValueAsString(mapping.toMap())).willThrow(new JsonProcessingException("boom") {}); + + // when & then + assertThatThrownBy(() -> clubSheetRegistrationService.updateSheetRegistration( + clubId, + spreadsheetId, + analysisResult + )) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()).isEqualTo( + ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET)); + + verify(clubRepository, never()).updateSheetRegistration(clubId, spreadsheetId, null); + } + + @Test + @DisplayName("시트 등록 update 대상 동아리가 없으면 NOT_FOUND_CLUB 예외를 던진다") + void updateSheetRegistrationThrowsWhenClubIsMissing() throws Exception { + // given + Integer clubId = 1; + String spreadsheetId = "spreadsheet-id"; + SheetColumnMapping mapping = SheetColumnMapping.defaultMapping(); + SheetHeaderMapper.SheetAnalysisResult analysisResult = new SheetHeaderMapper.SheetAnalysisResult( + mapping, + null, + null + ); + + given(objectMapper.writeValueAsString(mapping.toMap())).willReturn("{}"); + given(clubRepository.updateSheetRegistration(clubId, spreadsheetId, "{}")).willReturn(0); + + // when & then + assertThatThrownBy(() -> clubSheetRegistrationService.updateSheetRegistration( + clubId, + spreadsheetId, + analysisResult + )) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()).isEqualTo( + ApiResponseCode.NOT_FOUND_CLUB)); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/inquiry/service/InquiryServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/inquiry/service/InquiryServiceTest.java new file mode 100644 index 000000000..9fc7a9d37 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/inquiry/service/InquiryServiceTest.java @@ -0,0 +1,39 @@ +package gg.agit.konect.unit.domain.inquiry.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.context.ApplicationEventPublisher; + +import gg.agit.konect.domain.inquiry.event.InquirySubmittedEvent; +import gg.agit.konect.domain.inquiry.service.InquiryService; +import gg.agit.konect.support.ServiceTestSupport; + +class InquiryServiceTest extends ServiceTestSupport { + + @Mock + private ApplicationEventPublisher applicationEventPublisher; + + @InjectMocks + private InquiryService inquiryService; + + @Test + @DisplayName("문의 내용을 원문 그대로 이벤트로 발행한다") + void submitInquiryPublishesEventWithOriginalContent() { + // given + String content = " 앱 사용 중 오류가 발생했습니다. "; + + // when + inquiryService.submitInquiry(content); + + // then + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(InquirySubmittedEvent.class); + verify(applicationEventPublisher).publishEvent(eventCaptor.capture()); + assertThat(eventCaptor.getValue().content()).isEqualTo(content); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/notification/service/NotificationInboxSseServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/notification/service/NotificationInboxSseServiceTest.java index f5aa81969..41b725c56 100644 --- a/src/test/java/gg/agit/konect/unit/domain/notification/service/NotificationInboxSseServiceTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/notification/service/NotificationInboxSseServiceTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatNoException; import java.lang.reflect.Field; import java.util.Map; @@ -109,20 +110,19 @@ void subscribeCompletesPreviousEmitterBeforeReplacement() throws Exception { } @Test - @DisplayName("send는 IOException 발생 시 emitter를 제거한다") - void sendRemovesEmitterOnIOException() { + @DisplayName("send는 이미 완료된 emitter가 남아 있어도 예외 없이 정리한다") + void sendRemovesCompletedEmitterOnIllegalStateException() throws Exception { // given - notificationInboxSseService.subscribe(1); + // subscribe의 completion callback에 의존하지 않고, + // 이미 종료된 emitter가 맵에 남아 있는 상태를 결정적으로 재현한다. + SseEmitter emitter = new SseEmitter(); + emitter.complete(); + emitters().put(1, emitter); NotificationInboxResponse response = createMockNotificationResponse(); - // when - // emitter가 존재하는 상태에서 전송 시도 - notificationInboxSseService.send(1, response); - - // then - // 메서드가 정상적으로 동작하는지 확인 - assertThatCode(() -> notificationInboxSseService.send(1, response)) - .doesNotThrowAnyException(); + // when & then + assertThatNoException().isThrownBy(() -> notificationInboxSseService.send(1, response)); + assertThat(emitters()).doesNotContainKey(1); } @SuppressWarnings("unchecked") diff --git a/src/test/java/gg/agit/konect/unit/domain/notification/service/NotificationServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/notification/service/NotificationServiceTest.java index da8a16510..7281419e1 100644 --- a/src/test/java/gg/agit/konect/unit/domain/notification/service/NotificationServiceTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/notification/service/NotificationServiceTest.java @@ -118,6 +118,24 @@ void registerTokenRejectsInvalidExpoToken() { verify(notificationDeviceTokenRepository, never()).findByUserId(any()); } + @Test + @DisplayName("registerToken은 ExponentPushToken 형식도 허용한다") + void registerTokenAcceptsExponentPushTokenFormat() { + // given + User user = createUser(1, "2021136001"); + String exponentToken = "ExponentPushToken[valid-token]"; + given(userRepository.getById(1)).willReturn(user); + given(notificationDeviceTokenRepository.findByUserId(1)).willReturn(Optional.empty()); + + // when + notificationService.registerToken(1, new NotificationTokenRegisterRequest(exponentToken)); + + // then + verify(notificationDeviceTokenRepository).save(argThat(token -> + token.getUser().equals(user) && token.getToken().equals(exponentToken) + )); + } + @Test @DisplayName("deleteToken은 일치하는 토큰이 있을 때만 삭제한다") void deleteTokenDeletesOnlyMatchingToken() { @@ -295,6 +313,39 @@ void sendClubApplicationApprovedNotificationSendsInboxSseAndPush() { ); } + @Test + @DisplayName("지원 제출 알림은 푸시 토큰이 없어도 인앱 알림과 SSE를 유지한다") + void sendClubApplicationSubmittedNotificationKeepsInboxAndSseWhenPushTokenMissing() { + assertClubApplicationNotificationKeepsInboxAndSseWithoutPush( + NotificationInboxType.CLUB_APPLICATION_SUBMITTED, + "홍길동님이 동아리 가입을 신청했어요.", + "mypage/manager/7/applications/1", + () -> notificationService.sendClubApplicationSubmittedNotification(3, 1, 7, "KONECT", "홍길동") + ); + } + + @Test + @DisplayName("지원 승인 알림은 푸시 토큰이 없어도 인앱 알림과 SSE를 유지한다") + void sendClubApplicationApprovedNotificationKeepsInboxAndSseWhenPushTokenMissing() { + assertClubApplicationNotificationKeepsInboxAndSseWithoutPush( + NotificationInboxType.CLUB_APPLICATION_APPROVED, + "동아리 지원이 승인되었어요.", + "clubs/7", + () -> notificationService.sendClubApplicationApprovedNotification(3, 7, "KONECT") + ); + } + + @Test + @DisplayName("지원 거절 알림은 푸시 토큰이 없어도 인앱 알림과 SSE를 유지한다") + void sendClubApplicationRejectedNotificationKeepsInboxAndSseWhenPushTokenMissing() { + assertClubApplicationNotificationKeepsInboxAndSseWithoutPush( + NotificationInboxType.CLUB_APPLICATION_REJECTED, + "동아리 지원이 거절되었어요.", + "clubs/7", + () -> notificationService.sendClubApplicationRejectedNotification(3, 7, "KONECT") + ); + } + @Test @DisplayName("registerToken은 null 토큰 값에 대해 NullPointerException을 발생시킨다") void registerTokenThrowsExceptionForNullToken() { @@ -777,4 +828,29 @@ private User createUser(Integer id, String studentNumber) { .imageUrl("https://example.com/profile-" + id + ".png") .build(); } + + private void assertClubApplicationNotificationKeepsInboxAndSseWithoutPush( + NotificationInboxType type, + String body, + String path, + Runnable notificationSender + ) { + User user = createUser(3, "2021136003"); + NotificationInbox inbox = NotificationInbox.of(user, type, "KONECT", body, path); + given(notificationInboxService.save(3, type, "KONECT", body, path)).willReturn(inbox); + given(notificationDeviceTokenRepository.findTokensByUserId(3)).willReturn(List.of()); + + notificationSender.run(); + + verify(notificationInboxService).save(3, type, "KONECT", body, path); + ArgumentCaptor responseCaptor = + ArgumentCaptor.forClass(NotificationInboxResponse.class); + verify(notificationInboxService).sendSse(eq(3), responseCaptor.capture()); + NotificationInboxResponse response = responseCaptor.getValue(); + assertThat(response.type()).isEqualTo(type); + assertThat(response.title()).isEqualTo("KONECT"); + assertThat(response.body()).isEqualTo(body); + assertThat(response.path()).isEqualTo(path); + verify(expoPushClient, never()).sendNotification(any(), any(), any(), any(), any()); + } } diff --git a/src/test/java/gg/agit/konect/unit/domain/studytime/dto/StudyTimeRankingResponseTest.java b/src/test/java/gg/agit/konect/unit/domain/studytime/dto/StudyTimeRankingResponseTest.java new file mode 100644 index 000000000..6753caf32 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/studytime/dto/StudyTimeRankingResponseTest.java @@ -0,0 +1,90 @@ +package gg.agit.konect.unit.domain.studytime.dto; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import gg.agit.konect.domain.studytime.dto.StudyTimeRankingResponse; +import gg.agit.konect.domain.studytime.model.RankingType; +import gg.agit.konect.domain.studytime.model.StudyTimeRanking; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.support.fixture.RankingTypeFixture; +import gg.agit.konect.support.fixture.UniversityFixture; + +class StudyTimeRankingResponseTest { + + @Test + @DisplayName("한 글자 개인 이름은 마스킹하지 않는다") + void fromKeepsSingleLetterPersonalRankingName() { + // given + StudyTimeRanking ranking = createRanking("김"); + + // when + StudyTimeRankingResponse response = StudyTimeRankingResponse.from(ranking, 1, "PERSONAL"); + + // then + assertThat(response.name()).isEqualTo("김"); + assertThat(response.rank()).isEqualTo(1); + } + + @Test + @DisplayName("개인 랭킹 이름은 첫 글자와 마지막 글자만 남기고 마스킹한다") + void fromMasksPersonalRankingName() { + // given + StudyTimeRanking ranking = createRanking("김민수"); + + // when + StudyTimeRankingResponse response = StudyTimeRankingResponse.from(ranking, 1, "PERSONAL"); + + // then + assertThat(response.name()).isEqualTo("김*수"); + } + + @Test + @DisplayName("두 글자 개인 이름은 첫 글자만 남기고 마스킹한다") + void fromMasksTwoLetterPersonalRankingName() { + // given + StudyTimeRanking ranking = createRanking("길동"); + + // when + StudyTimeRankingResponse response = StudyTimeRankingResponse.from(ranking, 1, "PERSONAL"); + + // then + assertThat(response.name()).isEqualTo("길*"); + } + + @Test + @DisplayName("학번 랭킹 이름은 입학연도 뒤 두 자리만 노출한다") + void fromDisplaysLastTwoDigitsForStudentNumberRanking() { + // given + StudyTimeRanking ranking = createRanking("2024"); + + // when + StudyTimeRankingResponse response = StudyTimeRankingResponse.from(ranking, 3, "STUDENT_NUMBER"); + + // then + assertThat(response.name()).isEqualTo("24"); + assertThat(response.rank()).isEqualTo(3); + } + + @Test + @DisplayName("동아리 랭킹 이름은 원문을 유지한다") + void fromKeepsClubRankingName() { + // given + StudyTimeRanking ranking = createRanking("BCSD Lab"); + + // when + StudyTimeRankingResponse response = StudyTimeRankingResponse.from(ranking, 2, "CLUB"); + + // then + assertThat(response.name()).isEqualTo("BCSD Lab"); + } + + private StudyTimeRanking createRanking(String targetName) { + RankingType rankingType = RankingTypeFixture.createWithId(1); + University university = UniversityFixture.createWithId(1); + + return StudyTimeRanking.of(rankingType, university, 10, targetName, 100L, 1000L); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/studytime/service/StudyTimeSchedulerServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/studytime/service/StudyTimeSchedulerServiceTest.java new file mode 100644 index 000000000..6e97c00c7 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/studytime/service/StudyTimeSchedulerServiceTest.java @@ -0,0 +1,58 @@ +package gg.agit.konect.unit.domain.studytime.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDate; + +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.studytime.repository.StudyTimeRankingRepository; +import gg.agit.konect.domain.studytime.service.StudyTimeSchedulerService; +import gg.agit.konect.support.ServiceTestSupport; + +class StudyTimeSchedulerServiceTest extends ServiceTestSupport { + + @Mock + private StudyTimeRankingRepository studyTimeRankingRepository; + + @InjectMocks + private StudyTimeSchedulerService studyTimeSchedulerService; + + @Test + @DisplayName("월초에는 일간과 월간 랭킹을 한 번에 초기화한다") + void resetStudyTimeRankingResetsDailyAndMonthlyOnFirstDayOfMonth() { + // given + LocalDate firstDayOfMonth = LocalDate.of(2026, 5, 1); + when(studyTimeRankingRepository.resetDailyAndMonthlySeconds()).thenReturn(10); + + // when + int updatedCount = studyTimeSchedulerService.resetStudyTimeRanking(firstDayOfMonth); + + // then + assertThat(updatedCount).isEqualTo(10); + verify(studyTimeRankingRepository).resetDailyAndMonthlySeconds(); + verify(studyTimeRankingRepository, never()).resetDailySeconds(); + } + + @Test + @DisplayName("월초가 아니면 일간 랭킹만 초기화한다") + void resetStudyTimeRankingResetsOnlyDailyOnNonFirstDayOfMonth() { + // given + LocalDate nonFirstDayOfMonth = LocalDate.of(2026, 5, 2); + when(studyTimeRankingRepository.resetDailySeconds()).thenReturn(7); + + // when + int updatedCount = studyTimeSchedulerService.resetStudyTimeRanking(nonFirstDayOfMonth); + + // then + assertThat(updatedCount).isEqualTo(7); + verify(studyTimeRankingRepository).resetDailySeconds(); + verify(studyTimeRankingRepository, never()).resetDailyAndMonthlySeconds(); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/studytime/service/StudyTimerServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/studytime/service/StudyTimerServiceTest.java new file mode 100644 index 000000000..10dfa11c4 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/studytime/service/StudyTimerServiceTest.java @@ -0,0 +1,116 @@ +package gg.agit.konect.unit.domain.studytime.service; + +import static gg.agit.konect.global.code.ApiResponseCode.ALREADY_RUNNING_STUDY_TIMER; +import static gg.agit.konect.global.code.ApiResponseCode.STUDY_TIMER_TIME_MISMATCH; +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.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import java.time.LocalDateTime; + +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 gg.agit.konect.domain.studytime.dto.StudyTimerStopRequest; +import gg.agit.konect.domain.studytime.dto.StudyTimerSyncRequest; +import gg.agit.konect.domain.studytime.model.StudyTimer; +import gg.agit.konect.domain.studytime.repository.StudyTimeDailyRepository; +import gg.agit.konect.domain.studytime.repository.StudyTimerRepository; +import gg.agit.konect.domain.studytime.service.StudyTimeQueryService; +import gg.agit.konect.domain.studytime.service.StudyTimerService; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.StudyTimerFixture; +import gg.agit.konect.support.fixture.UserFixture; +import jakarta.persistence.EntityManager; + +class StudyTimerServiceTest extends ServiceTestSupport { + + @Mock + private StudyTimeQueryService studyTimeQueryService; + + @Mock + private StudyTimerRepository studyTimerRepository; + + @Mock + private StudyTimeDailyRepository studyTimeDailyRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private EntityManager entityManager; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @InjectMocks + private StudyTimerService studyTimerService; + + @Test + @DisplayName("이미 실행 중인 타이머가 있으면 새 타이머를 시작하지 않는다") + void startRejectsAlreadyRunningTimer() { + // given + given(studyTimerRepository.existsByUserId(1)).willReturn(true); + + // when & then + assertThatThrownBy(() -> studyTimerService.start(1)) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()) + .isEqualTo(ALREADY_RUNNING_STUDY_TIMER)); + + verifyNoInteractions(userRepository, entityManager, eventPublisher); + verify(studyTimerRepository, never()).save(any()); + } + + @Test + @DisplayName("sync 시간 불일치가 3초 이상이면 타이머를 삭제하고 공부 시간을 누적하지 않는다") + void syncDeletesTimerWhenElapsedTimeMismatches() { + // given + StudyTimer studyTimer = createTimerStartedOneHourAgo(); + given(studyTimerRepository.getByUserId(1)).willReturn(studyTimer); + + // when & then + assertThatThrownBy(() -> studyTimerService.sync(1, new StudyTimerSyncRequest(0L))) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()) + .isEqualTo(STUDY_TIMER_TIME_MISMATCH)); + + verify(studyTimerRepository).delete(studyTimer); + verifyNoInteractions(studyTimeDailyRepository, studyTimeQueryService); + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + @DisplayName("stop 시간 불일치가 3초 이상이면 타이머를 삭제하고 요약을 만들지 않는다") + void stopDeletesTimerWhenElapsedTimeMismatches() { + // given + StudyTimer studyTimer = createTimerStartedOneHourAgo(); + given(studyTimerRepository.getByUserId(1)).willReturn(studyTimer); + + // when & then + assertThatThrownBy(() -> studyTimerService.stop(1, new StudyTimerStopRequest(0L))) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()) + .isEqualTo(STUDY_TIMER_TIME_MISMATCH)); + + verify(studyTimerRepository).delete(studyTimer); + verifyNoInteractions(studyTimeDailyRepository, studyTimeQueryService); + verify(eventPublisher, never()).publishEvent(any()); + } + + private StudyTimer createTimerStartedOneHourAgo() { + User user = UserFixture.createUserWithId(1, "2021136001"); + LocalDateTime startedAt = LocalDateTime.now().minusHours(1); + return StudyTimerFixture.createStartedTimer(user, startedAt); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/upload/service/UploadServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/upload/service/UploadServiceTest.java index 9c9801e54..d30c74a79 100644 --- a/src/test/java/gg/agit/konect/unit/domain/upload/service/UploadServiceTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/upload/service/UploadServiceTest.java @@ -104,6 +104,26 @@ void uploadImageBuildsKeyWithoutPrefixWhenPrefixBlank() { assertThat(response.key()).matches("bank/\\d{4}-\\d{2}-\\d{2}-[0-9a-f\\-]{36}\\.png"); } + @Test + @DisplayName("uploadImage는 UNIVERSITY target을 university 경로로 저장한다") + void uploadImageBuildsUniversityKeyPath() { + // given + MockMultipartFile file = new MockMultipartFile( + "file", + "university.png", + "image/png", + "png-data".getBytes(StandardCharsets.UTF_8) + ); + + // when + ImageUploadResponse response = uploadService.uploadImage(file, UploadTarget.UNIVERSITY); + + // then + assertThat(response.key()).matches( + "konect/university/\\d{4}-\\d{2}-\\d{2}-[0-9a-f\\-]{36}\\.png" + ); + } + @Test @DisplayName("uploadImage는 leading slash prefix를 제거하고 trailing slash를 보정한다") void uploadImageNormalizesPrefixWithLeadingSlash() { diff --git a/src/test/java/gg/agit/konect/unit/domain/user/service/UserOAuthAccountServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/user/service/UserOAuthAccountServiceTest.java index e65917f43..f1207c227 100644 --- a/src/test/java/gg/agit/konect/unit/domain/user/service/UserOAuthAccountServiceTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/user/service/UserOAuthAccountServiceTest.java @@ -246,14 +246,21 @@ void linkPrimaryOAuthAccountDeletesExpiredWithdrawnAccountBeforeSavingReplacemen void cleanupExpiredWithdrawnUserOAuthAccountsDeletesUsingThreshold() { // given LocalDateTime now = LocalDateTime.of(2026, 4, 10, 9, 30); - given(userOAuthAccountRepository.deleteAllByWithdrawnUsersBefore(now.minusDays(7))).willReturn(3); + given(userOAuthAccountRepository.deleteRevokedExpiredWithdrawnOAuthAccountsBefore( + now.minusDays(7), + Provider.APPLE + )) + .willReturn(3); // when int deletedCount = userOAuthAccountService.cleanupExpiredWithdrawnUserOAuthAccounts(now); // then assertThat(deletedCount).isEqualTo(3); - verify(userOAuthAccountRepository).deleteAllByWithdrawnUsersBefore(now.minusDays(7)); + verify(userOAuthAccountRepository).deleteRevokedExpiredWithdrawnOAuthAccountsBefore( + now.minusDays(7), + Provider.APPLE + ); verify(userOAuthAccountRepository).flush(); } diff --git a/src/test/java/gg/agit/konect/unit/domain/user/service/UserSchedulerServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/user/service/UserSchedulerServiceTest.java new file mode 100644 index 000000000..89d4b21a0 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/user/service/UserSchedulerServiceTest.java @@ -0,0 +1,93 @@ +package gg.agit.konect.unit.domain.user.service; + +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.time.LocalDateTime; +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.user.enums.Provider; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.model.UserOAuthAccount; +import gg.agit.konect.domain.user.service.UserOAuthAccountService; +import gg.agit.konect.domain.user.service.UserSchedulerService; +import gg.agit.konect.domain.user.service.UserSchedulerTxService; +import gg.agit.konect.infrastructure.oauth.AppleTokenRevocationService; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.UserFixture; + +class UserSchedulerServiceTest extends ServiceTestSupport { + + @Mock + private UserSchedulerTxService userSchedulerTxService; + + @Mock + private UserOAuthAccountService userOAuthAccountService; + + @Mock + private AppleTokenRevocationService appleTokenRevocationService; + + @InjectMocks + private UserSchedulerService userSchedulerService; + + @Test + @DisplayName("Apple 토큰 revoke 후 같은 실행에서 만료 OAuth 계정을 정리한다") + void cleanupExpiredWithdrawnOAuthAccountsRevokesAppleTokensBeforeCleanup() { + // given + LocalDateTime now = LocalDateTime.of(2026, 4, 24, 0, 10); + User user = UserFixture.createWithdrawnUser(1, "2021136001", now.minusDays(8)); + UserOAuthAccount account = UserOAuthAccount.of( + user, + Provider.APPLE, + "apple-provider-id", + "apple@konect.test", + "apple-refresh-token" + ); + given(userSchedulerTxService.findAccountsToRevoke(now.minusDays(7))).willReturn(List.of(account)); + given(userOAuthAccountService.cleanupExpiredWithdrawnUserOAuthAccounts(now)).willReturn(1); + + // when + userSchedulerService.cleanupExpiredWithdrawnOAuthAccountsAfterRestoreWindow(now); + + // then + InOrder inOrder = inOrder(appleTokenRevocationService, userSchedulerTxService, userOAuthAccountService); + inOrder.verify(appleTokenRevocationService).revoke("apple-refresh-token"); + inOrder.verify(userSchedulerTxService).clearAppleRefreshTokenIfMatches(account.getId(), "apple-refresh-token"); + inOrder.verify(userOAuthAccountService).cleanupExpiredWithdrawnUserOAuthAccounts(now); + } + + @Test + @DisplayName("Apple 토큰 revoke가 실패해도 정리는 진행하되 토큰 제거는 하지 않는다") + void cleanupExpiredWithdrawnOAuthAccountsKeepsFailedAppleTokenForRetry() { + // given + LocalDateTime now = LocalDateTime.of(2026, 4, 24, 0, 10); + User user = UserFixture.createWithdrawnUser(1, "2021136001", now.minusDays(8)); + UserOAuthAccount account = UserOAuthAccount.of( + user, + Provider.APPLE, + "apple-provider-id", + "apple@konect.test", + "apple-refresh-token" + ); + given(userSchedulerTxService.findAccountsToRevoke(now.minusDays(7))).willReturn(List.of(account)); + given(userOAuthAccountService.cleanupExpiredWithdrawnUserOAuthAccounts(now)).willReturn(0); + willThrow(new IllegalStateException("Apple revoke failed")) + .given(appleTokenRevocationService).revoke("apple-refresh-token"); + + // when + userSchedulerService.cleanupExpiredWithdrawnOAuthAccountsAfterRestoreWindow(now); + + // then + verify(userSchedulerTxService, never()).clearAppleRefreshTokenIfMatches(account.getId(), "apple-refresh-token"); + verify(userOAuthAccountService).cleanupExpiredWithdrawnUserOAuthAccounts(now); + } +} diff --git a/src/test/java/gg/agit/konect/unit/global/config/WebConfigTest.java b/src/test/java/gg/agit/konect/unit/global/config/WebConfigTest.java new file mode 100644 index 000000000..15f2dc281 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/global/config/WebConfigTest.java @@ -0,0 +1,53 @@ +package gg.agit.konect.unit.global.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Field; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.web.servlet.config.annotation.CorsRegistration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.cors.CorsConfiguration; + +import gg.agit.konect.global.auth.web.AuthorizationInterceptor; +import gg.agit.konect.global.auth.web.LoginCheckInterceptor; +import gg.agit.konect.global.auth.web.LoginUserArgumentResolver; +import gg.agit.konect.global.config.CorsProperties; +import gg.agit.konect.global.config.WebConfig; + +class WebConfigTest { + + @Test + @DisplayName("CORS 응답은 request id 헤더를 브라우저에 노출한다") + void exposesRequestIdHeader() throws Exception { + // given + WebConfig webConfig = new WebConfig( + new CorsProperties(List.of("http://localhost:3000")), + org.mockito.Mockito.mock(LoginCheckInterceptor.class), + org.mockito.Mockito.mock(AuthorizationInterceptor.class), + org.mockito.Mockito.mock(LoginUserArgumentResolver.class) + ); + CorsRegistry registry = new CorsRegistry(); + + // when + webConfig.addCorsMappings(registry); + + // then + CorsConfiguration corsConfiguration = firstCorsConfiguration(registry); + assertThat(corsConfiguration.getExposedHeaders()) + .contains("Authorization", "X-Request-ID"); + } + + @SuppressWarnings("unchecked") + private CorsConfiguration firstCorsConfiguration(CorsRegistry registry) throws Exception { + Field registrationsField = CorsRegistry.class.getDeclaredField("registrations"); + registrationsField.setAccessible(true); + List registrations = (List)registrationsField.get(registry); + + Field configField = CorsRegistration.class.getDeclaredField("config"); + configField.setAccessible(true); + return (CorsConfiguration)configField.get(registrations.getFirst()); + } +} diff --git a/src/test/java/gg/agit/konect/unit/global/exception/GlobalExceptionHandlerTest.java b/src/test/java/gg/agit/konect/unit/global/exception/GlobalExceptionHandlerTest.java new file mode 100644 index 000000000..febaad45e --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/global/exception/GlobalExceptionHandlerTest.java @@ -0,0 +1,152 @@ +package gg.agit.konect.unit.global.exception; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.util.ContentCachingRequestWrapper; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; + +import gg.agit.konect.global.exception.GlobalExceptionHandler; + +@ExtendWith(OutputCaptureExtension.class) +class GlobalExceptionHandlerTest { + + private final Logger exceptionHandlerLogger = (Logger)LoggerFactory.getLogger(GlobalExceptionHandler.class); + private Level originalLevel; + + @BeforeEach + void setUp() { + originalLevel = exceptionHandlerLogger.getLevel(); + exceptionHandlerLogger.setLevel(Level.DEBUG); + } + + @AfterEach + void tearDown() { + exceptionHandlerLogger.setLevel(originalLevel); + MDC.clear(); + } + + @Test + @DisplayName("예상하지 못한 예외도 디버그 로그에서 요청 본문을 확인할 수 있다") + void logsRequestBodyAtDebugLevelForUnexpectedException(CapturedOutput output) { + // given + MDC.put("requestId", "request-123"); + GlobalExceptionHandler handler = new GlobalExceptionHandler(); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/clubs"); + request.setContentType("application/json"); + request.addHeader("Authorization", "Bearer secret-token"); + request.addHeader("Cookie", "session=secret-cookie"); + request.addHeader("X-Request-ID", "request-1"); + request.setQueryString( + "access%5Ftoken=encoded-query-secret&access_token=query-secret&code=oauth-code&name=KONECT" + + "&token=repeat-secret-one&token=repeat-secret-two" + ); + request.setContent(""" + {"name":"KONECT"} + """.getBytes()); + ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(request); + + // when + var response = handler.handleException(wrappedRequest, new RuntimeException("boom")); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(output) + .contains("Request ID: `request-123`") + .contains("Request [requestId: request-123]: POST /clubs") + .contains("Authorization=***") + .contains("Cookie=***") + .contains("X-Request-ID=request-1") + .doesNotContain("secret-token") + .doesNotContain("secret-cookie") + .contains( + "Query String [requestId: request-123]: " + + "access%5Ftoken=***&access_token=***&code=***&name=KONECT&token=***&token=***" + ) + .doesNotContain("encoded-query-secret") + .doesNotContain("query-secret") + .doesNotContain("oauth-code") + .doesNotContain("repeat-secret-one") + .doesNotContain("repeat-secret-two") + .contains("Body [requestId: request-123]: {\"name\":\"KONECT\"}"); + } + + @Test + @DisplayName("DEBUG 로그가 꺼져 있으면 요청 상세 정보를 계산하지 않는다") + void skipsRequestDetailLoggingWhenDebugIsDisabled(CapturedOutput output) { + // given + exceptionHandlerLogger.setLevel(Level.INFO); + GlobalExceptionHandler handler = new GlobalExceptionHandler(); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/clubs"); + request.setContentType("application/json"); + request.setContent(""" + {"name":"KONECT"} + """.getBytes()); + ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(request); + + // when + var response = handler.handleException(wrappedRequest, new RuntimeException("boom")); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(output) + .doesNotContain("Request [requestId:") + .doesNotContain("Body [requestId:"); + } + + @Test + @DisplayName("스택트레이스가 비어 있는 예외도 2차 예외 없이 처리한다") + void handlesUnexpectedExceptionWithoutStackTrace(CapturedOutput output) { + // given + MDC.put("requestId", "request-empty-stack"); + GlobalExceptionHandler handler = new GlobalExceptionHandler(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/clubs"); + RuntimeException exception = new RuntimeException("boom"); + exception.setStackTrace(new StackTraceElement[0]); + + // when + var response = handler.handleException(request, exception); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(output) + .contains("Request ID: `request-empty-stack`") + .contains("Location: `unknown:0`"); + } + + @Test + @DisplayName("스택트레이스가 null인 예외도 2차 예외 없이 처리한다") + void handlesUnexpectedExceptionWithNullStackTrace(CapturedOutput output) { + // given + MDC.put("requestId", "request-null-stack"); + GlobalExceptionHandler handler = new GlobalExceptionHandler(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/clubs"); + RuntimeException exception = new RuntimeException("boom") { + @Override + public StackTraceElement[] getStackTrace() { + return null; + } + }; + + // when + var response = handler.handleException(request, exception); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(output) + .contains("Request ID: `request-null-stack`") + .contains("Location: `unknown:0`"); + } +} diff --git a/src/test/java/gg/agit/konect/unit/global/logging/LogbackTracingPatternTest.java b/src/test/java/gg/agit/konect/unit/global/logging/LogbackTracingPatternTest.java new file mode 100644 index 000000000..1785d94fa --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/global/logging/LogbackTracingPatternTest.java @@ -0,0 +1,91 @@ +package gg.agit.konect.unit.global.logging; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.StringReader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import javax.xml.parsers.DocumentBuilderFactory; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.xml.sax.InputSource; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.PatternLayout; +import ch.qos.logback.classic.spi.LoggingEvent; + +class LogbackTracingPatternTest { + + @Test + @DisplayName("운영 로그 패턴은 Datadog canonical MDC 키만 사용한다") + void usesDatadogCanonicalMdcKeys() throws Exception { + // given + String logbackConfig = Files.readString(Path.of("src/main/resources/logback-spring.xml")); + + // when & then + List patterns = tracePatterns(logbackConfig); + assertThat(patterns).hasSize(3); + assertThat(patterns).allSatisfy(pattern -> { + assertThat(pattern).contains("%X{dd.trace_id:-}"); + assertThat(pattern).contains("%X{dd.span_id:-}"); + assertThat(pattern).contains("%X{requestId:-}"); + assertThat(pattern).doesNotContain("%X{trace_id:-}"); + assertThat(pattern).doesNotContain("%X{traceId:-}"); + assertThat(pattern).doesNotContain("%X{span_id:-}"); + assertThat(pattern).doesNotContain("%X{spanId:-}"); + }); + } + + @Test + @DisplayName("로그 패턴은 trace, span, request id를 구분된 값으로 렌더링한다") + void rendersTraceSectionWithCanonicalKeys() { + // given + String pattern = "[trace=%X{dd.trace_id:-} span=%X{dd.span_id:-} request=%X{requestId:-}] %msg%n"; + LoggerContext loggerContext = (LoggerContext)LoggerFactory.getILoggerFactory(); + Logger logger = loggerContext.getLogger("test"); + LoggingEvent loggingEvent = new LoggingEvent( + LogbackTracingPatternTest.class.getName(), + logger, + Level.INFO, + "test message", + null, + null + ); + loggingEvent.setMDCPropertyMap(Map.of( + "dd.trace_id", "123", + "dd.span_id", "456", + "requestId", "req-789" + )); + + PatternLayout layout = new PatternLayout(); + layout.setContext(loggerContext); + layout.setPattern(pattern); + layout.start(); + + // when + String rendered = layout.doLayout(loggingEvent); + + // then + assertThat(rendered).isEqualTo("[trace=123 span=456 request=req-789] test message" + System.lineSeparator()); + } + + private List tracePatterns(String logbackConfig) throws Exception { + var documentBuilderFactory = DocumentBuilderFactory.newInstance(); + documentBuilderFactory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + var documentBuilder = documentBuilderFactory.newDocumentBuilder(); + var document = documentBuilder.parse(new InputSource(new StringReader(logbackConfig))); + var patternNodes = document.getElementsByTagName("pattern"); + + return java.util.stream.IntStream.range(0, patternNodes.getLength()) + .mapToObj(index -> patternNodes.item(index).getTextContent().trim()) + .filter(pattern -> pattern.contains("trace=")) + .toList(); + } +} diff --git a/src/test/java/gg/agit/konect/unit/global/logging/MdcTaskDecoratorTest.java b/src/test/java/gg/agit/konect/unit/global/logging/MdcTaskDecoratorTest.java new file mode 100644 index 000000000..648e7c5c6 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/global/logging/MdcTaskDecoratorTest.java @@ -0,0 +1,42 @@ +package gg.agit.konect.unit.global.logging; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; + +import gg.agit.konect.global.logging.MdcTaskDecorator; + +class MdcTaskDecoratorTest { + + @Test + @DisplayName("task decorator는 호출 스레드의 MDC를 작업 스레드로 전달한다") + void propagatesCallerMdcContext() { + // given + MdcTaskDecorator taskDecorator = new MdcTaskDecorator(); + AtomicReference requestIdInTask = new AtomicReference<>(); + AtomicReference traceIdInTask = new AtomicReference<>(); + MDC.put("requestId", "request-123"); + MDC.put("dd.trace_id", "trace-456"); + + try { + // when + Runnable decoratedTask = taskDecorator.decorate(() -> { + requestIdInTask.set(MDC.get("requestId")); + traceIdInTask.set(MDC.get("dd.trace_id")); + }); + MDC.clear(); + decoratedTask.run(); + + // then + assertThat(requestIdInTask.get()).isEqualTo("request-123"); + assertThat(traceIdInTask.get()).isEqualTo("trace-456"); + assertThat(MDC.getCopyOfContextMap()).isNull(); + } finally { + MDC.clear(); + } + } +} diff --git a/src/test/java/gg/agit/konect/unit/global/logging/RequestLoggingFilterTest.java b/src/test/java/gg/agit/konect/unit/global/logging/RequestLoggingFilterTest.java new file mode 100644 index 000000000..7c8b3fd7c --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/global/logging/RequestLoggingFilterTest.java @@ -0,0 +1,146 @@ +package gg.agit.konect.unit.global.logging; + +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 org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.support.StaticListableBeanFactory; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.util.AntPathMatcher; +import org.springframework.util.PathMatcher; +import org.springframework.web.util.ContentCachingRequestWrapper; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServlet; + +import gg.agit.konect.global.logging.LoggingProperties; +import gg.agit.konect.global.logging.RequestLoggingFilter; + +class RequestLoggingFilterTest { + + private static final String REQUEST_ID_HEADER = "X-Request-ID"; + + @Test + @DisplayName("유효한 request id 헤더는 응답 헤더에도 그대로 내려준다") + void echoesValidatedRequestIdHeader() throws ServletException, IOException { + // given + RequestLoggingFilter filter = createFilter(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/notifications/inbox"); + request.addHeader(REQUEST_ID_HEADER, "incoming-request-id"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when + filter.doFilter(request, response, new MockFilterChain(new NoOpServlet())); + + // then + assertThat(response.getHeader(REQUEST_ID_HEADER)).isEqualTo("incoming-request-id"); + } + + @Test + @DisplayName("앞뒤 공백이 있는 request id 헤더는 trim 후 응답 헤더에 내려준다") + void trimsIncomingRequestIdHeader() throws ServletException, IOException { + // given + RequestLoggingFilter filter = createFilter(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/notifications/inbox"); + request.addHeader(REQUEST_ID_HEADER, " incoming-request-id "); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when + filter.doFilter(request, response, new MockFilterChain(new NoOpServlet())); + + // then + assertThat(response.getHeader(REQUEST_ID_HEADER)).isEqualTo("incoming-request-id"); + } + + @Test + @DisplayName("request id 헤더가 없으면 생성한 값을 응답 헤더로 내려준다") + void generatesAndReturnsRequestIdHeader() throws ServletException, IOException { + // given + RequestLoggingFilter filter = createFilter(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/notifications/inbox"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when + filter.doFilter(request, response, new MockFilterChain(new NoOpServlet())); + + // then + assertThat(response.getHeader(REQUEST_ID_HEADER)).isNotBlank(); + } + + @Test + @DisplayName("유효하지 않은 request id 헤더면 새 값을 생성해 응답 헤더에 내려준다") + void generatesRequestIdForInvalidHeader() throws ServletException, IOException { + // given + RequestLoggingFilter filter = createFilter(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/notifications/inbox"); + request.addHeader(REQUEST_ID_HEADER, "invalid request id"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when + filter.doFilter(request, response, new MockFilterChain(new NoOpServlet())); + + // then + assertThat(response.getHeader(REQUEST_ID_HEADER)) + .isNotBlank() + .isNotEqualTo("invalid request id") + .matches("[A-Za-z0-9._-]{1,128}"); + } + + @Test + @DisplayName("요청 본문 캐시는 유지하되 원본 response를 그대로 전달한다") + void wrapsRequestOnlyForExceptionBodyLogging() throws ServletException, IOException { + // given + RequestLoggingFilter filter = createFilter(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/notifications/inbox"); + MockHttpServletResponse response = new MockHttpServletResponse(); + RequestWrapperResponseAssertFilterChain chain = new RequestWrapperResponseAssertFilterChain(response); + + // when + filter.doFilter(request, response, chain); + + // then + assertThat(chain.invoked).isTrue(); + } + + private RequestLoggingFilter createFilter() { + StaticListableBeanFactory beanFactory = new StaticListableBeanFactory(); + beanFactory.addBean("pathMatcher", new AntPathMatcher()); + ObjectProvider pathMatcherProvider = beanFactory.getBeanProvider(PathMatcher.class); + return new RequestLoggingFilter(pathMatcherProvider, new LoggingProperties(List.of())); + } + + private static class NoOpServlet extends HttpServlet { + + @Override + protected void service(jakarta.servlet.http.HttpServletRequest req, + jakarta.servlet.http.HttpServletResponse resp) { + resp.setStatus(MockHttpServletResponse.SC_OK); + } + } + + private static class RequestWrapperResponseAssertFilterChain implements FilterChain { + + private final ServletResponse expectedResponse; + private boolean invoked; + + private RequestWrapperResponseAssertFilterChain(ServletResponse expectedResponse) { + this.expectedResponse = expectedResponse; + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response) { + invoked = true; + assertThat(request).isInstanceOf(ContentCachingRequestWrapper.class); + assertThat(response).isSameAs(expectedResponse); + } + } +} diff --git a/src/test/java/gg/agit/konect/unit/infrastructure/claude/client/DatabaseSchemaCacheTest.java b/src/test/java/gg/agit/konect/unit/infrastructure/claude/client/DatabaseSchemaCacheTest.java new file mode 100644 index 000000000..a188223c8 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/infrastructure/claude/client/DatabaseSchemaCacheTest.java @@ -0,0 +1,177 @@ +package gg.agit.konect.unit.infrastructure.claude.client; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.dao.QueryTimeoutException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.test.util.ReflectionTestUtils; + +import gg.agit.konect.infrastructure.claude.client.DatabaseSchemaCache; +import gg.agit.konect.support.ServiceTestSupport; + +class DatabaseSchemaCacheTest extends ServiceTestSupport { + + private static final String FALLBACK_SCHEMA = + "DB 스키마 요약 조회에 실패했습니다. list_tables와 describe_table 도구로 다시 확인하세요."; + + @Mock + private JdbcTemplate jdbcTemplate; + + @InjectMocks + private DatabaseSchemaCache databaseSchemaCache; + + @Test + @DisplayName("정상 조회 시 DB 스키마 요약을 생성하고 서버 생명주기 동안 캐시한다") + void getSchemaSummaryFormatsAndCachesSchema() { + // given + givenTableRows(table("users", "서비스 사용자 계정")); + givenColumnRows( + column("users", "id", "int", "NO", "PRI", ""), + column("users", "email", "varchar(100)", "NO", "", "사용자 이메일") + ); + + // when + String firstSummary = databaseSchemaCache.getSchemaSummary(); + String secondSummary = databaseSchemaCache.getSchemaSummary(); + + // then + assertThat(firstSummary).isEqualTo(secondSummary); + assertThat(firstSummary) + .contains("- users: 서비스 사용자 계정") + .contains(" - id int NOT NULL PRI") + .contains(" - email varchar(100) NOT NULL: 사용자 이메일"); + verify(jdbcTemplate, times(1)).query(tableSchemaSql(), rowMapper()); + verify(jdbcTemplate, times(1)).query(columnSchemaSql(), rowMapper()); + } + + @Test + @DisplayName("스키마 조회 실패 시 짧은 재시도 대기 시간 동안 fallback을 반환한다") + void getSchemaSummaryReturnsFallbackWithoutRepeatedDbHitsWhenSchemaLoadFails() { + // given + given(jdbcTemplate.query(tableSchemaSql(), rowMapper())) + .willThrow(new QueryTimeoutException("timeout")); + + // when + String firstSummary = databaseSchemaCache.getSchemaSummary(); + String secondSummary = databaseSchemaCache.getSchemaSummary(); + + // then + assertThat(firstSummary).isEqualTo(FALLBACK_SCHEMA); + assertThat(secondSummary).isEqualTo(FALLBACK_SCHEMA); + verify(jdbcTemplate, times(1)).query(tableSchemaSql(), rowMapper()); + } + + @Test + @DisplayName("테이블 목록이 비어 있으면 성공 캐시로 저장하지 않는다") + void getSchemaSummaryDoesNotCacheEmptySchemaAsSuccess() { + // given + AtomicInteger tableQueryCount = new AtomicInteger(); + given(jdbcTemplate.query(tableSchemaSql(), rowMapper())) + .willAnswer(invocation -> { + if (tableQueryCount.getAndIncrement() == 0) { + return List.of(); + } + return mapRows(invocation.getArgument(1), table("users", "서비스 사용자 계정")); + }); + givenColumnRows(column("users", "id", "int", "NO", "PRI", "")); + + // when + String firstSummary = databaseSchemaCache.getSchemaSummary(); + ReflectionTestUtils.setField(databaseSchemaCache, "retrySchemaLoadAt", Instant.EPOCH); + String secondSummary = databaseSchemaCache.getSchemaSummary(); + + // then + assertThat(firstSummary).isEqualTo(FALLBACK_SCHEMA); + assertThat(secondSummary).contains("- users: 서비스 사용자 계정"); + verify(jdbcTemplate, times(2)).query(tableSchemaSql(), rowMapper()); + verify(jdbcTemplate, times(1)).query(columnSchemaSql(), rowMapper()); + } + + private void givenTableRows(ResultSet... rows) { + given(jdbcTemplate.query(tableSchemaSql(), rowMapper())) + .willAnswer(invocation -> mapRows(invocation.getArgument(1), rows)); + } + + private void givenColumnRows(ResultSet... rows) { + given(jdbcTemplate.query(columnSchemaSql(), rowMapper())) + .willAnswer(invocation -> mapRows(invocation.getArgument(1), rows)); + } + + private List mapRows(RowMapper rowMapper, ResultSet... rows) throws SQLException { + List mappedRows = new java.util.ArrayList<>(); + for (int i = 0; i < rows.length; i++) { + mappedRows.add(rowMapper.mapRow(rows[i], i)); + } + return mappedRows; + } + + private ResultSet table(String tableName, String tableComment) { + ResultSet resultSet = mock(ResultSet.class); + try { + given(resultSet.getString("table_name")).willReturn(tableName); + given(resultSet.getString("table_comment")).willReturn(tableComment); + } catch (SQLException e) { + throw new IllegalStateException(e); + } + return resultSet; + } + + private ResultSet column( + String tableName, + String columnName, + String columnType, + String isNullable, + String columnKey, + String columnComment + ) { + ResultSet resultSet = mock(ResultSet.class); + try { + given(resultSet.getString("table_name")).willReturn(tableName); + given(resultSet.getString("column_name")).willReturn(columnName); + given(resultSet.getString("column_type")).willReturn(columnType); + given(resultSet.getString("is_nullable")).willReturn(isNullable); + given(resultSet.getString("column_key")).willReturn(columnKey); + given(resultSet.getString("column_comment")).willReturn(columnComment); + } catch (SQLException e) { + throw new IllegalStateException(e); + } + return resultSet; + } + + private boolean isTableSchemaSql(String sql) { + return sql != null && sql.contains("information_schema.tables"); + } + + private boolean isColumnSchemaSql(String sql) { + return sql != null && sql.contains("information_schema.columns"); + } + + private String tableSchemaSql() { + return argThat(this::isTableSchemaSql); + } + + private String columnSchemaSql() { + return argThat(this::isColumnSchemaSql); + } + + @SuppressWarnings("unchecked") + private RowMapper rowMapper() { + return org.mockito.ArgumentMatchers.any(RowMapper.class); + } +} diff --git a/src/test/java/gg/agit/konect/unit/infrastructure/slack/listener/InquirySlackListenerTest.java b/src/test/java/gg/agit/konect/unit/infrastructure/slack/listener/InquirySlackListenerTest.java new file mode 100644 index 000000000..8e339684f --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/infrastructure/slack/listener/InquirySlackListenerTest.java @@ -0,0 +1,35 @@ +package gg.agit.konect.unit.infrastructure.slack.listener; + +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.inquiry.event.InquirySubmittedEvent; +import gg.agit.konect.infrastructure.slack.listener.InquirySlackListener; +import gg.agit.konect.infrastructure.slack.service.SlackNotificationService; +import gg.agit.konect.support.ServiceTestSupport; + +class InquirySlackListenerTest extends ServiceTestSupport { + + @Mock + private SlackNotificationService slackNotificationService; + + @InjectMocks + private InquirySlackListener inquirySlackListener; + + @Test + @DisplayName("문의 이벤트의 내용을 Slack 알림 서비스에 위임한다") + void handleInquirySubmittedDelegatesContentToSlackService() { + // given + InquirySubmittedEvent event = InquirySubmittedEvent.from("앱 사용 중 오류가 발생했습니다."); + + // when + inquirySlackListener.handleInquirySubmitted(event); + + // then + verify(slackNotificationService).notifyInquiry(event.content()); + } +}