diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000..33d20b9e --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,11 @@ +# CodeRabbit 설정 파일 +language: "ko-KR" # 리뷰 언어 한국어로 설정 +early_access: false +reviews: + profile: "chill" + request_changes_workflow: false # AI가 승인 거부를 못 하게 + high_level_summary: true # 전체적인 3줄 요약 제공 + auto_review: + enabled: true +chat: + auto_reply: true # 대댓글로 질문하면 답변 \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE/release.md b/.github/PULL_REQUEST_TEMPLATE/release.md index 9bd2d828..c0748814 100644 --- a/.github/PULL_REQUEST_TEMPLATE/release.md +++ b/.github/PULL_REQUEST_TEMPLATE/release.md @@ -4,16 +4,5 @@ 이번 PR은 **[브랜치A] → [브랜치B]** 병합을 위한 릴리즈 PR입니다. -### 📄 변경 요약 -- 스프린트 기능 묶음 반영 -- 주요 변경사항 하이라이트 (선택) - -### 🛠 스키마 / 환경 변수 변경 -- 없음 - -### 🧪 테스트 / QA -- smoke test 완료 -- staging QA 완료 (해당 시) - ### ⚠️ 참고 사항 \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 76cf8971..23253fd1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -9,65 +9,81 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - name: ✔️ GIT - Checkout Repository ✔️ + - name: GIT - Checkout Repository uses: actions/checkout@v4 - - name: 🔻 SETUP - Install JDK 21 🔻 + - name: SETUP - Install JDK 21 uses: actions/setup-java@v4 with: distribution: temurin java-version: 21 - - name: 🔐 CONFIG - Create application-prod.properties 🔐 - run: | - mkdir -p ./src/main/resources - echo "${{ secrets.ENV }}" > ./src/main/resources/application-prod.properties - - - name: ⏳ BUILD - Run Test & Build JAR ⏳ + - name: BUILD - Run Test & Build JAR run: | ./gradlew clean build - - name: ☁️ CONFIG - Configure AWS Credentials ☁️ + - name: CONFIG - Configure AWS Credentials uses: aws-actions/configure-aws-credentials@v4 with: aws-region: ap-northeast-2 aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - - name: ☁️ AWS - Login to Amazon ECR ☁️ - id: login-ecr - uses: aws-actions/amazon-ecr-login@v2 - - - name: 🐳 DOCKER - Build Docker Image 🐳 - run: docker build -t talkpick-server . - - - name: 🐳 DOCKER - Tag Docker Image 🐳 - run: docker tag talkpick-server ${{ steps.login-ecr.outputs.registry }}/talkpick-server:latest + - name: DOCKER - Build Docker Image + run: docker build -t talkpick-server:latest . + + - name: DOCKER_HUB - Login to Docker Hub + run: | + echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login \ + -u "${{ secrets.DOCKERHUB_USERNAME }}" \ + --password-stdin - - name: ☁️ AWS - Push Docker Image to ECR ☁️ - run: docker push ${{ steps.login-ecr.outputs.registry }}/talkpick-server:latest + - name: DOCKER_HUB - Push Docker image + run: | + docker tag talkpick-server:latest ${{ secrets.DOCKERHUB_USERNAME }}/talkpick-server:latest + docker push ${{ secrets.DOCKERHUB_USERNAME }}/talkpick-server:latest - - name: 🛠️ PERMISSIONS - Fix file ownership and permission before tar + - name: PERMISSIONS - Fix file ownership and permission before tar run: | sudo chown -R $(whoami):$(whoami) . chmod -R 755 scripts chmod 644 docker-compose.yml - - name: ☁️ AWS - Compress for CodeDeploy ☁️ - run: | - mkdir -p deploy-files - cp appspec.yml docker-compose.yml -t deploy-files - cp -r scripts deploy-files - cp src/main/resources/application.yml deploy-files - tar -czvf $GITHUB_SHA.tar.gz -C deploy-files . + - name: AWS - Fix Permissions on EC2 + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + script: | + target_dir="/home/${{ secrets.EC2_USER }}/TalkPick_Server" + sudo mkdir -p $target_dir + sudo chown -R $(whoami):$(whoami) $target_dir - - name: ☁️ AWS - Upload Archive to S3 ☁️ - run: aws s3 cp --region ap-northeast-2 ./$GITHUB_SHA.tar.gz s3://talkpick-server/$GITHUB_SHA.tar.gz + - name: SCP - Copy docker-compose.yml to EC2 + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + source: "docker-compose.yml" + target: "/home/${{ secrets.EC2_USER }}/TalkPick_Server" - - name: ☁️ AWS - Create Deployment to EC2 ☁️ - run: | - aws deploy create-deployment \ - --application-name talkpick-server \ - --deployment-config-name CodeDeployDefault.AllAtOnce \ - --deployment-group-name Production \ - --s3-location "bucket=talkpick-server,bundleType=tgz,key=$GITHUB_SHA.tar.gz" \ No newline at end of file + - name: AWS - Deploy to EC2 + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + script: | + mkdir -p /home/${{ secrets.EC2_USER }}/TalkPick_Server/env + cat << 'EOF' > /home/${{ secrets.EC2_USER }}/TalkPick_Server/env/.env + ${{ secrets.ENV }} + GRAFANA_CLOUD_USER=${{ secrets.GRAFANA_CLOUD_USER }} + GRAFANA_CLOUD_TOKEN=${{ secrets.GRAFANA_CLOUD_TOKEN }} + EOF + + cd /home/${{ secrets.EC2_USER }}/TalkPick_Server + docker compose pull talkpick-server + docker compose build nginx + docker compose up -d diff --git a/STRUCTURE.md b/STRUCTURE.md new file mode 100644 index 00000000..edb6bd72 --- /dev/null +++ b/STRUCTURE.md @@ -0,0 +1,128 @@ +# TalkPick 서버 구조 + +## 개요 +TalkPick 서버는 스몰토크 주제 추천 및 사용자 성향(MBTI 등) 기반 통계 서비스를 제공하는 서비스 +헥사고날 아키텍처(Ports & Adapters)를 기반으로 도메인 중심 설계를 따름 + +## 1. Global (공통 인프라 및 설정) + +### `talkPick.global.config` +* **`AsyncConfig`**: 비동기 작업 처리 설정 (`@EnableAsync`) +* **`CacheConfig`**: 로컬 캐싱 설정 (Caffeine 등) +* **`CorsFilter`**: CORS 정책 설정 (허용 출처, 헤더 등) +* **`JacksonConfig`**: JSON 직렬화/역직렬화 설정 +* **`JasyptConfig`**: 설정 파일 암호화 지원 +* **`JpaAuditingConfig`**: JPA 엔티티 자동 감시 (`BaseTime` 등) 활성화 +* **`QuerydslConfig`**: QueryDSL `JPAQueryFactory` 빈 등록 +* **`SchedulingConfig`**: 스케줄링 작업 활성화 (`@EnableScheduling`) +* **`SpringDocOpenApiConfig`**: Swagger/OpenAPI 문서 설정 +* **`WebConfig`**: 웹 MVC 설정 (ArgumentResolver 등 등록) + +### `talkPick.global.security` +* **Config** + * `SecurityConfig`: Spring Security 체인 설정. CSRF/FormLogin 비활성화, JWT 필터 추가, WhiteList 기반 경로 허용 +* **Filter** + * `JwtAuthenticationFilter`: JWT 토큰 파싱 및 유효성 검증, `SecurityContext`에 인증 정보 설정 + * `ExceptionHandlerFilter`: 필터 체인 내 예외 포착 및 핸들링 +* **JWT** + * `JwtProvider`: 토큰 생성, 검증, 정보 추출 (MemberId, Role) + * `JwtTokenCommandService`: 액세스/리프레시 토큰 발급 및 재발급 + * `RefreshTokenRepository`: 리프레시 토큰 저장소 +* **Resolver** + * `MemberIdArgumentResolver`: 컨트롤러 파라미터 `@MemberId`를 통해 인증된 사용자 ID 주입 + +### `talkPick.global.exception` +* **Handler**: `GlobalExceptionHandler` 및 도메인별 핸들러 (`MemberExceptionHandler`, `TopicExceptionHandler` 등) +* **Exception**: `TalkPickException` (루트 예외), `ErrorCode` (에러 코드 정의) + +### `talkPick.global.rateLimiter` +* **Adapter**: `RateLimiterManagerAdapter` (Caffeine + Bucket4j 기반 토큰 버킷 알고리즘 구현) +* **Aspect**: `RateLimiterAspect` (`@RateLimited` 어노테이션이 붙은 메서드 트래픽 제어) +* **Annotation**: `@RateLimited` + +### `talkPick.global.log` +* **Aspect**: `LoggerAspect` (현재 주석 처리됨, AOP 기반 로깅) + +### `talkPick.global.healthCheck` +* **API**: `DBHealthIndicator`, `UrlHealthIndicator` (시스템 상태 점검) + +--- + +## 2. Domain (핵심 비즈니스 로직) + +### **Member (회원)** +* **Entity**: `Member` (핵심 정보), `MemberLoginHistory` (로그인 기록), `MemberTerm` (약관 동의 내역) +* **Port (In/Out)** + * In: `MemberCommandUseCase`, `MemberQueryUseCase`, `MemberWithdrawalUseCase` + * Out: `MemberCommandRepositoryPort`, `MemberQueryRepositoryPort` 등 +* **Service (Application)** + * `MemberCommandService`: 회원가입, 프로필 수정(MBTI), 약관 동의, 로그아웃 + * `MemberQueryService`: 프로필 조회, 좋아요한 토픽 조회, 캘린더 결과 조회 + * `MemberWithdrawalService`: 회원 탈퇴(Soft Delete) 및 영구 삭제(Hard Delete) +* **Adapter (Out)** + * `MemberJpaRepository`: 기본 CRUD + * `MemberLikedTopicsQuerydslRepository`: 좋아요한 토픽 커서 페이징 조회 (복잡한 조인) + * `MemberTopicResultQuerydslRepository`: 일자별 토픽 결과 조회 + +### **Topic (토픽)** +* **Entity**: `Topic` (주제), `TopicStat` (통계), `Category`, `Keyword`, `TopicLikeHistory` +* **Port (In/Out)** + * In: `TopicCommandUseCase`, `TopicQueryUseCase` + * Out: `TopicQueryRepositoryPort`, `TopicLikeHistoryCommandRepositoryPort` 등 +* **Service (Application)** + * `TopicCommandService`: 좋아요 기능 (이벤트 발행) + * `TopicQueryService`: 카테고리 목록, 토픽 상세 조회 +* **Adapter (Out)** + * `TopicQuerydslRepository`: 토픽 검색 및 조회 + * `TopicStatJpaRepository`: 통계 데이터 관리 + +### **Random (랜덤 토픽)** +* **Entity**: `Random` (세션), `RandomTopicHistory` (진행 이력) +* **Port (In/Out)** + * In: `RandomCommandUseCase`, `RandomQueryUseCase` +* **Service (Application)** + * `RandomCommandService`: 세션 시작/종료, 다음 토픽 진행, 평점/한줄평 등록 + * `RandomQueryService`: 조건별 랜덤 토픽 추천 목록 조회 +* **Adapter (Out)** + * `RandomQuerydslRepository`, `RandomTopicHistoryQuerydslRepository`: 동적 쿼리 처리 + +### **Today (오늘의 토픽)** +* **Entity**: `TodayTopic` (사용자-토픽 매핑) +* **Service (Application)** + * `TodayTopicQueryService`: 사용자별 오늘의 토픽 조회 (캐싱 적용 `CacheManager`) +* **Adapter (Out)** + * `TodayTopicQuerydslRepository`: 오늘의 토픽 조회 최적화 + +### **Notice (공지사항)** +* **Entity**: `Notice`, `NoticeImage` +* **Service (Application)** + * `NoticeQueryService`: 공지사항 목록(커서 페이징) 및 상세 조회 +* **Adapter (Out)** + * `NoticeQuerydslRepository`: 페이징 쿼리 + +### **Inquiry (문의)** +* **Entity**: `Inquiry` +* **Service (Application)** + * `InquiryCommandService`: 문의 등록 + * `InquiryQueryService`: 내 문의 내역 조회 +* **Adapter (Out)** + * `InquiryQuerydslRepository`: 문의 내역 페이징 + +### **Term (약관)** +* **Entity**: `Term` +* **Adapter (Out)**: `TermJpaRepository` + +--- + +## 3. Batch (배치 및 스케줄러) +* **`MemberCleanupScheduler`**: 탈퇴 상태인 회원과 연관 데이터를 주기적으로 영구 삭제 (Hard Delete) +* **`TodayTopicCacheRefreshScheduler`**: 매일 자정 사용자별 새로운 '오늘의 토픽' 생성 및 캐시 갱신 +* **`MasterTokenGenerator`**: 개발/테스트용 마스터 토큰 생성 + +--- + +## 4. External (외부 연동) +* **Kakao**: `KakaoOidcService` (ID Token 검증, 공개키 조회, 사용자 정보 파싱) +* **Apple**: `AppleOidcService` (애플 로그인 지원) +* **Google**: `GoogleOidcService` (구글 로그인 지원) +* **Port**: `KakaoOidcUsecase` 등 인터페이스 정의로 결합도 낮춤 \ No newline at end of file diff --git a/appspec.yml b/appspec.yml deleted file mode 100644 index 2b2f3997..00000000 --- a/appspec.yml +++ /dev/null @@ -1,26 +0,0 @@ ---- -version: 0.0 -os: linux - -files: - - source: / - destination: /home/ec2-user/TalkPick_Server - overwrite: yes - -permissions: - - object: /home/ec2-user/TalkPick_Server - pattern: "**" - owner: ec2-user - group: ec2-user - mode: "755" - -hooks: - ApplicationStop: - - location: scripts/stop-server.sh - timeout: 60 - runas: ec2-user - - ApplicationStart: - - location: scripts/start-server.sh - timeout: 60 - runas: ec2-user \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 554016fe..9f4c3788 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,30 +1,16 @@ services: - ## talkpick + ## talkpick server talkpick-server: - image: 718513646976.dkr.ecr.ap-northeast-2.amazonaws.com/talkpick-server:latest + image: hszoo/talkpick-server:latest container_name: talkpick-server - ports: - - "8080:8080" networks: - t4y + ports: + - "8080:8080" restart: always - volumes: - - ./env/.env.properties:/app/config/.env.properties - environment: - - SPRING_CONFIG_IMPORT=optional:file:/app/config/.env.properties env_file: - - ./env/.env - - ## adminer - adminer: - image: adminer - container_name: talkpick-db-adminer - networks: - - t4y - restart: always - ports: - - "8081:8080" - + - /home/ec2-user/TalkPick_Server/env/.env + ## nginx nginx: build: ./nginx @@ -35,14 +21,24 @@ services: ports: - "443:443" - "80:80" - environment: - ORIGIN_CERTIFICATE: "${ORIGIN_CERTIFICATE}" - PRIVATE_CERTIFICATE_KEY: "${PRIVATE_CERTIFICATE_KEY}" depends_on: - talkpick-server - - adminer volumes: - ./nginx/conf:/etc/nginx/conf.d + - ./nginx/html/terms/privacy-policy:/usr/share/nginx/html/docs:ro + - /etc/ssl/cloudflare:/etc/ssl/cloudflare:ro + + ## promtail + promtail: + image: grafana/promtail:2.9.4 + container_name: promtail + networks: + - t4y + volumes: + - /var/lib/docker/containers:/var/lib/docker/containers:ro + - ./promtail.yaml:/etc/promtail/config.yml:ro + command: -config.file=/etc/promtail/config.yml + restart: unless-stopped networks: - t4y: \ No newline at end of file + t4y: diff --git a/nginx/Dockerfile b/nginx/Dockerfile deleted file mode 100644 index 1472fa21..00000000 --- a/nginx/Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -FROM nginx:stable-alpine - -# Nginx 설정 복사 -COPY conf /etc/nginx/conf.d -COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh - -# 컨테이너 시작 시 entrypoint.sh 실행 -ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file diff --git a/nginx/conf/default.conf b/nginx/conf/default.conf deleted file mode 100644 index d48c3a09..00000000 --- a/nginx/conf/default.conf +++ /dev/null @@ -1,23 +0,0 @@ -server { - listen 443 ssl; - server_name talkpick.co.kr *.talkpick.co.kr; - - ssl_certificate /etc/ssl/cloudflare/origin.crt; - ssl_certificate_key /etc/ssl/cloudflare/origin.key; - ssl_protocols TLSv1.2 TLSv1.3; - ssl_prefer_server_ciphers on; - - location / { - proxy_pass http://talkpick-server:8080; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } -} - -server { - listen 80; - server_name talkpick.co.kr *.talkpick.co.kr; - return 301 https://$host$request_uri; -} \ No newline at end of file diff --git a/nginx/entrypoint.sh b/nginx/entrypoint.sh deleted file mode 100644 index 3b80227f..00000000 --- a/nginx/entrypoint.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/sh -set -e - -# Secret 환경변수를 파일로 변환 -mkdir -p /etc/ssl/cloudflare -echo "$ORIGIN_CERTIFICATE" > /etc/ssl/cloudflare/origin.crt -echo "$PRIVATE_CERTIFICATE_KEY" > /etc/ssl/cloudflare/origin.key -chmod 600 /etc/ssl/cloudflare/origin.key - -# Nginx 실행 -nginx -g 'daemon off;' \ No newline at end of file diff --git a/prometheus.yml b/prometheus.yml deleted file mode 100644 index ecd28a37..00000000 --- a/prometheus.yml +++ /dev/null @@ -1,24 +0,0 @@ -global: - scrape_interval: 15s - scrape_timeout: 15s - evaluation_interval: 2m - - external_labels: - monitor: 'codelab-monitor' - query_log_file: query_log_file.log - -scrape_configs: - - job_name: 'monitoring-item' - scrape_interval: 10s - scrape_timeout: 10s - metrics_path: '/metrics' - scheme: 'http' - - static_configs: - - targets: [ - 'prometheus:9090', - 'node_exporter:9100', - 'localhost:8080' - ] - labels: - service: 'monitor' \ No newline at end of file diff --git a/promtail.yaml b/promtail.yaml new file mode 100644 index 00000000..b807954e --- /dev/null +++ b/promtail.yaml @@ -0,0 +1,25 @@ +server: + http_listen_port: 0 + grpc_listen_port: 0 + +positions: + filename: /tmp/positions.yaml + +clients: + - url: https://logs-prod-030.grafana.net/loki/api/v1/push + basic_auth: + username: ${GRAFANA_CLOUD_USER} + password: ${GRAFANA_CLOUD_TOKEN} + +scrape_configs: + - job_name: docker + static_configs: + - targets: + - localhost + labels: + job: docker + __path__: /var/lib/docker/containers/*/*.log + + pipeline_stages: + - docker: {} + - labels: \ No newline at end of file diff --git a/scripts/dummy_data.sql b/scripts/dummy_data.sql index df9c6417..16115d97 100644 --- a/scripts/dummy_data.sql +++ b/scripts/dummy_data.sql @@ -14,15 +14,15 @@ VALUES ( NOW() ); -INSERT INTO category (title, image_url, category_group) VALUES - ('소개팅/과팅', 'https://dummyimage.com/600x400/000/fff&text=소개팅', 'STRANGER'), - ('그룹 첫 모임', 'https://dummyimage.com/600x400/111/fff&text=그룹모임', 'STRANGER'), - ('룸메 첫 만남', 'https://dummyimage.com/600x400/222/fff&text=룸메', 'STRANGER'), - ('기타/아이스브레이킹', 'https://dummyimage.com/600x400/333/fff&text=기타', 'STRANGER'), - ('가족', 'https://dummyimage.com/600x400/444/fff&text=가족', 'CLOSE'), - ('친구', 'https://dummyimage.com/600x400/555/fff&text=친구', 'CLOSE'), - ('연인', 'https://dummyimage.com/600x400/666/fff&text=연인', 'CLOSE'), - ('동료', 'https://dummyimage.com/600x400/777/fff&text=동료', 'CLOSE'); +INSERT INTO category (title, image_url) VALUES + ('소개팅/과팅', 'https://dummyimage.com/600x400/000/fff&text=소개팅'), + ('그룹 첫 모임', 'https://dummyimage.com/600x400/111/fff&text=그룹모임'), + ('룸메 첫 만남', 'https://dummyimage.com/600x400/222/fff&text=룸메'), + ('기타/아이스브레이킹', 'https://dummyimage.com/600x400/333/fff&text=기타'), + ('가족', 'https://dummyimage.com/600x400/444/fff&text=가족'), + ('친구', 'https://dummyimage.com/600x400/555/fff&text=친구'), + ('연인', 'https://dummyimage.com/600x400/666/fff&text=연인'), + ('동료', 'https://dummyimage.com/600x400/777/fff&text=동료'); INSERT INTO keyword (name, image_url, icon_url) VALUES ('만약에', 'https://dummyimage.com/600x400/f44/fff&text=만약에', 'https://dummyimage.com/100x100/f44/fff&text=만약에'), diff --git a/scripts/start-server.sh b/scripts/start-server.sh deleted file mode 100644 index c7b0d825..00000000 --- a/scripts/start-server.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/bash -set -e - -echo "--------------- START : Talkpick Server Deploy -----------------" -cd /home/ec2-user/TalkPick_Server - -echo "📂 현재 디렉토리: $(pwd)" -echo "📄 파일 목록:" -ls -al - -echo "🔐 ECR 로그인" -aws ecr get-login-password --region ap-northeast-2 \ - | docker login --username AWS --password-stdin 718513646976.dkr.ecr.ap-northeast-2.amazonaws.com - -echo "⏸️ 실행 중인 컨테이너 중지" -docker compose down || true - -echo "▶️ 컨테이너 실행" -docker compose up -d - -echo "✅ 서버 배포 완료" \ No newline at end of file diff --git a/scripts/stop-server.sh b/scripts/stop-server.sh deleted file mode 100644 index e761b80d..00000000 --- a/scripts/stop-server.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash - -# 컨테이너 stop -docker stop talkpick-server || true -docker rm talkpick-server || true - -docker pull 718513646976.dkr.ecr.ap-northeast-2.amazonaws.com/talkpick-server:latest \ No newline at end of file diff --git a/src/main/java/talkPick/batch/member/scheduler/MemberCleanupScheduler.java b/src/main/java/talkPick/batch/member/scheduler/MemberCleanupScheduler.java new file mode 100644 index 00000000..352a8591 --- /dev/null +++ b/src/main/java/talkPick/batch/member/scheduler/MemberCleanupScheduler.java @@ -0,0 +1,50 @@ +package talkPick.batch.member.scheduler; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import talkPick.domain.member.adapter.out.repository.MemberJpaRepository; +import talkPick.domain.member.domain.Member; +import talkPick.domain.member.port.in.MemberWithdrawalUseCase; +import talkPick.global.model.TalkPickStatus; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + + +@Slf4j +@Component +@RequiredArgsConstructor +public class MemberCleanupScheduler { + + private final MemberJpaRepository memberRepository; + private final MemberWithdrawalUseCase memberWithdrawalUseCase; + + /** + * 매일 새벽 3시에 탈퇴한 지 15일이 지난 회원을 영구 삭제합니다. + */ + @Scheduled(cron = "0 0 3 * * *") + public void cleanupExpiredMembers() { + log.info("회원 탈퇴 데이터 정리 스케줄러 시작"); + + LocalDateTime thresholdDate = LocalDateTime.now().minusDays(15); + Optional expiredMember = memberRepository.findTop1ByStatusAndDeletedAtBefore(TalkPickStatus.DIS_ACTIVE, thresholdDate); + + if (expiredMember.isPresent()) { + Member member = expiredMember.get(); + try { + log.info("삭제 대상 회원 ID: {}", member.getId()); + memberWithdrawalUseCase.hardDelete(member.getId()); + log.info("회원 ID {} 영구 삭제 완료", member.getId()); + } catch (Exception e) { + log.error("회원 ID {} 영구 삭제 중 오류 발생", member.getId(), e); + } + } else { + log.info("삭제 대상 회원이 없습니다."); + } + + log.info("회원 탈퇴 데이터 정리 스케줄러 종료"); + } +} diff --git a/src/main/java/talkPick/domain/inquiry/adapter/out/repository/InquiryJpaRepository.java b/src/main/java/talkPick/domain/inquiry/adapter/out/repository/InquiryJpaRepository.java index bed05ca2..f8898bf0 100644 --- a/src/main/java/talkPick/domain/inquiry/adapter/out/repository/InquiryJpaRepository.java +++ b/src/main/java/talkPick/domain/inquiry/adapter/out/repository/InquiryJpaRepository.java @@ -1,7 +1,15 @@ package talkPick.domain.inquiry.adapter.out.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import talkPick.domain.inquiry.domain.Inquiry; public interface InquiryJpaRepository extends JpaRepository { + void deleteByMemberId(Long memberId); + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM Inquiry i WHERE i.memberId = :memberId") + void deleteAllByMemberIdInBulk(@Param("memberId") Long memberId); } diff --git a/src/main/java/talkPick/domain/member/adapter/in/MemberCommandApi.java b/src/main/java/talkPick/domain/member/adapter/in/MemberCommandApi.java index b246d657..3e6ad66e 100644 --- a/src/main/java/talkPick/domain/member/adapter/in/MemberCommandApi.java +++ b/src/main/java/talkPick/domain/member/adapter/in/MemberCommandApi.java @@ -17,10 +17,21 @@ public interface MemberCommandApi { JwtResDTO.Login kakaoOAuth2Login( @Valid @RequestBody MemberReqDto.OAuth2LoginRequest request, HttpServletResponse response); - @PatchMapping("/apple/login") + @PostMapping("/apple/login") @Operation(summary = "APPLE Oauth2 로그인 API", description = "APPLE OAuth2 로그인 API 입니다.") JwtResDTO.Login appleOauth2Login (@Valid @RequestBody MemberReqDto.OAuth2LoginRequest request, HttpServletResponse response); + @PostMapping("/google/login") + @Operation(summary = "GOOGLE OAuth2 로그인 API", description = "GOOGLE OAuth2 로그인 API 입니다.") + JwtResDTO.Login googleOauth2Login( + @Valid @RequestBody MemberReqDto.OAuth2LoginRequest request, HttpServletResponse response); + + @PostMapping("/{provider}/reactivate") + @Operation(summary = "계정 복구 API", description = "탈퇴한 계정(kakao/apple/google)을 복구하고 로그인합니다. provider: 'kakao', 'apple' 또는 'google'") + JwtResDTO.Login reactivateMember( + @Parameter(description = "kakao, apple 또는 google", example = "kakao") @PathVariable("provider") String provider, + @Valid @RequestBody MemberReqDto.OAuth2LoginRequest request, HttpServletResponse response); + @PostMapping("/token/refresh") @Operation(summary = "액세스 토큰 재발급", description = "쿠키에 담긴 리프레시 토큰으로 액세스 토큰을 재발급합니다.") JwtResDTO.AccessToken refreshAccessToken( diff --git a/src/main/java/talkPick/domain/member/adapter/in/MemberCommandController.java b/src/main/java/talkPick/domain/member/adapter/in/MemberCommandController.java index efae082f..55a743e7 100644 --- a/src/main/java/talkPick/domain/member/adapter/in/MemberCommandController.java +++ b/src/main/java/talkPick/domain/member/adapter/in/MemberCommandController.java @@ -10,11 +10,13 @@ import talkPick.domain.member.adapter.out.dto.MemberResDto; import talkPick.domain.member.domain.type.LoginType; import talkPick.external.apple.port.in.AppleOidcUsecase; +import talkPick.external.google.port.in.GoogleOidcUsecase; import talkPick.external.kakao.port.in.KakaoOidcUsecase; import talkPick.global.security.jwt.dto.JwtResDTO; import talkPick.domain.member.domain.Member; import talkPick.domain.member.dto.*; import talkPick.domain.member.port.in.MemberCommandUseCase; +import talkPick.domain.member.port.in.MemberWithdrawalUseCase; import talkPick.global.security.jwt.port.in.JwtTokenCommandUseCase; /** @@ -24,10 +26,13 @@ @RestController @RequiredArgsConstructor @Slf4j +@RequestMapping("/api/v1/members") public class MemberCommandController implements MemberCommandApi { private final KakaoOidcUsecase kakaoOidcService; private final AppleOidcUsecase appleOidcService; + private final GoogleOidcUsecase googleOidcService; private final MemberCommandUseCase memberCommandUseCase; + private final MemberWithdrawalUseCase memberWithdrawalUseCase; // 의존성 추가 private final JwtTokenCommandUseCase jwtTokenCommandUseCase; @@ -49,6 +54,7 @@ public JwtResDTO.Login kakaoOAuth2Login( ); } + @Override public JwtResDTO.Login appleOauth2Login (@Valid @RequestBody MemberReqDto.OAuth2LoginRequest request, HttpServletResponse response) { MemberDataDto.MemberData appleMemberData = appleOidcService.verifyAndParseIdToken(request); Member member = memberCommandUseCase.findOrCreateMember(appleMemberData, LoginType.APPLE); @@ -64,6 +70,58 @@ public JwtResDTO.Login appleOauth2Login (@Valid @RequestBody MemberReqDto.OAuth2 ); } + @Override + public JwtResDTO.Login googleOauth2Login( + @Valid @RequestBody MemberReqDto.OAuth2LoginRequest request, HttpServletResponse response + ) { + MemberDataDto.MemberData googleMemberData = googleOidcService.verifyAndParseIdToken(request); + Member member = memberCommandUseCase.findOrCreateMember(googleMemberData, LoginType.GOOGLE); + JwtResDTO.GeneratedTokens generatedTokens = jwtTokenCommandUseCase.generateToken(member); + + setRefreshTokenCookie(response, generatedTokens.refreshToken(), generatedTokens.refreshExpiredTime()); + + return JwtResDTO.Login.of( + member.getId(), + member.getMemberRole().toString(), + generatedTokens.accessToken(), + generatedTokens.accessExpiredTime() + ); + } + + @Override + public JwtResDTO.Login reactivateMember( + @PathVariable("provider") String provider, + @Valid @RequestBody MemberReqDto.OAuth2LoginRequest request, HttpServletResponse response + ) { + MemberDataDto.MemberData memberData; + LoginType loginType; + + if ("kakao".equalsIgnoreCase(provider)) { + memberData = kakaoOidcService.verifyAndParseIdToken(request); + loginType = LoginType.KAKAO; + } else if ("apple".equalsIgnoreCase(provider)) { + memberData = appleOidcService.verifyAndParseIdToken(request); + loginType = LoginType.APPLE; + } else if ("google".equalsIgnoreCase(provider)) { + memberData = googleOidcService.verifyAndParseIdToken(request); + loginType = LoginType.GOOGLE; + } else { + throw new IllegalArgumentException("지원하지 않는 Provider입니다: " + provider); + } + + Member member = memberCommandUseCase.reactivateMember(memberData, loginType); + JwtResDTO.GeneratedTokens generatedTokens = jwtTokenCommandUseCase.generateToken(member); + + setRefreshTokenCookie(response, generatedTokens.refreshToken(), generatedTokens.refreshExpiredTime()); + + return JwtResDTO.Login.of( + member.getId(), + member.getMemberRole().toString(), + generatedTokens.accessToken(), + generatedTokens.accessExpiredTime() + ); + } + // refresh token을 HttpOnly 쿠키로 설정하는 헬퍼 메서드 private void setRefreshTokenCookie(HttpServletResponse response, String refreshToken, Long refreshExpiredTime) { Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken); @@ -116,7 +174,8 @@ public void logout( public void deleteMember( @RequestHeader(value = "Authorization", required = false) String authorization ) { - memberCommandUseCase.delete(authorization); + // Facade 서비스 호출로 변경 + memberWithdrawalUseCase.withdraw(authorization); } @Override @@ -126,6 +185,4 @@ public void changeComment( memberCommandUseCase.TopicResultCommentChange(authorization, request); } - - -} \ No newline at end of file +} diff --git a/src/main/java/talkPick/domain/member/adapter/in/MemberQueryController.java b/src/main/java/talkPick/domain/member/adapter/in/MemberQueryController.java index d53bb85a..4634acae 100644 --- a/src/main/java/talkPick/domain/member/adapter/in/MemberQueryController.java +++ b/src/main/java/talkPick/domain/member/adapter/in/MemberQueryController.java @@ -14,6 +14,7 @@ @RestController @RequiredArgsConstructor @Slf4j +@RequestMapping("/api/v1/members") public class MemberQueryController implements MemberQueryApi { private final MemberQueryUseCase memberQueryUseCase; diff --git a/src/main/java/talkPick/domain/member/adapter/in/dto/MemberReqDto.java b/src/main/java/talkPick/domain/member/adapter/in/dto/MemberReqDto.java index 92237cf2..6344198b 100644 --- a/src/main/java/talkPick/domain/member/adapter/in/dto/MemberReqDto.java +++ b/src/main/java/talkPick/domain/member/adapter/in/dto/MemberReqDto.java @@ -36,12 +36,6 @@ public static class MemberSignupRequest { @NotEmpty(message = "닉네임은 필수입니다.") @Size(max = 25, message = "닉네임은 최대 25자입니다.") private String nickname; - @NotNull(message = "성별은 필수입니다.") - private Gender gender; - @NotNull(message = "생년월일은 필수입니다.") - private LocalDate birth; - @NotNull(message = "프로필 이미지는 필수입니다.") - private String profileImgUrl; @NotNull(message = "mbti는 필수입니다.") private MBTI mbti; } diff --git a/src/main/java/talkPick/domain/member/adapter/out/MemberTermCommandRepositoryAdapter.java b/src/main/java/talkPick/domain/member/adapter/out/MemberTermCommandRepositoryAdapter.java index f03cbb92..b5cbb91a 100644 --- a/src/main/java/talkPick/domain/member/adapter/out/MemberTermCommandRepositoryAdapter.java +++ b/src/main/java/talkPick/domain/member/adapter/out/MemberTermCommandRepositoryAdapter.java @@ -22,6 +22,11 @@ public Optional findByMemberIdAndTermId(Long memberId, Long termId) public MemberTerm save(MemberTerm memberTerm) { return repository.save(memberTerm); } + + @Override + public void deleteByMemberId(Long memberId) { + repository.deleteByMemberId(memberId); + } } diff --git a/src/main/java/talkPick/domain/member/adapter/out/dto/MemberResDto.java b/src/main/java/talkPick/domain/member/adapter/out/dto/MemberResDto.java index 816305c0..8205739a 100644 --- a/src/main/java/talkPick/domain/member/adapter/out/dto/MemberResDto.java +++ b/src/main/java/talkPick/domain/member/adapter/out/dto/MemberResDto.java @@ -56,7 +56,7 @@ public static class MemberTopicResultResDto { @AllArgsConstructor @NoArgsConstructor public static class MemberLikedTopicResDto { - private Long id; // 좋아요 누른 토픽 id (TopicLikeHistory 테이블) + private Long topicId; // 좋아요 누른 토픽 id (Topic 테이블) private String title; //토픽 주제 (Topic 테이블) private String keyword; //키워드 (Topickeyword 테이블) private Category category; //카테고리 (TopicCategory 테이블) diff --git a/src/main/java/talkPick/domain/member/adapter/out/repository/MemberJpaRepository.java b/src/main/java/talkPick/domain/member/adapter/out/repository/MemberJpaRepository.java index c5948d47..791c2761 100644 --- a/src/main/java/talkPick/domain/member/adapter/out/repository/MemberJpaRepository.java +++ b/src/main/java/talkPick/domain/member/adapter/out/repository/MemberJpaRepository.java @@ -2,10 +2,15 @@ import org.springframework.data.jpa.repository.JpaRepository; import talkPick.domain.member.domain.Member; +import talkPick.global.model.TalkPickStatus; +import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; public interface MemberJpaRepository extends JpaRepository { Optional findByProviderId(String sub); Optional findByEmail(String email); + Optional findTop1ByStatusAndDeletedAtBefore(TalkPickStatus status, LocalDateTime dateTime); + List findByStatusAndDeletedAtBefore(TalkPickStatus status, LocalDateTime dateTime); } diff --git a/src/main/java/talkPick/domain/member/adapter/out/repository/MemberLikedTopicsQuerydslRepository.java b/src/main/java/talkPick/domain/member/adapter/out/repository/MemberLikedTopicsQuerydslRepository.java index 8de96e8b..957224fd 100644 --- a/src/main/java/talkPick/domain/member/adapter/out/repository/MemberLikedTopicsQuerydslRepository.java +++ b/src/main/java/talkPick/domain/member/adapter/out/repository/MemberLikedTopicsQuerydslRepository.java @@ -41,7 +41,7 @@ public List findMemberLikedTopics(Member me // size + 1개를 조회하여 다음 페이지 존재 여부 확인 return queryFactory .select(Projections.constructor(MemberResDto.MemberLikedTopicResDto.class, - tlh.id, + t.id, // 토픽 ID t.title, // 토픽 주제 (String) k.name, // 키워드 (Keyword) c, // 카테고리 (Category) diff --git a/src/main/java/talkPick/domain/member/adapter/out/repository/MemberLoginHistoryJpaRepository.java b/src/main/java/talkPick/domain/member/adapter/out/repository/MemberLoginHistoryJpaRepository.java index acb08f9d..9e5ef8d4 100644 --- a/src/main/java/talkPick/domain/member/adapter/out/repository/MemberLoginHistoryJpaRepository.java +++ b/src/main/java/talkPick/domain/member/adapter/out/repository/MemberLoginHistoryJpaRepository.java @@ -1,8 +1,15 @@ package talkPick.domain.member.adapter.out.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import talkPick.domain.member.domain.MemberLoginHistory; public interface MemberLoginHistoryJpaRepository extends JpaRepository { void deleteByMemberId(Long memberId); -} + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM MemberLoginHistory m WHERE m.memberId = :memberId") + void deleteAllByMemberIdInBulk(@Param("memberId") Long memberId); +} \ No newline at end of file diff --git a/src/main/java/talkPick/domain/member/adapter/out/repository/MemberTermJpaRepository.java b/src/main/java/talkPick/domain/member/adapter/out/repository/MemberTermJpaRepository.java index 4e83d35f..224f8151 100644 --- a/src/main/java/talkPick/domain/member/adapter/out/repository/MemberTermJpaRepository.java +++ b/src/main/java/talkPick/domain/member/adapter/out/repository/MemberTermJpaRepository.java @@ -1,12 +1,20 @@ package talkPick.domain.member.adapter.out.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import talkPick.domain.member.domain.mapping.MemberTerm; -import talkPick.domain.term.domain.Term; import java.util.Optional; public interface MemberTermJpaRepository extends JpaRepository { // 특정 약관 및 유저의 동의 상태 조회 Optional findByMemberIdAndTermId(Long memberId, Long termId); -} + + void deleteByMemberId(Long memberId); + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM MemberTerm m WHERE m.memberId = :memberId") + void deleteAllByMemberIdInBulk(@Param("memberId") Long memberId); +} \ No newline at end of file diff --git a/src/main/java/talkPick/domain/member/adapter/out/repository/MemberTopicHistoryJpaRepository.java b/src/main/java/talkPick/domain/member/adapter/out/repository/MemberTopicHistoryJpaRepository.java new file mode 100644 index 00000000..7bbb3254 --- /dev/null +++ b/src/main/java/talkPick/domain/member/adapter/out/repository/MemberTopicHistoryJpaRepository.java @@ -0,0 +1,15 @@ +package talkPick.domain.member.adapter.out.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import talkPick.domain.topic.domain.member.MemberTopicHistory; + +public interface MemberTopicHistoryJpaRepository extends JpaRepository { + void deleteByMemberId(Long memberId); + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM MemberTopicHistory m WHERE m.memberId = :memberId") + void deleteAllByMemberIdInBulk(@Param("memberId") Long memberId); +} diff --git a/src/main/java/talkPick/domain/member/adapter/out/repository/MemberTopicResultJpaRepository.java b/src/main/java/talkPick/domain/member/adapter/out/repository/MemberTopicResultJpaRepository.java index 93980aca..4ed246f8 100644 --- a/src/main/java/talkPick/domain/member/adapter/out/repository/MemberTopicResultJpaRepository.java +++ b/src/main/java/talkPick/domain/member/adapter/out/repository/MemberTopicResultJpaRepository.java @@ -1,10 +1,19 @@ package talkPick.domain.member.adapter.out.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import talkPick.domain.topic.domain.member.MemberTopicResult; import java.util.Optional; public interface MemberTopicResultJpaRepository extends JpaRepository { Optional findByMemberTopicHistoryId(Long memberTopicHistoryId); + + void deleteByMemberId(Long memberId); + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM MemberTopicResult m WHERE m.memberId = :memberId") + void deleteAllByMemberIdInBulk(@Param("memberId") Long memberId); } diff --git a/src/main/java/talkPick/domain/member/application/MemberCommandService.java b/src/main/java/talkPick/domain/member/application/MemberCommandService.java index 7c14e144..3b18ef60 100644 --- a/src/main/java/talkPick/domain/member/application/MemberCommandService.java +++ b/src/main/java/talkPick/domain/member/application/MemberCommandService.java @@ -1,7 +1,6 @@ package talkPick.domain.member.application; import lombok.RequiredArgsConstructor; -import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import talkPick.domain.member.port.out.MemberCommandRepositoryPort; @@ -15,6 +14,7 @@ import talkPick.domain.member.adapter.in.dto.MemberReqDto; import talkPick.domain.member.adapter.out.dto.MemberResDto; import talkPick.domain.member.port.in.MemberCommandUseCase; +import talkPick.domain.member.port.in.MemberWithdrawalUseCase; import talkPick.domain.member.port.out.MemberLoginHistoryCommandRepositoryPort; import talkPick.domain.member.port.out.MemberQueryRepositoryPort; import talkPick.domain.term.port.out.TermQueryRepositoryPort; @@ -27,13 +27,13 @@ import talkPick.global.model.TalkPickStatus; import talkPick.global.security.jwt.util.JwtProvider; + import java.util.List; @Service @Transactional @RequiredArgsConstructor public class MemberCommandService implements MemberCommandUseCase { - private static final String DEFAULT_PROFILE_IMG_URL = "https://example.com/images/default-profile.png"; private final MemberCommandRepositoryPort memberCommandRepositoryPort; private final TermQueryRepositoryPort termQueryRepositoryPort; @@ -41,9 +41,11 @@ public class MemberCommandService implements MemberCommandUseCase { private final RefreshTokenRepository refreshTokenRepository; private final MemberLoginHistoryCommandRepositoryPort memberLoginHistoryRepository; private final MemberQueryRepositoryPort memberQueryRepositoryPort; + private final MemberWithdrawalUseCase memberWithdrawalUseCase; private final JwtProvider jwtProvider; private final MemberTopicResultJpaRepository memberTopicResultJpaRepository; + /** * 회원 프로필 수정 */ @@ -70,9 +72,30 @@ public MemberResDto.MemberProfileResponse updateProfile(String authorization, Me public Member findOrCreateMember(MemberDataDto.MemberData MemberData, LoginType loginType) { Member findOrNewMember = memberCommandRepositoryPort.findByProviderId(MemberData.getSub()) .orElseGet(() -> MemberConverter.toMember(MemberData, loginType)); + + if (findOrNewMember.getStatus() == TalkPickStatus.DIS_ACTIVE) { + throw new MemberExceptionHandler(ErrorCode.MEMBER_IS_WITHDRAWN); + } + return memberCommandRepositoryPort.save(findOrNewMember); } + /** + * 탈퇴한 회원 복구 + */ + @Override + public Member reactivateMember(MemberDataDto.MemberData memberData, LoginType loginType) { + Member member = memberCommandRepositoryPort.findByProviderId(memberData.getSub()) + .orElseThrow(() -> new MemberExceptionHandler(ErrorCode.MEMBER_NOT_FOUND)); + + if (member.getStatus() == TalkPickStatus.DIS_ACTIVE) { + member.reactivate(); + return memberCommandRepositoryPort.save(member); + } + + return member; + } + /** * 회원 가입(추가 정보 입력 및 상태 변경 처리) */ @@ -88,29 +111,15 @@ public MemberResDto.MemberSignupResponse memberSignup(String authorization, Memb } // 추가 정보 입력 - findMember.updateBirth(request.getBirth()); - findMember.updateGender(request.getGender()); findMember.updateMbti(request.getMbti()); findMember.updateNickname(request.getNickname()); - String profileImgUrl = request.getProfileImgUrl(); - if (profileImgUrl == null || profileImgUrl.trim().isEmpty()) { - findMember.updateProfileImgUrl(DEFAULT_PROFILE_IMG_URL); - } else { - findMember.updateProfileImgUrl(profileImgUrl); - } - // 회원 ACTIVE 상태 변경 findMember.updateStatus(TalkPickStatus.ACTIVE); memberCommandRepositoryPort.save(findMember); - // 이메일 회원은 임시 토큰 삭제 처리 -// if (findMember.getLoginType() == LoginType.EMAIL) { -// refreshTokenRepository.findByMember(findMember).ifPresent(refreshTokenRepository::delete); -// } - // 소셜 로그인 회원 가입 완료 시 로그인 기록 저장 - if (findMember.getLoginType() == LoginType.KAKAO || findMember.getLoginType() == LoginType.APPLE) { + if (findMember.getLoginType() == LoginType.KAKAO || findMember.getLoginType() == LoginType.APPLE || findMember.getLoginType() == LoginType.GOOGLE) { MemberLoginHistory loginHistory = MemberConverter.toLoginHistory(findMember); memberLoginHistoryRepository.save(loginHistory); } @@ -177,20 +186,10 @@ public void logout(String authorization) { memberLoginHistoryRepository.deleteByMemberId(findMember.getId()); } - // 회원 탈퇴 처리 (상태 비활성화, 토큰 및 로그인 기록 삭제) + // 회원 탈퇴 처리 @Override public void delete(String authorization) { - Long memberId = jwtProvider.getMemberId(authorization); - - Member findMember = memberQueryRepositoryPort.findMemberById(memberId); - - findMember.updateStatus(TalkPickStatus.DIS_ACTIVE); - memberCommandRepositoryPort.save(findMember); - - refreshTokenRepository.findByMember(findMember).ifPresent(refreshTokenRepository::delete); - - // 로그인 기록 삭제 - memberLoginHistoryRepository.deleteByMemberId(findMember.getId()); + memberWithdrawalUseCase.withdraw(authorization); } // 토픽 캘린더 조회 코멘트 수정 @@ -218,9 +217,7 @@ public void TopicResultCommentChange(String authorization, MemberReqDto.TopicRes // 회원 가입 시 필수 정보 검증 private boolean validateAdditionalInfo(MemberReqDto.MemberSignupRequest request) { return request.getNickname() != null && - request.getMbti() != null && - request.getGender() != null && - request.getBirth() != null; + request.getMbti() != null; } // 필수 약관 동의 여부 검증 diff --git a/src/main/java/talkPick/domain/member/application/MemberQueryService.java b/src/main/java/talkPick/domain/member/application/MemberQueryService.java index 3f25603c..50c39851 100644 --- a/src/main/java/talkPick/domain/member/application/MemberQueryService.java +++ b/src/main/java/talkPick/domain/member/application/MemberQueryService.java @@ -62,7 +62,7 @@ public CursorPageResponse getMemberLikedTop CursorPageResponse.Cursor nextCursor = null; if (hasNext && !memberLikedTopics.isEmpty()) { MemberResDto.MemberLikedTopicResDto last = memberLikedTopics.get(memberLikedTopics.size() - 1); - nextCursor = new CursorPageResponse.Cursor(last.getCreatedDate(), last.getId()); + nextCursor = new CursorPageResponse.Cursor(last.getCreatedDate(), last.getTopicId()); } // 커서 기반 페이징 응답 반환 diff --git a/src/main/java/talkPick/domain/member/application/MemberWithdrawalService.java b/src/main/java/talkPick/domain/member/application/MemberWithdrawalService.java new file mode 100644 index 00000000..35017a6b --- /dev/null +++ b/src/main/java/talkPick/domain/member/application/MemberWithdrawalService.java @@ -0,0 +1,73 @@ +package talkPick.domain.member.application; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import talkPick.domain.inquiry.adapter.out.repository.InquiryJpaRepository; +import talkPick.domain.member.adapter.out.repository.MemberJpaRepository; +import talkPick.domain.member.domain.Member; +import talkPick.domain.member.adapter.out.repository.MemberLoginHistoryJpaRepository; +import talkPick.domain.member.adapter.out.repository.MemberTermJpaRepository; +import talkPick.domain.member.adapter.out.repository.MemberTopicHistoryJpaRepository; +import talkPick.domain.member.adapter.out.repository.MemberTopicResultJpaRepository; +import talkPick.domain.member.port.in.MemberWithdrawalUseCase; +import talkPick.domain.random.adapter.out.repository.RandomJpaRepository; +import talkPick.domain.random.adapter.out.repository.RandomTopicHistoryJpaRepository; +import talkPick.domain.today.adapter.out.repository.TodayTopicJpaRepository; +import talkPick.domain.topic.adapter.out.repository.TopicLikeHistoryJpaRepository; +import talkPick.global.security.jwt.repository.RefreshTokenRepository; +import talkPick.global.security.jwt.util.JwtProvider; + +@Service +@RequiredArgsConstructor +public class MemberWithdrawalService implements MemberWithdrawalUseCase { + + private final JwtProvider jwtProvider; + private final MemberJpaRepository memberRepository; + private final RefreshTokenRepository refreshTokenRepository; + + // 연관 데이터 리포지토리들 + private final InquiryJpaRepository inquiryRepository; + private final MemberTermJpaRepository memberTermRepository; + private final MemberLoginHistoryJpaRepository memberLoginHistoryRepository; + private final MemberTopicHistoryJpaRepository memberTopicHistoryRepository; + private final MemberTopicResultJpaRepository memberTopicResultRepository; + private final RandomJpaRepository randomRepository; + private final RandomTopicHistoryJpaRepository randomTopicHistoryRepository; + private final TodayTopicJpaRepository todayTopicRepository; + private final TopicLikeHistoryJpaRepository topicLikeHistoryRepository; + + @Override + @Transactional + public void withdraw(String authorization) { + Long memberId = jwtProvider.getMemberId(authorization); + + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); + + // 1. Refresh Token 삭제 (즉시 로그아웃 효과) + refreshTokenRepository.deleteAllByMemberIdInBulk(memberId); + + // 2. 소프트 삭제 처리 + member.withdraw(); + memberRepository.save(member); + } + + @Override + @Transactional + public void hardDelete(Long memberId) { + // 1. 연관 데이터 일괄 삭제 + inquiryRepository.deleteAllByMemberIdInBulk(memberId); + memberTermRepository.deleteAllByMemberIdInBulk(memberId); + memberLoginHistoryRepository.deleteAllByMemberIdInBulk(memberId); + memberTopicHistoryRepository.deleteAllByMemberIdInBulk(memberId); + memberTopicResultRepository.deleteAllByMemberIdInBulk(memberId); + randomRepository.deleteAllByMemberIdInBulk(memberId); + randomTopicHistoryRepository.deleteAllByMemberIdInBulk(memberId); + todayTopicRepository.deleteAllByMemberIdInBulk(memberId); + topicLikeHistoryRepository.deleteAllByMemberIdInBulk(memberId); + + // 2. 회원 영구 삭제 + memberRepository.deleteById(memberId); + } +} diff --git a/src/main/java/talkPick/domain/member/converter/MemberConverter.java b/src/main/java/talkPick/domain/member/converter/MemberConverter.java index 66d01635..dc8bdc91 100644 --- a/src/main/java/talkPick/domain/member/converter/MemberConverter.java +++ b/src/main/java/talkPick/domain/member/converter/MemberConverter.java @@ -5,17 +5,14 @@ import talkPick.domain.member.domain.Member; import talkPick.domain.member.domain.MemberLoginHistory; import talkPick.domain.member.domain.mapping.MemberTerm; -import talkPick.domain.member.domain.type.Gender; import talkPick.domain.member.domain.type.LoginType; import talkPick.domain.member.dto.MemberDataDto; -import talkPick.domain.member.adapter.in.dto.MemberReqDto; import talkPick.domain.member.adapter.out.dto.MemberResDto; import talkPick.domain.term.domain.Term; import talkPick.global.model.TalkPickStatus; import java.time.LocalDateTime; public class MemberConverter { - private static final String DEFAULT_PROFILE_IMG_URL = "https://example.com/images/default-profile.png"; private static final String DEFAULT_NICKNAME = "토픽"; public static MemberDataDto.MemberData toKakaoMemberData(io.jsonwebtoken.Claims claims) { @@ -25,15 +22,27 @@ public static MemberDataDto.MemberData toKakaoMemberData(io.jsonwebtoken.Claims .build(); } + public static MemberDataDto.MemberData toAppleMemberData(Claims claims) { + return MemberDataDto.MemberData.builder() + .sub(claims.getSubject()) + .email(claims.get("email", String.class) != null ? claims.get("email", String.class) : "NONE") + .build(); + } + + public static MemberDataDto.MemberData toGoogleMemberData(io.jsonwebtoken.Claims claims) { + return MemberDataDto.MemberData.builder() + .sub(claims.getSubject()) + .email(claims.get("email", String.class)) + .build(); + } + public static Member toMember(MemberDataDto.MemberData MemberData, LoginType loginType) { return Member.builder() .email(MemberData.getEmail()) .memberRole(Role.MEMBER) .nickname(DEFAULT_NICKNAME) - .gender(Gender.NONE) .loginType(loginType) .status(TalkPickStatus.PENDING) - .profileImageUrl(DEFAULT_PROFILE_IMG_URL) .providerId(MemberData.getSub()) .build(); @@ -75,11 +84,4 @@ public static MemberLoginHistory toLoginHistory(Member member) { .loginTime(LocalDateTime.now()) .build(); } - - public static MemberDataDto.MemberData toAppleMemberData(Claims claims) { - return MemberDataDto.MemberData.builder() - .sub(claims.getSubject()) - .email(claims.get("email", String.class) != null ? claims.get("email", String.class) : "NONE") - .build(); - } -} +} \ No newline at end of file diff --git a/src/main/java/talkPick/domain/member/domain/Member.java b/src/main/java/talkPick/domain/member/domain/Member.java index 9c84ff19..9d9ff444 100644 --- a/src/main/java/talkPick/domain/member/domain/Member.java +++ b/src/main/java/talkPick/domain/member/domain/Member.java @@ -10,6 +10,7 @@ import talkPick.global.model.TalkPickStatus; import java.time.LocalDate; +import java.time.LocalDateTime; @Getter @Setter @@ -54,17 +55,6 @@ public class Member extends BaseTime { ) private String nickname; - @Column( - columnDefinition = "DATE COMMENT '생년월일'" - ) - private LocalDate birth; - - @Enumerated(EnumType.STRING) - @Column( - columnDefinition = "VARCHAR(10) COMMENT '성별(남/여 등)'" - ) - private Gender gender; - @Enumerated(EnumType.STRING) @Column( length = 6, @@ -89,33 +79,19 @@ public class Member extends BaseTime { @Column( length = 255, - nullable = false, - columnDefinition = "VARCHAR(255) COMMENT '프로필 이미지 URL'" + columnDefinition = "VARCHAR(255) COMMENT 'OAuth Provider 식별값(구글, 카카오 등 연결용)'" ) - private String profileImageUrl; + private String providerId; @Column( - length = 255, - columnDefinition = "VARCHAR(255) COMMENT 'OAuth Provider 식별값(구글, 카카오 등 연결용)'" + columnDefinition = "DATETIME COMMENT '회원 탈퇴 일시'" ) - private String providerId; + private LocalDateTime deletedAt; public void updateNickname(String nickname) { this.nickname = nickname; } - public void updateBirth(LocalDate birth) { - this.birth = birth; - } - - public void updateGender(Gender gender) { - this.gender = gender; - } - - public void updateProfileImgUrl(String profileImgUrl) { - this.profileImageUrl = profileImageUrl; - } - public void updateMbti(MBTI mbti) {this.mbti = mbti;} public void updateStatus(TalkPickStatus talkPickStatus) {this.status = talkPickStatus;} @@ -124,4 +100,14 @@ public void updatePassword(String password) { this.password = password; } + public void withdraw() { + this.status = TalkPickStatus.DIS_ACTIVE; + this.deletedAt = LocalDateTime.now(); + } + + public void reactivate() { + this.status = TalkPickStatus.ACTIVE; + this.deletedAt = null; + } + } diff --git a/src/main/java/talkPick/domain/member/domain/type/LoginType.java b/src/main/java/talkPick/domain/member/domain/type/LoginType.java index 7297543b..5178b915 100644 --- a/src/main/java/talkPick/domain/member/domain/type/LoginType.java +++ b/src/main/java/talkPick/domain/member/domain/type/LoginType.java @@ -1,5 +1,5 @@ package talkPick.domain.member.domain.type; public enum LoginType { - KAKAO, EMAIL, APPLE + KAKAO, APPLE, GOOGLE } diff --git a/src/main/java/talkPick/domain/member/port/in/MemberCommandUseCase.java b/src/main/java/talkPick/domain/member/port/in/MemberCommandUseCase.java index 9ff8e94b..ad71eea5 100644 --- a/src/main/java/talkPick/domain/member/port/in/MemberCommandUseCase.java +++ b/src/main/java/talkPick/domain/member/port/in/MemberCommandUseCase.java @@ -7,13 +7,12 @@ import talkPick.domain.member.adapter.out.dto.MemberResDto; public interface MemberCommandUseCase { -// Member findOrCreateEmailMember(MemberReqDto.MemberEmailRequest emailReqDto); -// Member loginEmailMember(MemberReqDto.MemberEmailRequest emailReqDto); MemberResDto.MemberProfileResponse updateProfile(String authorization, MemberReqDto.ProfileUpdateRequest request); Member findOrCreateMember(MemberDataDto.MemberData kakaoMemberData, LoginType loginType); + Member reactivateMember(MemberDataDto.MemberData memberData, LoginType loginType); MemberResDto.MemberSignupResponse memberSignup(String authorization, MemberReqDto.MemberSignupRequest request); MemberResDto.TermAgreementResponse termAgreement(String authorization, MemberReqDto.TermAgreementRequest request); void logout(String authorization); - void delete(String authorization); void TopicResultCommentChange(String authorization, MemberReqDto.TopicResultCommentChangeRequest request); -} + void delete(String authorization); +} \ No newline at end of file diff --git a/src/main/java/talkPick/domain/member/port/in/MemberWithdrawalUseCase.java b/src/main/java/talkPick/domain/member/port/in/MemberWithdrawalUseCase.java new file mode 100644 index 00000000..5953f93d --- /dev/null +++ b/src/main/java/talkPick/domain/member/port/in/MemberWithdrawalUseCase.java @@ -0,0 +1,6 @@ +package talkPick.domain.member.port.in; + +public interface MemberWithdrawalUseCase { + void withdraw(String authorization); + void hardDelete(Long memberId); +} diff --git a/src/main/java/talkPick/domain/member/port/out/MemberTermCommandRepositoryPort.java b/src/main/java/talkPick/domain/member/port/out/MemberTermCommandRepositoryPort.java index 09867418..0d1382f6 100644 --- a/src/main/java/talkPick/domain/member/port/out/MemberTermCommandRepositoryPort.java +++ b/src/main/java/talkPick/domain/member/port/out/MemberTermCommandRepositoryPort.java @@ -7,6 +7,7 @@ public interface MemberTermCommandRepositoryPort { Optional findByMemberIdAndTermId(Long memberId, Long termId); MemberTerm save(MemberTerm memberTerm); + void deleteByMemberId(Long memberId); } diff --git a/src/main/java/talkPick/domain/notice/adapter/out/event/NoticeReadEventHandler.java b/src/main/java/talkPick/domain/notice/adapter/out/event/NoticeReadEventHandler.java index cf6e4832..88bd7501 100644 --- a/src/main/java/talkPick/domain/notice/adapter/out/event/NoticeReadEventHandler.java +++ b/src/main/java/talkPick/domain/notice/adapter/out/event/NoticeReadEventHandler.java @@ -2,10 +2,12 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; import talkPick.domain.notice.adapter.out.repository.NoticeJpaRepository; import talkPick.domain.notice.domain.Notice; import talkPick.domain.notice.domain.event.NoticeReadEvent; @@ -17,8 +19,8 @@ public class NoticeReadEventHandler { private final NoticeJpaRepository noticeJpaRepository; @Async - @EventListener - @Transactional + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) public void handle(NoticeReadEvent event) { try { noticeJpaRepository.findById(event.getNoticeId()) diff --git a/src/main/java/talkPick/domain/notice/application/NoticeQueryService.java b/src/main/java/talkPick/domain/notice/application/NoticeQueryService.java index 6d3b0e8d..a1416f58 100644 --- a/src/main/java/talkPick/domain/notice/application/NoticeQueryService.java +++ b/src/main/java/talkPick/domain/notice/application/NoticeQueryService.java @@ -3,6 +3,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import talkPick.domain.notice.adapter.in.dto.NoticeReqDTO; import talkPick.domain.notice.adapter.out.dto.NoticeResDTO; import talkPick.domain.notice.domain.event.NoticeReadEvent; @@ -12,6 +13,7 @@ @Service @RequiredArgsConstructor +@Transactional(readOnly = true) public class NoticeQueryService implements NoticeQueryUseCase { private final NoticeQueryRepositoryPort noticeQueryRepositoryPort; private final ApplicationEventPublisher eventPublisher; diff --git a/src/main/java/talkPick/domain/random/adapter/in/RandomQueryApi.java b/src/main/java/talkPick/domain/random/adapter/in/RandomQueryApi.java index df42edc7..94211b7d 100644 --- a/src/main/java/talkPick/domain/random/adapter/in/RandomQueryApi.java +++ b/src/main/java/talkPick/domain/random/adapter/in/RandomQueryApi.java @@ -9,7 +9,6 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import talkPick.domain.random.adapter.out.dto.RandomResDTO; -import talkPick.domain.topic.domain.type.CategoryGroup; import talkPick.global.security.annotation.MemberId; import java.util.List; @@ -24,7 +23,7 @@ public interface RandomQueryApi { order는 현재 순서 기준으로 (1, 2, 3, 4) 넣어주세요. 랜덤 대화 주제 코스에서 톡픽들을 조회할 때, 해당 API를 한 번 요청해 주세요. - 랜덤 대화 주제 코스 첫 시도 시, 사용자가 선택한 카테고리 그룹+카테고리를 + 랜덤 대화 주제 코스 첫 시도 시, 사용자가 선택한 카테고리를 파라미터로 넣어서 요청 주세요. """ ) @@ -35,8 +34,6 @@ List getRandomTopics( @NotNull(message = "[ERROR] randomId 값이 존재하지 않습니다.") Long randomId, @RequestParam(name = "order", required = true) @NotNull(message = "[ERROR] order 값이 존재하지 않습니다.") Integer order, - @RequestParam(name = "categoryGroup", required = false) - CategoryGroup categoryGroup, @RequestParam(name = "category", required = false) String category diff --git a/src/main/java/talkPick/domain/random/adapter/in/RandomQueryController.java b/src/main/java/talkPick/domain/random/adapter/in/RandomQueryController.java index de8b6a55..5cbe8005 100644 --- a/src/main/java/talkPick/domain/random/adapter/in/RandomQueryController.java +++ b/src/main/java/talkPick/domain/random/adapter/in/RandomQueryController.java @@ -4,7 +4,7 @@ import org.springframework.web.bind.annotation.RestController; import talkPick.domain.random.adapter.out.dto.RandomResDTO; import talkPick.domain.random.port.in.RandomQueryUseCase; -import talkPick.domain.topic.domain.type.CategoryGroup; + import java.util.List; @RestController @@ -13,7 +13,7 @@ public class RandomQueryController implements RandomQueryApi { private final RandomQueryUseCase randomQueryUseCase; @Override - public List getRandomTopics(Long memberId, Long randomId, Integer order, CategoryGroup categoryGroup, String category) { - return randomQueryUseCase.getRandomTopics(memberId, randomId, order, categoryGroup, category); + public List getRandomTopics(Long memberId, Long randomId, Integer order, String category) { + return randomQueryUseCase.getRandomTopics(memberId, randomId, order, category); } } diff --git a/src/main/java/talkPick/domain/random/adapter/out/RandomQueryRepositoryAdapter.java b/src/main/java/talkPick/domain/random/adapter/out/RandomQueryRepositoryAdapter.java index 29a62816..fea9f46b 100644 --- a/src/main/java/talkPick/domain/random/adapter/out/RandomQueryRepositoryAdapter.java +++ b/src/main/java/talkPick/domain/random/adapter/out/RandomQueryRepositoryAdapter.java @@ -7,7 +7,6 @@ import talkPick.domain.random.adapter.out.repository.RandomQuerydslRepository; import talkPick.domain.random.domain.Random; import talkPick.domain.random.port.out.RandomQueryRepositoryPort; -import talkPick.domain.topic.domain.type.CategoryGroup; import talkPick.global.exception.handler.RandomExceptionHandler; import java.util.List; import static talkPick.global.exception.ErrorCode.RANDOM_NOT_FOUND; @@ -19,8 +18,8 @@ public class RandomQueryRepositoryAdapter implements RandomQueryRepositoryPort { private final RandomQuerydslRepository randomQuerydslRepository; @Override - public List findRandomTopics(Long memberId, Long randomId, Integer order, CategoryGroup categoryGroup, String category) { - var topics = randomQuerydslRepository.findRandomTopics(memberId, randomId, categoryGroup, category); + public List findRandomTopics(Long memberId, Long randomId, Integer order, String category) { + var topics = randomQuerydslRepository.findRandomTopics(memberId, randomId, category); return List.of(new RandomResDTO.RandomTopic(order, topics)); } diff --git a/src/main/java/talkPick/domain/random/adapter/out/dto/RandomResDTO.java b/src/main/java/talkPick/domain/random/adapter/out/dto/RandomResDTO.java index d11e0d50..014286fc 100644 --- a/src/main/java/talkPick/domain/random/adapter/out/dto/RandomResDTO.java +++ b/src/main/java/talkPick/domain/random/adapter/out/dto/RandomResDTO.java @@ -32,7 +32,6 @@ public static class RandomTopicDetail { private Long topicId; private String title; private String detail; - private String categoryGroup; private String category; private String keywordName; private String keywordImageUrl; diff --git a/src/main/java/talkPick/domain/random/adapter/out/repository/RandomJpaRepository.java b/src/main/java/talkPick/domain/random/adapter/out/repository/RandomJpaRepository.java index 18478b41..6cbc4bb7 100644 --- a/src/main/java/talkPick/domain/random/adapter/out/repository/RandomJpaRepository.java +++ b/src/main/java/talkPick/domain/random/adapter/out/repository/RandomJpaRepository.java @@ -1,9 +1,18 @@ package talkPick.domain.random.adapter.out.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import talkPick.domain.random.domain.Random; import java.util.Optional; public interface RandomJpaRepository extends JpaRepository { Optional findRandomByMemberIdAndId(Long memberId, Long randomId); -} + + void deleteByMemberId(Long memberId); + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM Random r WHERE r.memberId = :memberId") + void deleteAllByMemberIdInBulk(@Param("memberId") Long memberId); +} \ No newline at end of file diff --git a/src/main/java/talkPick/domain/random/adapter/out/repository/RandomQuerydslRepository.java b/src/main/java/talkPick/domain/random/adapter/out/repository/RandomQuerydslRepository.java index 1ea7c32e..107fee36 100644 --- a/src/main/java/talkPick/domain/random/adapter/out/repository/RandomQuerydslRepository.java +++ b/src/main/java/talkPick/domain/random/adapter/out/repository/RandomQuerydslRepository.java @@ -6,8 +6,9 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; import talkPick.domain.random.adapter.out.dto.RandomResDTO; -import talkPick.domain.topic.domain.type.CategoryGroup; import talkPick.global.model.TalkPickStatus; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; import static talkPick.domain.random.domain.QRandomTopicHistory.randomTopicHistory; import static talkPick.domain.topic.domain.QCategory.category; @@ -19,7 +20,7 @@ public class RandomQuerydslRepository { private final JPAQueryFactory queryFactory; - public List findRandomTopics(Long memberId, Long randomId, CategoryGroup categoryGroup, String categoryType){ + public List findRandomTopics(Long memberId, Long randomId, String categoryType){ List alreadyUsedTopicIds = queryFactory .select(randomTopicHistory.topicId) .from(randomTopicHistory) @@ -34,20 +35,15 @@ public List findRandomTopics(Long memberId, Long builder.and(topic.id.notIn(alreadyUsedTopicIds)); } - if (categoryGroup != null) { - builder.and(category.categoryGroup.eq(categoryGroup)); - } - if (categoryType != null) { builder.and(category.title.eq(categoryType)); } - return queryFactory + List topics = queryFactory .select(Projections.constructor(RandomResDTO.RandomTopicDetail.class, topic.id, topic.title, topic.detail, - category.categoryGroup.stringValue(), category.title, keyword.name, keyword.imageUrl, @@ -57,8 +53,13 @@ public List findRandomTopics(Long memberId, Long .leftJoin(category).on(topic.categoryId.eq(category.id)) .leftJoin(keyword).on(topic.keywordId.eq(keyword.id)) .where(builder) - .orderBy(com.querydsl.core.types.dsl.Expressions.numberTemplate(Double.class, "rand()").asc()) - .limit(4) + .limit(20) .fetch(); + + List shuffledTopics = new ArrayList<>(topics); + Collections.shuffle(shuffledTopics); + return shuffledTopics.stream() + .limit(4) + .toList(); } } \ No newline at end of file diff --git a/src/main/java/talkPick/domain/random/adapter/out/repository/RandomTopicHistoryJpaRepository.java b/src/main/java/talkPick/domain/random/adapter/out/repository/RandomTopicHistoryJpaRepository.java index 3f1dde26..179bf220 100644 --- a/src/main/java/talkPick/domain/random/adapter/out/repository/RandomTopicHistoryJpaRepository.java +++ b/src/main/java/talkPick/domain/random/adapter/out/repository/RandomTopicHistoryJpaRepository.java @@ -1,10 +1,19 @@ package talkPick.domain.random.adapter.out.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import talkPick.domain.random.domain.RandomTopicHistory; import java.util.Optional; public interface RandomTopicHistoryJpaRepository extends JpaRepository { Optional findByMemberIdAndRandomIdAndOrder(Long memberId, Long randomId, Integer order); -} \ No newline at end of file + + void deleteByMemberId(Long memberId); + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM RandomTopicHistory r WHERE r.memberId = :memberId") + void deleteAllByMemberIdInBulk(@Param("memberId") Long memberId); +} diff --git a/src/main/java/talkPick/domain/random/application/RandomQueryService.java b/src/main/java/talkPick/domain/random/application/RandomQueryService.java index 451edbe3..51a1da4a 100644 --- a/src/main/java/talkPick/domain/random/application/RandomQueryService.java +++ b/src/main/java/talkPick/domain/random/application/RandomQueryService.java @@ -6,7 +6,7 @@ import talkPick.domain.random.adapter.out.dto.RandomResDTO; import talkPick.domain.random.port.in.RandomQueryUseCase; import talkPick.domain.random.port.out.RandomQueryRepositoryPort; -import talkPick.domain.topic.domain.type.CategoryGroup; + import java.util.List; @Service @@ -16,7 +16,7 @@ public class RandomQueryService implements RandomQueryUseCase { private final RandomQueryRepositoryPort randomQueryRepositoryPort; @Override - public List getRandomTopics(Long memberId, Long randomId, Integer order, CategoryGroup categoryGroup, String category) { - return randomQueryRepositoryPort.findRandomTopics(memberId, randomId, order, categoryGroup, category); + public List getRandomTopics(Long memberId, Long randomId, Integer order, String category) { + return randomQueryRepositoryPort.findRandomTopics(memberId, randomId, order, category); } } \ No newline at end of file diff --git a/src/main/java/talkPick/domain/random/dto/MemberDataDTO.java b/src/main/java/talkPick/domain/random/dto/MemberDataDTO.java index 3f2fa244..a6c8b8e8 100644 --- a/src/main/java/talkPick/domain/random/dto/MemberDataDTO.java +++ b/src/main/java/talkPick/domain/random/dto/MemberDataDTO.java @@ -1,23 +1,12 @@ package talkPick.domain.random.dto; import talkPick.domain.member.domain.Member; -import talkPick.domain.member.domain.type.Gender; import talkPick.domain.member.domain.type.MBTI; -import java.time.LocalDate; -import java.time.Period; - public record MemberDataDTO( - MBTI mbti, - Gender gender, - Integer age + MBTI mbti ) { public static MemberDataDTO from(Member member) { - return new MemberDataDTO(member.getMbti(), member.getGender(), calculateAge(member.getBirth())); - } - - private static int calculateAge(LocalDate birth) { - if (birth == null) return 0; - return Period.between(birth, LocalDate.now()).getYears(); + return new MemberDataDTO(member.getMbti()); } } \ No newline at end of file diff --git a/src/main/java/talkPick/domain/random/port/in/RandomQueryUseCase.java b/src/main/java/talkPick/domain/random/port/in/RandomQueryUseCase.java index d75f86f4..b6995b4c 100644 --- a/src/main/java/talkPick/domain/random/port/in/RandomQueryUseCase.java +++ b/src/main/java/talkPick/domain/random/port/in/RandomQueryUseCase.java @@ -1,9 +1,9 @@ package talkPick.domain.random.port.in; import talkPick.domain.random.adapter.out.dto.RandomResDTO; -import talkPick.domain.topic.domain.type.CategoryGroup; + import java.util.List; public interface RandomQueryUseCase { - List getRandomTopics(Long memberId, Long randomId, Integer order, CategoryGroup categoryGroup, String category); + List getRandomTopics(Long memberId, Long randomId, Integer order, String category); } diff --git a/src/main/java/talkPick/domain/random/port/out/RandomQueryRepositoryPort.java b/src/main/java/talkPick/domain/random/port/out/RandomQueryRepositoryPort.java index 1d62a287..99f9a055 100644 --- a/src/main/java/talkPick/domain/random/port/out/RandomQueryRepositoryPort.java +++ b/src/main/java/talkPick/domain/random/port/out/RandomQueryRepositoryPort.java @@ -2,10 +2,10 @@ import talkPick.domain.random.adapter.out.dto.RandomResDTO; import talkPick.domain.random.domain.Random; -import talkPick.domain.topic.domain.type.CategoryGroup; + import java.util.List; public interface RandomQueryRepositoryPort { - List findRandomTopics(Long memberId, Long randomId, Integer order, CategoryGroup categoryGroup, String category); + List findRandomTopics(Long memberId, Long randomId, Integer order, String category); Random findRandomByMemberIdAndId(Long memberId, Long randomId); } \ No newline at end of file diff --git a/src/main/java/talkPick/domain/today/adapter/out/event/TodayTopicSavedEventHandler.java b/src/main/java/talkPick/domain/today/adapter/out/event/TodayTopicSavedEventHandler.java index 18bb225b..91871c2a 100644 --- a/src/main/java/talkPick/domain/today/adapter/out/event/TodayTopicSavedEventHandler.java +++ b/src/main/java/talkPick/domain/today/adapter/out/event/TodayTopicSavedEventHandler.java @@ -2,10 +2,12 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; import talkPick.domain.today.adapter.out.repository.TodayTopicJpaRepository; import talkPick.domain.today.domain.event.TodayTopicSavedEvent; @@ -16,8 +18,8 @@ public class TodayTopicSavedEventHandler { private final TodayTopicJpaRepository todayTopicJpaRepository; @Async - @Transactional - @EventListener + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) public void handle(TodayTopicSavedEvent event) { try { if (event.getTodayTopics() != null && !event.getTodayTopics().isEmpty()) { diff --git a/src/main/java/talkPick/domain/today/adapter/out/repository/TodayTopicJpaRepository.java b/src/main/java/talkPick/domain/today/adapter/out/repository/TodayTopicJpaRepository.java index eda474e5..44186f3a 100644 --- a/src/main/java/talkPick/domain/today/adapter/out/repository/TodayTopicJpaRepository.java +++ b/src/main/java/talkPick/domain/today/adapter/out/repository/TodayTopicJpaRepository.java @@ -1,7 +1,15 @@ package talkPick.domain.today.adapter.out.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import talkPick.domain.today.domain.TodayTopic; public interface TodayTopicJpaRepository extends JpaRepository { -} + void deleteByMemberId(Long memberId); + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM TodayTopic t WHERE t.memberId = :memberId") + void deleteAllByMemberIdInBulk(@Param("memberId") Long memberId); +} \ No newline at end of file diff --git a/src/main/java/talkPick/domain/today/application/TodayTopicQueryService.java b/src/main/java/talkPick/domain/today/application/TodayTopicQueryService.java index fe154032..18115190 100644 --- a/src/main/java/talkPick/domain/today/application/TodayTopicQueryService.java +++ b/src/main/java/talkPick/domain/today/application/TodayTopicQueryService.java @@ -4,9 +4,7 @@ import org.springframework.cache.Cache; import org.springframework.cache.CacheManager; import org.springframework.context.ApplicationEventPublisher; -import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import talkPick.domain.today.adapter.out.dto.TodayTopicResDTO; import talkPick.domain.today.domain.TodayTopic; @@ -39,16 +37,11 @@ public List getTodayTopics(Long memberId) { cache.put(memberId, todayTopics); } - publishSavedEvent(memberId, todayTopics); - return todayTopics; - } - - @Async - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void publishSavedEvent(Long memberId, List todayTopics) { var entities = todayTopics.stream() .map(t -> TodayTopic.of(memberId, t.topicId())) .toList(); eventPublisher.publishEvent(TodayTopicSavedEvent.of(this, entities)); + + return todayTopics; } } \ No newline at end of file diff --git a/src/main/java/talkPick/domain/topic/adapter/in/TopicQueryApi.java b/src/main/java/talkPick/domain/topic/adapter/in/TopicQueryApi.java index ac3a34ba..9ded97f8 100644 --- a/src/main/java/talkPick/domain/topic/adapter/in/TopicQueryApi.java +++ b/src/main/java/talkPick/domain/topic/adapter/in/TopicQueryApi.java @@ -8,9 +8,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import talkPick.domain.topic.adapter.out.dto.TopicResDTO; -import talkPick.domain.topic.domain.type.CategoryGroup; import java.util.List; @Validated @@ -18,8 +16,8 @@ @Tag(name = "톡픽 API", description = "톡픽 관련 API 입니다.") public interface TopicQueryApi { @GetMapping("/categories") - @Operation(summary = "카테고리 전체 조회 API", description = "카테고리 전체 조회 API 입니다. 조회를 원하는 Category의 CategoryGroup(STRANGER : 첫 만남, CLOSE : 가까운 사이)를 파라미터에 넣어서 보내주세요.") - List getCategories(@RequestParam(name = "categoryGroup") CategoryGroup categoryGroup); + @Operation(summary = "카테고리 전체 조회 API", description = "카테고리 전체 조회 API 입니다. 기존에 CategoryGroup을 파라미터로 넣어서 보냈는데, 이제 안 넣으시고 요청하셔도 됩니다.") + List getCategories(); @GetMapping("/{topicId}") @Operation(summary = "토픽 상세 조회 API", description = "토픽 상세 조회 API 입니다.") diff --git a/src/main/java/talkPick/domain/topic/adapter/in/TopicQueryController.java b/src/main/java/talkPick/domain/topic/adapter/in/TopicQueryController.java index 931faa28..145eaaf1 100644 --- a/src/main/java/talkPick/domain/topic/adapter/in/TopicQueryController.java +++ b/src/main/java/talkPick/domain/topic/adapter/in/TopicQueryController.java @@ -2,7 +2,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.RestController; -import talkPick.domain.topic.domain.type.CategoryGroup; import talkPick.domain.topic.port.in.TopicQueryUseCase; import talkPick.domain.topic.adapter.out.dto.TopicResDTO; import java.util.List; @@ -13,8 +12,8 @@ public class TopicQueryController implements TopicQueryApi { private final TopicQueryUseCase topicQueryUseCase; @Override - public List getCategories(CategoryGroup categoryGroup) { - return topicQueryUseCase.getCategories(categoryGroup); + public List getCategories() { + return topicQueryUseCase.getCategories(); } @Override diff --git a/src/main/java/talkPick/domain/topic/adapter/out/TopicQueryRepositoryAdapter.java b/src/main/java/talkPick/domain/topic/adapter/out/TopicQueryRepositoryAdapter.java index 8c25c9bf..b5b40c3b 100644 --- a/src/main/java/talkPick/domain/topic/adapter/out/TopicQueryRepositoryAdapter.java +++ b/src/main/java/talkPick/domain/topic/adapter/out/TopicQueryRepositoryAdapter.java @@ -2,7 +2,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import talkPick.domain.topic.domain.type.CategoryGroup; import talkPick.domain.topic.domain.Topic; import talkPick.domain.topic.port.out.TopicQueryRepositoryPort; import talkPick.domain.topic.adapter.out.dto.TopicResDTO; @@ -25,8 +24,8 @@ public Topic findTopicById(final Long topicId) { } @Override - public List findCategoriesByCategoryGroup(CategoryGroup categoryGroup) { - return Optional.ofNullable(topicQuerydslRepository.findCategoriesByCategoryGroup(categoryGroup)) + public List findCategories() { + return Optional.ofNullable(topicQuerydslRepository.findCategories()) .orElse(Collections.emptyList()); } diff --git a/src/main/java/talkPick/domain/topic/adapter/out/dto/TopicResDTO.java b/src/main/java/talkPick/domain/topic/adapter/out/dto/TopicResDTO.java index 44b87ff7..ce0c009e 100644 --- a/src/main/java/talkPick/domain/topic/adapter/out/dto/TopicResDTO.java +++ b/src/main/java/talkPick/domain/topic/adapter/out/dto/TopicResDTO.java @@ -1,8 +1,5 @@ package talkPick.domain.topic.adapter.out.dto; -import talkPick.domain.topic.domain.Keyword; -import talkPick.domain.topic.domain.type.CategoryGroup; - public class TopicResDTO { public record Topic( Long id, @@ -12,8 +9,7 @@ public record Topic( public record Categories( Long categoryId, String title, - String imageUrl, - CategoryGroup categoryGroup + String imageUrl ) {} public record TopicDetail( @@ -21,7 +17,6 @@ public record TopicDetail( String title, String detail, String category, - CategoryGroup categoryGroup, String keywordName, String keywordImageUrl, String topicImageUrl diff --git a/src/main/java/talkPick/domain/topic/adapter/out/event/TopicLikedEventHandler.java b/src/main/java/talkPick/domain/topic/adapter/out/event/TopicLikedEventHandler.java new file mode 100644 index 00000000..60a0e196 --- /dev/null +++ b/src/main/java/talkPick/domain/topic/adapter/out/event/TopicLikedEventHandler.java @@ -0,0 +1,30 @@ +package talkPick.domain.topic.adapter.out.event; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +import talkPick.domain.topic.domain.event.TopicLikedEvent; +import talkPick.domain.topic.port.out.TopicStatCommandRepositoryPort; + +@Slf4j +@Component +@RequiredArgsConstructor +public class TopicLikedEventHandler { + private final TopicStatCommandRepositoryPort topicStatCommandRepositoryPort; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handle(TopicLikedEvent event) { + try { + topicStatCommandRepositoryPort.incrementLikeCount(event.getTopicId()); + } catch (Exception e) { + log.error("토픽 좋아요 수 증가 실패 - topicId: {}", event.getTopicId(), e); + } + } +} \ No newline at end of file diff --git a/src/main/java/talkPick/domain/topic/adapter/out/repository/TopicLikeHistoryJpaRepository.java b/src/main/java/talkPick/domain/topic/adapter/out/repository/TopicLikeHistoryJpaRepository.java index 9303a8d9..7609c6cf 100644 --- a/src/main/java/talkPick/domain/topic/adapter/out/repository/TopicLikeHistoryJpaRepository.java +++ b/src/main/java/talkPick/domain/topic/adapter/out/repository/TopicLikeHistoryJpaRepository.java @@ -1,7 +1,15 @@ package talkPick.domain.topic.adapter.out.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import talkPick.domain.topic.domain.TopicLikeHistory; public interface TopicLikeHistoryJpaRepository extends JpaRepository { -} + void deleteByMemberId(Long memberId); + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM TopicLikeHistory t WHERE t.memberId = :memberId") + void deleteAllByMemberIdInBulk(@Param("memberId") Long memberId); +} \ No newline at end of file diff --git a/src/main/java/talkPick/domain/topic/adapter/out/repository/TopicQuerydslRepository.java b/src/main/java/talkPick/domain/topic/adapter/out/repository/TopicQuerydslRepository.java index 486929ab..b3eb05df 100644 --- a/src/main/java/talkPick/domain/topic/adapter/out/repository/TopicQuerydslRepository.java +++ b/src/main/java/talkPick/domain/topic/adapter/out/repository/TopicQuerydslRepository.java @@ -5,7 +5,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; import talkPick.domain.topic.adapter.out.dto.TopicResDTO; -import talkPick.domain.topic.domain.type.CategoryGroup; import java.util.List; import static talkPick.domain.topic.domain.QCategory.category; import static talkPick.domain.topic.domain.QKeyword.keyword; @@ -17,15 +16,13 @@ public class TopicQuerydslRepository { private final JPAQueryFactory queryFactory; - public List findCategoriesByCategoryGroup(CategoryGroup categoryGroup) { + public List findCategories() { return queryFactory.select(Projections.constructor(TopicResDTO.Categories.class, category.id, category.title, - category.imageUrl, - category.categoryGroup + category.imageUrl )) .from(category) - .where(category.categoryGroup.eq(categoryGroup)) .fetch(); } @@ -35,7 +32,6 @@ public TopicResDTO.TopicDetail findTopicDetailById(Long topicId) { topic.title, topic.detail, category.title, - category.categoryGroup, keyword.name, keyword.imageUrl, topic.imageUrl diff --git a/src/main/java/talkPick/domain/topic/adapter/out/repository/TopicStatJpaRepository.java b/src/main/java/talkPick/domain/topic/adapter/out/repository/TopicStatJpaRepository.java index c6977428..cbbf7a34 100644 --- a/src/main/java/talkPick/domain/topic/adapter/out/repository/TopicStatJpaRepository.java +++ b/src/main/java/talkPick/domain/topic/adapter/out/repository/TopicStatJpaRepository.java @@ -11,7 +11,7 @@ public interface TopicStatJpaRepository extends JpaRepository { Optional findByTopicId(Long topicId); - @Modifying + @Modifying(clearAutomatically = true, flushAutomatically = true) @Query("UPDATE TopicStat SET likeCount = likeCount + 1 WHERE topicId = :topicId") void incrementLikeCount(@Param("topicId") Long topicId); } diff --git a/src/main/java/talkPick/domain/topic/application/TopicCommandService.java b/src/main/java/talkPick/domain/topic/application/TopicCommandService.java index 62b2e800..c60110f3 100644 --- a/src/main/java/talkPick/domain/topic/application/TopicCommandService.java +++ b/src/main/java/talkPick/domain/topic/application/TopicCommandService.java @@ -1,23 +1,24 @@ package talkPick.domain.topic.application; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import talkPick.domain.topic.port.out.TopicLikeHistoryCommandRepositoryPort; -import talkPick.domain.topic.port.out.TopicStatCommandRepositoryPort; +import talkPick.domain.topic.domain.event.TopicLikedEvent; import talkPick.domain.topic.port.in.TopicCommandUseCase; +import talkPick.domain.topic.port.out.TopicLikeHistoryCommandRepositoryPort; import talkPick.global.security.annotation.MemberId; @Service @Transactional @RequiredArgsConstructor public class TopicCommandService implements TopicCommandUseCase { - private final TopicStatCommandRepositoryPort topicStatCommandRepositoryPort; private final TopicLikeHistoryCommandRepositoryPort topicLikeHistoryCommandRepositoryPort; + private final ApplicationEventPublisher eventPublisher; @Override public void addLike(@MemberId Long memberId, Long topicId) { - topicStatCommandRepositoryPort.incrementLikeCount(topicId); topicLikeHistoryCommandRepositoryPort.save(memberId, topicId); + eventPublisher.publishEvent(TopicLikedEvent.of(this, memberId, topicId)); } } \ No newline at end of file diff --git a/src/main/java/talkPick/domain/topic/application/TopicQueryService.java b/src/main/java/talkPick/domain/topic/application/TopicQueryService.java index ea1e9e08..22e97c29 100644 --- a/src/main/java/talkPick/domain/topic/application/TopicQueryService.java +++ b/src/main/java/talkPick/domain/topic/application/TopicQueryService.java @@ -4,7 +4,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import talkPick.domain.topic.adapter.out.dto.TopicResDTO; -import talkPick.domain.topic.domain.type.CategoryGroup; import talkPick.domain.topic.port.in.TopicQueryUseCase; import talkPick.domain.topic.port.out.TopicQueryRepositoryPort; import java.util.List; @@ -16,8 +15,8 @@ public class TopicQueryService implements TopicQueryUseCase { private final TopicQueryRepositoryPort topicQueryRepositoryPort; @Override - public List getCategories(CategoryGroup categoryGroup) { - return topicQueryRepositoryPort.findCategoriesByCategoryGroup(categoryGroup); + public List getCategories() { + return topicQueryRepositoryPort.findCategories(); } @Override diff --git a/src/main/java/talkPick/domain/topic/domain/Category.java b/src/main/java/talkPick/domain/topic/domain/Category.java index f22a123a..cc810688 100644 --- a/src/main/java/talkPick/domain/topic/domain/Category.java +++ b/src/main/java/talkPick/domain/topic/domain/Category.java @@ -2,7 +2,6 @@ import jakarta.persistence.*; import lombok.*; -import talkPick.domain.topic.domain.type.CategoryGroup; @Getter @Entity @@ -22,15 +21,10 @@ public class Category { @Column(name = "image_url", nullable = true, length = 500, columnDefinition = "VARCHAR(500) COMMENT '카테고리 이미지 URL'") private String imageUrl; - @Enumerated(EnumType.STRING) - @Column(name = "category_group", nullable = false, columnDefinition = "VARCHAR(20) COMMENT '카테고리 그룹'") - private CategoryGroup categoryGroup; - - public static Category of(String title, String imageUrl, CategoryGroup categoryGroup) { + public static Category of(String title, String imageUrl) { return Category.builder() .title(title) .imageUrl(imageUrl) - .categoryGroup(categoryGroup) .build(); } } \ No newline at end of file diff --git a/src/main/java/talkPick/domain/topic/domain/TopicStat.java b/src/main/java/talkPick/domain/topic/domain/TopicStat.java index f04201f9..d9b232d2 100644 --- a/src/main/java/talkPick/domain/topic/domain/TopicStat.java +++ b/src/main/java/talkPick/domain/topic/domain/TopicStat.java @@ -2,12 +2,7 @@ import jakarta.persistence.*; import lombok.*; -import talkPick.domain.member.domain.Member; -import talkPick.domain.member.domain.type.Gender; -import talkPick.domain.member.domain.type.MBTI; -import java.time.LocalDate; -//TODO 동시성 고려해야 함. @Getter @Entity @Builder @@ -77,10 +72,6 @@ public class TopicStat { @Column(name = "average_talk_time", nullable = false, columnDefinition = "BIGINT COMMENT '평균 토크 시간(ms)'") private long averageTalkTime; - @Version - @Column(name = "version", nullable = false, columnDefinition = "BIGINT COMMENT '버전'") - private Long version; - public static TopicStat of(Long topicId) { return TopicStat.builder() .topicId(topicId) @@ -92,8 +83,6 @@ public static TopicStat of(Long topicId) { .tCount(0) .jCount(0) .pCount(0) - .averageTalkTime(0) - .selectCount(0) .likeCount(0) .teenCount(0) .twentiesCount(0) @@ -102,82 +91,8 @@ public static TopicStat of(Long topicId) { .fiftiesCount(0) .maleCount(0) .femaleCount(0) + .selectCount(0) + .averageTalkTime(0) .build(); } - - public void addLike() { - this.likeCount++; - } - - //TODO 이 메서드 호출할 때 락 체크 + 리트라이 필요 - public void update(Member member, long talkTime) { - MBTI mbti = MBTI.INFP; - updateMBTI(mbti); - updateAge(member.getBirth()); - updateGender(member.getGender()); - updateAverageTalkTime(talkTime); - this.selectCount++; - } - - private void updateMBTI(MBTI mbti) { - if (mbti != null) { - String mbtiString = mbti.name(); - if (mbtiString.startsWith("E")) { - this.eCount++; - } else if (mbtiString.startsWith("I")) { - this.iCount++; - } - if (mbtiString.charAt(1) == 'S') { - this.sCount++; - } else if (mbtiString.charAt(1) == 'N') { - this.nCount++; - } - if (mbtiString.charAt(2) == 'F') { - this.fCount++; - } else if (mbtiString.charAt(2) == 'T') { - this.tCount++; - } - if (mbtiString.charAt(3) == 'J') { - this.jCount++; - } else if (mbtiString.charAt(3) == 'P') { - this.pCount++; - } - } - } - - private void updateAge(LocalDate birth) { - if (birth != null) { - int age = LocalDate.now().getYear() - birth.getYear(); - - if (age < 20) { - this.teenCount++; - } else if (age < 30) { - this.twentiesCount++; - } else if (age < 40) { - this.thirtiesCount++; - } else if (age < 50) { - this.fortiesCount++; - } else { - this.fiftiesCount++; - } - } - } - - private void updateGender(Gender gender) { - if (gender != null) { - if (gender == Gender.MALE) { - this.maleCount++; - } else if (gender == Gender.FEMALE) { - this.femaleCount++; - } - } - } - - private void updateAverageTalkTime(long talkTime) { - if (this.selectCount == 1) { - this.averageTalkTime = talkTime; - } else { - this.averageTalkTime = ((this.averageTalkTime * (this.selectCount - 1)) + talkTime) / this.selectCount; - } - } } \ No newline at end of file diff --git a/src/main/java/talkPick/domain/topic/domain/event/TopicLikedEvent.java b/src/main/java/talkPick/domain/topic/domain/event/TopicLikedEvent.java new file mode 100644 index 00000000..332f1271 --- /dev/null +++ b/src/main/java/talkPick/domain/topic/domain/event/TopicLikedEvent.java @@ -0,0 +1,20 @@ +package talkPick.domain.topic.domain.event; + +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +@Getter +public class TopicLikedEvent extends ApplicationEvent { + private final Long memberId; + private final Long topicId; + + private TopicLikedEvent(Object source, Long memberId, Long topicId) { + super(source); + this.memberId = memberId; + this.topicId = topicId; + } + + public static TopicLikedEvent of(Object source, Long memberId, Long topicId) { + return new TopicLikedEvent(source, memberId, topicId); + } +} diff --git a/src/main/java/talkPick/domain/topic/domain/type/CategoryGroup.java b/src/main/java/talkPick/domain/topic/domain/type/CategoryGroup.java deleted file mode 100644 index 1410b617..00000000 --- a/src/main/java/talkPick/domain/topic/domain/type/CategoryGroup.java +++ /dev/null @@ -1,6 +0,0 @@ -package talkPick.domain.topic.domain.type; - -public enum CategoryGroup { - STRANGER, // 첫 만남 - CLOSE // 가까운 사이 -} \ No newline at end of file diff --git a/src/main/java/talkPick/domain/topic/port/in/TopicQueryUseCase.java b/src/main/java/talkPick/domain/topic/port/in/TopicQueryUseCase.java index bacd39fe..5bb33766 100644 --- a/src/main/java/talkPick/domain/topic/port/in/TopicQueryUseCase.java +++ b/src/main/java/talkPick/domain/topic/port/in/TopicQueryUseCase.java @@ -1,10 +1,9 @@ package talkPick.domain.topic.port.in; import talkPick.domain.topic.adapter.out.dto.TopicResDTO; -import talkPick.domain.topic.domain.type.CategoryGroup; import java.util.List; public interface TopicQueryUseCase { - List getCategories(CategoryGroup categoryGroup); + List getCategories(); TopicResDTO.TopicDetail getTopicDetail(Long topicId); } \ No newline at end of file diff --git a/src/main/java/talkPick/domain/topic/port/out/TopicQueryRepositoryPort.java b/src/main/java/talkPick/domain/topic/port/out/TopicQueryRepositoryPort.java index 9ae772aa..1d2786d6 100644 --- a/src/main/java/talkPick/domain/topic/port/out/TopicQueryRepositoryPort.java +++ b/src/main/java/talkPick/domain/topic/port/out/TopicQueryRepositoryPort.java @@ -1,12 +1,11 @@ package talkPick.domain.topic.port.out; -import talkPick.domain.topic.domain.type.CategoryGroup; import talkPick.domain.topic.adapter.out.dto.TopicResDTO; import talkPick.domain.topic.domain.Topic; import java.util.List; public interface TopicQueryRepositoryPort { Topic findTopicById(final Long topicId); - List findCategoriesByCategoryGroup(CategoryGroup categoryGroup); + List findCategories(); TopicResDTO.TopicDetail findTopicDetail(Long topicId); } \ No newline at end of file diff --git a/src/main/java/talkPick/external/apple/application/AppleOidcService.java b/src/main/java/talkPick/external/apple/application/AppleOidcService.java index d23f7de8..80b9468c 100644 --- a/src/main/java/talkPick/external/apple/application/AppleOidcService.java +++ b/src/main/java/talkPick/external/apple/application/AppleOidcService.java @@ -8,7 +8,6 @@ import io.jsonwebtoken.Jwts; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import talkPick.domain.member.adapter.in.dto.MemberReqDto; import talkPick.domain.member.converter.MemberConverter; @@ -34,8 +33,7 @@ public class AppleOidcService implements AppleOidcUsecase { private static final String JWK_URL = "https://appleid.apple.com/auth/keys"; // iss 검증 값 private static final String ISSUER = "https://appleid.apple.com"; - @Value("${apple.bundle-id}") - private String BUNDLE_ID; + private static final String BUNDLE_ID = "io.tuist.TalkPick"; @Override public MemberDataDto.MemberData verifyAndParseIdToken(MemberReqDto.OAuth2LoginRequest request) { @@ -58,8 +56,10 @@ public MemberDataDto.MemberData verifyAndParseIdToken(MemberReqDto.OAuth2LoginRe break; } } - if (matchedKey == null) throw new AppleHandler(ErrorCode.ERROR_ON_VERIFYING); - + if (matchedKey == null) { + log.error("Apple Matching Key not found for kid: {}", kid); + throw new AppleHandler(ErrorCode.ERROR_ON_VERIFYING, "애플 공개키 목록에서 kid가 일치하는 키를 찾을 수 없습니다. kid: " + kid); + } String n = matchedKey.get("n").asText(); String e = matchedKey.get("e").asText(); @@ -79,20 +79,27 @@ public MemberDataDto.MemberData verifyAndParseIdToken(MemberReqDto.OAuth2LoginRe Object audObj = claims.get("aud"); boolean audOk = false; + log.info("Apple BUNDLE_ID: {}, Token aud: {}", BUNDLE_ID, audObj); + if (audObj instanceof String audStr) { audOk = BUNDLE_ID.equals(audStr); } else if (audObj instanceof List audList) { audOk = audList.stream().anyMatch(a -> BUNDLE_ID.equals(String.valueOf(a))); } - if (!audOk) throw new AppleHandler(ErrorCode.INVALID_JWT_TOKEN); + + if (!audOk) { + log.error("Apple Audience mismatch. Expected: {}, Received: {}", BUNDLE_ID, audObj); + throw new AppleHandler(ErrorCode.INVALID_JWT_TOKEN, "애플 토큰의 aud(Audience)가 일치하지 않습니다. 기대값: " + BUNDLE_ID + ", 실제값: " + audObj); + } return MemberConverter.toAppleMemberData(claims); } catch (ExpiredJwtException e) { - throw new AppleHandler(ErrorCode.EXPIRED_JWT_TOKEN); + log.error("Apple Token Expired", e); + throw new AppleHandler(ErrorCode.EXPIRED_JWT_TOKEN, "애플 토큰이 만료되었습니다: " + e.getMessage()); } catch (Exception e) { - log.error("Apple OAuth Error", e); - throw new AppleHandler(ErrorCode.ERROR_ON_VERIFYING); + log.error("Apple OAuth Error: {}", e.getMessage(), e); + throw new AppleHandler(ErrorCode.ERROR_ON_VERIFYING, "애플 토큰 검증 중 오류가 발생했습니다: " + e.getMessage()); } } } \ No newline at end of file diff --git a/src/main/java/talkPick/external/google/application/GoogleOidcService.java b/src/main/java/talkPick/external/google/application/GoogleOidcService.java new file mode 100644 index 00000000..fe41860f --- /dev/null +++ b/src/main/java/talkPick/external/google/application/GoogleOidcService.java @@ -0,0 +1,99 @@ +package talkPick.external.google.application; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtParser; +import io.jsonwebtoken.Jwts; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import talkPick.domain.member.adapter.in.dto.MemberReqDto; +import talkPick.domain.member.converter.MemberConverter; +import talkPick.domain.member.dto.MemberDataDto; +import talkPick.external.google.port.in.GoogleOidcUsecase; +import talkPick.global.exception.ErrorCode; +import talkPick.global.exception.handler.GoogleHandler; + +import java.math.BigInteger; +import java.net.URL; +import java.security.KeyFactory; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.RSAPublicKeySpec; +import java.util.Base64; + +@Slf4j +@Service +@RequiredArgsConstructor +public class GoogleOidcService implements GoogleOidcUsecase { + + // Google JWKS URL + private static final String JWK_URL = "https://www.googleapis.com/oauth2/v3/certs"; + + @Value("${google.client-id}") + private String CLIENT_ID; + + @Override + public MemberDataDto.MemberData verifyAndParseIdToken(MemberReqDto.OAuth2LoginRequest request) { + try { + String idToken = request.getIdToken(); + + // 1. 헤더 파싱해서 kid 찾기 + String[] parts = idToken.split("\\."); + if (parts.length != 3) throw new GoogleHandler(ErrorCode.INVALID_JWT_TOKEN); + + String headerJson = new String(Base64.getUrlDecoder().decode(parts[0])); + ObjectMapper mapper = new ObjectMapper(); + JsonNode header = mapper.readTree(headerJson); + String kid = header.get("kid").asText(); + + // 2. 구글 공개키 목록(JWKS) 가져와서 kid 일치하는 키 찾기 + JsonNode keys = mapper.readTree(new URL(JWK_URL)).get("keys"); + JsonNode matchedKey = null; + for (JsonNode key : keys) { + if (key.get("kid").asText().equals(kid)) { + matchedKey = key; + break; + } + } + if (matchedKey == null) throw new GoogleHandler(ErrorCode.ERROR_ON_VERIFYING); + + // 3. RSA Public Key 생성 + String n = matchedKey.get("n").asText(); + String e = matchedKey.get("e").asText(); + BigInteger modulus = new BigInteger(1, Base64.getUrlDecoder().decode(n)); + BigInteger exponent = new BigInteger(1, Base64.getUrlDecoder().decode(e)); + RSAPublicKeySpec spec = new RSAPublicKeySpec(modulus, exponent); + RSAPublicKey publicKey = (RSAPublicKey) KeyFactory.getInstance("RSA").generatePublic(spec); + + // 4. 토큰 검증 + JwtParser parser = Jwts.parserBuilder() + .setSigningKey(publicKey) + .setAllowedClockSkewSeconds(300) + .build(); + + Claims claims = parser.parseClaimsJws(idToken).getBody(); + + // 5. aud (Client ID) 검증 및 iss 검증 + String aud = claims.getAudience(); + if (aud == null || !aud.equals(CLIENT_ID)) { + throw new GoogleHandler(ErrorCode.INVALID_JWT_TOKEN); + } + + String iss = claims.getIssuer(); + if (!"https://accounts.google.com".equals(iss) && !"accounts.google.com".equals(iss)) { + throw new GoogleHandler(ErrorCode.INVALID_JWT_TOKEN); + } + + return MemberConverter.toGoogleMemberData(claims); + + } catch (ExpiredJwtException e) { + throw new GoogleHandler(ErrorCode.EXPIRED_JWT_TOKEN); + } catch (Exception e) { + log.error("Google OAuth Error", e); + throw new GoogleHandler(ErrorCode.ERROR_ON_VERIFYING); + } + } +} diff --git a/src/main/java/talkPick/external/google/port/in/GoogleOidcUsecase.java b/src/main/java/talkPick/external/google/port/in/GoogleOidcUsecase.java new file mode 100644 index 00000000..a30b3616 --- /dev/null +++ b/src/main/java/talkPick/external/google/port/in/GoogleOidcUsecase.java @@ -0,0 +1,11 @@ +package talkPick.external.google.port.in; + +import talkPick.domain.member.adapter.in.dto.MemberReqDto; +import talkPick.domain.member.dto.MemberDataDto; + +public interface GoogleOidcUsecase { + /** + * Google ID Token 검증 및 회원 정보 추출 + */ + MemberDataDto.MemberData verifyAndParseIdToken(MemberReqDto.OAuth2LoginRequest request); +} diff --git a/src/main/java/talkPick/global/config/SchedulingConfig.java b/src/main/java/talkPick/global/config/SchedulingConfig.java new file mode 100644 index 00000000..5fbe5415 --- /dev/null +++ b/src/main/java/talkPick/global/config/SchedulingConfig.java @@ -0,0 +1,20 @@ +package talkPick.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.SchedulingConfigurer; +import org.springframework.scheduling.config.ScheduledTaskRegistrar; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +@Configuration +public class SchedulingConfig implements SchedulingConfigurer { + + @Override + public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { + ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler(); + taskScheduler.setPoolSize(3); + taskScheduler.setThreadNamePrefix("scheduled-task-"); + taskScheduler.initialize(); + + taskRegistrar.setTaskScheduler(taskScheduler); + } +} diff --git a/src/main/java/talkPick/global/exception/ErrorCode.java b/src/main/java/talkPick/global/exception/ErrorCode.java index c76ee30e..97de4952 100644 --- a/src/main/java/talkPick/global/exception/ErrorCode.java +++ b/src/main/java/talkPick/global/exception/ErrorCode.java @@ -70,6 +70,7 @@ public enum ErrorCode { // Member MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 회원입니다."), + MEMBER_IS_WITHDRAWN(HttpStatus.FORBIDDEN, "탈퇴한 회원입니다."), MEMBER_EMAIL_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "이미 가입된 이메일입니다."), INVALID_MEMBER_INFO(HttpStatus.BAD_REQUEST, "회원 필수 정보가 누락되었습니다."), PASSWORD_REQUIRED(HttpStatus.BAD_REQUEST, "비밀번호는 필수입니다."), diff --git a/src/main/java/talkPick/global/exception/GlobalExceptionHandler.java b/src/main/java/talkPick/global/exception/GlobalExceptionHandler.java index 13f09a64..ce42dd93 100644 --- a/src/main/java/talkPick/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/talkPick/global/exception/GlobalExceptionHandler.java @@ -27,10 +27,14 @@ public class GlobalExceptionHandler { // 커스텀 예외 처리 @ExceptionHandler(TalkPickException.class) public ResponseEntity> talkPickExceptionHandler(final TalkPickException e) { + String message = e.getMessage(); + if (message == null || message.isBlank()) { + message = e.getErrorCode().getMessage(); + } return ResponseEntity .status(e.getErrorCode().getStatus()) .headers(jsonHeaders) - .body(ApiResponse.ofErrorCode(e.getErrorCode())); + .body(ApiResponse.ofErrorCode(e.getErrorCode(), message)); } // 유효성 검사 실패 예외 처리 diff --git a/src/main/java/talkPick/global/exception/handler/AppleHandler.java b/src/main/java/talkPick/global/exception/handler/AppleHandler.java index 1da65cff..ae80bdcf 100644 --- a/src/main/java/talkPick/global/exception/handler/AppleHandler.java +++ b/src/main/java/talkPick/global/exception/handler/AppleHandler.java @@ -7,4 +7,8 @@ public class AppleHandler extends TalkPickException { public AppleHandler(ErrorCode errorCode) { super(errorCode); } + + public AppleHandler(ErrorCode errorCode, String message) { + super(errorCode, message); + } } \ No newline at end of file diff --git a/src/main/java/talkPick/global/exception/handler/GoogleHandler.java b/src/main/java/talkPick/global/exception/handler/GoogleHandler.java new file mode 100644 index 00000000..828c0ee8 --- /dev/null +++ b/src/main/java/talkPick/global/exception/handler/GoogleHandler.java @@ -0,0 +1,10 @@ +package talkPick.global.exception.handler; + +import talkPick.global.exception.ErrorCode; +import talkPick.global.exception.TalkPickException; + +public class GoogleHandler extends TalkPickException { + public GoogleHandler(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/talkPick/global/response/ApiResponse.java b/src/main/java/talkPick/global/response/ApiResponse.java index f3cbd121..f96cbf57 100644 --- a/src/main/java/talkPick/global/response/ApiResponse.java +++ b/src/main/java/talkPick/global/response/ApiResponse.java @@ -71,6 +71,15 @@ public static ApiResponse ofErrorCode(ErrorCode errorCode) { .build(); } + public static ApiResponse ofErrorCode(ErrorCode errorCode, String message) { + return ApiResponse.builder() + .status("FAIL") + .message(message) + .timestamp(LocalDateTime.now()) + .httpStatus(errorCode.getStatus().value()) + .build(); + } + public static ApiResponse ofErrorCode(ErrorCode errorCode, T data) { return ApiResponse.builder() .status("FAIL") diff --git a/src/main/java/talkPick/global/security/config/SecurityConfig.java b/src/main/java/talkPick/global/security/config/SecurityConfig.java index 34409074..6a47e29b 100644 --- a/src/main/java/talkPick/global/security/config/SecurityConfig.java +++ b/src/main/java/talkPick/global/security/config/SecurityConfig.java @@ -53,6 +53,7 @@ public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Excepti .authorizeHttpRequests( authorizationManagerRequestMatcherRegistry -> authorizationManagerRequestMatcherRegistry + .requestMatchers("/api/v1/members/google/login").permitAll() .requestMatchers(PATHS).permitAll() // whiteList는 인증 없이 접근 가능 .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .anyRequest().authenticated() diff --git a/src/main/java/talkPick/global/security/jwt/repository/RefreshTokenRepository.java b/src/main/java/talkPick/global/security/jwt/repository/RefreshTokenRepository.java index 95b0c40f..ab4cdcdf 100644 --- a/src/main/java/talkPick/global/security/jwt/repository/RefreshTokenRepository.java +++ b/src/main/java/talkPick/global/security/jwt/repository/RefreshTokenRepository.java @@ -1,6 +1,9 @@ package talkPick.global.security.jwt.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import talkPick.domain.member.domain.Member; import talkPick.global.security.jwt.RefreshToken; @@ -13,4 +16,8 @@ public interface RefreshTokenRepository extends JpaRepository findByMember(Member member); Optional findByMemberId(Long memberId); void deleteByMember(Member member); -} \ No newline at end of file + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM RefreshToken r WHERE r.member.id = :memberId") + void deleteAllByMemberIdInBulk(@Param("memberId") Long memberId); +} diff --git a/src/main/java/talkPick/global/security/model/WhiteList.java b/src/main/java/talkPick/global/security/model/WhiteList.java index 2b473a15..ef38569c 100644 --- a/src/main/java/talkPick/global/security/model/WhiteList.java +++ b/src/main/java/talkPick/global/security/model/WhiteList.java @@ -8,6 +8,10 @@ private WhiteList() {} // 인스턴스화 방지 "/api/v1/admin/login", "/api/v1/members/kakao/login", "/api/v1/members/apple/login", + "/api/v1/members/google/login", + "/api/v1/members/kakao/reactivate", + "/api/v1/members/google/reactivate", + "/api/v1/members/apple/reactivate", "/api/v1/members/token/refresh", "/api/v1/inquiry", "/swagger-ui/**", @@ -19,3 +23,4 @@ private WhiteList() {} // 인스턴스화 방지 "/actuator/health/**" }; } + diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml deleted file mode 100644 index 6372f7af..00000000 --- a/src/main/resources/application-local.yml +++ /dev/null @@ -1,91 +0,0 @@ -jwt: - secret: ${JWT_SECRET} - accessTokenExpireTime: ${JWT_ACCESS_EXPIRE_TIME} - refreshTokenExpireTime: ${JWT_REFRESH_EXPIRE_TIME} - -server: - port: ${SERVER_PORT} - tomcat: - threads: - max: ${THREADS_MAX} - accept-count: ${ACCEPT_COUNT} - -spring: - autoconfigure: - exclude: org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration,org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration - config: - import: optional:file:.env[.properties] - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME} - username: ${DB_USER} - password: ${DB_PASSWORD} - hikari: - connection-timeout: ${CONNECTION_TIMEOUT} - maximum-pool-size: ${MAXIMUM_POOL_SIZE} - idle-timeout: ${IDLE_TIMEOUT} - max-lifetime: ${MAX_LIFETIME} - jpa: - show-sql: true - properties: - hibernate: - format_sql: true - database-platform: org.hibernate.dialect.MySQL8Dialect - hibernate: - ddl-auto: update - servlet: - multipart: - max-file-size: ${MAX_FILE_SIZE} - max-request-size: ${MAX_REQUEST_SIZE} - messages: - basename: messages,errors - mvc: - throw-exception-if-no-handler-found: true - web: - resources: - add-mappings: false - -logging: - level: - root: info - org.hibernate.SQL: debug - org.hibernate.orm.jdbc.bind: trace - org.springframework.web.cors: debug -# org.apache.coyote.http11: trace - -management: - endpoint: - health: - show-details: always - endpoints: - web: - exposure: - include: health, prometheus - -kakao: - client-id: ${KAKAO_CLIENT_ID} - redirect-uri: ${KAKAO_REDIRECT_URI} - response-type: ${KAKAO_RESPONSE_TYPE} - -apple: - bundle-id: ${APPLE_BUNDLE_ID} - -resilience4j: - circuitbreaker: - instances: - llm: - registerHealthIndicator: true - slidingWindowSize: 10 - failureRateThreshold: 50 - waitDurationInOpenState: 10s - -healthcheck: - url: ${HEALTH_CHECK_URL} - -jasypt: - admin: - secret-key: ${JASYPT_ADMIN_SECRET_KEY} - member: - secret-key: ${JASYPT_MEMBER_SECRET_KEY} - algorithm: ${JASYPT_ALGORITHM:PBEWITHHMACSHA512ANDAES_256} - pool-size: ${JASYPT_POOL_SIZE:1} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ef46c2ad..b51678d4 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,3 +1,93 @@ +jwt: + secret: ${JWT_SECRET} + accessTokenExpireTime: ${JWT_ACCESS_EXPIRE_TIME} + refreshTokenExpireTime: ${JWT_REFRESH_EXPIRE_TIME} + +server: + forward-headers-strategy: framework + port: ${SERVER_PORT} + tomcat: + threads: + max: ${THREADS_MAX} + accept-count: ${ACCEPT_COUNT} + spring: - profiles: - active: local \ No newline at end of file + autoconfigure: + exclude: org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration,org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME} + username: ${DB_USER} + password: ${DB_PASSWORD} + hikari: + connection-timeout: ${CONNECTION_TIMEOUT} + maximum-pool-size: ${MAXIMUM_POOL_SIZE} + idle-timeout: ${IDLE_TIMEOUT} + max-lifetime: ${MAX_LIFETIME} + jpa: + show-sql: true + properties: + hibernate: + format_sql: true + database-platform: org.hibernate.dialect.MySQL8Dialect + hibernate: + ddl-auto: update + servlet: + multipart: + max-file-size: ${MAX_FILE_SIZE} + max-request-size: ${MAX_REQUEST_SIZE} + messages: + basename: messages,errors + mvc: + throw-exception-if-no-handler-found: true + web: + resources: + add-mappings: false + +logging: + level: + root: info + org.hibernate.SQL: debug + org.hibernate.orm.jdbc.bind: trace + org.springframework.web.cors: debug +# org.apache.coyote.http11: trace + +management: + endpoint: + health: + show-details: always + endpoints: + web: + exposure: + include: health, prometheus + +kakao: + client-id: ${KAKAO_CLIENT_ID} + redirect-uri: ${KAKAO_REDIRECT_URI} + response-type: ${KAKAO_RESPONSE_TYPE} + +apple: + bundle-id: ${APPLE_BUNDLE_ID} + +google: + client-id: ${GOOGLE_CLIENT_ID} + +resilience4j: + circuitbreaker: + instances: + llm: + registerHealthIndicator: true + slidingWindowSize: 10 + failureRateThreshold: 50 + waitDurationInOpenState: 10s + +healthcheck: + url: ${HEALTH_CHECK_URL} + +jasypt: + admin: + secret-key: ${JASYPT_ADMIN_SECRET_KEY} + member: + secret-key: ${JASYPT_MEMBER_SECRET_KEY} + algorithm: ${JASYPT_ALGORITHM:PBEWITHHMACSHA512ANDAES_256} + pool-size: ${JASYPT_POOL_SIZE:1} diff --git a/src/test/java/talkPick/notice/application/NoticeQueryServiceTest.java b/src/test/java/talkPick/notice/application/NoticeQueryServiceTest.java new file mode 100644 index 00000000..dbc440f9 --- /dev/null +++ b/src/test/java/talkPick/notice/application/NoticeQueryServiceTest.java @@ -0,0 +1,230 @@ +package talkPick.notice.application; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import talkPick.domain.notice.adapter.in.dto.NoticeReqDTO; +import talkPick.domain.notice.adapter.out.dto.NoticeResDTO; +import talkPick.domain.notice.application.NoticeQueryService; +import talkPick.domain.notice.domain.event.NoticeReadEvent; +import talkPick.domain.notice.port.out.NoticeQueryRepositoryPort; +import talkPick.global.response.CursorPageResponse; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; + +@ExtendWith(MockitoExtension.class) +@DisplayName("NoticeQueryService 테스트") +class NoticeQueryServiceTest { + + @InjectMocks + private NoticeQueryService noticeQueryService; + + @Mock + private NoticeQueryRepositoryPort noticeQueryRepositoryPort; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @Test + @DisplayName("커서 기반 페이징으로 공지사항 목록 조회 테스트") + void 커서_기반_페이징으로_공지사항_목록_조회_테스트() { + // given + NoticeReqDTO.Cursor cursor = new NoticeReqDTO.Cursor( + LocalDateTime.now(), + 100L, + 20 + ); + + List noticeList = List.of( + new NoticeResDTO.NoticeSummary(1L, "제목1", "내용1", LocalDateTime.now(), LocalDateTime.now()), + new NoticeResDTO.NoticeSummary(2L, "제목2", "내용2", LocalDateTime.now(), LocalDateTime.now()) + ); + + CursorPageResponse expectedResponse = CursorPageResponse.builder() + .items(noticeList) + .hasNext(true) + .build(); + + given(noticeQueryRepositoryPort.findNoticesWithCursor(cursor)) + .willReturn(expectedResponse); + + // when + CursorPageResponse response = noticeQueryService.getNotices(cursor); + + // then + assertAll( + () -> assertThat(response).isNotNull(), + () -> assertThat(response.getItems()).hasSize(2), + () -> assertThat(response.isHasNext()).isTrue(), + () -> verify(noticeQueryRepositoryPort, times(1)).findNoticesWithCursor(cursor) + ); + } + + @Test + @DisplayName("공지사항 목록 조회 시 빈 결과 반환 테스트") + void 공지사항_목록_조회시_빈_결과_반환_테스트() { + // given + NoticeReqDTO.Cursor cursor = new NoticeReqDTO.Cursor( + LocalDateTime.now(), + 1L, + 20 + ); + + CursorPageResponse emptyResponse = CursorPageResponse.builder() + .items(Collections.emptyList()) + .hasNext(false) + .build(); + + given(noticeQueryRepositoryPort.findNoticesWithCursor(cursor)) + .willReturn(emptyResponse); + + // when + CursorPageResponse response = noticeQueryService.getNotices(cursor); + + // then + assertAll( + () -> assertThat(response).isNotNull(), + () -> assertThat(response.getItems()).isEmpty(), + () -> assertThat(response.isHasNext()).isFalse(), + () -> verify(noticeQueryRepositoryPort, times(1)).findNoticesWithCursor(cursor) + ); + } + + @Test + @DisplayName("공지사항 상세 조회 및 조회 이벤트 발행 테스트") + void 공지사항_상세_조회_및_조회_이벤트_발행_테스트() { + // given + Long noticeId = 100L; + NoticeResDTO.NoticeDetail expectedDetail = new NoticeResDTO.NoticeDetail( + noticeId, + "공지사항 제목", + "공지사항 내용입니다.", + 50, + LocalDateTime.now(), + LocalDateTime.now() + ); + expectedDetail.addImageUrls(List.of( + "https://example.com/image1.png", + "https://example.com/image2.png" + )); + + given(noticeQueryRepositoryPort.findNoticeDetailById(noticeId)) + .willReturn(expectedDetail); + + // when + NoticeResDTO.NoticeDetail result = noticeQueryService.getNoticeDetail(noticeId); + + // then + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.getNoticeId()).isEqualTo(noticeId), + () -> assertThat(result.getTitle()).isEqualTo("공지사항 제목"), + () -> assertThat(result.getContent()).isEqualTo("공지사항 내용입니다."), + () -> assertThat(result.getReadCount()).isEqualTo(50), + () -> assertThat(result.getImageUrls()).hasSize(2), + () -> verify(noticeQueryRepositoryPort, times(1)).findNoticeDetailById(noticeId), + () -> verify(eventPublisher, times(1)).publishEvent(any(NoticeReadEvent.class)) + ); + } + + @Test + @DisplayName("이미지가 없는 공지사항 상세 조회 테스트") + void 이미지가_없는_공지사항_상세_조회_테스트() { + // given + Long noticeId = 200L; + NoticeResDTO.NoticeDetail expectedDetail = new NoticeResDTO.NoticeDetail( + noticeId, + "이미지 없는 공지", + "내용", + 10, + LocalDateTime.now(), + LocalDateTime.now() + ); + expectedDetail.addImageUrls(Collections.emptyList()); + + given(noticeQueryRepositoryPort.findNoticeDetailById(noticeId)) + .willReturn(expectedDetail); + + // when + NoticeResDTO.NoticeDetail result = noticeQueryService.getNoticeDetail(noticeId); + + // then + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.getImageUrls()).isEmpty(), + () -> verify(eventPublisher, times(1)).publishEvent(any(NoticeReadEvent.class)) + ); + } + + @Test + @DisplayName("공지사항 상세 조회 시 Repository 조회 후 이벤트 발행 순서 확인 테스트") + void 공지사항_상세_조회시_Repository_조회_후_이벤트_발행_순서_확인_테스트() { + // given + Long noticeId = 300L; + NoticeResDTO.NoticeDetail mockDetail = new NoticeResDTO.NoticeDetail( + noticeId, + "제목", + "내용", + 0, + LocalDateTime.now(), + LocalDateTime.now() + ); + + given(noticeQueryRepositoryPort.findNoticeDetailById(noticeId)) + .willReturn(mockDetail); + + // when + noticeQueryService.getNoticeDetail(noticeId); + + // then + var inOrder = org.mockito.Mockito.inOrder(noticeQueryRepositoryPort, eventPublisher); + inOrder.verify(noticeQueryRepositoryPort).findNoticeDetailById(noticeId); + inOrder.verify(eventPublisher).publishEvent(any(NoticeReadEvent.class)); + } + + @Test + @DisplayName("공지사항 목록 조회 시 Repository 예외 발생 테스트") + void 공지사항_목록_조회시_Repository_예외_발생_테스트() { + // given + NoticeReqDTO.Cursor cursor = new NoticeReqDTO.Cursor( + LocalDateTime.now(), + 1L, + 20 + ); + + given(noticeQueryRepositoryPort.findNoticesWithCursor(cursor)) + .willThrow(new RuntimeException("DB Connection failed")); + + // when & then + org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class, + () -> noticeQueryService.getNotices(cursor)); + } + + @Test + @DisplayName("공지사항 상세 조회 시 Repository 예외 발생 테스트") + void 공지사항_상세_조회시_Repository_예외_발생_테스트() { + // given + Long noticeId = -1L; + + given(noticeQueryRepositoryPort.findNoticeDetailById(noticeId)) + .willThrow(new IllegalArgumentException("Notice not found")); + + // when & then + org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class, + () -> noticeQueryService.getNoticeDetail(noticeId)); + } +} \ No newline at end of file diff --git a/src/test/java/talkPick/notice/domain/NoticeImageTest.java b/src/test/java/talkPick/notice/domain/NoticeImageTest.java new file mode 100644 index 00000000..834dd744 --- /dev/null +++ b/src/test/java/talkPick/notice/domain/NoticeImageTest.java @@ -0,0 +1,66 @@ +package talkPick.notice.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import talkPick.domain.notice.domain.NoticeImage; +import talkPick.global.model.TalkPickStatus; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("NoticeImage 도메인 테스트") +class NoticeImageTest { + + @Test + @DisplayName("of 메서드로 NoticeImage 생성 테스트") + void of_메서드로_NoticeImage_생성_테스트() { + // given + Long noticeId = 100L; + String imageUrl = "https://example.com/notice-image.png"; + TalkPickStatus status = TalkPickStatus.ACTIVE; + + // when + NoticeImage noticeImage = NoticeImage.of(noticeId, imageUrl, status); + + // then + assertAll( + () -> assertThat(noticeImage).isNotNull(), + () -> assertThat(noticeImage.getNoticeId()).isEqualTo(noticeId), + () -> assertThat(noticeImage.getImageUrl()).isEqualTo(imageUrl), + () -> assertThat(noticeImage.getStatus()).isEqualTo(status) + ); + } + + @Test + @DisplayName("DIS_ACTIVE 상태로 NoticeImage 생성 테스트") + void DIS_ACTIVE_상태로_NoticeImage_생성_테스트() { + // given + Long noticeId = 100L; + String imageUrl = "https://example.com/deleted-image.png"; + TalkPickStatus status = TalkPickStatus.DIS_ACTIVE; + + // when + NoticeImage noticeImage = NoticeImage.of(noticeId, imageUrl, status); + + // then + assertThat(noticeImage.getStatus()).isEqualTo(TalkPickStatus.DIS_ACTIVE); + } + + @Test + @DisplayName("다양한 이미지 URL 형식으로 NoticeImage 생성 테스트") + void 다양한_이미지_URL_형식으로_NoticeImage_생성_테스트() { + // given + Long noticeId = 100L; + String[] imageUrls = { + "https://cdn.example.com/images/notice/12345.jpg", + "https://s3.amazonaws.com/bucket/notice-img.png", + "https://example.com/path/to/image.webp" + }; + + // when & then + for (String url : imageUrls) { + NoticeImage noticeImage = NoticeImage.of(noticeId, url, TalkPickStatus.ACTIVE); + assertThat(noticeImage.getImageUrl()).isEqualTo(url); + } + } +} \ No newline at end of file diff --git a/src/test/java/talkPick/notice/domain/NoticeTest.java b/src/test/java/talkPick/notice/domain/NoticeTest.java new file mode 100644 index 00000000..e99d60f8 --- /dev/null +++ b/src/test/java/talkPick/notice/domain/NoticeTest.java @@ -0,0 +1,106 @@ +package talkPick.notice.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import talkPick.domain.notice.domain.Notice; +import talkPick.global.model.TalkPickStatus; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("Notice 도메인 테스트") +class NoticeTest { + + @Test + @DisplayName("of 메서드로 Notice 생성 테스트") + void of_메서드로_Notice_생성_테스트() { + // given + Long adminId = 1L; + String title = "공지사항 제목"; + String content = "공지사항 내용입니다."; + Integer readCount = 0; + TalkPickStatus status = TalkPickStatus.ACTIVE; + + // when + Notice notice = Notice.of(adminId, title, content, readCount, status); + + // then + assertAll( + () -> assertThat(notice).isNotNull(), + () -> assertThat(notice.getAdminId()).isEqualTo(adminId), + () -> assertThat(notice.getTitle()).isEqualTo(title), + () -> assertThat(notice.getContent()).isEqualTo(content), + () -> assertThat(notice.getReadCount()).isEqualTo(readCount), + () -> assertThat(notice.getStatus()).isEqualTo(status) + ); + } + + @Test + @DisplayName("plusReadCount 호출 시 조회수 1 증가 테스트") + void plusReadCount_호출시_조회수_1_증가_테스트() { + // given + Notice notice = Notice.of(1L, "제목", "내용", 0, TalkPickStatus.ACTIVE); + Integer initialReadCount = notice.getReadCount(); + + // when + notice.plusReadCount(); + + // then + assertThat(notice.getReadCount()).isEqualTo(initialReadCount + 1); + } + + @Test + @DisplayName("plusReadCount 여러 번 호출 시 조회수 누적 증가 테스트") + void plusReadCount_여러번_호출시_조회수_누적_증가_테스트() { + // given + Notice notice = Notice.of(1L, "제목", "내용", 10, TalkPickStatus.ACTIVE); + int incrementCount = 5; + + // when + for (int i = 0; i < incrementCount; i++) { + notice.plusReadCount(); + } + + // then + assertThat(notice.getReadCount()).isEqualTo(15); + } + + @Test + @DisplayName("DIS_ACTIVE 상태로 Notice 생성 테스트") + void DIS_ACTIVE_상태로_Notice_생성_테스트() { + // given + TalkPickStatus status = TalkPickStatus.DIS_ACTIVE; + + // when + Notice notice = Notice.of(1L, "비활성 공지", "내용", 0, status); + + // then + assertThat(notice.getStatus()).isEqualTo(TalkPickStatus.DIS_ACTIVE); + } + + @Test + @DisplayName("조회수가 큰 값일 때 plusReadCount 호출 테스트") + void 조회수가_큰_값일때_plusReadCount_호출_테스트() { + // given + Notice notice = Notice.of(1L, "제목", "내용", Integer.MAX_VALUE - 10, TalkPickStatus.ACTIVE); + + // when + notice.plusReadCount(); + + // then + assertThat(notice.getReadCount()).isEqualTo(Integer.MAX_VALUE - 9); + } + + @Test + @DisplayName("다양한 초기 조회수로 Notice 생성 테스트") + void 다양한_초기_조회수로_Notice_생성_테스트() { + // given + Integer[] readCounts = {0, 10, 100, 1000, 10000}; + + // when & then + for (Integer readCount : readCounts) { + Notice notice = Notice.of(1L, "제목", "내용", readCount, TalkPickStatus.ACTIVE); + assertThat(notice.getReadCount()).isEqualTo(readCount); + } + } +} diff --git a/src/test/java/talkPick/notice/domain/event/NoticeReadEventTest.java b/src/test/java/talkPick/notice/domain/event/NoticeReadEventTest.java new file mode 100644 index 00000000..a73e4576 --- /dev/null +++ b/src/test/java/talkPick/notice/domain/event/NoticeReadEventTest.java @@ -0,0 +1,44 @@ +package talkPick.notice.domain.event; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import talkPick.domain.notice.domain.event.NoticeReadEvent; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("NoticeReadEvent 도메인 이벤트 테스트") +class NoticeReadEventTest { + + @Test + @DisplayName("of 메서드로 NoticeReadEvent 생성 테스트") + void of_메서드로_NoticeReadEvent_생성_테스트() { + // given + Object source = this; + Long noticeId = 100L; + + // when + NoticeReadEvent event = NoticeReadEvent.of(source, noticeId); + + // then + assertAll( + () -> assertThat(event).isNotNull(), + () -> assertThat(event.getNoticeId()).isEqualTo(noticeId), + () -> assertThat(event.getSource()).isEqualTo(source) + ); + } + + @Test + @DisplayName("다양한 noticeId로 NoticeReadEvent 생성 테스트") + void 다양한_noticeId로_NoticeReadEvent_생성_테스트() { + // given + Object source = this; + Long[] noticeIds = {1L, 999L, 123456L}; + + // when & then + for (Long noticeId : noticeIds) { + NoticeReadEvent event = NoticeReadEvent.of(source, noticeId); + assertThat(event.getNoticeId()).isEqualTo(noticeId); + } + } +} \ No newline at end of file diff --git a/src/test/java/talkPick/random/application/RandomQueryServiceTest.java b/src/test/java/talkPick/random/application/RandomQueryServiceTest.java new file mode 100644 index 00000000..04fceed3 --- /dev/null +++ b/src/test/java/talkPick/random/application/RandomQueryServiceTest.java @@ -0,0 +1,94 @@ +package talkPick.random.application; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import talkPick.domain.random.adapter.out.dto.RandomResDTO; +import talkPick.domain.random.application.RandomQueryService; +import talkPick.domain.random.port.out.RandomQueryRepositoryPort; + +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RandomQueryService 테스트") +class RandomQueryServiceTest { + + @InjectMocks + private RandomQueryService randomQueryService; + + @Mock + private RandomQueryRepositoryPort randomQueryRepositoryPort; + + @Test + @DisplayName("랜덤 토픽 목록 조회 테스트") + void 랜덤_토픽_목록_조회_테스트() { + // given + Long memberId = 1L; + Long randomId = 100L; + Integer order = 1; + String category = "일상"; + + List mockTopics = List.of( + new RandomResDTO.RandomTopic(1, List.of( + new RandomResDTO.RandomTopicDetail(1L, "토픽1", "설명1", "일상", "키워드1", "img1.png", "icon1.png") + )), + new RandomResDTO.RandomTopic(2, List.of( + new RandomResDTO.RandomTopicDetail(2L, "토픽2", "설명2", "대화", "키워드2", "img2.png", "icon2.png") + )) + ); + + given(randomQueryRepositoryPort.findRandomTopics(memberId, randomId, order, category)) + .willReturn(mockTopics); + + // when + List result = randomQueryService.getRandomTopics( + memberId, randomId, order, category + ); + + // then + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result).hasSize(2), + () -> assertThat(result.get(0).getOrder()).isEqualTo(1), + () -> assertThat(result.get(1).getOrder()).isEqualTo(2), + () -> verify(randomQueryRepositoryPort, times(1)) + .findRandomTopics(memberId, randomId, order, category) + ); + } + + @Test + @DisplayName("랜덤 토픽 조회 시 빈 결과 반환 테스트") + void 랜덤_토픽_조회시_빈_결과_반환_테스트() { + // given + Long memberId = 1L; + Long randomId = 100L; + Integer order = 1; + String category = "일상"; + + given(randomQueryRepositoryPort.findRandomTopics(memberId, randomId, order, category)) + .willReturn(Collections.emptyList()); + + // when + List result = randomQueryService.getRandomTopics( + memberId, randomId, order, category + ); + + // then + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result).isEmpty(), + () -> verify(randomQueryRepositoryPort, times(1)) + .findRandomTopics(memberId, randomId, order, category) + ); + } +} \ No newline at end of file diff --git a/src/test/java/talkPick/random/domain/RandomTest.java b/src/test/java/talkPick/random/domain/RandomTest.java new file mode 100644 index 00000000..ed5d8dca --- /dev/null +++ b/src/test/java/talkPick/random/domain/RandomTest.java @@ -0,0 +1,163 @@ +package talkPick.random.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import talkPick.domain.random.adapter.in.dto.RandomReqDTO; +import talkPick.domain.random.domain.Random; +import talkPick.domain.random.domain.type.RandomType; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("Random 도메인 테스트") +class RandomTest { + + @Test + @DisplayName("from 메서드로 Random 생성 테스트") + void from_메서드로_Random_생성_테스트() { + // given + Long memberId = 1L; + + // when + Random random = Random.from(memberId); + + // then + assertAll( + () -> assertThat(random).isNotNull(), + () -> assertThat(random.getMemberId()).isEqualTo(memberId), + () -> assertThat(random.getType()).isEqualTo(RandomType.START), + () -> assertThat(random.getOneLine()).isNull(), + () -> assertThat(random.getRating()).isNull() + ); + } + + @Test + @DisplayName("quit 호출 시 RandomType이 QUIT으로 변경 테스트") + void quit_호출시_RandomType_QUIT으로_변경_테스트() { + // given + Random random = Random.from(1L); + + // when + random.quit(); + + // then + assertThat(random.getType()).isEqualTo(RandomType.QUIT); + } + + @Test + @DisplayName("end 호출 시 RandomType이 COMPLETED로 변경 테스트") + void end_호출시_RandomType_COMPLETED로_변경_테스트() { + // given + Random random = Random.from(1L); + + // when + random.end(); + + // then + assertThat(random.getType()).isEqualTo(RandomType.COMPLETED); + } + + @Test + @DisplayName("rate 호출 시 평점 설정 테스트") + void rate_호출시_평점_설정_테스트() { + // given + Random random = Random.from(1L); + RandomReqDTO.Rate rateDTO = new RandomReqDTO.Rate(5); + + // when + random.rate(rateDTO); + + // then + assertThat(random.getRating()).isEqualTo(5); + } + + @Test + @DisplayName("comment 호출 시 한 줄 평 설정 테스트") + void comment_호출시_한줄평_설정_테스트() { + // given + Random random = Random.from(1L); + String oneLine = "정말 재밌었어요!"; + RandomReqDTO.Comment commentDTO = new RandomReqDTO.Comment(oneLine); + + // when + random.comment(commentDTO); + + // then + assertThat(random.getOneLine()).isEqualTo(oneLine); + } + + @Test + @DisplayName("rate와 comment 호출 시 모두 설정 테스트") + void rate와_comment_호출시_모두_설정_테스트() { + // given + Random random = Random.from(1L); + RandomReqDTO.Rate rateDTO = new RandomReqDTO.Rate(4); + RandomReqDTO.Comment commentDTO = new RandomReqDTO.Comment("좋아요"); + + // when + random.rate(rateDTO); + random.comment(commentDTO); + + // then + assertAll( + () -> assertThat(random.getRating()).isEqualTo(4), + () -> assertThat(random.getOneLine()).isEqualTo("좋아요") + ); + } + + @Test + @DisplayName("START 상태에서 QUIT으로 상태 전이 테스트") + void START_상태에서_QUIT으로_상태_전이_테스트() { + // given + Random random = Random.from(1L); + assertThat(random.getType()).isEqualTo(RandomType.START); + + // when + random.quit(); + + // then + assertThat(random.getType()).isEqualTo(RandomType.QUIT); + } + + @Test + @DisplayName("START 상태에서 COMPLETED로 상태 전이 테스트") + void START_상태에서_COMPLETED로_상태_전이_테스트() { + // given + Random random = Random.from(1L); + assertThat(random.getType()).isEqualTo(RandomType.START); + + // when + random.end(); + + // then + assertThat(random.getType()).isEqualTo(RandomType.COMPLETED); + } + + @Test + @DisplayName("다양한 평점 값으로 rate 호출 테스트") + void 다양한_평점_값으로_rate_호출_테스트() { + // given + Integer[] ratings = {1, 2, 3, 4, 5}; + + // when & then + for (Integer rating : ratings) { + Random random = Random.from(1L); + RandomReqDTO.Rate rateDTO = new RandomReqDTO.Rate(rating); + random.rate(rateDTO); + assertThat(random.getRating()).isEqualTo(rating); + } + } + + @Test + @DisplayName("다양한 memberId로 Random 생성 테스트") + void 다양한_memberId로_Random_생성_테스트() { + // given + Long[] memberIds = {1L, 100L, 999L, 12345L}; + + // when & then + for (Long memberId : memberIds) { + Random random = Random.from(memberId); + assertThat(random.getMemberId()).isEqualTo(memberId); + } + } +} \ No newline at end of file diff --git a/src/test/java/talkPick/random/domain/RandomTopicHistoryTest.java b/src/test/java/talkPick/random/domain/RandomTopicHistoryTest.java new file mode 100644 index 00000000..e33a4bc2 --- /dev/null +++ b/src/test/java/talkPick/random/domain/RandomTopicHistoryTest.java @@ -0,0 +1,131 @@ +package talkPick.random.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import talkPick.domain.random.adapter.in.dto.RandomReqDTO; +import talkPick.domain.random.domain.RandomTopicHistory; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("RandomTopicHistory 도메인 테스트") +class RandomTopicHistoryTest { + + @Test + @DisplayName("of 메서드로 RandomTopicHistory 생성 테스트") + void of_메서드로_RandomTopicHistory_생성_테스트() { + // given + Long memberId = 1L; + Long randomId = 100L; + RandomReqDTO.Record recordDTO = new RandomReqDTO.Record(200L, 1); + + // when + RandomTopicHistory history = RandomTopicHistory.of(memberId, randomId, recordDTO); + + // then + assertAll( + () -> assertThat(history).isNotNull(), + () -> assertThat(history.getMemberId()).isEqualTo(memberId), + () -> assertThat(history.getRandomId()).isEqualTo(randomId), + () -> assertThat(history.getTopicId()).isEqualTo(200L), + () -> assertThat(history.getOrder()).isEqualTo(1), + () -> assertThat(history.getStartAt()).isNotNull(), + () -> assertThat(history.getEndAt()).isNull() + ); + } + + @Test + @DisplayName("ofRecord 메서드로 RandomTopicHistory 생성 테스트") + void ofRecord_메서드로_RandomTopicHistory_생성_테스트() { + // given + Long memberId = 1L; + Long randomId = 100L; + LocalDateTime startAt = LocalDateTime.now().minusMinutes(10); + LocalDateTime endAt = LocalDateTime.now(); + RandomReqDTO.TotalRecord totalRecordDTO = new RandomReqDTO.TotalRecord( + 200L, 1, startAt, endAt + ); + + // when + RandomTopicHistory history = RandomTopicHistory.ofRecord(memberId, randomId, totalRecordDTO); + + // then + assertAll( + () -> assertThat(history).isNotNull(), + () -> assertThat(history.getMemberId()).isEqualTo(memberId), + () -> assertThat(history.getRandomId()).isEqualTo(randomId), + () -> assertThat(history.getTopicId()).isEqualTo(200L), + () -> assertThat(history.getOrder()).isEqualTo(1), + () -> assertThat(history.getStartAt()).isEqualTo(startAt), + () -> assertThat(history.getEndAt()).isEqualTo(endAt) + ); + } + + @Test + @DisplayName("next 호출 시 endAt 설정 테스트") + void next_호출시_endAt_설정_테스트() { + // given + RandomReqDTO.Record recordDTO = new RandomReqDTO.Record(200L, 1); + RandomTopicHistory history = RandomTopicHistory.of(1L, 100L, recordDTO); + assertThat(history.getEndAt()).isNull(); + + // when + history.next(); + + // then + assertThat(history.getEndAt()).isNotNull(); + } + + @Test + @DisplayName("다양한 order로 RandomTopicHistory 생성 테스트") + void 다양한_order로_RandomTopicHistory_생성_테스트() { + // given + Long memberId = 1L; + Long randomId = 100L; + Integer[] orders = {1, 2, 3, 5, 10}; + + // when & then + for (Integer order : orders) { + RandomReqDTO.Record recordDTO = new RandomReqDTO.Record(200L, order); + RandomTopicHistory history = RandomTopicHistory.of(memberId, randomId, recordDTO); + assertThat(history.getOrder()).isEqualTo(order); + } + } + + @Test + @DisplayName("next 호출 전후로 endAt 값 변경 확인 테스트") + void next_호출_전후로_endAt_값_변경_확인_테스트() { + // given + RandomReqDTO.Record recordDTO = new RandomReqDTO.Record(200L, 1); + RandomTopicHistory history = RandomTopicHistory.of(1L, 100L, recordDTO); + + // when + LocalDateTime beforeNext = history.getEndAt(); + history.next(); + LocalDateTime afterNext = history.getEndAt(); + + // then + assertAll( + () -> assertThat(beforeNext).isNull(), + () -> assertThat(afterNext).isNotNull() + ); + } + + @Test + @DisplayName("큰 값의 order로 RandomTopicHistory 생성 테스트") + void 큰_값의_order로_RandomTopicHistory_생성_테스트() { + // given + Long memberId = 1L; + Long randomId = 100L; + Integer largeOrder = Integer.MAX_VALUE; + RandomReqDTO.Record recordDTO = new RandomReqDTO.Record(200L, largeOrder); + + // when + RandomTopicHistory history = RandomTopicHistory.of(memberId, randomId, recordDTO); + + // then + assertThat(history.getOrder()).isEqualTo(largeOrder); + } +} \ No newline at end of file diff --git a/src/test/java/talkPick/rateLimiter/adapter/RateLimiterManagerAdapterTest.java b/src/test/java/talkPick/rateLimiter/RateLimiterManagerAdapterTest.java similarity index 97% rename from src/test/java/talkPick/rateLimiter/adapter/RateLimiterManagerAdapterTest.java rename to src/test/java/talkPick/rateLimiter/RateLimiterManagerAdapterTest.java index 065d38b8..810ac635 100644 --- a/src/test/java/talkPick/rateLimiter/adapter/RateLimiterManagerAdapterTest.java +++ b/src/test/java/talkPick/rateLimiter/RateLimiterManagerAdapterTest.java @@ -1,4 +1,4 @@ -package talkPick.rateLimiter.adapter; +package talkPick.rateLimiter; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; diff --git a/src/test/java/talkPick/rateLimiter/adapter/RateLimitControllerTest.java b/src/test/java/talkPick/rateLimiter/adapter/RateLimitControllerTest.java deleted file mode 100644 index 29db1e17..00000000 --- a/src/test/java/talkPick/rateLimiter/adapter/RateLimitControllerTest.java +++ /dev/null @@ -1,50 +0,0 @@ -//package talkPick.rateLimiter.adapter; -// -//import org.junit.jupiter.api.DisplayName; -//import org.junit.jupiter.api.Test; -//import org.springframework.beans.factory.annotation.Autowired; -//import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -//import org.springframework.boot.test.context.SpringBootTest; -//import org.springframework.boot.test.mock.mockito.MockBean; -//import org.springframework.test.web.servlet.MockMvc; -//import talkPick.batch.topic.port.TopicCacheManager; -//import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -//import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -// -//@SpringBootTest -//@AutoConfigureMockMvc -//class RateLimitControllerTest { -// -// @Autowired -// private MockMvc mockMvc; -// @MockBean -// private TopicCacheManager topicCacheManager; -// -// @Test -// @DisplayName("✅ 실제 Controller, 실제 RateLimiterManager 테스트") -// void 실제_Controller_테스트() throws Exception { -// String uri = "/test"; -// String ip1 = "127.0.0.1"; -// String ip2 = "127.0.0.2"; -// -// for (int i = 1; i <= 10; i++) { -// mockMvc.perform(get(uri).with(request -> { -// request.setRemoteAddr(ip1); -// return request; -// })) -// .andExpect(status().isOk()); -// } -// -// mockMvc.perform(get(uri).with(request -> { -// request.setRemoteAddr(ip1); -// return request; -// })) -// .andExpect(status().isTooManyRequests()); -// -// mockMvc.perform(get(uri).with(request -> { -// request.setRemoteAddr(ip2); -// return request; -// })) -// .andExpect(status().isOk()); -// } -//} \ No newline at end of file diff --git a/src/test/java/talkPick/today/application/TodayTopicQueryServiceTest.java b/src/test/java/talkPick/today/application/TodayTopicQueryServiceTest.java new file mode 100644 index 00000000..f69ff712 --- /dev/null +++ b/src/test/java/talkPick/today/application/TodayTopicQueryServiceTest.java @@ -0,0 +1,141 @@ +package talkPick.today.application; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.context.ApplicationEventPublisher; +import talkPick.domain.today.adapter.out.dto.TodayTopicResDTO; +import talkPick.domain.today.application.TodayTopicQueryService; +import talkPick.domain.today.domain.event.TodayTopicSavedEvent; +import talkPick.domain.today.port.out.TodayTopicQueryRepositoryPort; + +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("TodayTopicQueryService 테스트") +class TodayTopicQueryServiceTest { + + @InjectMocks + private TodayTopicQueryService todayTopicQueryService; + + @Mock + private TodayTopicQueryRepositoryPort todayTopicQueryRepositoryPort; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @Mock + private CacheManager cacheManager; + + @Mock + private Cache cache; + + @Test + @DisplayName("오늘의 토픽 조회 시 캐시 미존재로 DB 조회 및 이벤트 발행 테스트") + void 오늘의_토픽_조회시_캐시_미존재로_DB_조회_및_이벤트_발행_테스트() { + // given + Long memberId = 1L; + List mockTopics = List.of( + new TodayTopicResDTO.TodayTopic(100L, "토픽1", "카테고리1", "키워드1", "icon1.png"), + new TodayTopicResDTO.TodayTopic(200L, "토픽2", "카테고리2", "키워드2", "icon2.png") + ); + + given(cacheManager.getCache("todayTopics")).willReturn(cache); + given(cache.get(memberId)).willReturn(null); + given(todayTopicQueryRepositoryPort.findTodayTopics(memberId)).willReturn(mockTopics); + willDoNothing().given(cache).put(eq(memberId), any()); + willDoNothing().given(eventPublisher).publishEvent(any(TodayTopicSavedEvent.class)); + + // when + List result = todayTopicQueryService.getTodayTopics(memberId); + + // then + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result).hasSize(2), + () -> verify(todayTopicQueryRepositoryPort, times(1)).findTodayTopics(memberId), + () -> verify(cache, times(1)).put(eq(memberId), any()), + () -> verify(eventPublisher, times(1)).publishEvent(any(TodayTopicSavedEvent.class)) + ); + } + + @Test + @DisplayName("오늘의 토픽 조회 시 빈 결과 반환 테스트") + void 오늘의_토픽_조회시_빈_결과_반환_테스트() { + // given + Long memberId = 1L; + List emptyList = Collections.emptyList(); + + given(cacheManager.getCache("todayTopics")).willReturn(cache); + given(cache.get(memberId)).willReturn(null); + given(todayTopicQueryRepositoryPort.findTodayTopics(memberId)).willReturn(emptyList); + willDoNothing().given(cache).put(eq(memberId), any()); + willDoNothing().given(eventPublisher).publishEvent(any(TodayTopicSavedEvent.class)); + + // when + List result = todayTopicQueryService.getTodayTopics(memberId); + + // then + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result).isEmpty(), + () -> verify(todayTopicQueryRepositoryPort, times(1)).findTodayTopics(memberId), + () -> verify(eventPublisher, times(1)).publishEvent(any(TodayTopicSavedEvent.class)) + ); + } + + @Test + @DisplayName("오늘의 토픽 조회 시 캐시 매니저 null인 경우 정상 조회 테스트") + void 오늘의_토픽_조회시_캐시_매니저_null인_경우_정상_조회_테스트() { + // given + Long memberId = 1L; + List mockTopics = List.of( + new TodayTopicResDTO.TodayTopic(100L, "토픽1", "카테고리1", "키워드1", "icon1.png") + ); + + given(cacheManager.getCache("todayTopics")).willReturn(null); + given(todayTopicQueryRepositoryPort.findTodayTopics(memberId)).willReturn(mockTopics); + willDoNothing().given(eventPublisher).publishEvent(any(TodayTopicSavedEvent.class)); + + // when + List result = todayTopicQueryService.getTodayTopics(memberId); + + // then + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result).hasSize(1), + () -> verify(todayTopicQueryRepositoryPort, times(1)).findTodayTopics(memberId), + () -> verify(eventPublisher, times(1)).publishEvent(any(TodayTopicSavedEvent.class)) + ); + } + + @Test + @DisplayName("오늘의 토픽 조회 시 Repository 예외 발생 테스트") + void 오늘의_토픽_조회시_Repository_예외_발생_테스트() { + // given + Long memberId = 1L; + + given(cacheManager.getCache("todayTopics")).willReturn(cache); + given(cache.get(memberId)).willReturn(null); + given(todayTopicQueryRepositoryPort.findTodayTopics(memberId)) + .willThrow(new RuntimeException("DB error")); + + // when & then + org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class, + () -> todayTopicQueryService.getTodayTopics(memberId)); + } +} \ No newline at end of file diff --git a/src/test/java/talkPick/today/domain/TodayTopicTest.java b/src/test/java/talkPick/today/domain/TodayTopicTest.java new file mode 100644 index 00000000..38173967 --- /dev/null +++ b/src/test/java/talkPick/today/domain/TodayTopicTest.java @@ -0,0 +1,66 @@ +package talkPick.today.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import talkPick.domain.today.domain.TodayTopic; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("TodayTopic 도메인 테스트") +class TodayTopicTest { + + @Test + @DisplayName("of 메서드로 TodayTopic 생성 테스트") + void of_메서드로_TodayTopic_생성_테스트() { + // given + Long memberId = 1L; + Long topicId = 100L; + + // when + TodayTopic todayTopic = TodayTopic.of(memberId, topicId); + + // then + assertAll( + () -> assertThat(todayTopic).isNotNull(), + () -> assertThat(todayTopic.getMemberId()).isEqualTo(memberId), + () -> assertThat(todayTopic.getTopicId()).isEqualTo(topicId) + ); + } + + @Test + @DisplayName("다양한 memberId와 topicId로 TodayTopic 생성 테스트") + void 다양한_memberId와_topicId로_TodayTopic_생성_테스트() { + // given + Long[] memberIds = {1L, 100L, 999L}; + Long[] topicIds = {10L, 200L, 3000L}; + + // when & then + for (int i = 0; i < memberIds.length; i++) { + Long memberId = memberIds[i]; + Long topicId = topicIds[i]; + TodayTopic todayTopic = TodayTopic.of(memberId, topicId); + assertAll( + () -> assertThat(todayTopic.getMemberId()).isEqualTo(memberId), + () -> assertThat(todayTopic.getTopicId()).isEqualTo(topicId) + ); + } + } + + @Test + @DisplayName("큰 값의 memberId와 topicId로 TodayTopic 생성 테스트") + void 큰_값의_memberId와_topicId로_TodayTopic_생성_테스트() { + // given + Long memberId = Long.MAX_VALUE; + Long topicId = Long.MAX_VALUE - 1; + + // when + TodayTopic todayTopic = TodayTopic.of(memberId, topicId); + + // then + assertAll( + () -> assertThat(todayTopic.getMemberId()).isEqualTo(memberId), + () -> assertThat(todayTopic.getTopicId()).isEqualTo(topicId) + ); + } +} \ No newline at end of file diff --git a/src/test/java/talkPick/today/domain/event/TodayTopicSavedEventTest.java b/src/test/java/talkPick/today/domain/event/TodayTopicSavedEventTest.java new file mode 100644 index 00000000..d5847205 --- /dev/null +++ b/src/test/java/talkPick/today/domain/event/TodayTopicSavedEventTest.java @@ -0,0 +1,75 @@ +package talkPick.today.domain.event; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import talkPick.domain.today.domain.TodayTopic; +import talkPick.domain.today.domain.event.TodayTopicSavedEvent; + +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("TodayTopicSavedEvent 도메인 이벤트 테스트") +class TodayTopicSavedEventTest { + + @Test + @DisplayName("of 메서드로 TodayTopicSavedEvent 생성 테스트") + void of_메서드로_TodayTopicSavedEvent_생성_테스트() { + // given + Object source = this; + List todayTopics = List.of( + TodayTopic.of(1L, 100L), + TodayTopic.of(1L, 200L) + ); + + // when + TodayTopicSavedEvent event = TodayTopicSavedEvent.of(source, todayTopics); + + // then + assertAll( + () -> assertThat(event).isNotNull(), + () -> assertThat(event.getTodayTopics()).hasSize(2), + () -> assertThat(event.getTodayTopics()).isEqualTo(todayTopics), + () -> assertThat(event.getSource()).isEqualTo(source) + ); + } + + @Test + @DisplayName("빈 리스트로 TodayTopicSavedEvent 생성 테스트") + void 빈_리스트로_TodayTopicSavedEvent_생성_테스트() { + // given + Object source = this; + List emptyList = Collections.emptyList(); + + // when + TodayTopicSavedEvent event = TodayTopicSavedEvent.of(source, emptyList); + + // then + assertAll( + () -> assertThat(event).isNotNull(), + () -> assertThat(event.getTodayTopics()).isEmpty() + ); + } + + @Test + @DisplayName("여러 개의 TodayTopic으로 이벤트 생성 테스트") + void 여러_개의_TodayTopic으로_이벤트_생성_테스트() { + // given + Object source = this; + List todayTopics = List.of( + TodayTopic.of(1L, 100L), + TodayTopic.of(1L, 200L), + TodayTopic.of(1L, 300L), + TodayTopic.of(1L, 400L), + TodayTopic.of(1L, 500L) + ); + + // when + TodayTopicSavedEvent event = TodayTopicSavedEvent.of(source, todayTopics); + + // then + assertThat(event.getTodayTopics()).hasSize(5); + } +} \ No newline at end of file diff --git a/src/test/java/talkPick/topic/application/TopicCommandServiceTest.java b/src/test/java/talkPick/topic/application/TopicCommandServiceTest.java index b23a1e4d..f1972627 100644 --- a/src/test/java/talkPick/topic/application/TopicCommandServiceTest.java +++ b/src/test/java/talkPick/topic/application/TopicCommandServiceTest.java @@ -1,58 +1,113 @@ -//package talkPick.topic.application; -// -//import org.junit.jupiter.api.DisplayName; -//import org.junit.jupiter.api.Test; -//import org.junit.jupiter.api.extension.ExtendWith; -//import org.mockito.InjectMocks; -//import org.mockito.Mock; -//import org.mockito.junit.jupiter.MockitoExtension; -//import talkPick.domain.topic.application.TopicCommandService; -//import talkPick.domain.topic.domain.TopicStat; -//import talkPick.domain.topic.port.out.TopicLikeHistoryCommandRepositoryPort; -//import talkPick.domain.topic.port.out.TopicStatQueryRepositoryPort; -//import talkPick.global.exception.ErrorCode; -//import talkPick.global.exception.handler.TopicExceptionHandler; -//import static org.junit.jupiter.api.Assertions.*; -//import static org.mockito.BDDMockito.given; -//import static org.mockito.Mockito.*; -// -//@ExtendWith(MockitoExtension.class) -//class TopicCommandServiceTest { -// @InjectMocks -// private TopicCommandService topicCommandService; -// @Mock -// private TopicStatQueryRepositoryPort topicStatQueryRepositoryPort; -// @Mock -// private TopicLikeHistoryCommandRepositoryPort topicLikeHistoryCommandRepositoryPort; -// -// @Test -// @DisplayName("✅ 좋아요 성공 테스트") -// void 좋아요_성공_테스트() { -// // given -// Long memberId = 1L; -// Long topicId = 100L; -// TopicStat mockTopicStat = mock(TopicStat.class); -// -// given(topicStatQueryRepositoryPort.findTopicStatByTopicId(topicId)).willReturn(mockTopicStat); -// -// // when -// topicCommandService.addLike(memberId, topicId); -// -// // then -// verify(mockTopicStat).addLike(); -// verify(topicLikeHistoryCommandRepositoryPort).save(memberId, topicId); -// } -// -//// @Test -//// @DisplayName("🚨 없는 TopicId로 좋아요 실패 테스트") -//// void 없는_TopicId로_좋아요_실패_테스트() { -//// // given -//// Long memberId = 1L; -//// Long topicId = 100L; -//// -//// given(topicStatQueryRepositoryPort.findTopicStatByTopicId(topicId)).willThrow(new TopicExceptionHandler(ErrorCode.TOPIC_NOT_FOUND)); -//// -//// // when && then -//// assertThrows(TopicExceptionHandler.class, () -> topicCommandService.addLike(memberId, topicId)); -//// } -//} \ No newline at end of file +package talkPick.topic.application; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import talkPick.domain.topic.application.TopicCommandService; +import talkPick.domain.topic.domain.event.TopicLikedEvent; +import talkPick.domain.topic.port.out.TopicLikeHistoryCommandRepositoryPort; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.*; +import org.mockito.ArgumentCaptor; + +@ExtendWith(MockitoExtension.class) +@DisplayName("TopicCommandService 테스트") +class TopicCommandServiceTest { + + @InjectMocks + private TopicCommandService topicCommandService; + + @Mock + private TopicLikeHistoryCommandRepositoryPort topicLikeHistoryCommandRepositoryPort; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @Test + @DisplayName("토픽 좋아요 추가 및 이벤트 발행 테스트") + void 토픽_좋아요_추가_및_이벤트_발행_테스트() { + // given + Long memberId = 1L; + Long topicId = 100L; + + willDoNothing().given(topicLikeHistoryCommandRepositoryPort).save(memberId, topicId); + willDoNothing().given(eventPublisher).publishEvent(any(TopicLikedEvent.class)); + + // when + topicCommandService.addLike(memberId, topicId); + + // then + verify(topicLikeHistoryCommandRepositoryPort, times(1)).save(memberId, topicId); + verify(eventPublisher, times(1)).publishEvent(any(TopicLikedEvent.class)); + } + + @Test + @DisplayName("토픽 좋아요 추가 시 Repository 저장 후 이벤트 발행 순서 확인 테스트") + void 토픽_좋아요_추가시_Repository_저장_후_이벤트_발행_순서_확인_테스트() { + // given + Long memberId = 1L; + Long topicId = 100L; + + willDoNothing().given(topicLikeHistoryCommandRepositoryPort).save(memberId, topicId); + willDoNothing().given(eventPublisher).publishEvent(any(TopicLikedEvent.class)); + + // when + topicCommandService.addLike(memberId, topicId); + + // then + var inOrder = org.mockito.Mockito.inOrder(topicLikeHistoryCommandRepositoryPort, eventPublisher); + inOrder.verify(topicLikeHistoryCommandRepositoryPort).save(memberId, topicId); + inOrder.verify(eventPublisher).publishEvent(any(TopicLikedEvent.class)); + } + + @Test + @DisplayName("토픽 좋아요 추가 시 ArgumentCaptor로 이벤트 내용 검증 테스트") + void 토픽_좋아요_추가시_ArgumentCaptor로_이벤트_내용_검증_테스트() { + // given + Long memberId = 1L; + Long topicId = 100L; + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(TopicLikedEvent.class); + + willDoNothing().given(topicLikeHistoryCommandRepositoryPort).save(memberId, topicId); + willDoNothing().given(eventPublisher).publishEvent(any(TopicLikedEvent.class)); + + // when + topicCommandService.addLike(memberId, topicId); + + // then + verify(topicLikeHistoryCommandRepositoryPort).save(memberId, topicId); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + + TopicLikedEvent capturedEvent = eventCaptor.getValue(); + assertAll( + () -> assertThat(capturedEvent).isNotNull(), + () -> assertThat(capturedEvent.getMemberId()).isEqualTo(memberId), + () -> assertThat(capturedEvent.getTopicId()).isEqualTo(topicId) + ); + } + + @Test + @DisplayName("토픽 좋아요 추가 시 Repository 예외 발생 테스트") + void 토픽_좋아요_추가시_Repository_예외_발생_테스트() { + // given + Long memberId = 1L; + Long topicId = 100L; + + willThrow(new IllegalStateException("Already liked")) + .given(topicLikeHistoryCommandRepositoryPort).save(memberId, topicId); + + // when & then + org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, + () -> topicCommandService.addLike(memberId, topicId)); + + verify(eventPublisher, never()).publishEvent(any(TopicLikedEvent.class)); + } +} \ No newline at end of file diff --git a/src/test/java/talkPick/topic/domain/TopicStatTest.java b/src/test/java/talkPick/topic/domain/TopicStatTest.java new file mode 100644 index 00000000..79af8ad2 --- /dev/null +++ b/src/test/java/talkPick/topic/domain/TopicStatTest.java @@ -0,0 +1,76 @@ +package talkPick.topic.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import talkPick.domain.topic.domain.TopicStat; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("TopicStat 도메인 테스트") +class TopicStatTest { + + @Test + @DisplayName("of 메서드로 TopicStat 생성 테스트") + void of_메서드로_TopicStat_생성_테스트() { + // given + Long topicId = 100L; + + // when + TopicStat topicStat = TopicStat.of(topicId); + + // then + assertAll( + () -> assertThat(topicStat).isNotNull(), + () -> assertThat(topicStat.getTopicId()).isEqualTo(topicId), + () -> assertThat(topicStat.getECount()).isEqualTo(0), + () -> assertThat(topicStat.getICount()).isEqualTo(0), + () -> assertThat(topicStat.getSCount()).isEqualTo(0), + () -> assertThat(topicStat.getNCount()).isEqualTo(0), + () -> assertThat(topicStat.getFCount()).isEqualTo(0), + () -> assertThat(topicStat.getTCount()).isEqualTo(0), + () -> assertThat(topicStat.getJCount()).isEqualTo(0), + () -> assertThat(topicStat.getPCount()).isEqualTo(0), + () -> assertThat(topicStat.getLikeCount()).isEqualTo(0), + () -> assertThat(topicStat.getTeenCount()).isEqualTo(0), + () -> assertThat(topicStat.getTwentiesCount()).isEqualTo(0), + () -> assertThat(topicStat.getThirtiesCount()).isEqualTo(0), + () -> assertThat(topicStat.getFortiesCount()).isEqualTo(0), + () -> assertThat(topicStat.getFiftiesCount()).isEqualTo(0), + () -> assertThat(topicStat.getMaleCount()).isEqualTo(0), + () -> assertThat(topicStat.getFemaleCount()).isEqualTo(0), + () -> assertThat(topicStat.getSelectCount()).isEqualTo(0), + () -> assertThat(topicStat.getAverageTalkTime()).isEqualTo(0L) + ); + } + + @Test + @DisplayName("다양한 topicId로 TopicStat 생성 테스트") + void 다양한_topicId로_TopicStat_생성_테스트() { + // given + Long[] topicIds = {1L, 100L, 999L, 12345L}; + + // when & then + for (Long topicId : topicIds) { + TopicStat topicStat = TopicStat.of(topicId); + assertThat(topicStat.getTopicId()).isEqualTo(topicId); + } + } + + @Test + @DisplayName("TopicStat 생성 시 모든 카운트 0 초기화 테스트") + void TopicStat_생성시_모든_카운트_0_초기화_테스트() { + // given + Long topicId = 100L; + + // when + TopicStat topicStat = TopicStat.of(topicId); + + // then + assertAll( + () -> assertThat(topicStat.getLikeCount()).isZero(), + () -> assertThat(topicStat.getSelectCount()).isZero(), + () -> assertThat(topicStat.getAverageTalkTime()).isZero() + ); + } +} \ No newline at end of file diff --git a/src/test/java/talkPick/topic/domain/event/TopicLikedEventTest.java b/src/test/java/talkPick/topic/domain/event/TopicLikedEventTest.java new file mode 100644 index 00000000..8d701de3 --- /dev/null +++ b/src/test/java/talkPick/topic/domain/event/TopicLikedEventTest.java @@ -0,0 +1,52 @@ +package talkPick.topic.domain.event; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import talkPick.domain.topic.domain.event.TopicLikedEvent; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("TopicLikedEvent 도메인 이벤트 테스트") +class TopicLikedEventTest { + + @Test + @DisplayName("of 메서드로 TopicLikedEvent 생성 테스트") + void of_메서드로_TopicLikedEvent_생성_테스트() { + // given + Object source = this; + Long memberId = 1L; + Long topicId = 100L; + + // when + TopicLikedEvent event = TopicLikedEvent.of(source, memberId, topicId); + + // then + assertAll( + () -> assertThat(event).isNotNull(), + () -> assertThat(event.getMemberId()).isEqualTo(memberId), + () -> assertThat(event.getTopicId()).isEqualTo(topicId), + () -> assertThat(event.getSource()).isEqualTo(source) + ); + } + + @Test + @DisplayName("다양한 memberId와 topicId로 TopicLikedEvent 생성 테스트") + void 다양한_memberId와_topicId로_TopicLikedEvent_생성_테스트() { + // given + Object source = this; + Long[] memberIds = {1L, 100L, 999L}; + Long[] topicIds = {10L, 200L, 3000L}; + + // when & then + for (int i = 0; i < memberIds.length; i++) { + Long memberId = memberIds[i]; + Long topicId = topicIds[i]; + TopicLikedEvent event = TopicLikedEvent.of(source, memberId, topicId); + assertAll( + () -> assertThat(event.getMemberId()).isEqualTo(memberId), + () -> assertThat(event.getTopicId()).isEqualTo(topicId) + ); + } + } +} \ No newline at end of file