From ec6ff2c1cfa36c48d4fa52809c2e5bc91016e46a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=84=ED=98=84=EB=B9=88?= <63556578+limhb708@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:49:21 +0900 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20Feat:=20=EC=96=B4=EB=93=9C?= =?UTF-8?q?=EB=AF=BC=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#173)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 215 ++++++-- .../admin/controller/AdminController.java | 257 +++++++++ .../admin/dto/request/AdminRequest.java | 92 ++++ .../admin/dto/response/AdminResponse.java | 326 ++++++++++++ .../backend/admin/entity/AdminReportType.java | 10 + .../admin/exception/AdminErrorCode.java | 74 +++ .../admin/exception/AdminException.java | 23 + .../backend/admin/service/AdminService.java | 50 ++ .../admin/service/AdminServiceImpl.java | 497 ++++++++++++++++++ .../com/dodo/backend/board/entity/Board.java | 26 +- .../board/repository/BoardRepository.java | 21 + .../backend/common/config/SecurityConfig.java | 1 + .../exception/GlobalExceptionHandler.java | 10 + .../dodo/backend/report/entity/Report.java | 9 + .../report/repository/ReportRepository.java | 90 ++++ .../admin/controller/AdminControllerTest.java | 120 +++++ .../admin/service/AdminServiceTest.java | 175 ++++++ .../backend/main/service/MainServiceTest.java | 12 +- 18 files changed, 1969 insertions(+), 39 deletions(-) create mode 100644 src/main/java/com/dodo/backend/admin/controller/AdminController.java create mode 100644 src/main/java/com/dodo/backend/admin/dto/request/AdminRequest.java create mode 100644 src/main/java/com/dodo/backend/admin/dto/response/AdminResponse.java create mode 100644 src/main/java/com/dodo/backend/admin/entity/AdminReportType.java create mode 100644 src/main/java/com/dodo/backend/admin/exception/AdminErrorCode.java create mode 100644 src/main/java/com/dodo/backend/admin/exception/AdminException.java create mode 100644 src/main/java/com/dodo/backend/admin/service/AdminService.java create mode 100644 src/main/java/com/dodo/backend/admin/service/AdminServiceImpl.java create mode 100644 src/test/java/com/dodo/backend/admin/controller/AdminControllerTest.java create mode 100644 src/test/java/com/dodo/backend/admin/service/AdminServiceTest.java diff --git a/README.md b/README.md index b2032f9..eb4c285 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,44 @@ ## ✨ 백엔드 주요 기능 (Key Backend Features) - **✅ 사용자 및 펫 관리** - > 회원 가입/로그인, 소셜 로그인(OAuth2)
펫 등록, 정보 수정, 건강 기록 관리 + > 회원 가입/로그인, Google/Naver 소셜 로그인
+ > JWT 기반 인증/인가, 이메일 인증
+ > 펫 등록, 정보 수정, 이미지 관리, 가족 권한 관리 -- **✅ 산책 경로 및 실시간 위치 추적** - > GPS 기반 실시간 위치 업데이트 (WebSocket)
안전 구역(Geo-fencing) 설정 및 알림 (FCM) +- **✅ 활동 및 건강 데이터 관리** + > 활동 기록, 심박수, 몸무게 기록 저장 및 조회
+ > 반려동물 누적 데이터를 기반으로 건강 상태 분석
+ > 건강 리포트 생성 및 조회 -- **✅ 활동 및 건강 통계** - > 산책 기록, 활동량, 심박수, 수면 패턴 분석
통계 데이터 저장 및 조회 (MySQL + MyBatis) +- **✅ 산책 경로 및 위치 관리** + > GPS 기반 위치 좌표 저장 및 산책 경로 관리
+ > 안전 구역(Geo-fencing) 설정 및 이탈 감지 구조
+ > WebSocket 기반 실시간 위치 추적 기능 확장 가능 + +- **✅ AI 건강 분석** + > 반려동물 기본 정보, 활동 기록, 심박수, 몸무게 데이터를 조합
+ > OpenAI API 기반 건강 분석 리포트 생성
+ > 보호자가 이해하기 쉬운 건강 관리 가이드 제공 - **✅ 커뮤니티** - > 게시글 작성/조회/댓글 기능
검색 및 인기 게시글 추천 + > 게시글 작성/조회/수정/삭제
+ > 댓글, 반응, 신고 기능
+ > 관리자 기능을 통한 커뮤니티 운영 관리 - **✅ 알림 및 푸시** - > 활동 목표 달성 알림
지오펜싱 이탈 시 실시간 알림
커뮤니티 댓글/답글 알림 + > Firebase Cloud Messaging(FCM) 기반 푸시 알림
+ > 울타리 이탈, 댓글/반응 등 주요 이벤트 알림 구조
+ > 사용자별 FCM 토큰 관리 -- **✅ 성능 최적화** - > Redis 캐싱: 인기 산책 경로, 반려동물 위치
RabbitMQ 기반 비동기 처리: 산책 데이터 후처리, 대량 알림 +- **✅ 파일 및 이미지 관리** + > Cloudinary 기반 이미지 업로드
+ > 반려동물 이미지 및 커뮤니티 이미지 저장
+ > Apache Tika 기반 파일 타입 검증 구조 -
+- **✅ 배포 자동화** + > GitHub Actions 기반 CD Workflow
+ > Self-hosted Runner를 활용한 Ubuntu 서버 자동 배포
+ > develop 브랜치 push 시 빌드 및 애플리케이션 재시작 ## ⚙️ 기술 스택 (Tech Stack) @@ -26,44 +46,175 @@ ### Backend & Infrastructure

- - + + + + +

+ +### Database & Persistence +

+ +

-### Persistence & Real-time +### Auth & External API

- - - - + + + +

+ +### Real-time & Notification +

+ + +

+## 🤖 백엔드 아키텍처 (Backend System Architecture) + +백엔드 서버를 중심으로 사용자, 반려동물, 활동 기록, 위치, 건강 분석, 커뮤니티 데이터가 처리되는 구조입니다. +
-## 🤖 백엔드 아키텍처 (Backend System Architecture) +**처리 흐름** + +```mermaid +flowchart TD + Client[앱/웹 클라이언트] -->|사용자 요청| Backend[Spring Boot Backend] -백엔드 서버를 중심으로 사용자, 펫, 산책, 커뮤니티 데이터가 처리되는 구조 다이어그램 -(사용자 요청 → Spring Boot 서버 처리 → MySQL/JPA/MyBatis 데이터 저장 → Redis 캐싱 및 RabbitMQ 비동기 처리 → WebSocket/FCM을 통한 실시간 알림/위치 업데이트 → 앱/웹 클라이언트 제공) + Backend --> Security[Spring Security + JWT] + Security --> Domain[도메인 로직 처리] + + Domain --> Database[(MySQL)] + Domain --> JPA[Spring Data JPA] + Domain --> MyBatis[MyBatis] + Domain --> Redis[(Redis)] + + Domain --> Cloudinary[Cloudinary
이미지 저장] + Domain --> OpenAI[OpenAI API
건강 분석 리포트 생성] + + Domain --> WebSocket[WebSocket
실시간 위치 처리] + Domain --> FCM[FCM
푸시 알림 전송] + + WebSocket --> Client + FCM --> Client + Backend -->|API 응답| Client +```
+**도메인 패키지 구성** + +```text +📦 src/main/java/com/dodo/backend +┣ 📂 activityhistory # 반려동물 활동 기록 관리 +┣ 📂 admin # 관리자 기능 +┣ 📂 auth # 로그인, 소셜 로그인, JWT 인증 +┣ 📂 board # 커뮤니티 게시글 +┣ 📂 comment # 댓글 +┣ 📂 common # 공통 응답, 예외, 설정, 핸들러 +┣ 📂 fence # 안전 구역 및 울타리 관리 +┣ 📂 fcm-tokens # FCM 토큰 관리 +┣ 📂 healthanalysis # AI 기반 건강 분석 +┣ 📂 heartrate # 심박수 데이터 관리 +┣ 📂 imagefile # 이미지 파일 관리 +┣ 📂 mail # 이메일 발송 +┣ 📂 notification # 알림 관리 +┣ 📂 pet # 반려동물 정보 관리 +┣ 📂 petspecialnote # 반려동물 특이사항 관리 +┣ 📂 petweight # 반려동물 몸무게 기록 +┣ 📂 reaction # 게시글 반응 +┣ 📂 report # 신고 및 리포트 +┣ 📂 routepoint # 위치 좌표 및 산책 경로 포인트 +┣ 📂 user # 사용자 정보 관리 +┗ 📂 userpet # 사용자-반려동물 관계 관리 +``` + +## 🔐 인증 구조 (Authentication) + +DoDo 백엔드는 JWT 기반 인증 구조를 사용합니다. + +```text +1. 사용자가 일반 로그인 또는 소셜 로그인을 요청합니다. +2. 서버는 사용자 정보를 검증합니다. +3. 인증에 성공하면 Access Token과 Refresh Token을 발급합니다. +4. 클라이언트는 보호된 API 요청 시 Authorization Header에 Access Token을 포함합니다. +5. 서버는 JWT를 검증한 뒤 사용자 권한에 따라 요청을 처리합니다. +``` + +## 🗄️ 데이터 처리 방식 (Database & Persistence) + +| 구분 | 기술 | 역할 | +| ------------- | --------------- | ---------------------------------------- | +| Main Database | MySQL | 사용자, 반려동물, 활동 기록, 커뮤니티 데이터 저장 | +| ORM | Spring Data JPA | 도메인 엔티티 중심의 기본 CRD 처리 | +| SQL Mapper | MyBatis | 복잡한 조회, 통계성 데이터 조회 및 직접 SQL 기반 Update 처리 | +| Cache | Redis | 빠른 조회 및 인증/세션성 데이터 처리 | +| File Storage | Cloudinary | 반려동물 이미지 및 커뮤니티 이미지 저장 | + +## 🤖 AI 건강 분석 (AI Health Analysis) + +DoDo는 반려동물의 누적 데이터를 기반으로 AI 건강 분석 기능을 제공합니다. + +```mermaid +flowchart TD + PetInfo[반려동물 기본 정보] + Activity[활동 기록] + HeartRate[심박수 데이터] + Weight[몸무게 기록] + Note[특이사항] + Question[보호자 질문] + + PetInfo --> Prompt[건강 분석 프롬프트 구성] + Activity --> Prompt + HeartRate --> Prompt + Weight --> Prompt + Note --> Prompt + Question --> Prompt + + Prompt --> OpenAI[OpenAI API 요청] + OpenAI --> Result[AI 분석 결과 수신] + Result --> Report[건강 분석 리포트 생성] + Report --> Storage[(MySQL 저장)] + Report --> Client[사용자에게 분석 결과 제공] +``` + +## 🚀 배포 구조 (Deployment) + +DoDo 백엔드는 GitHub Actions와 Self-hosted Runner를 활용하여 배포를 자동화합니다. + +```mermaid +flowchart TD + Push[develop 브랜치 push] --> Actions[GitHub Actions CD Workflow 실행] + Actions --> Runner[Self-hosted Runner 실행] + Runner --> Checkout[코드 체크아웃] + Checkout --> JDK[JDK 17 설정] + JDK --> Config[GitHub Secrets 기반 application.yml 생성] + Config --> Build[Gradle bootJar 빌드] + Build --> Restart[Ubuntu 서버 systemd 서비스 재시작] + Restart --> App[Spring Boot 애플리케이션 반영 완료] +``` + ## 🤝 Conventions + 우리 프로젝트는 원활한 협업을 위해 아래와 같은 규칙을 따릅니다. - **[Commit Convention](./.github/COMMIT_CONVENTION.md)** -
- -## 📊 백엔드 참고자료 출처 (Sensor Source) - -👉🏻 **[Spring Boot Microservices with RabbitMQ Example](https://rameshfadatare.medium.com/spring-boot-microservices-with-rabbitmq-example-92a38cbe08fc)** +## 📊 백엔드 참고자료 출처 (Reference) -
+👉🏻 **[Spring Boot 공식 문서](https://spring.io/projects/spring-boot)** +👉🏻 **[Spring Security 공식 문서](https://spring.io/projects/spring-security)** +👉🏻 **[Firebase Cloud Messaging 공식 문서](https://firebase.google.com/docs/cloud-messaging)** +👉🏻 **[OpenAI API 공식 문서](https://platform.openai.com/docs)** +👉🏻 **[Cloudinary 공식 문서](https://cloudinary.com/documentation)** ## 💁‍♂️ 팀원 소개 (Team Members) @@ -71,16 +222,16 @@ - 백현빈 프로필
- 백현빈 + 백현빈 프로필
+ 백현빈 +
- 임현빈 프로필
- 임현빈 + 임현빈 프로필
+ 임현빈 +
- - diff --git a/src/main/java/com/dodo/backend/admin/controller/AdminController.java b/src/main/java/com/dodo/backend/admin/controller/AdminController.java new file mode 100644 index 0000000..58250db --- /dev/null +++ b/src/main/java/com/dodo/backend/admin/controller/AdminController.java @@ -0,0 +1,257 @@ +package com.dodo.backend.admin.controller; + +import com.dodo.backend.admin.dto.request.AdminRequest.AnnouncementCreateRequest; +import com.dodo.backend.admin.dto.request.AdminRequest.AnnouncementUpdateRequest; +import com.dodo.backend.admin.dto.request.AdminRequest.ReportStatusUpdateRequest; +import com.dodo.backend.admin.dto.request.AdminRequest.UserStatusUpdateRequest; +import com.dodo.backend.admin.dto.response.AdminResponse.AdminSimpleResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.AnnouncementDetailResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.AnnouncementListResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.BoardReportDetailResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.CommentReportDetailResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.ReportListResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.UserReportDetailResponse; +import com.dodo.backend.admin.entity.AdminReportType; +import com.dodo.backend.admin.service.AdminService; +import com.dodo.backend.report.entity.ReportStatus; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +/** + * 관리자 API 요청을 처리하는 컨트롤러입니다. + */ +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/admin") +@Tag(name = "Admin API", description = "관리자 기능 API") +public class AdminController { + + private final AdminService adminService; + + /** + * 특정 게시글의 신고 상세 내역을 조회합니다. + * + * @param boardId 신고 상세 내역을 조회할 게시글 ID + * @return 게시글 신고 상세 내역 + */ + @Operation(summary = "게시글 신고 상세 조회", description = "특정 게시글에 접수된 신고 상세 내역을 조회합니다.") + @GetMapping("/reports/board/{boardId}") + public ResponseEntity getBoardReportDetail(@PathVariable Long boardId) { + log.info("관리자 게시글 신고 상세 조회 요청 - BoardId: {}", boardId); + return ResponseEntity.ok(adminService.getBoardReportDetail(boardId)); + } + + /** + * 특정 유저의 신고 상세 내역을 조회합니다. + * + * @param userId 신고 상세 내역을 조회할 유저 ID + * @return 유저 신고 상세 내역 + */ + @Operation(summary = "유저 신고 상세 조회", description = "특정 유저에 접수된 신고 상세 내역을 조회합니다.") + @GetMapping("/reports/user/{userId}") + public ResponseEntity getUserReportDetail(@PathVariable UUID userId) { + log.info("관리자 유저 신고 상세 조회 요청 - UserId: {}", userId); + return ResponseEntity.ok(adminService.getUserReportDetail(userId)); + } + + /** + * 특정 댓글의 신고 상세 내역을 조회합니다. + * + * @param commentId 신고 상세 내역을 조회할 댓글 ID + * @return 댓글 신고 상세 내역 + */ + @Operation(summary = "댓글 신고 상세 조회", description = "특정 댓글에 접수된 신고 상세 내역을 조회합니다.") + @GetMapping("/reports/comment/{commentId}") + public ResponseEntity getCommentReportDetail(@PathVariable Long commentId) { + log.info("관리자 댓글 신고 상세 조회 요청 - CommentId: {}", commentId); + return ResponseEntity.ok(adminService.getCommentReportDetail(commentId)); + } + + /** + * 신고 목록을 조회합니다. + * + * @param reportType 조회할 신고 대상 유형 + * @param reportStatus 조회할 신고 처리 상태 + * @param page 조회할 페이지 번호 + * @param size 페이지당 신고 목록 개수 + * @param sort 정렬 조건 + * @return 신고 목록 조회 결과 + */ + @Operation(summary = "신고 목록 조회", description = "신고 유형과 처리 상태에 따라 신고 목록을 조회합니다.") + @GetMapping("/reports") + public ResponseEntity getReportList( + @RequestParam AdminReportType reportType, + @RequestParam(required = false) ReportStatus reportStatus, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @RequestParam(required = false) String sort + ) { + log.info("관리자 신고 목록 조회 요청 - Type: {}, Status: {}, Page: {}, Size: {}, Sort: {}", + reportType, reportStatus, page, size, sort); + return ResponseEntity.ok(adminService.getReportList(reportType, reportStatus, page, size, sort)); + } + + /** + * 유저 계정 상태를 변경합니다. + * + * @param userId 상태를 변경할 유저 ID + * @param request 변경할 유저 상태 요청 + * @return 상태 변경 성공 메시지 + */ + @Operation(summary = "유저 계정 상태 변경", description = "관리자가 유저 계정 상태를 변경합니다.") + @PatchMapping("/users/{userId}/status") + public ResponseEntity updateUserStatus( + @PathVariable UUID userId, + @Valid @RequestBody UserStatusUpdateRequest request + ) { + log.info("관리자 유저 상태 변경 요청 - UserId: {}, Status: {}", userId, request.getStatus()); + return ResponseEntity.ok(adminService.updateUserStatus(userId, request)); + } + + /** + * 게시글을 강제로 삭제합니다. + * + * @param boardId 삭제할 게시글 ID + * @return 응답 본문이 없는 204 응답 + */ + @Operation(summary = "게시글 강제 삭제", description = "관리자가 게시글을 삭제 상태로 변경합니다.") + @DeleteMapping("/boards/{boardId}") + public ResponseEntity deleteBoard(@PathVariable Long boardId) { + log.info("관리자 게시글 강제 삭제 요청 - BoardId: {}", boardId); + adminService.deleteBoard(boardId); + return ResponseEntity.noContent().build(); + } + + /** + * 댓글을 강제로 삭제합니다. + * + * @param commentId 삭제할 댓글 ID + * @return 응답 본문이 없는 204 응답 + */ + @Operation(summary = "댓글 강제 삭제", description = "관리자가 댓글을 강제로 삭제합니다.") + @DeleteMapping("/comments/{commentId}") + public ResponseEntity deleteComment(@PathVariable Long commentId) { + log.info("관리자 댓글 강제 삭제 요청 - CommentId: {}", commentId); + adminService.deleteComment(commentId); + return ResponseEntity.noContent().build(); + } + + /** + * 신고 처리 상태를 변경합니다. + * + * @param reportId 상태를 변경할 신고 ID + * @param request 변경할 신고 처리 상태 요청 + * @return 상태 변경 성공 메시지 + */ + @Operation(summary = "신고 처리 상태 변경", description = "관리자가 신고 처리 상태를 변경합니다.") + @PatchMapping("/reports/{reportId}/status") + public ResponseEntity updateReportStatus( + @PathVariable Long reportId, + @Valid @RequestBody ReportStatusUpdateRequest request + ) { + log.info("관리자 신고 상태 변경 요청 - ReportId: {}, Status: {}", reportId, request.getStatus()); + return ResponseEntity.ok(adminService.updateReportStatus(reportId, request)); + } + + /** + * 공지를 작성합니다. + * + * @param request 공지 작성 요청 + * @param userDetails 인증된 관리자 정보 + * @return 공지 작성 성공 메시지 + */ + @Operation(summary = "공지 작성", description = "관리자가 공지를 작성합니다.") + @PostMapping("/announcements") + public ResponseEntity createAnnouncement( + @Valid @RequestBody AnnouncementCreateRequest request, + @AuthenticationPrincipal UserDetails userDetails + ) { + UUID adminId = UUID.fromString(userDetails.getUsername()); + log.info("관리자 공지 작성 요청 - AdminId: {}", adminId); + return ResponseEntity + .status(HttpStatus.OK) + .body(adminService.createAnnouncement(adminId, request)); + } + + /** + * 공지를 삭제합니다. + * + * @param boardId 삭제할 공지 게시글 ID + * @return 응답 본문이 없는 204 응답 + */ + @Operation(summary = "공지 삭제", description = "관리자가 공지를 삭제합니다.") + @DeleteMapping("/announcements/{boardId}") + public ResponseEntity deleteAnnouncement(@PathVariable Long boardId) { + log.info("관리자 공지 삭제 요청 - BoardId: {}", boardId); + adminService.deleteAnnouncement(boardId); + return ResponseEntity.noContent().build(); + } + + /** + * 공지를 수정합니다. + * + * @param boardId 수정할 공지 게시글 ID + * @param request 공지 수정 요청 + * @return 응답 본문이 없는 204 응답 + */ + @Operation(summary = "공지 수정", description = "관리자가 공지를 수정합니다.") + @PatchMapping("/announcements/{boardId}") + public ResponseEntity updateAnnouncement( + @PathVariable Long boardId, + @RequestBody AnnouncementUpdateRequest request + ) { + log.info("관리자 공지 수정 요청 - BoardId: {}", boardId); + adminService.updateAnnouncement(boardId, request); + return ResponseEntity.noContent().build(); + } + + /** + * 공지 목록을 조회합니다. + * + * @param pageable 공지 목록 페이지 요청 정보 + * @return 공지 목록 조회 결과 + */ + @Operation(summary = "공지 목록 조회", description = "공지 목록을 페이지 단위로 조회합니다.") + @GetMapping("/announcements") + public ResponseEntity getAnnouncementList( + @ParameterObject @PageableDefault(size = 10) Pageable pageable + ) { + log.info("관리자 공지 목록 조회 요청 - Page: {}, Size: {}", pageable.getPageNumber(), pageable.getPageSize()); + return ResponseEntity.ok(adminService.getAnnouncementList(pageable)); + } + + /** + * 공지 상세를 조회합니다. + * + * @param boardId 조회할 공지 게시글 ID + * @return 공지 상세 정보 + */ + @Operation(summary = "공지 상세 조회", description = "공지 상세 내용을 조회합니다.") + @GetMapping("/announcements/{boardId}") + public ResponseEntity getAnnouncementDetail(@PathVariable Long boardId) { + log.info("관리자 공지 상세 조회 요청 - BoardId: {}", boardId); + return ResponseEntity.ok(adminService.getAnnouncementDetail(boardId)); + } +} diff --git a/src/main/java/com/dodo/backend/admin/dto/request/AdminRequest.java b/src/main/java/com/dodo/backend/admin/dto/request/AdminRequest.java new file mode 100644 index 0000000..fc1bb31 --- /dev/null +++ b/src/main/java/com/dodo/backend/admin/dto/request/AdminRequest.java @@ -0,0 +1,92 @@ +package com.dodo.backend.admin.dto.request; + +import com.dodo.backend.board.entity.NoticeTag; +import com.dodo.backend.report.entity.ReportStatus; +import com.dodo.backend.user.entity.UserStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 관리자 API에서 사용하는 요청 DTO를 모아 둔 클래스입니다. + */ +@Schema(description = "관리자 요청 DTO 그룹") +public class AdminRequest { + + /** + * 유저 계정 상태 변경 요청 DTO입니다. + */ + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "유저 계정 상태 변경 요청") + public static class UserStatusUpdateRequest { + + @NotNull(message = "상태 값은 필수입니다.") + @Schema(description = "변경할 유저 상태", example = "SUSPENDED") + private UserStatus status; + } + + /** + * 신고 처리 상태 변경 요청 DTO입니다. + */ + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "신고 처리 상태 변경 요청") + public static class ReportStatusUpdateRequest { + + @NotNull(message = "상태 값은 필수입니다.") + @Schema(description = "변경할 신고 처리 상태", example = "COMPLETED") + private ReportStatus status; + } + + /** + * 공지 작성 요청 DTO입니다. + */ + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "공지 작성 요청") + public static class AnnouncementCreateRequest { + + @NotBlank(message = "공지 제목은 필수입니다.") + @Schema(description = "공지 제목", example = "공지 제목") + private String boardTitle; + + @NotBlank(message = "공지 내용은 필수입니다.") + @Schema(description = "공지 내용", example = "공지 내용입니다.") + private String boardContent; + + @Schema(description = "공지 이미지 URL", example = "https://example.com/images/notice.jpg") + private String imageFileUrl; + + @Schema(description = "조회 수", example = "10") + private Integer viewCount; + + @Schema(description = "공지 태그", example = "INFO") + private NoticeTag noticeTag; + } + + /** + * 공지 수정 요청 DTO입니다. + */ + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "공지 수정 요청") + public static class AnnouncementUpdateRequest { + + @Schema(description = "수정할 공지 제목", example = "수정된 공지입니다.") + private String boardTitle; + + @Schema(description = "수정할 공지 내용", example = "수정수정수정") + private String boardContent; + + @Schema(description = "수정할 공지 이미지 URL", example = "https://example.com/images/bori.jpg") + private String imageFileUrl; + } +} diff --git a/src/main/java/com/dodo/backend/admin/dto/response/AdminResponse.java b/src/main/java/com/dodo/backend/admin/dto/response/AdminResponse.java new file mode 100644 index 0000000..d8435bb --- /dev/null +++ b/src/main/java/com/dodo/backend/admin/dto/response/AdminResponse.java @@ -0,0 +1,326 @@ +package com.dodo.backend.admin.dto.response; + +import com.dodo.backend.board.entity.Board; +import com.dodo.backend.comment.entity.Comment; +import com.dodo.backend.report.entity.Report; +import com.dodo.backend.report.entity.ReportReason; +import com.dodo.backend.report.entity.ReportStatus; +import com.dodo.backend.user.entity.User; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.domain.Page; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 관리자 API에서 사용하는 응답 DTO를 모아 둔 클래스입니다. + */ +@Schema(description = "관리자 응답 DTO 그룹") +public class AdminResponse { + + /** + * 단순 메시지 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "관리자 단순 응답") + public static class AdminSimpleResponse { + private String message; + + public static AdminSimpleResponse toDto(String message) { + return AdminSimpleResponse.builder() + .message(message) + .build(); + } + } + + /** + * 페이지 정보 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "페이지 정보") + public static class PageInfoResponse { + private int page; + private int size; + private long totalElements; + private int totalPages; + + public static PageInfoResponse toDto(int page, int size, long totalElements) { + int totalPages = size == 0 ? 0 : (int) Math.ceil((double) totalElements / size); + return PageInfoResponse.builder() + .page(page) + .size(size) + .totalElements(totalElements) + .totalPages(totalPages) + .build(); + } + + public static PageInfoResponse toDto(Page page) { + return PageInfoResponse.builder() + .page(page.getNumber()) + .size(page.getSize()) + .totalElements(page.getTotalElements()) + .totalPages(page.getTotalPages()) + .build(); + } + } + + /** + * 유저 요약 정보 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "유저 요약 정보") + public static class UserInfoResponse { + private String userId; + private String nickname; + + public static UserInfoResponse toDto(User user) { + if (user == null) { + return null; + } + return UserInfoResponse.builder() + .userId(user.getUsersId() == null ? null : user.getUsersId().toString()) + .nickname(user.getNickname()) + .build(); + } + } + + /** + * 신고 게시글 정보 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "신고 게시글 정보") + public static class BoardInfoResponse { + private String boardTitle; + private String boardContent; + private LocalDateTime boardCreatedAt; + + public static BoardInfoResponse toDto(Board board) { + return BoardInfoResponse.builder() + .boardTitle(board.getBoardTitle()) + .boardContent(board.getBoardContent()) + .boardCreatedAt(board.getBoardCreatedAt()) + .build(); + } + } + + /** + * 신고 상세 아이템 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "신고 상세 아이템") + public static class ReportDetailItemResponse { + private Long reportId; + private UserInfoResponse reporterInfo; + private ReportReason reportReason; + private ReportStatus reportStatus; + private LocalDateTime reportCreatedAt; + + public static ReportDetailItemResponse toDto(Report report) { + return ReportDetailItemResponse.builder() + .reportId(report.getReportId()) + .reporterInfo(UserInfoResponse.toDto(report.getReporter())) + .reportReason(report.getReportReason()) + .reportStatus(report.getReportStatus()) + .reportCreatedAt(report.getCreatedAt()) + .build(); + } + } + + /** + * 게시글 신고 상세 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "게시글 신고 상세 응답") + public static class BoardReportDetailResponse { + private Long boardId; + private BoardInfoResponse boardInfo; + private UserInfoResponse reportedUserInfo; + private long totalReportCount; + private List reports; + } + + /** + * 유저 신고 상세 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "유저 신고 상세 응답") + public static class UserReportDetailResponse { + private UserInfoResponse reportedUserInfo; + private long totalReportCount; + private List reports; + } + + /** + * 댓글 신고 상세 아이템 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "댓글 신고 상세 아이템") + public static class CommentReportItemResponse { + private Long reportId; + private String reporterNickname; + private ReportReason reportReason; + private LocalDateTime reportCreatedAt; + + public static CommentReportItemResponse toDto(Report report) { + return CommentReportItemResponse.builder() + .reportId(report.getReportId()) + .reporterNickname(report.getReporter() == null ? null : report.getReporter().getNickname()) + .reportReason(report.getReportReason()) + .reportCreatedAt(report.getCreatedAt()) + .build(); + } + } + + /** + * 댓글 신고 상세 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "댓글 신고 상세 응답") + public static class CommentReportDetailResponse { + private Long commentId; + private String commentContent; + private String reportedNickname; + private long totalReportCount; + private List reports; + + public static CommentReportDetailResponse toDto(Comment comment, List reports) { + return CommentReportDetailResponse.builder() + .commentId(comment.getCommentId()) + .commentContent(comment.getCommentContent()) + .reportedNickname(comment.getUser() == null ? null : comment.getUser().getNickname()) + .totalReportCount(reports.size()) + .reports(reports.stream().map(CommentReportItemResponse::toDto).toList()) + .build(); + } + } + + /** + * 신고 목록 대상 정보 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "신고 목록 대상 정보") + public static class ReportTargetInfoResponse { + private Object id; + private String summary; + } + + /** + * 신고 목록 아이템 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "신고 목록 아이템") + public static class ReportListItemResponse { + private String reportType; + private ReportTargetInfoResponse targetInfo; + private UserInfoResponse reportedUser; + private long totalReportCount; + private ReportReason representativeReason; + private ReportStatus reportStatus; + private LocalDateTime lastReportedAt; + } + + /** + * 신고 목록 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "신고 목록 응답") + public static class ReportListResponse { + private PageInfoResponse pageInfo; + private List data; + } + + /** + * 공지 목록 아이템 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "공지 목록 아이템") + public static class AnnouncementItemResponse { + private Long boardId; + private String boardTitle; + private String boardContent; + private String imageFileUrl; + private Integer viewCount; + private LocalDateTime boardCreatedAt; + + public static AnnouncementItemResponse toDto(Board board, String imageFileUrl) { + return AnnouncementItemResponse.builder() + .boardId(board.getBoardId()) + .boardTitle(board.getBoardTitle()) + .boardContent(board.getBoardContent()) + .imageFileUrl(imageFileUrl) + .viewCount(board.getViewCount()) + .boardCreatedAt(board.getBoardCreatedAt()) + .build(); + } + } + + /** + * 공지 목록 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "공지 목록 응답") + public static class AnnouncementListResponse { + private PageInfoResponse pageInfo; + private List data; + } + + /** + * 공지 상세 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "공지 상세 응답") + public static class AnnouncementDetailResponse { + private Long boardId; + private String boardTitle; + private String boardContent; + private String imageFileUrl; + private Integer viewCount; + private LocalDateTime boardCreatedAt; + private LocalDateTime boardModifiedAt; + + public static AnnouncementDetailResponse toDto(Board board, String imageFileUrl) { + return AnnouncementDetailResponse.builder() + .boardId(board.getBoardId()) + .boardTitle(board.getBoardTitle()) + .boardContent(board.getBoardContent()) + .imageFileUrl(imageFileUrl) + .viewCount(board.getViewCount()) + .boardCreatedAt(board.getBoardCreatedAt()) + .boardModifiedAt(board.getModifiedAt()) + .build(); + } + } +} diff --git a/src/main/java/com/dodo/backend/admin/entity/AdminReportType.java b/src/main/java/com/dodo/backend/admin/entity/AdminReportType.java new file mode 100644 index 0000000..a47e65b --- /dev/null +++ b/src/main/java/com/dodo/backend/admin/entity/AdminReportType.java @@ -0,0 +1,10 @@ +package com.dodo.backend.admin.entity; + +/** + * 관리자 신고 목록에서 조회할 신고 대상 유형입니다. + */ +public enum AdminReportType { + BOARD, + USER, + COMMENT +} diff --git a/src/main/java/com/dodo/backend/admin/exception/AdminErrorCode.java b/src/main/java/com/dodo/backend/admin/exception/AdminErrorCode.java new file mode 100644 index 0000000..d74b694 --- /dev/null +++ b/src/main/java/com/dodo/backend/admin/exception/AdminErrorCode.java @@ -0,0 +1,74 @@ +package com.dodo.backend.admin.exception; + +import com.dodo.backend.common.exception.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +/** + * 관리자 도메인에서 발생하는 예외 상황을 관리하는 에러 코드입니다. + */ +@Getter +@AllArgsConstructor +public enum AdminErrorCode implements BaseErrorCode { + + /** + * 요청 값이 올바르지 않은 경우 사용합니다. + */ + INVALID_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."), + + /** + * 신고당한 게시글이 존재하지 않는 경우 사용합니다. + */ + REPORTED_BOARD_NOT_FOUND(HttpStatus.NOT_FOUND, "신고당한 게시글이 존재하지 않습니다."), + + /** + * 신고당한 유저가 존재하지 않는 경우 사용합니다. + */ + REPORTED_USER_NOT_FOUND(HttpStatus.NOT_FOUND, "신고당한 유저가 존재하지 않습니다."), + + /** + * 신고당한 댓글이 존재하지 않는 경우 사용합니다. + */ + REPORTED_COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "신고당한 댓글이 존재하지 않습니다."), + + /** + * 유저가 존재하지 않는 경우 사용합니다. + */ + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 유저입니다."), + + /** + * 게시글이 존재하지 않는 경우 사용합니다. + */ + BOARD_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 게시글입니다."), + + /** + * 댓글이 존재하지 않는 경우 사용합니다. + */ + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 댓글입니다."), + + /** + * 신고 내역이 존재하지 않는 경우 사용합니다. + */ + REPORT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 신고 내역입니다."), + + /** + * 공지가 존재하지 않는 경우 사용합니다. + */ + ANNOUNCEMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 공지입니다."), + + /** + * 서버 내부 오류가 발생한 경우 사용합니다. + */ + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."); + + /** + * HTTP 상태 코드입니다. + */ + private final HttpStatus httpStatus; + + /** + * 에러 응답 메시지입니다. + */ + private final String message; +} diff --git a/src/main/java/com/dodo/backend/admin/exception/AdminException.java b/src/main/java/com/dodo/backend/admin/exception/AdminException.java new file mode 100644 index 0000000..9d290a2 --- /dev/null +++ b/src/main/java/com/dodo/backend/admin/exception/AdminException.java @@ -0,0 +1,23 @@ +package com.dodo.backend.admin.exception; + +import com.dodo.backend.common.exception.BaseErrorCode; +import lombok.Getter; + +/** + * 관리자 도메인 비즈니스 로직에서 발생하는 예외입니다. + */ +@Getter +public class AdminException extends RuntimeException { + + private final BaseErrorCode errorCode; + + /** + * 관리자 도메인 예외를 생성합니다. + * + * @param errorCode 에러 코드 + */ + public AdminException(BaseErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/dodo/backend/admin/service/AdminService.java b/src/main/java/com/dodo/backend/admin/service/AdminService.java new file mode 100644 index 0000000..75f13dc --- /dev/null +++ b/src/main/java/com/dodo/backend/admin/service/AdminService.java @@ -0,0 +1,50 @@ +package com.dodo.backend.admin.service; + +import com.dodo.backend.admin.dto.request.AdminRequest.AnnouncementCreateRequest; +import com.dodo.backend.admin.dto.request.AdminRequest.AnnouncementUpdateRequest; +import com.dodo.backend.admin.dto.request.AdminRequest.ReportStatusUpdateRequest; +import com.dodo.backend.admin.dto.request.AdminRequest.UserStatusUpdateRequest; +import com.dodo.backend.admin.dto.response.AdminResponse.AdminSimpleResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.AnnouncementDetailResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.AnnouncementListResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.BoardReportDetailResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.CommentReportDetailResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.ReportListResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.UserReportDetailResponse; +import com.dodo.backend.admin.entity.AdminReportType; +import com.dodo.backend.report.entity.ReportStatus; +import org.springframework.data.domain.Pageable; + +import java.util.UUID; + +/** + * 관리자 API 비즈니스 로직을 정의하는 서비스 인터페이스입니다. + */ +public interface AdminService { + + BoardReportDetailResponse getBoardReportDetail(Long boardId); + + UserReportDetailResponse getUserReportDetail(UUID userId); + + CommentReportDetailResponse getCommentReportDetail(Long commentId); + + ReportListResponse getReportList(AdminReportType reportType, ReportStatus reportStatus, int page, int size, String sort); + + AdminSimpleResponse updateUserStatus(UUID userId, UserStatusUpdateRequest request); + + void deleteBoard(Long boardId); + + void deleteComment(Long commentId); + + AdminSimpleResponse updateReportStatus(Long reportId, ReportStatusUpdateRequest request); + + AdminSimpleResponse createAnnouncement(UUID adminId, AnnouncementCreateRequest request); + + void deleteAnnouncement(Long boardId); + + void updateAnnouncement(Long boardId, AnnouncementUpdateRequest request); + + AnnouncementListResponse getAnnouncementList(Pageable pageable); + + AnnouncementDetailResponse getAnnouncementDetail(Long boardId); +} diff --git a/src/main/java/com/dodo/backend/admin/service/AdminServiceImpl.java b/src/main/java/com/dodo/backend/admin/service/AdminServiceImpl.java new file mode 100644 index 0000000..af16347 --- /dev/null +++ b/src/main/java/com/dodo/backend/admin/service/AdminServiceImpl.java @@ -0,0 +1,497 @@ +package com.dodo.backend.admin.service; + +import com.dodo.backend.admin.dto.request.AdminRequest.AnnouncementCreateRequest; +import com.dodo.backend.admin.dto.request.AdminRequest.AnnouncementUpdateRequest; +import com.dodo.backend.admin.dto.request.AdminRequest.ReportStatusUpdateRequest; +import com.dodo.backend.admin.dto.request.AdminRequest.UserStatusUpdateRequest; +import com.dodo.backend.admin.dto.response.AdminResponse.AdminSimpleResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.AnnouncementDetailResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.AnnouncementItemResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.AnnouncementListResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.BoardInfoResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.BoardReportDetailResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.CommentReportDetailResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.PageInfoResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.ReportDetailItemResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.ReportListItemResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.ReportListResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.ReportTargetInfoResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.UserInfoResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.UserReportDetailResponse; +import com.dodo.backend.admin.entity.AdminReportType; +import com.dodo.backend.admin.exception.AdminException; +import com.dodo.backend.board.entity.Board; +import com.dodo.backend.board.entity.BoardStatus; +import com.dodo.backend.board.entity.BoardType; +import com.dodo.backend.board.mapper.BoardMapper; +import com.dodo.backend.board.repository.BoardRepository; +import com.dodo.backend.comment.entity.Comment; +import com.dodo.backend.comment.repository.CommentRepository; +import com.dodo.backend.imagefile.service.ImageFileService; +import com.dodo.backend.report.entity.Report; +import com.dodo.backend.report.entity.ReportStatus; +import com.dodo.backend.report.repository.ReportRepository; +import com.dodo.backend.user.entity.User; +import com.dodo.backend.user.mapper.UserMapper; +import com.dodo.backend.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static com.dodo.backend.admin.exception.AdminErrorCode.ANNOUNCEMENT_NOT_FOUND; +import static com.dodo.backend.admin.exception.AdminErrorCode.BOARD_NOT_FOUND; +import static com.dodo.backend.admin.exception.AdminErrorCode.COMMENT_NOT_FOUND; +import static com.dodo.backend.admin.exception.AdminErrorCode.INVALID_REQUEST; +import static com.dodo.backend.admin.exception.AdminErrorCode.REPORTED_BOARD_NOT_FOUND; +import static com.dodo.backend.admin.exception.AdminErrorCode.REPORTED_COMMENT_NOT_FOUND; +import static com.dodo.backend.admin.exception.AdminErrorCode.REPORTED_USER_NOT_FOUND; +import static com.dodo.backend.admin.exception.AdminErrorCode.REPORT_NOT_FOUND; +import static com.dodo.backend.admin.exception.AdminErrorCode.USER_NOT_FOUND; + +/** + * {@link AdminService} 구현체입니다. + */ +@Service +@RequiredArgsConstructor +public class AdminServiceImpl implements AdminService { + + private static final int MAX_PAGE_SIZE = 100; + private static final String STATUS_UPDATE_SUCCESS_MESSAGE = "성공적으로 상태를 변경했습니다."; + + private final ReportRepository reportRepository; + private final BoardRepository boardRepository; + private final CommentRepository commentRepository; + private final UserRepository userRepository; + private final UserMapper userMapper; + private final BoardMapper boardMapper; + private final ImageFileService imageFileService; + + /** + * 특정 게시글의 신고 상세 내역을 조회합니다. + * + * @param boardId 신고 상세 내역을 조회할 게시글 ID + * @return 게시글 신고 상세 내역 + */ + @Transactional(readOnly = true) + @Override + public BoardReportDetailResponse getBoardReportDetail(Long boardId) { + validatePositiveId(boardId); + Board board = boardRepository.findById(boardId) + .orElseThrow(() -> new AdminException(REPORTED_BOARD_NOT_FOUND)); + List reports = reportRepository.findAllByBoard_BoardIdOrderByCreatedAtDesc(boardId); + + return BoardReportDetailResponse.builder() + .boardId(board.getBoardId()) + .boardInfo(BoardInfoResponse.toDto(board)) + .reportedUserInfo(UserInfoResponse.toDto(board.getUser())) + .totalReportCount(reports.size()) + .reports(reports.stream().map(ReportDetailItemResponse::toDto).toList()) + .build(); + } + + /** + * 특정 유저의 신고 상세 내역을 조회합니다. + * + * @param userId 신고 상세 내역을 조회할 유저 ID + * @return 유저 신고 상세 내역 + */ + @Transactional(readOnly = true) + @Override + public UserReportDetailResponse getUserReportDetail(UUID userId) { + if (userId == null) { + throw new AdminException(INVALID_REQUEST); + } + + User reportedUser = userRepository.findById(userId) + .orElseThrow(() -> new AdminException(REPORTED_USER_NOT_FOUND)); + List reports = reportRepository.findAllByReportedUser_UsersIdOrderByCreatedAtDesc(userId); + + return UserReportDetailResponse.builder() + .reportedUserInfo(UserInfoResponse.toDto(reportedUser)) + .totalReportCount(reports.size()) + .reports(reports.stream().map(ReportDetailItemResponse::toDto).toList()) + .build(); + } + + /** + * 특정 댓글의 신고 상세 내역을 조회합니다. + * + * @param commentId 신고 상세 내역을 조회할 댓글 ID + * @return 댓글 신고 상세 내역 + */ + @Transactional(readOnly = true) + @Override + public CommentReportDetailResponse getCommentReportDetail(Long commentId) { + validatePositiveId(commentId); + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new AdminException(REPORTED_COMMENT_NOT_FOUND)); + List reports = reportRepository.findAllByComment_CommentIdOrderByCreatedAtDesc(commentId); + + return CommentReportDetailResponse.toDto(comment, reports); + } + + /** + * 신고 유형별 신고 목록을 조회합니다. + * + * @param reportType 조회할 신고 대상 유형 + * @param reportStatus 조회할 신고 처리 상태 + * @param page 조회할 페이지 번호 + * @param size 페이지당 신고 목록 개수 + * @param sort 정렬 조건 + * @return 신고 목록 조회 결과 + */ + @Transactional(readOnly = true) + @Override + public ReportListResponse getReportList(AdminReportType reportType, ReportStatus reportStatus, int page, int size, String sort) { + validatePageRequest(reportType, page, size); + + List reports = findReports(reportType, reportStatus); + List groupedItems = groupReports(reportType, reports); + groupedItems.sort(buildReportListComparator(sort)); + + int fromIndex = Math.min(page * size, groupedItems.size()); + int toIndex = Math.min(fromIndex + size, groupedItems.size()); + List pageItems = groupedItems.subList(fromIndex, toIndex); + + return ReportListResponse.builder() + .pageInfo(PageInfoResponse.toDto(page, size, groupedItems.size())) + .data(pageItems) + .build(); + } + + /** + * 유저 계정 상태를 변경합니다. + * + * @param userId 상태를 변경할 유저 ID + * @param request 변경할 유저 상태 요청 + * @return 상태 변경 성공 메시지 + */ + @Transactional + @Override + public AdminSimpleResponse updateUserStatus(UUID userId, UserStatusUpdateRequest request) { + if (userId == null || request == null || request.getStatus() == null) { + throw new AdminException(INVALID_REQUEST); + } + if (!userRepository.existsById(userId)) { + throw new AdminException(USER_NOT_FOUND); + } + + userMapper.updateUserStatus(userId, request.getStatus().name()); + return AdminSimpleResponse.toDto(STATUS_UPDATE_SUCCESS_MESSAGE); + } + + /** + * 게시글을 강제로 삭제합니다. + * + * @param boardId 삭제할 게시글 ID + */ + @Transactional + @Override + public void deleteBoard(Long boardId) { + validatePositiveId(boardId); + if (!boardRepository.existsById(boardId)) { + throw new AdminException(BOARD_NOT_FOUND); + } + + boardMapper.deleteBoard(boardId, BoardStatus.DELETED.name()); + imageFileService.deleteBoardImages(boardId); + } + + /** + * 댓글을 강제로 삭제합니다. + * + * @param commentId 삭제할 댓글 ID + */ + @Transactional + @Override + public void deleteComment(Long commentId) { + validatePositiveId(commentId); + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new AdminException(COMMENT_NOT_FOUND)); + + reportRepository.deleteAllByComment(comment); + commentRepository.delete(comment); + } + + /** + * 신고 처리 상태를 변경합니다. + * + * @param reportId 상태를 변경할 신고 ID + * @param request 변경할 신고 처리 상태 요청 + * @return 상태 변경 성공 메시지 + */ + @Transactional + @Override + public AdminSimpleResponse updateReportStatus(Long reportId, ReportStatusUpdateRequest request) { + validatePositiveId(reportId); + if (request == null || request.getStatus() == null) { + throw new AdminException(INVALID_REQUEST); + } + + Report report = reportRepository.findById(reportId) + .orElseThrow(() -> new AdminException(REPORT_NOT_FOUND)); + report.updateReportStatus(request.getStatus()); + + return AdminSimpleResponse.toDto(STATUS_UPDATE_SUCCESS_MESSAGE); + } + + /** + * 공지를 작성합니다. + * + * @param adminId 공지를 작성하는 관리자 ID + * @param request 공지 작성 요청 + * @return 공지 작성 성공 메시지 + */ + @Transactional + @Override + public AdminSimpleResponse createAnnouncement(UUID adminId, AnnouncementCreateRequest request) { + validateAnnouncementCreateRequest(adminId, request); + + User admin = userRepository.findById(adminId) + .orElseThrow(() -> new AdminException(USER_NOT_FOUND)); + Board board = Board.builder() + .user(admin) + .boardTitle(request.getBoardTitle()) + .boardContent(request.getBoardContent()) + .viewCount(request.getViewCount() == null ? 0 : request.getViewCount()) + .boardStatus(BoardStatus.PUBLISHED) + .boardStatusUpdatedAt(LocalDateTime.now()) + .boardType(BoardType.NOTICE) + .noticeTag(request.getNoticeTag()) + .build(); + + Board savedBoard = boardRepository.save(board); + imageFileService.saveBoardImages(savedBoard, toImageList(request.getImageFileUrl())); + + return AdminSimpleResponse.toDto("공지가 성공적으로 작성되었습니다."); + } + + /** + * 공지를 삭제합니다. + * + * @param boardId 삭제할 공지 게시글 ID + */ + @Transactional + @Override + public void deleteAnnouncement(Long boardId) { + Board board = findAnnouncement(boardId); + board.updateBoardStatus(BoardStatus.DELETED); + imageFileService.deleteBoardImages(boardId); + } + + /** + * 공지를 수정합니다. + * + * @param boardId 수정할 공지 게시글 ID + * @param request 공지 수정 요청 + */ + @Transactional + @Override + public void updateAnnouncement(Long boardId, AnnouncementUpdateRequest request) { + validateAnnouncementUpdateRequest(request); + + Board board = findAnnouncement(boardId); + board.updateAnnouncement(request.getBoardTitle(), request.getBoardContent()); + if (request.getImageFileUrl() != null) { + imageFileService.replaceBoardImages(board, toImageList(request.getImageFileUrl())); + } + } + + /** + * 공지 목록을 조회합니다. + * + * @param pageable 공지 목록 페이지 요청 정보 + * @return 공지 목록 조회 결과 + */ + @Transactional(readOnly = true) + @Override + public AnnouncementListResponse getAnnouncementList(Pageable pageable) { + if (pageable == null || pageable.getPageNumber() < 0 || pageable.getPageSize() <= 0 || pageable.getPageSize() > MAX_PAGE_SIZE) { + throw new AdminException(INVALID_REQUEST); + } + + Page page = boardRepository.findAllByBoardTypeAndBoardStatus(BoardType.NOTICE, BoardStatus.PUBLISHED, pageable); + List items = page.getContent().stream() + .map(board -> AnnouncementItemResponse.toDto(board, firstImageUrl(board.getBoardId()))) + .toList(); + + return AnnouncementListResponse.builder() + .pageInfo(PageInfoResponse.toDto(page)) + .data(items) + .build(); + } + + /** + * 공지 상세를 조회합니다. + * + * @param boardId 조회할 공지 게시글 ID + * @return 공지 상세 정보 + */ + @Transactional(readOnly = true) + @Override + public AnnouncementDetailResponse getAnnouncementDetail(Long boardId) { + Board board = findAnnouncement(boardId); + return AnnouncementDetailResponse.toDto(board, firstImageUrl(boardId)); + } + + private List findReports(AdminReportType reportType, ReportStatus reportStatus) { + return switch (reportType) { + case BOARD -> reportStatus == null + ? reportRepository.findAllByBoardIsNotNull() + : reportRepository.findAllByBoardIsNotNullAndReportStatus(reportStatus); + case USER -> reportStatus == null + ? reportRepository.findAllByReportedUserIsNotNull() + : reportRepository.findAllByReportedUserIsNotNullAndReportStatus(reportStatus); + case COMMENT -> reportStatus == null + ? reportRepository.findAllByCommentIsNotNull() + : reportRepository.findAllByCommentIsNotNullAndReportStatus(reportStatus); + }; + } + + private List groupReports(AdminReportType reportType, List reports) { + Function classifier = switch (reportType) { + case BOARD -> report -> report.getBoard().getBoardId(); + case USER -> report -> report.getReportedUser().getUsersId(); + case COMMENT -> report -> report.getComment().getCommentId(); + }; + + return reports.stream() + .collect(Collectors.groupingBy(classifier)) + .values() + .stream() + .map(group -> toReportListItem(reportType, group)) + .collect(Collectors.toCollection(ArrayList::new)); + } + + private ReportListItemResponse toReportListItem(AdminReportType reportType, List reports) { + List sortedReports = new ArrayList<>(reports); + sortedReports.sort(Comparator.comparing(Report::getCreatedAt, Comparator.nullsLast(Comparator.naturalOrder())).reversed()); + Report latestReport = sortedReports.get(0); + + return ReportListItemResponse.builder() + .reportType(reportType.name()) + .targetInfo(buildTargetInfo(reportType, latestReport)) + .reportedUser(buildReportedUserInfo(reportType, latestReport)) + .totalReportCount(sortedReports.size()) + .representativeReason(latestReport.getReportReason()) + .reportStatus(resolveAggregateStatus(sortedReports)) + .lastReportedAt(latestReport.getCreatedAt()) + .build(); + } + + private ReportTargetInfoResponse buildTargetInfo(AdminReportType reportType, Report report) { + return switch (reportType) { + case BOARD -> ReportTargetInfoResponse.builder() + .id(report.getBoard().getBoardId()) + .summary(report.getBoard().getBoardTitle()) + .build(); + case USER -> ReportTargetInfoResponse.builder() + .id(report.getReportedUser().getUsersId().toString()) + .summary("사용자 닉네임: " + report.getReportedUser().getNickname()) + .build(); + case COMMENT -> ReportTargetInfoResponse.builder() + .id(report.getComment().getCommentId()) + .summary(report.getComment().getCommentContent()) + .build(); + }; + } + + private UserInfoResponse buildReportedUserInfo(AdminReportType reportType, Report report) { + return switch (reportType) { + case BOARD -> UserInfoResponse.toDto(report.getBoard().getUser()); + case USER -> UserInfoResponse.toDto(report.getReportedUser()); + case COMMENT -> UserInfoResponse.toDto(report.getComment().getUser()); + }; + } + + private ReportStatus resolveAggregateStatus(List reports) { + return reports.stream().anyMatch(report -> report.getReportStatus() == ReportStatus.PENDING) + ? ReportStatus.PENDING + : ReportStatus.COMPLETED; + } + + private Comparator buildReportListComparator(String sort) { + String normalized = sort == null || sort.isBlank() ? "lastReportedAt,desc" : sort; + String[] tokens = normalized.split(","); + String property = tokens[0].trim(); + boolean descending = tokens.length < 2 || !"asc".equalsIgnoreCase(tokens[1].trim()); + + Comparator comparator = switch (property) { + case "totalReportCount" -> Comparator.comparing(ReportListItemResponse::getTotalReportCount); + case "lastReportedAt" -> Comparator.comparing(ReportListItemResponse::getLastReportedAt, Comparator.nullsLast(Comparator.naturalOrder())); + default -> throw new AdminException(INVALID_REQUEST); + }; + + return descending ? comparator.reversed() : comparator; + } + + private Board findAnnouncement(Long boardId) { + validatePositiveId(boardId); + return boardRepository.findByBoardIdAndBoardType(boardId, BoardType.NOTICE) + .filter(board -> board.getBoardStatus() != BoardStatus.DELETED) + .orElseThrow(() -> new AdminException(ANNOUNCEMENT_NOT_FOUND)); + } + + private String firstImageUrl(Long boardId) { + List imageUrls = imageFileService.getBoardImageUrls(boardId); + return imageUrls.isEmpty() ? null : imageUrls.get(0); + } + + private List toImageList(String imageFileUrl) { + if (imageFileUrl == null || imageFileUrl.isBlank()) { + return List.of(); + } + return List.of(imageFileUrl); + } + + private void validateAnnouncementCreateRequest(UUID adminId, AnnouncementCreateRequest request) { + if (adminId == null || request == null || isBlank(request.getBoardTitle()) || isBlank(request.getBoardContent())) { + throw new AdminException(INVALID_REQUEST); + } + if (request.getViewCount() != null && request.getViewCount() < 0) { + throw new AdminException(INVALID_REQUEST); + } + } + + private void validateAnnouncementUpdateRequest(AnnouncementUpdateRequest request) { + if (request == null) { + throw new AdminException(INVALID_REQUEST); + } + boolean hasTitle = request.getBoardTitle() != null; + boolean hasContent = request.getBoardContent() != null; + boolean hasImage = request.getImageFileUrl() != null; + + if (!hasTitle && !hasContent && !hasImage) { + throw new AdminException(INVALID_REQUEST); + } + if ((hasTitle && request.getBoardTitle().isBlank()) || (hasContent && request.getBoardContent().isBlank())) { + throw new AdminException(INVALID_REQUEST); + } + } + + private void validatePageRequest(AdminReportType reportType, int page, int size) { + if (reportType == null || page < 0 || size <= 0 || size > MAX_PAGE_SIZE || page > Integer.MAX_VALUE / size) { + throw new AdminException(INVALID_REQUEST); + } + } + + private void validatePositiveId(Long id) { + if (id == null || id <= 0) { + throw new AdminException(INVALID_REQUEST); + } + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/src/main/java/com/dodo/backend/board/entity/Board.java b/src/main/java/com/dodo/backend/board/entity/Board.java index 0bb4ad2..0a2471a 100644 --- a/src/main/java/com/dodo/backend/board/entity/Board.java +++ b/src/main/java/com/dodo/backend/board/entity/Board.java @@ -13,7 +13,7 @@ /** * 게시글(Board) 정보를 관리하는 엔티티입니다. *

- * 데이터베이스 {@code board} 테이블과 매핑되며, 작성자, 제목/본문, 조회수, + * 데이터베이스 {@code board} 테이블과 매핑되며, 작성자, 제목/본문, 조회 수, * 게시글 상태 및 게시판 유형 정보를 포함합니다. */ @Entity @@ -68,4 +68,28 @@ public class Board { @Column(name = "notice_tag") private NoticeTag noticeTag; + /** + * 공지 게시글 내용을 수정합니다. + * + * @param boardTitle 수정할 공지 제목 + * @param boardContent 수정할 공지 내용 + */ + public void updateAnnouncement(String boardTitle, String boardContent) { + if (boardTitle != null) { + this.boardTitle = boardTitle; + } + if (boardContent != null) { + this.boardContent = boardContent; + } + } + + /** + * 게시글 상태를 변경합니다. + * + * @param boardStatus 변경할 게시글 상태 + */ + public void updateBoardStatus(BoardStatus boardStatus) { + this.boardStatus = boardStatus; + this.boardStatusUpdatedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/com/dodo/backend/board/repository/BoardRepository.java b/src/main/java/com/dodo/backend/board/repository/BoardRepository.java index bd858f6..62b9c03 100644 --- a/src/main/java/com/dodo/backend/board/repository/BoardRepository.java +++ b/src/main/java/com/dodo/backend/board/repository/BoardRepository.java @@ -3,11 +3,13 @@ import com.dodo.backend.board.entity.Board; import com.dodo.backend.board.entity.BoardStatus; import com.dodo.backend.board.entity.BoardType; +import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; /** * {@link Board} 엔티티의 데이터베이스 접근을 담당하는 리포지토리 인터페이스입니다. @@ -28,4 +30,23 @@ List findByBoardTypeAndBoardStatusOrderByBoardCreatedAtDesc( BoardStatus boardStatus, Pageable pageable ); + + /** + * 게시글 유형과 상태에 맞는 게시글 목록을 페이지 단위로 조회합니다. + * + * @param boardType 조회할 게시글 유형 + * @param boardStatus 조회할 게시글 상태 + * @param pageable 페이지 및 정렬 정보 + * @return 조건에 맞는 게시글 페이지 + */ + Page findAllByBoardTypeAndBoardStatus(BoardType boardType, BoardStatus boardStatus, Pageable pageable); + + /** + * 게시글 ID와 게시글 유형으로 게시글을 조회합니다. + * + * @param boardId 게시글 ID + * @param boardType 게시글 유형 + * @return 조회된 게시글 + */ + Optional findByBoardIdAndBoardType(Long boardId, BoardType boardType); } diff --git a/src/main/java/com/dodo/backend/common/config/SecurityConfig.java b/src/main/java/com/dodo/backend/common/config/SecurityConfig.java index 41f93df..6406957 100644 --- a/src/main/java/com/dodo/backend/common/config/SecurityConfig.java +++ b/src/main/java/com/dodo/backend/common/config/SecurityConfig.java @@ -84,6 +84,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/swagger-ui.html", "/swagger-ui/**" ).denyAll() + .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) .exceptionHandling(exception -> exception diff --git a/src/main/java/com/dodo/backend/common/exception/GlobalExceptionHandler.java b/src/main/java/com/dodo/backend/common/exception/GlobalExceptionHandler.java index d6ff619..be9aff3 100644 --- a/src/main/java/com/dodo/backend/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/dodo/backend/common/exception/GlobalExceptionHandler.java @@ -1,6 +1,7 @@ package com.dodo.backend.common.exception; import com.dodo.backend.activityhistory.exception.ActivityHistoryException; +import com.dodo.backend.admin.exception.AdminException; import com.dodo.backend.auth.exception.AuthException; import com.dodo.backend.board.exception.BoardException; import com.dodo.backend.fence.exception.FenceException; @@ -130,6 +131,15 @@ protected ResponseEntity handleReactionException(ReactionExceptio return toResponseEntity(e.getErrorCode()); } + /** + * 관리자(Admin) 도메인 비즈니스 로직에서 발생하는 {@link AdminException}을 처리합니다. + */ + @ExceptionHandler(AdminException.class) + protected ResponseEntity handleAdminException(AdminException e) { + log.error("AdminException occurred: {}", e.getErrorCode()); + return toResponseEntity(e.getErrorCode()); + } + /** * 신고(Report) 도메인 비즈니스 로직에서 발생하는 {@link ReportException}을 처리합니다. */ diff --git a/src/main/java/com/dodo/backend/report/entity/Report.java b/src/main/java/com/dodo/backend/report/entity/Report.java index 6d60e98..95b4a2e 100644 --- a/src/main/java/com/dodo/backend/report/entity/Report.java +++ b/src/main/java/com/dodo/backend/report/entity/Report.java @@ -80,4 +80,13 @@ public class Report { @CreatedDate @Column(name = "report_created_at", nullable = false, updatable = false) private LocalDateTime createdAt; + + /** + * 신고 처리 상태를 변경합니다. + * + * @param reportStatus 변경할 신고 처리 상태 + */ + public void updateReportStatus(ReportStatus reportStatus) { + this.reportStatus = reportStatus; + } } diff --git a/src/main/java/com/dodo/backend/report/repository/ReportRepository.java b/src/main/java/com/dodo/backend/report/repository/ReportRepository.java index e84d098..59dcfe4 100644 --- a/src/main/java/com/dodo/backend/report/repository/ReportRepository.java +++ b/src/main/java/com/dodo/backend/report/repository/ReportRepository.java @@ -3,10 +3,15 @@ import com.dodo.backend.board.entity.Board; import com.dodo.backend.comment.entity.Comment; import com.dodo.backend.report.entity.Report; +import com.dodo.backend.report.entity.ReportStatus; import com.dodo.backend.user.entity.User; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.List; +import java.util.UUID; + /** * {@link Report} 엔티티의 데이터베이스 접근을 담당하는 repository입니다. */ @@ -39,4 +44,89 @@ public interface ReportRepository extends JpaRepository { * @return 이미 신고했으면 true */ boolean existsByReporterAndComment(User reporter, Comment comment); + + /** + * 특정 게시글에 대한 신고 목록을 조회합니다. + * + * @param boardId 게시글 ID + * @return 신고 목록 + */ + @EntityGraph(attributePaths = {"reporter", "board", "board.user"}) + List findAllByBoard_BoardIdOrderByCreatedAtDesc(Long boardId); + + /** + * 특정 유저에 대한 신고 목록을 조회합니다. + * + * @param userId 유저 ID + * @return 신고 목록 + */ + @EntityGraph(attributePaths = {"reporter", "reportedUser"}) + List findAllByReportedUser_UsersIdOrderByCreatedAtDesc(UUID userId); + + /** + * 특정 댓글에 대한 신고 목록을 조회합니다. + * + * @param commentId 댓글 ID + * @return 신고 목록 + */ + @EntityGraph(attributePaths = {"reporter", "comment", "comment.user"}) + List findAllByComment_CommentIdOrderByCreatedAtDesc(Long commentId); + + /** + * 게시글 신고 목록을 조회합니다. + * + * @return 게시글 신고 목록 + */ + @EntityGraph(attributePaths = {"reporter", "board", "board.user"}) + List findAllByBoardIsNotNull(); + + /** + * 특정 상태의 게시글 신고 목록을 조회합니다. + * + * @param reportStatus 신고 처리 상태 + * @return 게시글 신고 목록 + */ + @EntityGraph(attributePaths = {"reporter", "board", "board.user"}) + List findAllByBoardIsNotNullAndReportStatus(ReportStatus reportStatus); + + /** + * 유저 신고 목록을 조회합니다. + * + * @return 유저 신고 목록 + */ + @EntityGraph(attributePaths = {"reporter", "reportedUser"}) + List findAllByReportedUserIsNotNull(); + + /** + * 특정 상태의 유저 신고 목록을 조회합니다. + * + * @param reportStatus 신고 처리 상태 + * @return 유저 신고 목록 + */ + @EntityGraph(attributePaths = {"reporter", "reportedUser"}) + List findAllByReportedUserIsNotNullAndReportStatus(ReportStatus reportStatus); + + /** + * 댓글 신고 목록을 조회합니다. + * + * @return 댓글 신고 목록 + */ + @EntityGraph(attributePaths = {"reporter", "comment", "comment.user"}) + List findAllByCommentIsNotNull(); + + /** + * 특정 상태의 댓글 신고 목록을 조회합니다. + * + * @param reportStatus 신고 처리 상태 + * @return 댓글 신고 목록 + */ + @EntityGraph(attributePaths = {"reporter", "comment", "comment.user"}) + List findAllByCommentIsNotNullAndReportStatus(ReportStatus reportStatus); + + /** + * 댓글에 연결된 신고 내역을 삭제합니다. + * + * @param comment 삭제 대상 댓글 + */ + void deleteAllByComment(Comment comment); } diff --git a/src/test/java/com/dodo/backend/admin/controller/AdminControllerTest.java b/src/test/java/com/dodo/backend/admin/controller/AdminControllerTest.java new file mode 100644 index 0000000..08659a4 --- /dev/null +++ b/src/test/java/com/dodo/backend/admin/controller/AdminControllerTest.java @@ -0,0 +1,120 @@ +package com.dodo.backend.admin.controller; + +import com.dodo.backend.admin.dto.request.AdminRequest.ReportStatusUpdateRequest; +import com.dodo.backend.admin.dto.response.AdminResponse.AdminSimpleResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.BoardReportDetailResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.PageInfoResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.ReportListResponse; +import com.dodo.backend.admin.service.AdminService; +import com.dodo.backend.report.entity.ReportStatus; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.util.List; + +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.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 관리자 컨트롤러 API 경로를 검증하는 테스트 클래스입니다. + */ +@ExtendWith(MockitoExtension.class) +class AdminControllerTest { + + @Mock + private AdminService adminService; + + private MockMvc mockMvc; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(new AdminController(adminService)) + .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()) + .build(); + objectMapper = new ObjectMapper(); + } + + /** + * 게시글 신고 상세 조회 API가 200을 반환하는지 검증합니다. + */ + @Test + @DisplayName("게시글 신고 상세 조회 API 성공") + void getBoardReportDetail_Success() throws Exception { + BoardReportDetailResponse response = BoardReportDetailResponse.builder() + .boardId(1L) + .totalReportCount(0) + .reports(List.of()) + .build(); + given(adminService.getBoardReportDetail(1L)).willReturn(response); + + mockMvc.perform(get("/admin/reports/board/{boardId}", 1L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.boardId").value(1)); + + verify(adminService).getBoardReportDetail(1L); + } + + /** + * 신고 목록 조회 API가 200을 반환하는지 검증합니다. + */ + @Test + @DisplayName("신고 목록 조회 API 성공") + void getReportList_Success() throws Exception { + ReportListResponse response = ReportListResponse.builder() + .pageInfo(PageInfoResponse.toDto(0, 10, 0)) + .data(List.of()) + .build(); + given(adminService.getReportList(any(), eq(null), eq(0), eq(10), eq("lastReportedAt,desc"))).willReturn(response); + + mockMvc.perform(get("/admin/reports") + .param("reportType", "BOARD") + .param("sort", "lastReportedAt,desc")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.pageInfo.page").value(0)); + } + + /** + * 신고 처리 상태 변경 API가 200을 반환하는지 검증합니다. + */ + @Test + @DisplayName("신고 처리 상태 변경 API 성공") + void updateReportStatus_Success() throws Exception { + given(adminService.updateReportStatus(eq(10L), any(ReportStatusUpdateRequest.class))) + .willReturn(AdminSimpleResponse.toDto("성공적으로 상태를 변경했습니다.")); + + mockMvc.perform(patch("/admin/reports/{reportId}/status", 10L) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ReportStatusUpdateRequest(ReportStatus.COMPLETED)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("성공적으로 상태를 변경했습니다.")); + } + + /** + * 게시글 강제 삭제 API가 204를 반환하는지 검증합니다. + */ + @Test + @DisplayName("게시글 강제 삭제 API 성공") + void deleteBoard_Success() throws Exception { + mockMvc.perform(delete("/admin/boards/{boardId}", 1L)) + .andExpect(status().isNoContent()); + + verify(adminService).deleteBoard(1L); + } +} diff --git a/src/test/java/com/dodo/backend/admin/service/AdminServiceTest.java b/src/test/java/com/dodo/backend/admin/service/AdminServiceTest.java new file mode 100644 index 0000000..feea5bc --- /dev/null +++ b/src/test/java/com/dodo/backend/admin/service/AdminServiceTest.java @@ -0,0 +1,175 @@ +package com.dodo.backend.admin.service; + +import com.dodo.backend.admin.dto.request.AdminRequest.ReportStatusUpdateRequest; +import com.dodo.backend.admin.dto.request.AdminRequest.UserStatusUpdateRequest; +import com.dodo.backend.admin.dto.response.AdminResponse.BoardReportDetailResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.ReportListResponse; +import com.dodo.backend.admin.entity.AdminReportType; +import com.dodo.backend.board.entity.Board; +import com.dodo.backend.board.entity.BoardStatus; +import com.dodo.backend.board.entity.BoardType; +import com.dodo.backend.board.mapper.BoardMapper; +import com.dodo.backend.board.repository.BoardRepository; +import com.dodo.backend.comment.repository.CommentRepository; +import com.dodo.backend.imagefile.service.ImageFileService; +import com.dodo.backend.report.entity.Report; +import com.dodo.backend.report.entity.ReportReason; +import com.dodo.backend.report.entity.ReportStatus; +import com.dodo.backend.report.repository.ReportRepository; +import com.dodo.backend.user.entity.User; +import com.dodo.backend.user.entity.UserStatus; +import com.dodo.backend.user.mapper.UserMapper; +import com.dodo.backend.user.repository.UserRepository; +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 java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +/** + * 관리자 서비스 로직을 검증하는 테스트 클래스입니다. + */ +@ExtendWith(MockitoExtension.class) +class AdminServiceTest { + + @Mock + private ReportRepository reportRepository; + + @Mock + private BoardRepository boardRepository; + + @Mock + private CommentRepository commentRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private UserMapper userMapper; + + @Mock + private BoardMapper boardMapper; + + @Mock + private ImageFileService imageFileService; + + @InjectMocks + private AdminServiceImpl adminService; + + /** + * 게시글 신고 상세 조회 시 게시글과 신고 목록을 반환하는지 검증합니다. + */ + @Test + @DisplayName("게시글 신고 상세 조회 성공") + void getBoardReportDetail_Success() { + User writer = createUser(UUID.randomUUID(), "작성자"); + User reporter = createUser(UUID.randomUUID(), "신고자"); + Board board = createBoard(1L, writer); + Report report = createBoardReport(10L, reporter, board, ReportReason.ABUSE); + + given(boardRepository.findById(1L)).willReturn(Optional.of(board)); + given(reportRepository.findAllByBoard_BoardIdOrderByCreatedAtDesc(1L)).willReturn(List.of(report)); + + BoardReportDetailResponse response = adminService.getBoardReportDetail(1L); + + assertNotNull(response); + assertEquals(1L, response.getBoardId()); + assertEquals(1, response.getTotalReportCount()); + assertEquals("작성자", response.getReportedUserInfo().getNickname()); + } + + /** + * 신고 목록 조회 시 대상별로 신고가 묶이는지 검증합니다. + */ + @Test + @DisplayName("게시글 신고 목록 조회 성공") + void getReportList_Success() { + User writer = createUser(UUID.randomUUID(), "작성자"); + User reporter = createUser(UUID.randomUUID(), "신고자"); + Board board = createBoard(1L, writer); + Report report = createBoardReport(10L, reporter, board, ReportReason.SPAM); + + given(reportRepository.findAllByBoardIsNotNull()).willReturn(List.of(report)); + + ReportListResponse response = adminService.getReportList(AdminReportType.BOARD, null, 0, 10, "lastReportedAt,desc"); + + assertNotNull(response); + assertEquals(1, response.getData().size()); + assertEquals("BOARD", response.getData().get(0).getReportType()); + assertEquals(1, response.getData().get(0).getTotalReportCount()); + } + + /** + * 유저 상태 변경 시 UserMapper가 호출되는지 검증합니다. + */ + @Test + @DisplayName("유저 계정 상태 변경 성공") + void updateUserStatus_Success() { + UUID userId = UUID.randomUUID(); + given(userRepository.existsById(userId)).willReturn(true); + + adminService.updateUserStatus(userId, new UserStatusUpdateRequest(UserStatus.SUSPENDED)); + + verify(userMapper).updateUserStatus(userId, UserStatus.SUSPENDED.name()); + } + + /** + * 신고 상태 변경 시 엔티티의 상태가 변경되는지 검증합니다. + */ + @Test + @DisplayName("신고 처리 상태 변경 성공") + void updateReportStatus_Success() { + Report report = createBoardReport( + 10L, + createUser(UUID.randomUUID(), "신고자"), + createBoard(1L, createUser(UUID.randomUUID(), "작성자")), + ReportReason.SPAM + ); + given(reportRepository.findById(10L)).willReturn(Optional.of(report)); + + adminService.updateReportStatus(10L, new ReportStatusUpdateRequest(ReportStatus.COMPLETED)); + + assertEquals(ReportStatus.COMPLETED, report.getReportStatus()); + } + + private User createUser(UUID userId, String nickname) { + return User.builder() + .usersId(userId) + .nickname(nickname) + .build(); + } + + private Board createBoard(Long boardId, User user) { + return Board.builder() + .boardId(boardId) + .user(user) + .boardTitle("게시글 제목") + .boardContent("게시글 내용") + .boardType(BoardType.FREE) + .boardStatus(BoardStatus.PUBLISHED) + .boardCreatedAt(LocalDateTime.of(2025, 10, 1, 10, 0)) + .build(); + } + + private Report createBoardReport(Long reportId, User reporter, Board board, ReportReason reason) { + return Report.builder() + .reportId(reportId) + .reporter(reporter) + .board(board) + .reportReason(reason) + .reportStatus(ReportStatus.PENDING) + .createdAt(LocalDateTime.of(2025, 10, 1, 11, 0)) + .build(); + } +} diff --git a/src/test/java/com/dodo/backend/main/service/MainServiceTest.java b/src/test/java/com/dodo/backend/main/service/MainServiceTest.java index 04d6701..ea60201 100644 --- a/src/test/java/com/dodo/backend/main/service/MainServiceTest.java +++ b/src/test/java/com/dodo/backend/main/service/MainServiceTest.java @@ -127,7 +127,7 @@ void getMainPage_WithPetProfilesAndHealthReports() { given(boardRepository.findByBoardTypeAndBoardStatusOrderByBoardCreatedAtDesc( BoardType.NOTICE, BoardStatus.PUBLISHED, - PageRequest.of(0, 3) + PageRequest.of(0, 5) )).willReturn(List.of(notice)); given(imageFileService.getBoardImageUrls(notice.getBoardId())) .willReturn(List.of("https://example.com/notice/11.png")); @@ -174,7 +174,7 @@ void getMainPage_WithPetProfilesAndHealthReports() { verify(boardRepository).findByBoardTypeAndBoardStatusOrderByBoardCreatedAtDesc( BoardType.NOTICE, BoardStatus.PUBLISHED, - PageRequest.of(0, 3) + PageRequest.of(0, 5) ); verify(imageFileService).getBoardImageUrls(notice.getBoardId()); @@ -204,7 +204,7 @@ void getMainPage_WithoutPets_ReturnsEmptyLists() { given(boardRepository.findByBoardTypeAndBoardStatusOrderByBoardCreatedAtDesc( BoardType.NOTICE, BoardStatus.PUBLISHED, - PageRequest.of(0, 3) + PageRequest.of(0, 5) )).willReturn(Collections.emptyList()); // when @@ -220,7 +220,7 @@ void getMainPage_WithoutPets_ReturnsEmptyLists() { verify(boardRepository).findByBoardTypeAndBoardStatusOrderByBoardCreatedAtDesc( BoardType.NOTICE, BoardStatus.PUBLISHED, - PageRequest.of(0, 3) + PageRequest.of(0, 5) ); verifyNoInteractions(healthAnalysisService); verifyNoInteractions(imageFileService); @@ -270,7 +270,7 @@ void getMainPage_PetWithoutHealthAnalysis() { given(boardRepository.findByBoardTypeAndBoardStatusOrderByBoardCreatedAtDesc( BoardType.NOTICE, BoardStatus.PUBLISHED, - PageRequest.of(0, 3) + PageRequest.of(0, 5) )).willReturn(Collections.emptyList()); // when @@ -287,7 +287,7 @@ void getMainPage_PetWithoutHealthAnalysis() { verify(boardRepository).findByBoardTypeAndBoardStatusOrderByBoardCreatedAtDesc( BoardType.NOTICE, BoardStatus.PUBLISHED, - PageRequest.of(0, 3) + PageRequest.of(0, 5) ); log.info("테스트 종료: 건강 분석 이력 없음 응답 검증 완료"); From 6b005ec1fbbd7c509ce92c4a3403b6b371120c7c Mon Sep 17 00:00:00 2001 From: limhb708 Date: Sun, 28 Jun 2026 09:49:17 +0900 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9C=A8=20Feat:=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=B0=8F=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 관리자 전용 로그인 API 추가 - 관리자 알림 스케줄 등록 API 추가 - @Scheduled 기반 예약 알림 실행 로직 구현 - 예약 알림 DB 저장 및 FCM 발송 처리 추가 - 일부 API 성공 응답을 200과 message 응답으로 정리 --- .../admin/controller/AdminController.java | 78 +++++-- .../admin/dto/response/AdminResponse.java | 5 +- .../backend/admin/service/AdminService.java | 3 +- .../admin/service/AdminServiceImpl.java | 64 +++++- .../auth/controller/AuthController.java | 24 +- .../backend/auth/dto/request/AuthRequest.java | 18 +- .../auth/dto/response/AuthResponse.java | 34 ++- .../backend/auth/service/AuthService.java | 6 +- .../backend/auth/service/AuthServiceImpl.java | 52 ++++- .../common/config/SchedulingConfig.java | 9 + .../backend/common/config/SecurityConfig.java | 1 + .../exception/GlobalExceptionHandler.java | 20 ++ .../controller/FcmTokenController.java | 66 ++++++ .../fcmtoken/dto/request/FcmTokenRequest.java | 40 ++++ .../dto/response/FcmTokenResponse.java | 30 +++ .../backend/fcmtoken/entity/DeviceType.java | 10 + .../backend/fcmtoken/entity/FcmToken.java | 84 +++++++ .../fcmtoken/exception/FcmTokenErrorCode.java | 32 +++ .../fcmtoken/exception/FcmTokenException.java | 17 ++ .../repository/FcmTokenRepository.java | 24 ++ .../fcmtoken/service/FcmTokenService.java | 16 ++ .../fcmtoken/service/FcmTokenServiceImpl.java | 87 ++++++++ .../controller/NotificationController.java | 162 ++++++++++++++ .../dto/request/NotificationRequest.java | 73 +++++++ .../dto/response/NotificationResponse.java | 139 ++++++++++++ .../notification/entity/Notification.java | 81 +++++++ .../entity/NotificationSchedule.java | 94 ++++++++ .../NotificationScheduleRepeatType.java | 7 + .../entity/NotificationScheduleStatus.java | 7 + .../NotificationScheduleTargetType.java | 6 + .../notification/entity/NotificationType.java | 13 ++ .../exception/NotificationErrorCode.java | 42 ++++ .../exception/NotificationException.java | 17 ++ .../repository/NotificationRepository.java | 50 +++++ .../NotificationScheduleRepository.java | 18 ++ .../service/FcmNotificationSender.java | 105 +++++++++ .../service/NotificationScheduleService.java | 11 + .../NotificationScheduleServiceImpl.java | 181 ++++++++++++++++ .../service/NotificationService.java | 26 +++ .../service/NotificationServiceImpl.java | 205 ++++++++++++++++++ .../report/controller/ReportController.java | 19 +- .../user/repository/UserRepository.java | 5 + .../admin/controller/AdminControllerTest.java | 126 ++++++++++- .../admin/service/AdminServiceTest.java | 58 +++++ .../backend/auth/service/AuthServiceTest.java | 48 +++- .../controller/FcmTokenControllerTest.java | 92 ++++++++ .../fcmtoken/service/FcmTokenServiceTest.java | 141 ++++++++++++ .../NotificationControllerTest.java | 135 ++++++++++++ .../service/NotificationServiceTest.java | 138 ++++++++++++ .../controller/ReportControllerTest.java | 18 +- 50 files changed, 2673 insertions(+), 64 deletions(-) create mode 100644 src/main/java/com/dodo/backend/common/config/SchedulingConfig.java create mode 100644 src/main/java/com/dodo/backend/fcmtoken/controller/FcmTokenController.java create mode 100644 src/main/java/com/dodo/backend/fcmtoken/dto/request/FcmTokenRequest.java create mode 100644 src/main/java/com/dodo/backend/fcmtoken/dto/response/FcmTokenResponse.java create mode 100644 src/main/java/com/dodo/backend/fcmtoken/entity/DeviceType.java create mode 100644 src/main/java/com/dodo/backend/fcmtoken/entity/FcmToken.java create mode 100644 src/main/java/com/dodo/backend/fcmtoken/exception/FcmTokenErrorCode.java create mode 100644 src/main/java/com/dodo/backend/fcmtoken/exception/FcmTokenException.java create mode 100644 src/main/java/com/dodo/backend/fcmtoken/repository/FcmTokenRepository.java create mode 100644 src/main/java/com/dodo/backend/fcmtoken/service/FcmTokenService.java create mode 100644 src/main/java/com/dodo/backend/fcmtoken/service/FcmTokenServiceImpl.java create mode 100644 src/main/java/com/dodo/backend/notification/controller/NotificationController.java create mode 100644 src/main/java/com/dodo/backend/notification/dto/request/NotificationRequest.java create mode 100644 src/main/java/com/dodo/backend/notification/dto/response/NotificationResponse.java create mode 100644 src/main/java/com/dodo/backend/notification/entity/Notification.java create mode 100644 src/main/java/com/dodo/backend/notification/entity/NotificationSchedule.java create mode 100644 src/main/java/com/dodo/backend/notification/entity/NotificationScheduleRepeatType.java create mode 100644 src/main/java/com/dodo/backend/notification/entity/NotificationScheduleStatus.java create mode 100644 src/main/java/com/dodo/backend/notification/entity/NotificationScheduleTargetType.java create mode 100644 src/main/java/com/dodo/backend/notification/entity/NotificationType.java create mode 100644 src/main/java/com/dodo/backend/notification/exception/NotificationErrorCode.java create mode 100644 src/main/java/com/dodo/backend/notification/exception/NotificationException.java create mode 100644 src/main/java/com/dodo/backend/notification/repository/NotificationRepository.java create mode 100644 src/main/java/com/dodo/backend/notification/repository/NotificationScheduleRepository.java create mode 100644 src/main/java/com/dodo/backend/notification/service/FcmNotificationSender.java create mode 100644 src/main/java/com/dodo/backend/notification/service/NotificationScheduleService.java create mode 100644 src/main/java/com/dodo/backend/notification/service/NotificationScheduleServiceImpl.java create mode 100644 src/main/java/com/dodo/backend/notification/service/NotificationService.java create mode 100644 src/main/java/com/dodo/backend/notification/service/NotificationServiceImpl.java create mode 100644 src/test/java/com/dodo/backend/fcmtoken/controller/FcmTokenControllerTest.java create mode 100644 src/test/java/com/dodo/backend/fcmtoken/service/FcmTokenServiceTest.java create mode 100644 src/test/java/com/dodo/backend/notification/controller/NotificationControllerTest.java create mode 100644 src/test/java/com/dodo/backend/notification/service/NotificationServiceTest.java diff --git a/src/main/java/com/dodo/backend/admin/controller/AdminController.java b/src/main/java/com/dodo/backend/admin/controller/AdminController.java index 58250db..ad30767 100644 --- a/src/main/java/com/dodo/backend/admin/controller/AdminController.java +++ b/src/main/java/com/dodo/backend/admin/controller/AdminController.java @@ -13,15 +13,19 @@ import com.dodo.backend.admin.dto.response.AdminResponse.UserReportDetailResponse; import com.dodo.backend.admin.entity.AdminReportType; import com.dodo.backend.admin.service.AdminService; +import com.dodo.backend.notification.dto.request.NotificationRequest.NotificationScheduleCreateRequest; +import com.dodo.backend.notification.dto.response.NotificationResponse.NotificationScheduleCreateResponse; +import com.dodo.backend.notification.service.NotificationScheduleService; import com.dodo.backend.report.entity.ReportStatus; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springdoc.core.annotations.ParameterObject; -import org.springframework.data.domain.Pageable; -import org.springframework.data.web.PageableDefault; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -49,6 +53,7 @@ public class AdminController { private final AdminService adminService; + private final NotificationScheduleService notificationScheduleService; /** * 특정 게시글의 신고 상세 내역을 조회합니다. @@ -134,28 +139,36 @@ public ResponseEntity updateUserStatus( * 게시글을 강제로 삭제합니다. * * @param boardId 삭제할 게시글 ID - * @return 응답 본문이 없는 204 응답 + * @return 게시글 삭제 성공 메시지 */ @Operation(summary = "게시글 강제 삭제", description = "관리자가 게시글을 삭제 상태로 변경합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "게시글이 성공적으로 강제 삭제되었습니다.", + content = @Content(schema = @Schema(implementation = AdminSimpleResponse.class))) + }) @DeleteMapping("/boards/{boardId}") - public ResponseEntity deleteBoard(@PathVariable Long boardId) { + public ResponseEntity deleteBoard(@PathVariable Long boardId) { log.info("관리자 게시글 강제 삭제 요청 - BoardId: {}", boardId); adminService.deleteBoard(boardId); - return ResponseEntity.noContent().build(); + return ResponseEntity.ok(AdminSimpleResponse.toDto("게시글이 성공적으로 강제 삭제되었습니다.")); } /** * 댓글을 강제로 삭제합니다. * * @param commentId 삭제할 댓글 ID - * @return 응답 본문이 없는 204 응답 + * @return 댓글 삭제 성공 메시지 */ @Operation(summary = "댓글 강제 삭제", description = "관리자가 댓글을 강제로 삭제합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "댓글이 성공적으로 강제 삭제되었습니다.", + content = @Content(schema = @Schema(implementation = AdminSimpleResponse.class))) + }) @DeleteMapping("/comments/{commentId}") - public ResponseEntity deleteComment(@PathVariable Long commentId) { + public ResponseEntity deleteComment(@PathVariable Long commentId) { log.info("관리자 댓글 강제 삭제 요청 - CommentId: {}", commentId); adminService.deleteComment(commentId); - return ResponseEntity.noContent().build(); + return ResponseEntity.ok(AdminSimpleResponse.toDto("댓글이 성공적으로 강제 삭제되었습니다.")); } /** @@ -195,18 +208,37 @@ public ResponseEntity createAnnouncement( .body(adminService.createAnnouncement(adminId, request)); } + @Operation(summary = "알림 스케줄 등록", description = "관리자가 지정 시간에 발송될 알림 스케줄을 등록합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "알림 스케줄 등록 성공", + content = @Content(schema = @Schema(implementation = NotificationScheduleCreateResponse.class))) + }) + @PostMapping("/notification-schedules") + public ResponseEntity createNotificationSchedule( + @Valid @RequestBody NotificationScheduleCreateRequest request, + @AuthenticationPrincipal UserDetails userDetails + ) { + UUID adminId = UUID.fromString(userDetails.getUsername()); + log.info("관리자 알림 스케줄 등록 요청 - AdminId: {}, ScheduledAt: {}", adminId, request.getScheduledAt()); + return ResponseEntity.ok(notificationScheduleService.createSchedule(adminId, request)); + } + /** * 공지를 삭제합니다. * * @param boardId 삭제할 공지 게시글 ID - * @return 응답 본문이 없는 204 응답 + * @return 공지 삭제 성공 메시지 */ @Operation(summary = "공지 삭제", description = "관리자가 공지를 삭제합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "공지가 성공적으로 삭제되었습니다.", + content = @Content(schema = @Schema(implementation = AdminSimpleResponse.class))) + }) @DeleteMapping("/announcements/{boardId}") - public ResponseEntity deleteAnnouncement(@PathVariable Long boardId) { + public ResponseEntity deleteAnnouncement(@PathVariable Long boardId) { log.info("관리자 공지 삭제 요청 - BoardId: {}", boardId); adminService.deleteAnnouncement(boardId); - return ResponseEntity.noContent().build(); + return ResponseEntity.ok(AdminSimpleResponse.toDto("공지가 성공적으로 삭제되었습니다.")); } /** @@ -214,32 +246,40 @@ public ResponseEntity deleteAnnouncement(@PathVariable Long boardId) { * * @param boardId 수정할 공지 게시글 ID * @param request 공지 수정 요청 - * @return 응답 본문이 없는 204 응답 + * @return 공지 수정 성공 메시지 */ @Operation(summary = "공지 수정", description = "관리자가 공지를 수정합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "공지가 성공적으로 수정되었습니다.", + content = @Content(schema = @Schema(implementation = AdminSimpleResponse.class))) + }) @PatchMapping("/announcements/{boardId}") - public ResponseEntity updateAnnouncement( + public ResponseEntity updateAnnouncement( @PathVariable Long boardId, @RequestBody AnnouncementUpdateRequest request ) { log.info("관리자 공지 수정 요청 - BoardId: {}", boardId); adminService.updateAnnouncement(boardId, request); - return ResponseEntity.noContent().build(); + return ResponseEntity.ok(AdminSimpleResponse.toDto("공지가 성공적으로 수정되었습니다.")); } /** * 공지 목록을 조회합니다. * - * @param pageable 공지 목록 페이지 요청 정보 + * @param page 조회할 페이지 번호 + * @param size 페이지당 공지 개수 + * @param sort 정렬 조건 * @return 공지 목록 조회 결과 */ @Operation(summary = "공지 목록 조회", description = "공지 목록을 페이지 단위로 조회합니다.") @GetMapping("/announcements") public ResponseEntity getAnnouncementList( - @ParameterObject @PageableDefault(size = 10) Pageable pageable + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @RequestParam(defaultValue = "registrationUpdatedAt,desc") String sort ) { - log.info("관리자 공지 목록 조회 요청 - Page: {}, Size: {}", pageable.getPageNumber(), pageable.getPageSize()); - return ResponseEntity.ok(adminService.getAnnouncementList(pageable)); + log.info("관리자 공지 목록 조회 요청 - Page: {}, Size: {}, Sort: {}", page, size, sort); + return ResponseEntity.ok(adminService.getAnnouncementList(page, size, sort)); } /** diff --git a/src/main/java/com/dodo/backend/admin/dto/response/AdminResponse.java b/src/main/java/com/dodo/backend/admin/dto/response/AdminResponse.java index d8435bb..6a32038 100644 --- a/src/main/java/com/dodo/backend/admin/dto/response/AdminResponse.java +++ b/src/main/java/com/dodo/backend/admin/dto/response/AdminResponse.java @@ -293,6 +293,7 @@ public static AnnouncementItemResponse toDto(Board board, String imageFileUrl) { public static class AnnouncementListResponse { private PageInfoResponse pageInfo; private List data; + private String message; } /** @@ -310,8 +311,9 @@ public static class AnnouncementDetailResponse { private Integer viewCount; private LocalDateTime boardCreatedAt; private LocalDateTime boardModifiedAt; + private String message; - public static AnnouncementDetailResponse toDto(Board board, String imageFileUrl) { + public static AnnouncementDetailResponse toDto(Board board, String imageFileUrl, String message) { return AnnouncementDetailResponse.builder() .boardId(board.getBoardId()) .boardTitle(board.getBoardTitle()) @@ -320,6 +322,7 @@ public static AnnouncementDetailResponse toDto(Board board, String imageFileUrl) .viewCount(board.getViewCount()) .boardCreatedAt(board.getBoardCreatedAt()) .boardModifiedAt(board.getModifiedAt()) + .message(message) .build(); } } diff --git a/src/main/java/com/dodo/backend/admin/service/AdminService.java b/src/main/java/com/dodo/backend/admin/service/AdminService.java index 75f13dc..2a35cdd 100644 --- a/src/main/java/com/dodo/backend/admin/service/AdminService.java +++ b/src/main/java/com/dodo/backend/admin/service/AdminService.java @@ -13,7 +13,6 @@ import com.dodo.backend.admin.dto.response.AdminResponse.UserReportDetailResponse; import com.dodo.backend.admin.entity.AdminReportType; import com.dodo.backend.report.entity.ReportStatus; -import org.springframework.data.domain.Pageable; import java.util.UUID; @@ -44,7 +43,7 @@ public interface AdminService { void updateAnnouncement(Long boardId, AnnouncementUpdateRequest request); - AnnouncementListResponse getAnnouncementList(Pageable pageable); + AnnouncementListResponse getAnnouncementList(int page, int size, String sort); AnnouncementDetailResponse getAnnouncementDetail(Long boardId); } diff --git a/src/main/java/com/dodo/backend/admin/service/AdminServiceImpl.java b/src/main/java/com/dodo/backend/admin/service/AdminServiceImpl.java index af16347..ffbbdc6 100644 --- a/src/main/java/com/dodo/backend/admin/service/AdminServiceImpl.java +++ b/src/main/java/com/dodo/backend/admin/service/AdminServiceImpl.java @@ -36,7 +36,9 @@ import com.dodo.backend.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -67,6 +69,8 @@ public class AdminServiceImpl implements AdminService { private static final int MAX_PAGE_SIZE = 100; private static final String STATUS_UPDATE_SUCCESS_MESSAGE = "성공적으로 상태를 변경했습니다."; + private static final String ANNOUNCEMENT_LIST_SUCCESS_MESSAGE = "공지 목록을 조회했습니다."; + private static final String ANNOUNCEMENT_DETAIL_SUCCESS_MESSAGE = "공지 상세보기에 성공했습니다."; private final ReportRepository reportRepository; private final BoardRepository boardRepository; @@ -310,24 +314,26 @@ public void updateAnnouncement(Long boardId, AnnouncementUpdateRequest request) /** * 공지 목록을 조회합니다. * - * @param pageable 공지 목록 페이지 요청 정보 + * @param page 조회할 페이지 번호 + * @param size 페이지당 공지 개수 + * @param sort 정렬 조건 * @return 공지 목록 조회 결과 */ @Transactional(readOnly = true) @Override - public AnnouncementListResponse getAnnouncementList(Pageable pageable) { - if (pageable == null || pageable.getPageNumber() < 0 || pageable.getPageSize() <= 0 || pageable.getPageSize() > MAX_PAGE_SIZE) { - throw new AdminException(INVALID_REQUEST); - } + public AnnouncementListResponse getAnnouncementList(int page, int size, String sort) { + validateAnnouncementPageRequest(page, size); + Pageable pageable = PageRequest.of(page, size, buildAnnouncementSort(sort)); - Page page = boardRepository.findAllByBoardTypeAndBoardStatus(BoardType.NOTICE, BoardStatus.PUBLISHED, pageable); - List items = page.getContent().stream() + Page announcementPage = boardRepository.findAllByBoardTypeAndBoardStatus(BoardType.NOTICE, BoardStatus.PUBLISHED, pageable); + List items = announcementPage.getContent().stream() .map(board -> AnnouncementItemResponse.toDto(board, firstImageUrl(board.getBoardId()))) .toList(); return AnnouncementListResponse.builder() - .pageInfo(PageInfoResponse.toDto(page)) + .pageInfo(PageInfoResponse.toDto(announcementPage)) .data(items) + .message(ANNOUNCEMENT_LIST_SUCCESS_MESSAGE) .build(); } @@ -341,7 +347,7 @@ public AnnouncementListResponse getAnnouncementList(Pageable pageable) { @Override public AnnouncementDetailResponse getAnnouncementDetail(Long boardId) { Board board = findAnnouncement(boardId); - return AnnouncementDetailResponse.toDto(board, firstImageUrl(boardId)); + return AnnouncementDetailResponse.toDto(board, firstImageUrl(boardId), ANNOUNCEMENT_DETAIL_SUCCESS_MESSAGE); } private List findReports(AdminReportType reportType, ReportStatus reportStatus) { @@ -485,6 +491,46 @@ private void validatePageRequest(AdminReportType reportType, int page, int size) } } + private void validateAnnouncementPageRequest(int page, int size) { + if (page < 0 || size <= 0 || size > MAX_PAGE_SIZE) { + throw new AdminException(INVALID_REQUEST); + } + } + + private Sort buildAnnouncementSort(String sort) { + String normalized = sort == null || sort.isBlank() ? "registrationUpdatedAt,desc" : sort; + String[] tokens = normalized.split(","); + if (tokens.length > 2) { + throw new AdminException(INVALID_REQUEST); + } + + String property = resolveAnnouncementSortProperty(tokens[0].trim()); + Sort.Direction direction = tokens.length == 2 + ? parseSortDirection(tokens[1].trim()) + : Sort.Direction.DESC; + + return Sort.by(direction, property); + } + + private String resolveAnnouncementSortProperty(String property) { + return switch (property) { + case "registrationCreatedAt" -> "boardCreatedAt"; + case "registrationUpdatedAt" -> "modifiedAt"; + case "registrationStatus" -> "boardStatus"; + default -> throw new AdminException(INVALID_REQUEST); + }; + } + + private Sort.Direction parseSortDirection(String direction) { + if ("asc".equalsIgnoreCase(direction)) { + return Sort.Direction.ASC; + } + if ("desc".equalsIgnoreCase(direction)) { + return Sort.Direction.DESC; + } + throw new AdminException(INVALID_REQUEST); + } + private void validatePositiveId(Long id) { if (id == null || id <= 0) { throw new AdminException(INVALID_REQUEST); diff --git a/src/main/java/com/dodo/backend/auth/controller/AuthController.java b/src/main/java/com/dodo/backend/auth/controller/AuthController.java index 8d78619..2562450 100644 --- a/src/main/java/com/dodo/backend/auth/controller/AuthController.java +++ b/src/main/java/com/dodo/backend/auth/controller/AuthController.java @@ -1,10 +1,12 @@ package com.dodo.backend.auth.controller; import com.dodo.backend.auth.dto.request.AuthRequest; +import com.dodo.backend.auth.dto.request.AuthRequest.AdminLoginRequest; import com.dodo.backend.auth.dto.request.AuthRequest.LogoutRequest; import com.dodo.backend.auth.dto.request.AuthRequest.ReissueRequest; import com.dodo.backend.auth.dto.request.AuthRequest.SocialLoginRequest; import com.dodo.backend.auth.dto.response.AuthResponse; +import com.dodo.backend.auth.dto.response.AuthResponse.AdminLoginResponse; import com.dodo.backend.auth.dto.response.AuthResponse.DeviceAuthResponse; import com.dodo.backend.auth.dto.response.AuthResponse.SocialLoginResponse; import com.dodo.backend.auth.dto.response.AuthResponse.SocialRegisterResponse; @@ -92,6 +94,26 @@ public ResponseEntity doSocialLogin(@RequestBody @Valid SocialLoginRequest re return authService.socialLogin(request); } + @Operation(summary = "관리자 전용 로그인", description = "관리자 이메일과 비밀번호를 검증하고 ADMIN 권한 토큰을 발급합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "관리자 로그인 성공", + content = @Content(schema = @Schema(implementation = AdminLoginResponse.class))), + @ApiResponse(responseCode = "401", description = "이메일 또는 비밀번호 불일치", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "403", description = "정지/휴면/삭제 계정", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "429", description = "요청 횟수 제한 초과", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @PostMapping("/admin-login") + public ResponseEntity adminLogin(@RequestBody @Valid AdminLoginRequest request, + HttpServletRequest httpRequest) { + String clientIp = httpRequest.getRemoteAddr(); + log.info("관리자 로그인 요청 수신 - email: {}", request.getEmail()); + authService.checkRateLimit(clientIp); + return ResponseEntity.ok(authService.adminLogin(request)); + } + /** * 사용자의 리프레시 토큰을 만료시키고, 현재 사용 중인 액세스 토큰을 블랙리스트에 등록하여 로그아웃을 처리합니다. *

@@ -258,4 +280,4 @@ public ResponseEntity deviceReissue(@RequestBody @Valid ReissueRe log.info("장치 토큰 재발급 요청 수신"); return ResponseEntity.ok(authService.deviceReissueToken(request)); } -} \ No newline at end of file +} diff --git a/src/main/java/com/dodo/backend/auth/dto/request/AuthRequest.java b/src/main/java/com/dodo/backend/auth/dto/request/AuthRequest.java index 57629e4..02202f2 100644 --- a/src/main/java/com/dodo/backend/auth/dto/request/AuthRequest.java +++ b/src/main/java/com/dodo/backend/auth/dto/request/AuthRequest.java @@ -34,6 +34,22 @@ public static class SocialLoginRequest { private String code; } + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + @Schema(description = "관리자 전용 로그인 요청") + public static class AdminLoginRequest { + + @Schema(description = "관리자 이메일", example = "admin@dodo.com") + @NotBlank(message = "email은 필수 값입니다.") + private String email; + + @Schema(description = "관리자 로그인 비밀번호", example = "admin-password") + @NotBlank(message = "password는 필수 값입니다.") + private String password; + } + /** * 로그아웃 요청 시 리프레시 토큰을 전달받는 DTO입니다. */ @@ -77,4 +93,4 @@ public static class DeviceAuthRequest { @Schema(description = "디바이스 고유 ID", example = "ABC123XYZ") private String deviceId; } -} \ No newline at end of file +} diff --git a/src/main/java/com/dodo/backend/auth/dto/response/AuthResponse.java b/src/main/java/com/dodo/backend/auth/dto/response/AuthResponse.java index c209d5e..3970ee4 100644 --- a/src/main/java/com/dodo/backend/auth/dto/response/AuthResponse.java +++ b/src/main/java/com/dodo/backend/auth/dto/response/AuthResponse.java @@ -48,6 +48,38 @@ public static SocialLoginResponse toDto(String message, String profileUrl, Strin } } + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "관리자 로그인 성공 응답") + public static class AdminLoginResponse { + + @Schema(description = "응답 메시지", example = "관리자 로그인이 완료되었습니다.") + private String message; + + @Schema(description = "관리자 Access Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + private String accessToken; + + @Schema(description = "관리자 Refresh Token", example = "def50200f29184b294277418292...") + private String refreshToken; + + @Schema(description = "Access Token 만료 시간(밀리초)", example = "3600000") + private Long accessTokenExpiresIn; + + @Schema(description = "권한", example = "ADMIN") + private String role; + + public static AdminLoginResponse toDto(String accessToken, String refreshToken, Long accessTokenExpiresIn, String role) { + return AdminLoginResponse.builder() + .message("관리자 로그인이 완료되었습니다.") + .accessToken(accessToken) + .refreshToken(refreshToken) + .accessTokenExpiresIn(accessTokenExpiresIn) + .role(role) + .build(); + } + } + /** * 신규 회원가입 대상자일 경우 반환하는 응답 DTO입니다. */ @@ -166,4 +198,4 @@ public static DeviceAuthResponse toDto(String message, String accessToken, Strin .build(); } } -} \ No newline at end of file +} diff --git a/src/main/java/com/dodo/backend/auth/service/AuthService.java b/src/main/java/com/dodo/backend/auth/service/AuthService.java index 80442b1..f92b247 100644 --- a/src/main/java/com/dodo/backend/auth/service/AuthService.java +++ b/src/main/java/com/dodo/backend/auth/service/AuthService.java @@ -1,10 +1,12 @@ package com.dodo.backend.auth.service; import com.dodo.backend.auth.dto.request.AuthRequest; +import com.dodo.backend.auth.dto.request.AuthRequest.AdminLoginRequest; import com.dodo.backend.auth.dto.request.AuthRequest.DeviceAuthRequest; import com.dodo.backend.auth.dto.request.AuthRequest.LogoutRequest; import com.dodo.backend.auth.dto.request.AuthRequest.ReissueRequest; import com.dodo.backend.auth.dto.response.AuthResponse; +import com.dodo.backend.auth.dto.response.AuthResponse.AdminLoginResponse; import com.dodo.backend.auth.dto.response.AuthResponse.DeviceAuthResponse; import com.dodo.backend.auth.dto.response.AuthResponse.TokenResponse; import org.springframework.http.ResponseEntity; @@ -27,6 +29,8 @@ public interface AuthService { */ ResponseEntity socialLogin(AuthRequest.SocialLoginRequest request); + AdminLoginResponse adminLogin(AdminLoginRequest request); + /** * 클라이언트 IP를 기반으로 요청 횟수를 검증하여 비정상적인 접근을 제한합니다. *

@@ -81,4 +85,4 @@ public interface AuthService { * @throws com.dodo.backend.auth.exception.AuthException 토큰이 유효하지 않거나 만료된 경우, 또는 Redis에 존재하지 않는 경우 */ TokenResponse deviceReissueToken(ReissueRequest request); -} \ No newline at end of file +} diff --git a/src/main/java/com/dodo/backend/auth/service/AuthServiceImpl.java b/src/main/java/com/dodo/backend/auth/service/AuthServiceImpl.java index 8d1760a..d4ef8e4 100644 --- a/src/main/java/com/dodo/backend/auth/service/AuthServiceImpl.java +++ b/src/main/java/com/dodo/backend/auth/service/AuthServiceImpl.java @@ -1,10 +1,12 @@ package com.dodo.backend.auth.service; import com.dodo.backend.auth.client.SocialApiClient; +import com.dodo.backend.auth.dto.request.AuthRequest.AdminLoginRequest; import com.dodo.backend.auth.dto.request.AuthRequest.LogoutRequest; import com.dodo.backend.auth.dto.request.AuthRequest.ReissueRequest; import com.dodo.backend.auth.dto.request.AuthRequest.SocialLoginRequest; import com.dodo.backend.auth.dto.request.AuthRequest.DeviceAuthRequest; +import com.dodo.backend.auth.dto.response.AuthResponse.AdminLoginResponse; import com.dodo.backend.auth.dto.response.AuthResponse.SocialLoginResponse; import com.dodo.backend.auth.dto.response.AuthResponse.SocialRegisterResponse; import com.dodo.backend.auth.dto.response.AuthResponse.TokenResponse; @@ -14,13 +16,18 @@ import com.dodo.backend.auth.repository.RefreshTokenRepository; import com.dodo.backend.common.jwt.JwtTokenProvider; import com.dodo.backend.pet.service.PetService; +import com.dodo.backend.user.entity.User; +import com.dodo.backend.user.entity.UserRole; +import com.dodo.backend.user.repository.UserRepository; import com.dodo.backend.user.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; import java.util.List; import java.util.Map; @@ -45,9 +52,16 @@ public class AuthServiceImpl implements AuthService { private final List socialApiClients; private final RedisTemplate redisTemplate; private final UserService userService; + private final UserRepository userRepository; private final JwtTokenProvider jwtTokenProvider; private final RefreshTokenRepository refreshTokenRepository; + @Value("${admin.login.email:}") + private String adminLoginEmail; + + @Value("${admin.login.password:}") + private String adminLoginPassword; + /** * {@inheritDoc} *

@@ -122,6 +136,42 @@ public ResponseEntity socialLogin(SocialLoginRequest request) { } } + @Transactional + @Override + public AdminLoginResponse adminLogin(AdminLoginRequest request) { + if (!StringUtils.hasText(adminLoginPassword) + || !adminLoginPassword.equals(request.getPassword()) + || (StringUtils.hasText(adminLoginEmail) && !adminLoginEmail.equals(request.getEmail()))) { + throw new AuthException(LOGIN_FAILED); + } + + User admin = userRepository.findByEmail(request.getEmail()) + .orElseThrow(() -> new AuthException(LOGIN_FAILED)); + + validateUserStatus(admin.getUserStatus().name(), admin.getEmail()); + + if (admin.getRole() != UserRole.ADMIN) { + throw new AuthException(LOGIN_FAILED); + } + + String role = admin.getRole().name(); + String accessToken = jwtTokenProvider.createAccessToken(admin.getUsersId(), role); + String refreshToken = jwtTokenProvider.createRefreshToken(admin.getUsersId()); + + refreshTokenRepository.save(RefreshToken.builder() + .usersId(admin.getUsersId().toString()) + .refreshToken(refreshToken) + .role(role) + .build()); + + return AdminLoginResponse.toDto( + accessToken, + refreshToken, + jwtTokenProvider.getAccessTokenValidityInMilliseconds(), + role + ); + } + /** * 유저의 계정 상태가 로그인 가능한 상태인지 검증합니다. *

@@ -346,4 +396,4 @@ public TokenResponse deviceReissueToken(ReissueRequest request) { return TokenResponse.toDto(newAccessToken, newRefreshToken, expiresInSeconds, "성공적으로 토큰이 재발급되었습니다."); } -} \ No newline at end of file +} diff --git a/src/main/java/com/dodo/backend/common/config/SchedulingConfig.java b/src/main/java/com/dodo/backend/common/config/SchedulingConfig.java new file mode 100644 index 0000000..1b36550 --- /dev/null +++ b/src/main/java/com/dodo/backend/common/config/SchedulingConfig.java @@ -0,0 +1,9 @@ +package com.dodo.backend.common.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration +@EnableScheduling +public class SchedulingConfig { +} diff --git a/src/main/java/com/dodo/backend/common/config/SecurityConfig.java b/src/main/java/com/dodo/backend/common/config/SecurityConfig.java index 6406957..917995b 100644 --- a/src/main/java/com/dodo/backend/common/config/SecurityConfig.java +++ b/src/main/java/com/dodo/backend/common/config/SecurityConfig.java @@ -70,6 +70,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/v3/api-docs/**", "/scalar/**", "/auth/social-login", + "/auth/admin-login", "/view/login", "/google-login", "/naver-login", diff --git a/src/main/java/com/dodo/backend/common/exception/GlobalExceptionHandler.java b/src/main/java/com/dodo/backend/common/exception/GlobalExceptionHandler.java index be9aff3..31a6f98 100644 --- a/src/main/java/com/dodo/backend/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/dodo/backend/common/exception/GlobalExceptionHandler.java @@ -5,8 +5,10 @@ import com.dodo.backend.auth.exception.AuthException; import com.dodo.backend.board.exception.BoardException; import com.dodo.backend.fence.exception.FenceException; +import com.dodo.backend.fcmtoken.exception.FcmTokenException; import com.dodo.backend.healthanalysis.exception.HealthAnalysisException; import com.dodo.backend.imagefile.exception.ImageFileException; +import com.dodo.backend.notification.exception.NotificationException; import com.dodo.backend.pet.exception.PetException; import com.dodo.backend.petweight.exception.PetWeightException; import com.dodo.backend.reaction.exception.ReactionException; @@ -149,6 +151,24 @@ protected ResponseEntity handleReportException(ReportException e) return toResponseEntity(e.getErrorCode()); } + /** + * 알림(Notification) 도메인 비즈니스 로직에서 발생하는 {@link NotificationException}을 처리합니다. + */ + @ExceptionHandler(NotificationException.class) + protected ResponseEntity handleNotificationException(NotificationException e) { + log.error("NotificationException occurred: {}", e.getErrorCode()); + return toResponseEntity(e.getErrorCode()); + } + + /** + * FCM 토큰 도메인 비즈니스 로직에서 발생하는 {@link FcmTokenException}을 처리합니다. + */ + @ExceptionHandler(FcmTokenException.class) + protected ResponseEntity handleFcmTokenException(FcmTokenException e) { + log.error("FcmTokenException occurred: {}", e.getErrorCode()); + return toResponseEntity(e.getErrorCode()); + } + /** * 이미지 파일(ImageFile) 도메인 비즈니스 로직에서 발생하는 {@link ImageFileException}을 처리합니다. */ diff --git a/src/main/java/com/dodo/backend/fcmtoken/controller/FcmTokenController.java b/src/main/java/com/dodo/backend/fcmtoken/controller/FcmTokenController.java new file mode 100644 index 0000000..53270b7 --- /dev/null +++ b/src/main/java/com/dodo/backend/fcmtoken/controller/FcmTokenController.java @@ -0,0 +1,66 @@ +package com.dodo.backend.fcmtoken.controller; + +import com.dodo.backend.fcmtoken.dto.request.FcmTokenRequest.FcmTokenRegisterRequest; +import com.dodo.backend.fcmtoken.dto.response.FcmTokenResponse.FcmTokenSimpleResponse; +import com.dodo.backend.fcmtoken.service.FcmTokenService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/fcm-tokens") +@Tag(name = "FCM Token API", description = "FCM 푸시 토큰 관련 API") +public class FcmTokenController { + + private final FcmTokenService fcmTokenService; + + @Operation(summary = "FCM 푸시 토큰 등록", description = "로그인 사용자의 FCM 푸시 토큰을 등록합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "푸시 토큰이 성공적으로 등록되었습니다.", + content = @Content(schema = @Schema(implementation = FcmTokenSimpleResponse.class))) + }) + @PostMapping + public ResponseEntity registerToken( + @Valid @RequestBody FcmTokenRegisterRequest request, + @AuthenticationPrincipal UserDetails userDetails + ) { + UUID userId = UUID.fromString(userDetails.getUsername()); + log.info("FCM 토큰 등록 요청 - UserId: {}, DeviceType: {}", userId, request.getDeviceType()); + return ResponseEntity.ok(fcmTokenService.registerToken(userId, request)); + } + + @Operation(summary = "FCM 푸시 토큰 삭제", description = "로그인 사용자의 FCM 푸시 토큰을 삭제합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "푸시 토큰이 성공적으로 삭제되었습니다.", + content = @Content(schema = @Schema(implementation = FcmTokenSimpleResponse.class))) + }) + @DeleteMapping("/{token}") + public ResponseEntity deleteToken( + @PathVariable String token, + @AuthenticationPrincipal UserDetails userDetails + ) { + UUID userId = UUID.fromString(userDetails.getUsername()); + log.info("FCM 토큰 삭제 요청 - UserId: {}", userId); + fcmTokenService.deleteToken(userId, token); + return ResponseEntity.ok(FcmTokenSimpleResponse.toDto("푸시 토큰이 성공적으로 삭제되었습니다.")); + } +} diff --git a/src/main/java/com/dodo/backend/fcmtoken/dto/request/FcmTokenRequest.java b/src/main/java/com/dodo/backend/fcmtoken/dto/request/FcmTokenRequest.java new file mode 100644 index 0000000..e925bac --- /dev/null +++ b/src/main/java/com/dodo/backend/fcmtoken/dto/request/FcmTokenRequest.java @@ -0,0 +1,40 @@ +package com.dodo.backend.fcmtoken.dto.request; + +import com.dodo.backend.fcmtoken.entity.DeviceType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * FCM 토큰 API에서 사용하는 요청 DTO를 모아 둔 클래스입니다. + */ +@Schema(description = "FCM 토큰 요청 DTO 그룹") +public class FcmTokenRequest { + + /** + * FCM 토큰 등록 요청 DTO입니다. + */ + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "FCM 토큰 등록 요청") + public static class FcmTokenRegisterRequest { + + @NotBlank(message = "푸시 토큰은 필수입니다.") + @Size(max = 512, message = "푸시 토큰은 512자 이하여야 합니다.") + @Schema(description = "FCM 푸시 토큰", example = "fcm_device_token_string") + private String token; + + @NotNull(message = "장치 유형은 필수입니다.") + @Schema(description = "장치 유형", example = "ANDROID") + private DeviceType deviceType; + + @Size(max = 100, message = "장치 이름은 100자 이하여야 합니다.") + @Schema(description = "장치 이름", example = "Galaxy Z Flip 5") + private String deviceName; + } +} diff --git a/src/main/java/com/dodo/backend/fcmtoken/dto/response/FcmTokenResponse.java b/src/main/java/com/dodo/backend/fcmtoken/dto/response/FcmTokenResponse.java new file mode 100644 index 0000000..d1dbc59 --- /dev/null +++ b/src/main/java/com/dodo/backend/fcmtoken/dto/response/FcmTokenResponse.java @@ -0,0 +1,30 @@ +package com.dodo.backend.fcmtoken.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +/** + * FCM 토큰 API에서 사용하는 응답 DTO를 모아 둔 클래스입니다. + */ +@Schema(description = "FCM 토큰 응답 DTO 그룹") +public class FcmTokenResponse { + + /** + * 단순 메시지 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "FCM 토큰 단순 응답") + public static class FcmTokenSimpleResponse { + private String message; + + public static FcmTokenSimpleResponse toDto(String message) { + return FcmTokenSimpleResponse.builder() + .message(message) + .build(); + } + } +} diff --git a/src/main/java/com/dodo/backend/fcmtoken/entity/DeviceType.java b/src/main/java/com/dodo/backend/fcmtoken/entity/DeviceType.java new file mode 100644 index 0000000..022c54e --- /dev/null +++ b/src/main/java/com/dodo/backend/fcmtoken/entity/DeviceType.java @@ -0,0 +1,10 @@ +package com.dodo.backend.fcmtoken.entity; + +/** + * FCM 토큰이 등록된 장치 유형입니다. + */ +public enum DeviceType { + ANDROID, + IOS, + WEB +} diff --git a/src/main/java/com/dodo/backend/fcmtoken/entity/FcmToken.java b/src/main/java/com/dodo/backend/fcmtoken/entity/FcmToken.java new file mode 100644 index 0000000..28b555f --- /dev/null +++ b/src/main/java/com/dodo/backend/fcmtoken/entity/FcmToken.java @@ -0,0 +1,84 @@ +package com.dodo.backend.fcmtoken.entity; + +import com.dodo.backend.user.entity.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +/** + * 사용자별 FCM 푸시 토큰 정보를 관리하는 엔티티입니다. + */ +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@EntityListeners(AuditingEntityListener.class) +@Table( + name = "fcm_token", + uniqueConstraints = { + @UniqueConstraint(name = "uk_fcm_token_token", columnNames = "token") + } +) +public class FcmToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "fcm_token_id") + private Long fcmTokenId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "token", nullable = false, length = 512) + private String token; + + @Enumerated(EnumType.STRING) + @Column(name = "device_type", nullable = false) + private DeviceType deviceType; + + @Column(name = "device_name", length = 100) + private String deviceName; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "modified_at") + private LocalDateTime modifiedAt; + + /** + * FCM 토큰의 소유자와 장치 정보를 갱신합니다. + * + * @param user 토큰을 등록한 사용자 + * @param deviceType 장치 유형 + * @param deviceName 장치 이름 + */ + public void updateTokenInfo(User user, DeviceType deviceType, String deviceName) { + this.user = user; + this.deviceType = deviceType; + this.deviceName = deviceName; + } +} diff --git a/src/main/java/com/dodo/backend/fcmtoken/exception/FcmTokenErrorCode.java b/src/main/java/com/dodo/backend/fcmtoken/exception/FcmTokenErrorCode.java new file mode 100644 index 0000000..d56e8ae --- /dev/null +++ b/src/main/java/com/dodo/backend/fcmtoken/exception/FcmTokenErrorCode.java @@ -0,0 +1,32 @@ +package com.dodo.backend.fcmtoken.exception; + +import com.dodo.backend.common.exception.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +/** + * FCM 토큰 도메인에서 발생하는 예외 상황을 관리하는 에러 코드입니다. + */ +@Getter +@AllArgsConstructor +public enum FcmTokenErrorCode implements BaseErrorCode { + + /** + * 요청 값이 올바르지 않은 경우 사용합니다. + */ + INVALID_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."), + + /** + * 삭제할 토큰을 찾을 수 없는 경우 사용합니다. + */ + FCM_TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 토큰을 찾을 수 없습니다."), + + /** + * 서버 내부 오류가 발생한 경우 사용합니다. + */ + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."); + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/dodo/backend/fcmtoken/exception/FcmTokenException.java b/src/main/java/com/dodo/backend/fcmtoken/exception/FcmTokenException.java new file mode 100644 index 0000000..270a851 --- /dev/null +++ b/src/main/java/com/dodo/backend/fcmtoken/exception/FcmTokenException.java @@ -0,0 +1,17 @@ +package com.dodo.backend.fcmtoken.exception; + +import lombok.Getter; + +/** + * FCM 토큰 도메인 비즈니스 예외입니다. + */ +@Getter +public class FcmTokenException extends RuntimeException { + + private final FcmTokenErrorCode errorCode; + + public FcmTokenException(FcmTokenErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/dodo/backend/fcmtoken/repository/FcmTokenRepository.java b/src/main/java/com/dodo/backend/fcmtoken/repository/FcmTokenRepository.java new file mode 100644 index 0000000..a97479e --- /dev/null +++ b/src/main/java/com/dodo/backend/fcmtoken/repository/FcmTokenRepository.java @@ -0,0 +1,24 @@ +package com.dodo.backend.fcmtoken.repository; + +import com.dodo.backend.fcmtoken.entity.FcmToken; +import com.dodo.backend.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * FCM 토큰 엔티티의 영속성 처리를 담당하는 Repository입니다. + */ +@Repository +public interface FcmTokenRepository extends JpaRepository { + + Optional findByToken(String token); + + Optional findByTokenAndUser(String token, User user); + + List findByUserUsersIdIn(Collection userIds); +} diff --git a/src/main/java/com/dodo/backend/fcmtoken/service/FcmTokenService.java b/src/main/java/com/dodo/backend/fcmtoken/service/FcmTokenService.java new file mode 100644 index 0000000..43f7ec0 --- /dev/null +++ b/src/main/java/com/dodo/backend/fcmtoken/service/FcmTokenService.java @@ -0,0 +1,16 @@ +package com.dodo.backend.fcmtoken.service; + +import com.dodo.backend.fcmtoken.dto.request.FcmTokenRequest.FcmTokenRegisterRequest; +import com.dodo.backend.fcmtoken.dto.response.FcmTokenResponse.FcmTokenSimpleResponse; + +import java.util.UUID; + +/** + * FCM 토큰 API 비즈니스 로직을 정의하는 서비스 인터페이스입니다. + */ +public interface FcmTokenService { + + FcmTokenSimpleResponse registerToken(UUID userId, FcmTokenRegisterRequest request); + + void deleteToken(UUID userId, String token); +} diff --git a/src/main/java/com/dodo/backend/fcmtoken/service/FcmTokenServiceImpl.java b/src/main/java/com/dodo/backend/fcmtoken/service/FcmTokenServiceImpl.java new file mode 100644 index 0000000..decf1c6 --- /dev/null +++ b/src/main/java/com/dodo/backend/fcmtoken/service/FcmTokenServiceImpl.java @@ -0,0 +1,87 @@ +package com.dodo.backend.fcmtoken.service; + +import com.dodo.backend.fcmtoken.dto.request.FcmTokenRequest.FcmTokenRegisterRequest; +import com.dodo.backend.fcmtoken.dto.response.FcmTokenResponse.FcmTokenSimpleResponse; +import com.dodo.backend.fcmtoken.entity.FcmToken; +import com.dodo.backend.fcmtoken.exception.FcmTokenException; +import com.dodo.backend.fcmtoken.repository.FcmTokenRepository; +import com.dodo.backend.user.entity.User; +import com.dodo.backend.user.exception.UserException; +import com.dodo.backend.user.service.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +import static com.dodo.backend.fcmtoken.exception.FcmTokenErrorCode.FCM_TOKEN_NOT_FOUND; +import static com.dodo.backend.fcmtoken.exception.FcmTokenErrorCode.INVALID_REQUEST; + +@Service +@RequiredArgsConstructor +public class FcmTokenServiceImpl implements FcmTokenService { + + private static final String REGISTER_SUCCESS_MESSAGE = "푸시 토큰이 성공적으로 등록되었습니다."; + + private final FcmTokenRepository fcmTokenRepository; + private final UserService userService; + + @Transactional + @Override + public FcmTokenSimpleResponse registerToken(UUID userId, FcmTokenRegisterRequest request) { + validateRegisterRequest(userId, request); + User user = findUser(userId); + + FcmToken fcmToken = fcmTokenRepository.findByToken(request.getToken()) + .map(existingToken -> { + existingToken.updateTokenInfo(user, request.getDeviceType(), normalizeDeviceName(request.getDeviceName())); + return existingToken; + }) + .orElseGet(() -> FcmToken.builder() + .user(user) + .token(request.getToken()) + .deviceType(request.getDeviceType()) + .deviceName(normalizeDeviceName(request.getDeviceName())) + .build()); + + fcmTokenRepository.save(fcmToken); + return FcmTokenSimpleResponse.toDto(REGISTER_SUCCESS_MESSAGE); + } + + @Transactional + @Override + public void deleteToken(UUID userId, String token) { + if (userId == null || token == null || token.isBlank()) { + throw new FcmTokenException(INVALID_REQUEST); + } + User user = findUser(userId); + FcmToken fcmToken = fcmTokenRepository.findByTokenAndUser(token, user) + .orElseThrow(() -> new FcmTokenException(FCM_TOKEN_NOT_FOUND)); + + fcmTokenRepository.delete(fcmToken); + } + + private void validateRegisterRequest(UUID userId, FcmTokenRegisterRequest request) { + if (userId == null || request == null || request.getToken() == null || request.getToken().isBlank() || request.getDeviceType() == null) { + throw new FcmTokenException(INVALID_REQUEST); + } + if (request.getToken().length() > 512 || (request.getDeviceName() != null && request.getDeviceName().length() > 100)) { + throw new FcmTokenException(INVALID_REQUEST); + } + } + + private User findUser(UUID userId) { + try { + return userService.getUserById(userId); + } catch (UserException e) { + throw new FcmTokenException(INVALID_REQUEST); + } + } + + private String normalizeDeviceName(String deviceName) { + if (deviceName == null || deviceName.isBlank()) { + return null; + } + return deviceName; + } +} diff --git a/src/main/java/com/dodo/backend/notification/controller/NotificationController.java b/src/main/java/com/dodo/backend/notification/controller/NotificationController.java new file mode 100644 index 0000000..4b4b44c --- /dev/null +++ b/src/main/java/com/dodo/backend/notification/controller/NotificationController.java @@ -0,0 +1,162 @@ +package com.dodo.backend.notification.controller; + +import com.dodo.backend.notification.dto.request.NotificationRequest.NotificationReadUpdateRequest; +import com.dodo.backend.notification.dto.response.NotificationResponse.NotificationListResponse; +import com.dodo.backend.notification.dto.response.NotificationResponse.NotificationSimpleResponse; +import com.dodo.backend.notification.dto.response.NotificationResponse.UnreadNotificationCountResponse; +import com.dodo.backend.notification.service.NotificationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +/** + * 알림 API 요청을 처리하는 컨트롤러입니다. + */ +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/notifications") +@Tag(name = "Notification API", description = "알림 관련 API") +public class NotificationController { + + private final NotificationService notificationService; + + /** + * 알림 목록을 조회합니다. + * + * @param page 조회할 페이지 번호 + * @param size 페이지당 알림 수 + * @param isRead 읽음 여부 필터 + * @param type 알림 유형 필터 + * @param userDetails 인증 사용자 정보 + * @return 알림 목록 조회 결과 + */ + @Operation(summary = "알림 목록 조회", description = "로그인 사용자의 알림 목록을 조회합니다.") + @GetMapping + public ResponseEntity getNotifications( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) Boolean isRead, + @RequestParam(required = false) String type, + @AuthenticationPrincipal UserDetails userDetails + ) { + UUID userId = UUID.fromString(userDetails.getUsername()); + log.info("알림 목록 조회 요청 - UserId: {}, Page: {}, Size: {}, IsRead: {}, Type: {}", userId, page, size, isRead, type); + return ResponseEntity.ok(notificationService.getNotifications(userId, page, size, isRead, type)); + } + + /** + * 특정 알림의 읽음 여부를 변경합니다. + * + * @param notificationId 읽음 여부를 변경할 알림 ID + * @param request 읽음 여부 변경 요청 + * @param userDetails 인증 사용자 정보 + * @return 읽음 처리 성공 메시지 + */ + @Operation(summary = "알림 읽음 처리", description = "특정 알림의 읽음 여부를 변경합니다.") + @PatchMapping("/{notificationId}") + public ResponseEntity updateReadStatus( + @PathVariable Long notificationId, + @Valid @RequestBody NotificationReadUpdateRequest request, + @AuthenticationPrincipal UserDetails userDetails + ) { + UUID userId = UUID.fromString(userDetails.getUsername()); + log.info("알림 읽음 처리 요청 - UserId: {}, NotificationId: {}", userId, notificationId); + return ResponseEntity.ok(notificationService.updateReadStatus(userId, notificationId, request)); + } + + /** + * 특정 알림을 삭제합니다. + * + * @param notificationId 삭제할 알림 ID + * @param userDetails 인증 사용자 정보 + * @return 알림 삭제 성공 메시지 + */ + @Operation(summary = "알림 삭제", description = "특정 알림을 삭제합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "알림이 성공적으로 삭제되었습니다.", + content = @Content(schema = @Schema(implementation = NotificationSimpleResponse.class))) + }) + @DeleteMapping("/{notificationId}") + public ResponseEntity deleteNotification( + @PathVariable Long notificationId, + @AuthenticationPrincipal UserDetails userDetails + ) { + UUID userId = UUID.fromString(userDetails.getUsername()); + log.info("알림 삭제 요청 - UserId: {}, NotificationId: {}", userId, notificationId); + notificationService.deleteNotification(userId, notificationId); + return ResponseEntity.ok(NotificationSimpleResponse.toDto("알림이 성공적으로 삭제되었습니다.")); + } + + /** + * 읽지 않은 알림 개수를 조회합니다. + * + * @param userDetails 인증 사용자 정보 + * @return 읽지 않은 알림 개수 + */ + @Operation(summary = "읽지 않은 알림 개수 조회", description = "로그인 사용자의 읽지 않은 알림 개수를 조회합니다.") + @GetMapping("/count/unread") + public ResponseEntity getUnreadCount( + @AuthenticationPrincipal UserDetails userDetails + ) { + UUID userId = UUID.fromString(userDetails.getUsername()); + log.info("읽지 않은 알림 개수 조회 요청 - UserId: {}", userId); + return ResponseEntity.ok(notificationService.getUnreadCount(userId)); + } + + /** + * 모든 알림을 읽음 처리합니다. + * + * @param userDetails 인증 사용자 정보 + * @return 전체 읽음 처리 성공 메시지 + */ + @Operation(summary = "모든 알림 읽음 처리", description = "로그인 사용자의 모든 알림을 읽음 처리합니다.") + @PatchMapping("/read-all") + public ResponseEntity readAll( + @AuthenticationPrincipal UserDetails userDetails + ) { + UUID userId = UUID.fromString(userDetails.getUsername()); + log.info("모든 알림 읽음 처리 요청 - UserId: {}", userId); + return ResponseEntity.ok(notificationService.readAll(userId)); + } + + /** + * 모든 알림을 삭제합니다. + * + * @param userDetails 인증 사용자 정보 + * @return 전체 알림 삭제 성공 메시지 + */ + @Operation(summary = "모든 알림 삭제", description = "로그인 사용자의 모든 알림을 삭제합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "모든 알림이 성공적으로 삭제되었습니다.", + content = @Content(schema = @Schema(implementation = NotificationSimpleResponse.class))) + }) + @DeleteMapping("/all") + public ResponseEntity deleteAll( + @AuthenticationPrincipal UserDetails userDetails + ) { + UUID userId = UUID.fromString(userDetails.getUsername()); + log.info("모든 알림 삭제 요청 - UserId: {}", userId); + notificationService.deleteAll(userId); + return ResponseEntity.ok(NotificationSimpleResponse.toDto("모든 알림이 성공적으로 삭제되었습니다.")); + } +} diff --git a/src/main/java/com/dodo/backend/notification/dto/request/NotificationRequest.java b/src/main/java/com/dodo/backend/notification/dto/request/NotificationRequest.java new file mode 100644 index 0000000..c84a2e0 --- /dev/null +++ b/src/main/java/com/dodo/backend/notification/dto/request/NotificationRequest.java @@ -0,0 +1,73 @@ +package com.dodo.backend.notification.dto.request; + +import com.dodo.backend.notification.entity.NotificationScheduleRepeatType; +import com.dodo.backend.notification.entity.NotificationScheduleTargetType; +import com.dodo.backend.notification.entity.NotificationType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.FutureOrPresent; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +/** + * 알림 API에서 사용하는 요청 DTO를 모아 둔 클래스입니다. + */ +@Schema(description = "알림 요청 DTO 그룹") +public class NotificationRequest { + + /** + * 알림 읽음 여부 변경 요청 DTO입니다. + */ + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "알림 읽음 여부 변경 요청") + public static class NotificationReadUpdateRequest { + + @NotNull(message = "알림 읽음 여부는 필수입니다.") + @Schema(description = "변경할 읽음 여부", example = "true") + private Boolean isRead; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "알림 스케줄 등록 요청") + public static class NotificationScheduleCreateRequest { + + @NotBlank(message = "title은 필수 값입니다.") + @Size(max = 255, message = "title은 255자 이하로 입력해주세요.") + @Schema(description = "알림 제목", example = "공지 알림") + private String title; + + @NotBlank(message = "body는 필수 값입니다.") + @Schema(description = "알림 내용", example = "새로운 공지가 등록되었습니다.") + private String body; + + @NotNull(message = "notificationType은 필수 값입니다.") + @Schema(description = "알림 유형", example = "SYSTEM") + private NotificationType notificationType; + + @NotNull(message = "targetType은 필수 값입니다.") + @Schema(description = "발송 대상 유형", example = "ALL") + private NotificationScheduleTargetType targetType; + + @Schema(description = "targetType이 USERS일 때 발송 대상 사용자 ID 목록") + private List targetUserIds; + + @NotNull(message = "scheduledAt은 필수 값입니다.") + @FutureOrPresent(message = "scheduledAt은 현재 또는 미래 시간이어야 합니다.") + @Schema(description = "예약 발송 시간", example = "2026-06-27T14:30:00") + private LocalDateTime scheduledAt; + + @Schema(description = "반복 유형. 미입력 시 NONE", example = "NONE") + private NotificationScheduleRepeatType repeatType; + } +} diff --git a/src/main/java/com/dodo/backend/notification/dto/response/NotificationResponse.java b/src/main/java/com/dodo/backend/notification/dto/response/NotificationResponse.java new file mode 100644 index 0000000..d406d30 --- /dev/null +++ b/src/main/java/com/dodo/backend/notification/dto/response/NotificationResponse.java @@ -0,0 +1,139 @@ +package com.dodo.backend.notification.dto.response; + +import com.dodo.backend.notification.entity.Notification; +import com.dodo.backend.notification.entity.NotificationSchedule; +import com.dodo.backend.notification.entity.NotificationScheduleStatus; +import com.dodo.backend.notification.entity.NotificationType; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.domain.Page; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 알림 API에서 사용하는 응답 DTO를 모아 둔 클래스입니다. + */ +@Schema(description = "알림 응답 DTO 그룹") +public class NotificationResponse { + + /** + * 단순 메시지 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "알림 단순 응답") + public static class NotificationSimpleResponse { + private String message; + + public static NotificationSimpleResponse toDto(String message) { + return NotificationSimpleResponse.builder() + .message(message) + .build(); + } + } + + /** + * 페이지 정보 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "페이지 정보") + public static class PageInfoResponse { + private int page; + private int size; + private long totalElements; + private int totalPages; + + public static PageInfoResponse toDto(Page page, int displayPage) { + return PageInfoResponse.builder() + .page(displayPage) + .size(page.getSize()) + .totalElements(page.getTotalElements()) + .totalPages(page.getTotalPages()) + .build(); + } + } + + /** + * 알림 목록 아이템 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "알림 목록 아이템") + public static class NotificationItemResponse { + private Long notificationId; + private String notificationTitle; + private String notificationBody; + private NotificationType notificationType; + private Long relatedId; + private Boolean isRead; + private LocalDateTime createdAt; + + public static NotificationItemResponse toDto(Notification notification) { + return NotificationItemResponse.builder() + .notificationId(notification.getNotificationId()) + .notificationTitle(notification.getNotificationTitle()) + .notificationBody(notification.getNotificationBody()) + .notificationType(notification.getNotificationType()) + .relatedId(notification.getRelatedId()) + .isRead(notification.getIsRead()) + .createdAt(notification.getNotificationCreatedAt()) + .build(); + } + } + + /** + * 알림 목록 조회 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "알림 목록 조회 응답") + public static class NotificationListResponse { + private PageInfoResponse pageInfo; + private List data; + } + + /** + * 읽지 않은 알림 개수 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "읽지 않은 알림 개수 응답") + public static class UnreadNotificationCountResponse { + private long unreadCount; + + public static UnreadNotificationCountResponse toDto(long unreadCount) { + return UnreadNotificationCountResponse.builder() + .unreadCount(unreadCount) + .build(); + } + } + + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "알림 스케줄 등록 응답") + public static class NotificationScheduleCreateResponse { + private String message; + private Long scheduleId; + private NotificationScheduleStatus scheduleStatus; + private LocalDateTime scheduledAt; + + public static NotificationScheduleCreateResponse toDto(NotificationSchedule schedule) { + return NotificationScheduleCreateResponse.builder() + .message("알림 스케줄이 성공적으로 등록되었습니다.") + .scheduleId(schedule.getNotificationScheduleId()) + .scheduleStatus(schedule.getScheduleStatus()) + .scheduledAt(schedule.getScheduledAt()) + .build(); + } + } +} diff --git a/src/main/java/com/dodo/backend/notification/entity/Notification.java b/src/main/java/com/dodo/backend/notification/entity/Notification.java new file mode 100644 index 0000000..38a8051 --- /dev/null +++ b/src/main/java/com/dodo/backend/notification/entity/Notification.java @@ -0,0 +1,81 @@ +package com.dodo.backend.notification.entity; + +import com.dodo.backend.user.entity.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +/** + * 알림(Notification) 정보를 관리하는 엔티티입니다. + *

+ * 데이터베이스 {@code notification} 테이블과 매핑되며, 알림 수신 사용자, 제목/본문, + * 알림 유형, 관련 리소스 ID, 읽음 여부, 생성 일시 정보를 포함합니다. + */ +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@EntityListeners(AuditingEntityListener.class) +@Table(name = "notification") +public class Notification { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "notification_id") + private Long notificationId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "notification_title", nullable = false, length = 255) + private String notificationTitle; + + @Column(name = "notification_body", nullable = false, columnDefinition = "TEXT") + private String notificationBody; + + @Enumerated(EnumType.STRING) + @Column(name = "notification_type", nullable = false) + private NotificationType notificationType; + + @Column(name = "related_id", nullable = false) + private Long relatedId; + + @Builder.Default + @ColumnDefault("false") + @Column(name = "is_read", nullable = false) + private Boolean isRead = false; + + @CreatedDate + @Column(name = "notification_created_at", nullable = false, updatable = false) + private LocalDateTime notificationCreatedAt; + + /** + * 알림 읽음 여부를 변경합니다. + * + * @param isRead 변경할 읽음 여부 + */ + public void updateReadStatus(Boolean isRead) { + this.isRead = isRead; + } +} diff --git a/src/main/java/com/dodo/backend/notification/entity/NotificationSchedule.java b/src/main/java/com/dodo/backend/notification/entity/NotificationSchedule.java new file mode 100644 index 0000000..f73c931 --- /dev/null +++ b/src/main/java/com/dodo/backend/notification/entity/NotificationSchedule.java @@ -0,0 +1,94 @@ +package com.dodo.backend.notification.entity; + +import com.dodo.backend.user.entity.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@EntityListeners(AuditingEntityListener.class) +@Table(name = "notification_schedule") +public class NotificationSchedule { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "notification_schedule_id") + private Long notificationScheduleId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "created_by", nullable = false) + private User createdBy; + + @Column(name = "notification_title", nullable = false, length = 255) + private String notificationTitle; + + @Column(name = "notification_body", nullable = false, columnDefinition = "TEXT") + private String notificationBody; + + @Enumerated(EnumType.STRING) + @Column(name = "notification_type", nullable = false) + private NotificationType notificationType; + + @Enumerated(EnumType.STRING) + @Column(name = "target_type", nullable = false) + private NotificationScheduleTargetType targetType; + + @Column(name = "target_user_ids", columnDefinition = "TEXT") + private String targetUserIds; + + @Enumerated(EnumType.STRING) + @Column(name = "repeat_type", nullable = false) + private NotificationScheduleRepeatType repeatType; + + @Enumerated(EnumType.STRING) + @Column(name = "schedule_status", nullable = false) + private NotificationScheduleStatus scheduleStatus; + + @Column(name = "scheduled_at", nullable = false) + private LocalDateTime scheduledAt; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "modified_at") + private LocalDateTime modifiedAt; + + @Column(name = "executed_at") + private LocalDateTime executedAt; + + public void complete(LocalDateTime executedAt) { + this.scheduleStatus = NotificationScheduleStatus.COMPLETED; + this.executedAt = executedAt; + } + + public void reschedule(LocalDateTime nextScheduledAt, LocalDateTime executedAt) { + this.scheduledAt = nextScheduledAt; + this.executedAt = executedAt; + this.scheduleStatus = NotificationScheduleStatus.PENDING; + } +} diff --git a/src/main/java/com/dodo/backend/notification/entity/NotificationScheduleRepeatType.java b/src/main/java/com/dodo/backend/notification/entity/NotificationScheduleRepeatType.java new file mode 100644 index 0000000..2f8db58 --- /dev/null +++ b/src/main/java/com/dodo/backend/notification/entity/NotificationScheduleRepeatType.java @@ -0,0 +1,7 @@ +package com.dodo.backend.notification.entity; + +public enum NotificationScheduleRepeatType { + NONE, + DAILY, + WEEKLY +} diff --git a/src/main/java/com/dodo/backend/notification/entity/NotificationScheduleStatus.java b/src/main/java/com/dodo/backend/notification/entity/NotificationScheduleStatus.java new file mode 100644 index 0000000..4cca127 --- /dev/null +++ b/src/main/java/com/dodo/backend/notification/entity/NotificationScheduleStatus.java @@ -0,0 +1,7 @@ +package com.dodo.backend.notification.entity; + +public enum NotificationScheduleStatus { + PENDING, + COMPLETED, + CANCELED +} diff --git a/src/main/java/com/dodo/backend/notification/entity/NotificationScheduleTargetType.java b/src/main/java/com/dodo/backend/notification/entity/NotificationScheduleTargetType.java new file mode 100644 index 0000000..62564a5 --- /dev/null +++ b/src/main/java/com/dodo/backend/notification/entity/NotificationScheduleTargetType.java @@ -0,0 +1,6 @@ +package com.dodo.backend.notification.entity; + +public enum NotificationScheduleTargetType { + ALL, + USERS +} diff --git a/src/main/java/com/dodo/backend/notification/entity/NotificationType.java b/src/main/java/com/dodo/backend/notification/entity/NotificationType.java new file mode 100644 index 0000000..d5d6c0a --- /dev/null +++ b/src/main/java/com/dodo/backend/notification/entity/NotificationType.java @@ -0,0 +1,13 @@ +package com.dodo.backend.notification.entity; + +/** + * 알림 대상 기능 유형을 정의하는 enum입니다. + */ +public enum NotificationType { + COMMENT, + BOARD, + REACTION, + PET, + HEALTH, + SYSTEM +} diff --git a/src/main/java/com/dodo/backend/notification/exception/NotificationErrorCode.java b/src/main/java/com/dodo/backend/notification/exception/NotificationErrorCode.java new file mode 100644 index 0000000..62aec3b --- /dev/null +++ b/src/main/java/com/dodo/backend/notification/exception/NotificationErrorCode.java @@ -0,0 +1,42 @@ +package com.dodo.backend.notification.exception; + +import com.dodo.backend.common.exception.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +/** + * 알림 도메인에서 발생하는 예외 상황을 관리하는 에러 코드입니다. + */ +@Getter +@AllArgsConstructor +public enum NotificationErrorCode implements BaseErrorCode { + + /** + * 요청 값이 올바르지 않은 경우 사용합니다. + */ + INVALID_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."), + + /** + * 알림을 찾을 수 없는 경우 사용합니다. + */ + NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 ID의 알림을 찾을 수 없습니다."), + + /** + * 알림 수정 권한이 없는 경우 사용합니다. + */ + NOTIFICATION_UPDATE_FORBIDDEN(HttpStatus.FORBIDDEN, "알림을 수정할 권한이 없습니다."), + + /** + * 알림 삭제 권한이 없는 경우 사용합니다. + */ + NOTIFICATION_DELETE_FORBIDDEN(HttpStatus.FORBIDDEN, "알림을 삭제할 권한이 없습니다."), + + /** + * 서버 내부 오류가 발생한 경우 사용합니다. + */ + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."); + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/dodo/backend/notification/exception/NotificationException.java b/src/main/java/com/dodo/backend/notification/exception/NotificationException.java new file mode 100644 index 0000000..dcabfbb --- /dev/null +++ b/src/main/java/com/dodo/backend/notification/exception/NotificationException.java @@ -0,0 +1,17 @@ +package com.dodo.backend.notification.exception; + +import lombok.Getter; + +/** + * 알림 도메인 비즈니스 예외입니다. + */ +@Getter +public class NotificationException extends RuntimeException { + + private final NotificationErrorCode errorCode; + + public NotificationException(NotificationErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/dodo/backend/notification/repository/NotificationRepository.java b/src/main/java/com/dodo/backend/notification/repository/NotificationRepository.java new file mode 100644 index 0000000..f0f9503 --- /dev/null +++ b/src/main/java/com/dodo/backend/notification/repository/NotificationRepository.java @@ -0,0 +1,50 @@ +package com.dodo.backend.notification.repository; + +import com.dodo.backend.notification.entity.Notification; +import com.dodo.backend.notification.entity.NotificationType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +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 java.util.Collection; +import java.util.UUID; + +/** + * 알림 엔티티의 영속성 처리를 담당하는 Repository입니다. + */ +@Repository +public interface NotificationRepository extends JpaRepository { + + Page findByUserUsersIdOrderByNotificationCreatedAtDescNotificationIdDesc(UUID userId, Pageable pageable); + + Page findByUserUsersIdAndIsReadOrderByNotificationCreatedAtDescNotificationIdDesc( + UUID userId, + Boolean isRead, + Pageable pageable + ); + + Page findByUserUsersIdAndNotificationTypeInOrderByNotificationCreatedAtDescNotificationIdDesc( + UUID userId, + Collection types, + Pageable pageable + ); + + Page findByUserUsersIdAndIsReadAndNotificationTypeInOrderByNotificationCreatedAtDescNotificationIdDesc( + UUID userId, + Boolean isRead, + Collection types, + Pageable pageable + ); + + long countByUserUsersIdAndIsReadFalse(UUID userId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("update Notification n set n.isRead = true where n.user.usersId = :userId and n.isRead = false") + int markAllAsRead(@Param("userId") UUID userId); + + void deleteAllByUserUsersId(UUID userId); +} diff --git a/src/main/java/com/dodo/backend/notification/repository/NotificationScheduleRepository.java b/src/main/java/com/dodo/backend/notification/repository/NotificationScheduleRepository.java new file mode 100644 index 0000000..35d3500 --- /dev/null +++ b/src/main/java/com/dodo/backend/notification/repository/NotificationScheduleRepository.java @@ -0,0 +1,18 @@ +package com.dodo.backend.notification.repository; + +import com.dodo.backend.notification.entity.NotificationSchedule; +import com.dodo.backend.notification.entity.NotificationScheduleStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public interface NotificationScheduleRepository extends JpaRepository { + + List findTop50ByScheduleStatusAndScheduledAtLessThanEqualOrderByScheduledAtAsc( + NotificationScheduleStatus scheduleStatus, + LocalDateTime scheduledAt + ); +} diff --git a/src/main/java/com/dodo/backend/notification/service/FcmNotificationSender.java b/src/main/java/com/dodo/backend/notification/service/FcmNotificationSender.java new file mode 100644 index 0000000..34e3eee --- /dev/null +++ b/src/main/java/com/dodo/backend/notification/service/FcmNotificationSender.java @@ -0,0 +1,105 @@ +package com.dodo.backend.notification.service; + +import com.dodo.backend.fcmtoken.entity.FcmToken; +import com.dodo.backend.fcmtoken.repository.FcmTokenRepository; +import com.dodo.backend.notification.entity.NotificationType; +import com.dodo.backend.user.entity.User; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.Notification; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FcmNotificationSender { + + private final FcmTokenRepository fcmTokenRepository; + private final ResourceLoader resourceLoader; + + @Value("${fcm.certification:}") + private String fcmCertification; + + public void sendToUsers(List users, String title, String body, NotificationType type, Long relatedId) { + if (users == null || users.isEmpty()) { + return; + } + + List userIds = users.stream() + .map(User::getUsersId) + .toList(); + List tokens = fcmTokenRepository.findByUserUsersIdIn(userIds); + if (tokens.isEmpty()) { + return; + } + + FirebaseMessaging messaging = getMessaging(); + if (messaging == null) { + return; + } + + tokens.forEach(token -> send(messaging, token, title, body, type, relatedId)); + } + + private void send(FirebaseMessaging messaging, FcmToken token, String title, String body, NotificationType type, Long relatedId) { + Message message = Message.builder() + .setToken(token.getToken()) + .setNotification(Notification.builder() + .setTitle(title) + .setBody(body) + .build()) + .putData("notificationType", type.name()) + .putData("relatedId", String.valueOf(relatedId)) + .build(); + + try { + messaging.send(message); + } catch (FirebaseMessagingException e) { + log.warn("FCM 발송 실패 - tokenId: {}, reason: {}", token.getFcmTokenId(), e.getMessage()); + } + } + + private FirebaseMessaging getMessaging() { + try { + if (FirebaseApp.getApps().isEmpty()) { + initializeFirebaseApp(); + } + return FirebaseMessaging.getInstance(); + } catch (Exception e) { + log.warn("FCM 초기화 실패로 푸시 발송을 건너뜁니다. reason: {}", e.getMessage()); + return null; + } + } + + private synchronized void initializeFirebaseApp() throws IOException { + if (!FirebaseApp.getApps().isEmpty()) { + return; + } + if (!StringUtils.hasText(fcmCertification)) { + throw new IllegalStateException("fcm.certification 설정이 없습니다."); + } + + Resource resource = resourceLoader.getResource(fcmCertification); + try (InputStream inputStream = resource.getInputStream()) { + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(inputStream)) + .build(); + FirebaseApp.initializeApp(options); + } + } +} diff --git a/src/main/java/com/dodo/backend/notification/service/NotificationScheduleService.java b/src/main/java/com/dodo/backend/notification/service/NotificationScheduleService.java new file mode 100644 index 0000000..a7c6823 --- /dev/null +++ b/src/main/java/com/dodo/backend/notification/service/NotificationScheduleService.java @@ -0,0 +1,11 @@ +package com.dodo.backend.notification.service; + +import com.dodo.backend.notification.dto.request.NotificationRequest.NotificationScheduleCreateRequest; +import com.dodo.backend.notification.dto.response.NotificationResponse.NotificationScheduleCreateResponse; + +import java.util.UUID; + +public interface NotificationScheduleService { + + NotificationScheduleCreateResponse createSchedule(UUID adminId, NotificationScheduleCreateRequest request); +} diff --git a/src/main/java/com/dodo/backend/notification/service/NotificationScheduleServiceImpl.java b/src/main/java/com/dodo/backend/notification/service/NotificationScheduleServiceImpl.java new file mode 100644 index 0000000..a390269 --- /dev/null +++ b/src/main/java/com/dodo/backend/notification/service/NotificationScheduleServiceImpl.java @@ -0,0 +1,181 @@ +package com.dodo.backend.notification.service; + +import com.dodo.backend.notification.dto.request.NotificationRequest.NotificationScheduleCreateRequest; +import com.dodo.backend.notification.dto.response.NotificationResponse.NotificationScheduleCreateResponse; +import com.dodo.backend.notification.entity.Notification; +import com.dodo.backend.notification.entity.NotificationSchedule; +import com.dodo.backend.notification.entity.NotificationScheduleRepeatType; +import com.dodo.backend.notification.entity.NotificationScheduleStatus; +import com.dodo.backend.notification.entity.NotificationScheduleTargetType; +import com.dodo.backend.notification.exception.NotificationException; +import com.dodo.backend.notification.repository.NotificationRepository; +import com.dodo.backend.notification.repository.NotificationScheduleRepository; +import com.dodo.backend.user.entity.User; +import com.dodo.backend.user.entity.UserStatus; +import com.dodo.backend.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +import static com.dodo.backend.notification.exception.NotificationErrorCode.INVALID_REQUEST; + +@Slf4j +@Service +@RequiredArgsConstructor +public class NotificationScheduleServiceImpl implements NotificationScheduleService { + + private final NotificationScheduleRepository notificationScheduleRepository; + private final NotificationRepository notificationRepository; + private final UserRepository userRepository; + private final FcmNotificationSender fcmNotificationSender; + + @Transactional + @Override + public NotificationScheduleCreateResponse createSchedule(UUID adminId, NotificationScheduleCreateRequest request) { + validateCreateRequest(adminId, request); + User admin = userRepository.findById(adminId) + .orElseThrow(() -> new NotificationException(INVALID_REQUEST)); + + NotificationSchedule schedule = NotificationSchedule.builder() + .createdBy(admin) + .notificationTitle(request.getTitle()) + .notificationBody(request.getBody()) + .notificationType(request.getNotificationType()) + .targetType(request.getTargetType()) + .targetUserIds(toTargetUserIds(request)) + .repeatType(resolveRepeatType(request.getRepeatType())) + .scheduleStatus(NotificationScheduleStatus.PENDING) + .scheduledAt(request.getScheduledAt()) + .build(); + + NotificationSchedule savedSchedule = notificationScheduleRepository.save(schedule); + return NotificationScheduleCreateResponse.toDto(savedSchedule); + } + + @Scheduled(fixedDelayString = "${notification.scheduler.fixed-delay:60000}") + @Transactional + public void executeDueSchedules() { + LocalDateTime now = LocalDateTime.now(); + List dueSchedules = + notificationScheduleRepository.findTop50ByScheduleStatusAndScheduledAtLessThanEqualOrderByScheduledAtAsc( + NotificationScheduleStatus.PENDING, + now + ); + + dueSchedules.forEach(schedule -> executeSchedule(schedule, now)); + } + + private void executeSchedule(NotificationSchedule schedule, LocalDateTime now) { + List targets = findTargets(schedule); + if (!targets.isEmpty()) { + notificationRepository.saveAll(targets.stream() + .map(user -> Notification.builder() + .user(user) + .notificationTitle(schedule.getNotificationTitle()) + .notificationBody(schedule.getNotificationBody()) + .notificationType(schedule.getNotificationType()) + .relatedId(schedule.getNotificationScheduleId()) + .isRead(false) + .build()) + .toList()); + + fcmNotificationSender.sendToUsers( + targets, + schedule.getNotificationTitle(), + schedule.getNotificationBody(), + schedule.getNotificationType(), + schedule.getNotificationScheduleId() + ); + } + + updateScheduleAfterExecution(schedule, now); + } + + private List findTargets(NotificationSchedule schedule) { + if (schedule.getTargetType() == NotificationScheduleTargetType.ALL) { + return userRepository.findByUserStatusAndNotificationEnabledTrue(UserStatus.ACTIVE); + } + + List targetUserIds = parseTargetUserIds(schedule.getTargetUserIds()); + if (targetUserIds.isEmpty()) { + return List.of(); + } + return userRepository.findByUsersIdInAndUserStatusAndNotificationEnabledTrue(targetUserIds, UserStatus.ACTIVE); + } + + private void updateScheduleAfterExecution(NotificationSchedule schedule, LocalDateTime now) { + if (schedule.getRepeatType() == NotificationScheduleRepeatType.DAILY) { + schedule.reschedule(nextDailyScheduleAt(schedule.getScheduledAt(), now), now); + return; + } + if (schedule.getRepeatType() == NotificationScheduleRepeatType.WEEKLY) { + schedule.reschedule(nextWeeklyScheduleAt(schedule.getScheduledAt(), now), now); + return; + } + schedule.complete(now); + } + + private LocalDateTime nextDailyScheduleAt(LocalDateTime scheduledAt, LocalDateTime now) { + LocalDateTime next = scheduledAt; + do { + next = next.plusDays(1); + } while (!next.isAfter(now)); + return next; + } + + private LocalDateTime nextWeeklyScheduleAt(LocalDateTime scheduledAt, LocalDateTime now) { + LocalDateTime next = scheduledAt; + do { + next = next.plusWeeks(1); + } while (!next.isAfter(now)); + return next; + } + + private NotificationScheduleRepeatType resolveRepeatType(NotificationScheduleRepeatType repeatType) { + return repeatType == null ? NotificationScheduleRepeatType.NONE : repeatType; + } + + private void validateCreateRequest(UUID adminId, NotificationScheduleCreateRequest request) { + if (adminId == null || request == null || request.getTargetType() == null) { + throw new NotificationException(INVALID_REQUEST); + } + if (request.getTargetType() == NotificationScheduleTargetType.USERS + && (request.getTargetUserIds() == null || request.getTargetUserIds().isEmpty())) { + throw new NotificationException(INVALID_REQUEST); + } + } + + private String toTargetUserIds(NotificationScheduleCreateRequest request) { + if (request.getTargetType() != NotificationScheduleTargetType.USERS) { + return null; + } + return request.getTargetUserIds().stream() + .distinct() + .map(UUID::toString) + .reduce((left, right) -> left + "," + right) + .orElseThrow(() -> new NotificationException(INVALID_REQUEST)); + } + + private List parseTargetUserIds(String targetUserIds) { + if (targetUserIds == null || targetUserIds.isBlank()) { + return List.of(); + } + try { + return Arrays.stream(targetUserIds.split(",")) + .map(String::trim) + .filter(value -> !value.isBlank()) + .map(UUID::fromString) + .toList(); + } catch (IllegalArgumentException e) { + log.warn("알림 스케줄 대상 사용자 ID 파싱 실패 - targetUserIds: {}", targetUserIds); + return List.of(); + } + } +} diff --git a/src/main/java/com/dodo/backend/notification/service/NotificationService.java b/src/main/java/com/dodo/backend/notification/service/NotificationService.java new file mode 100644 index 0000000..f349059 --- /dev/null +++ b/src/main/java/com/dodo/backend/notification/service/NotificationService.java @@ -0,0 +1,26 @@ +package com.dodo.backend.notification.service; + +import com.dodo.backend.notification.dto.request.NotificationRequest.NotificationReadUpdateRequest; +import com.dodo.backend.notification.dto.response.NotificationResponse.NotificationListResponse; +import com.dodo.backend.notification.dto.response.NotificationResponse.NotificationSimpleResponse; +import com.dodo.backend.notification.dto.response.NotificationResponse.UnreadNotificationCountResponse; + +import java.util.UUID; + +/** + * 알림 API 비즈니스 로직을 정의하는 서비스 인터페이스입니다. + */ +public interface NotificationService { + + NotificationListResponse getNotifications(UUID userId, int page, int size, Boolean isRead, String type); + + NotificationSimpleResponse updateReadStatus(UUID userId, Long notificationId, NotificationReadUpdateRequest request); + + void deleteNotification(UUID userId, Long notificationId); + + UnreadNotificationCountResponse getUnreadCount(UUID userId); + + NotificationSimpleResponse readAll(UUID userId); + + void deleteAll(UUID userId); +} diff --git a/src/main/java/com/dodo/backend/notification/service/NotificationServiceImpl.java b/src/main/java/com/dodo/backend/notification/service/NotificationServiceImpl.java new file mode 100644 index 0000000..5e6a03c --- /dev/null +++ b/src/main/java/com/dodo/backend/notification/service/NotificationServiceImpl.java @@ -0,0 +1,205 @@ +package com.dodo.backend.notification.service; + +import com.dodo.backend.notification.dto.request.NotificationRequest.NotificationReadUpdateRequest; +import com.dodo.backend.notification.dto.response.NotificationResponse.NotificationItemResponse; +import com.dodo.backend.notification.dto.response.NotificationResponse.NotificationListResponse; +import com.dodo.backend.notification.dto.response.NotificationResponse.NotificationSimpleResponse; +import com.dodo.backend.notification.dto.response.NotificationResponse.PageInfoResponse; +import com.dodo.backend.notification.dto.response.NotificationResponse.UnreadNotificationCountResponse; +import com.dodo.backend.notification.entity.Notification; +import com.dodo.backend.notification.entity.NotificationType; +import com.dodo.backend.notification.exception.NotificationErrorCode; +import com.dodo.backend.notification.exception.NotificationException; +import com.dodo.backend.notification.repository.NotificationRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +import static com.dodo.backend.notification.exception.NotificationErrorCode.INVALID_REQUEST; +import static com.dodo.backend.notification.exception.NotificationErrorCode.NOTIFICATION_DELETE_FORBIDDEN; +import static com.dodo.backend.notification.exception.NotificationErrorCode.NOTIFICATION_NOT_FOUND; +import static com.dodo.backend.notification.exception.NotificationErrorCode.NOTIFICATION_UPDATE_FORBIDDEN; + +/** + * {@link NotificationService} 구현체입니다. + */ +@Service +@RequiredArgsConstructor +public class NotificationServiceImpl implements NotificationService { + + private static final int MAX_PAGE_SIZE = 100; + private static final String READ_SUCCESS_MESSAGE = "알림이 성공적으로 읽음 처리되었습니다."; + private static final String READ_ALL_SUCCESS_MESSAGE = "모든 알림이 성공적으로 읽음 처리되었습니다."; + + private final NotificationRepository notificationRepository; + + /** + * 알림 목록을 조회합니다. + * + * @param userId 조회할 사용자 ID + * @param page 조회할 페이지 번호 + * @param size 페이지당 알림 수 + * @param isRead 읽음 여부 필터 + * @param type 알림 유형 필터 + * @return 알림 목록 조회 결과 + */ + @Transactional(readOnly = true) + @Override + public NotificationListResponse getNotifications(UUID userId, int page, int size, Boolean isRead, String type) { + validatePageRequest(userId, page, size); + Pageable pageable = PageRequest.of(page - 1, size); + List types = parseTypes(type); + + Page notifications = findNotifications(userId, isRead, types, pageable); + return NotificationListResponse.builder() + .pageInfo(PageInfoResponse.toDto(notifications, page)) + .data(notifications.getContent().stream().map(NotificationItemResponse::toDto).toList()) + .build(); + } + + /** + * 특정 알림의 읽음 여부를 변경합니다. + * + * @param userId 요청 사용자 ID + * @param notificationId 읽음 여부를 변경할 알림 ID + * @param request 읽음 여부 변경 요청 + * @return 읽음 처리 성공 메시지 + */ + @Transactional + @Override + public NotificationSimpleResponse updateReadStatus(UUID userId, Long notificationId, NotificationReadUpdateRequest request) { + if (request == null || request.getIsRead() == null) { + throw new NotificationException(INVALID_REQUEST); + } + Notification notification = findOwnedNotification(userId, notificationId, NOTIFICATION_UPDATE_FORBIDDEN); + notification.updateReadStatus(request.getIsRead()); + return NotificationSimpleResponse.toDto(READ_SUCCESS_MESSAGE); + } + + /** + * 특정 알림을 삭제합니다. + * + * @param userId 요청 사용자 ID + * @param notificationId 삭제할 알림 ID + */ + @Transactional + @Override + public void deleteNotification(UUID userId, Long notificationId) { + Notification notification = findOwnedNotification(userId, notificationId, NOTIFICATION_DELETE_FORBIDDEN); + notificationRepository.delete(notification); + } + + /** + * 읽지 않은 알림 개수를 조회합니다. + * + * @param userId 조회할 사용자 ID + * @return 읽지 않은 알림 개수 + */ + @Transactional(readOnly = true) + @Override + public UnreadNotificationCountResponse getUnreadCount(UUID userId) { + validateUserId(userId); + return UnreadNotificationCountResponse.toDto(notificationRepository.countByUserUsersIdAndIsReadFalse(userId)); + } + + /** + * 모든 알림을 읽음 처리합니다. + * + * @param userId 요청 사용자 ID + * @return 전체 읽음 처리 성공 메시지 + */ + @Transactional + @Override + public NotificationSimpleResponse readAll(UUID userId) { + validateUserId(userId); + notificationRepository.markAllAsRead(userId); + return NotificationSimpleResponse.toDto(READ_ALL_SUCCESS_MESSAGE); + } + + /** + * 모든 알림을 삭제합니다. + * + * @param userId 요청 사용자 ID + */ + @Transactional + @Override + public void deleteAll(UUID userId) { + validateUserId(userId); + notificationRepository.deleteAllByUserUsersId(userId); + } + + private Page findNotifications(UUID userId, Boolean isRead, List types, Pageable pageable) { + if (isRead != null && !types.isEmpty()) { + return notificationRepository.findByUserUsersIdAndIsReadAndNotificationTypeInOrderByNotificationCreatedAtDescNotificationIdDesc( + userId, + isRead, + types, + pageable + ); + } + if (isRead != null) { + return notificationRepository.findByUserUsersIdAndIsReadOrderByNotificationCreatedAtDescNotificationIdDesc(userId, isRead, pageable); + } + if (!types.isEmpty()) { + return notificationRepository.findByUserUsersIdAndNotificationTypeInOrderByNotificationCreatedAtDescNotificationIdDesc(userId, types, pageable); + } + return notificationRepository.findByUserUsersIdOrderByNotificationCreatedAtDescNotificationIdDesc(userId, pageable); + } + + private Notification findOwnedNotification(UUID userId, Long notificationId, NotificationErrorCode forbiddenErrorCode) { + validateUserId(userId); + validateId(notificationId); + Notification notification = notificationRepository.findById(notificationId) + .orElseThrow(() -> new NotificationException(NOTIFICATION_NOT_FOUND)); + if (notification.getUser() == null || !userId.equals(notification.getUser().getUsersId())) { + throw new NotificationException(forbiddenErrorCode); + } + return notification; + } + + private List parseTypes(String type) { + if (type == null || type.isBlank()) { + return List.of(); + } + try { + List types = Arrays.stream(type.split(",")) + .map(String::trim) + .filter(value -> !value.isBlank()) + .map(NotificationType::valueOf) + .distinct() + .toList(); + + if (types.isEmpty()) { + throw new NotificationException(INVALID_REQUEST); + } + return types; + } catch (IllegalArgumentException e) { + throw new NotificationException(INVALID_REQUEST); + } + } + + private void validatePageRequest(UUID userId, int page, int size) { + if (userId == null || page <= 0 || size <= 0 || size > MAX_PAGE_SIZE) { + throw new NotificationException(INVALID_REQUEST); + } + } + + private void validateUserId(UUID userId) { + if (userId == null) { + throw new NotificationException(INVALID_REQUEST); + } + } + + private void validateId(Long id) { + if (id == null || id <= 0) { + throw new NotificationException(INVALID_REQUEST); + } + } +} diff --git a/src/main/java/com/dodo/backend/report/controller/ReportController.java b/src/main/java/com/dodo/backend/report/controller/ReportController.java index 859c9c1..858416b 100644 --- a/src/main/java/com/dodo/backend/report/controller/ReportController.java +++ b/src/main/java/com/dodo/backend/report/controller/ReportController.java @@ -13,7 +13,6 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.userdetails.UserDetails; @@ -47,7 +46,7 @@ public class ReportController { */ @Operation(summary = "게시글 신고", description = "인증 사용자가 특정 게시글을 신고합니다.") @ApiResponses(value = { - @ApiResponse(responseCode = "201", description = "신고가 성공적으로 접수되었습니다.", + @ApiResponse(responseCode = "200", description = "신고가 성공적으로 접수되었습니다.", content = @Content(schema = @Schema(implementation = ReportSimpleResponse.class))), @ApiResponse(responseCode = "400", description = "잘못된 요청입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), @@ -69,9 +68,7 @@ public ResponseEntity reportBoard( UUID reporterId = UUID.fromString(userDetails.getUsername()); log.info("게시글 신고 요청 - Reporter: {}, BoardId: {}", reporterId, boardId); - return ResponseEntity - .status(HttpStatus.CREATED) - .body(reportService.reportBoard(reporterId, boardId, request)); + return ResponseEntity.ok(reportService.reportBoard(reporterId, boardId, request)); } /** @@ -84,7 +81,7 @@ public ResponseEntity reportBoard( */ @Operation(summary = "유저 신고", description = "인증 사용자가 특정 유저를 신고합니다.") @ApiResponses(value = { - @ApiResponse(responseCode = "201", description = "신고가 성공적으로 접수되었습니다.", + @ApiResponse(responseCode = "200", description = "신고가 성공적으로 접수되었습니다.", content = @Content(schema = @Schema(implementation = ReportSimpleResponse.class))), @ApiResponse(responseCode = "400", description = "잘못된 요청입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), @@ -106,9 +103,7 @@ public ResponseEntity reportUser( UUID reporterId = UUID.fromString(userDetails.getUsername()); log.info("유저 신고 요청 - Reporter: {}, ReportedUser: {}", reporterId, userId); - return ResponseEntity - .status(HttpStatus.CREATED) - .body(reportService.reportUser(reporterId, userId, request)); + return ResponseEntity.ok(reportService.reportUser(reporterId, userId, request)); } /** @@ -121,7 +116,7 @@ public ResponseEntity reportUser( */ @Operation(summary = "댓글 신고", description = "인증 사용자가 특정 댓글을 신고합니다.") @ApiResponses(value = { - @ApiResponse(responseCode = "201", description = "신고가 성공적으로 접수되었습니다.", + @ApiResponse(responseCode = "200", description = "신고가 성공적으로 접수되었습니다.", content = @Content(schema = @Schema(implementation = ReportSimpleResponse.class))), @ApiResponse(responseCode = "400", description = "잘못된 요청입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), @@ -143,8 +138,6 @@ public ResponseEntity reportComment( UUID reporterId = UUID.fromString(userDetails.getUsername()); log.info("댓글 신고 요청 - Reporter: {}, CommentId: {}", reporterId, commentId); - return ResponseEntity - .status(HttpStatus.CREATED) - .body(reportService.reportComment(reporterId, commentId, request)); + return ResponseEntity.ok(reportService.reportComment(reporterId, commentId, request)); } } diff --git a/src/main/java/com/dodo/backend/user/repository/UserRepository.java b/src/main/java/com/dodo/backend/user/repository/UserRepository.java index 5a784e6..f5e0879 100644 --- a/src/main/java/com/dodo/backend/user/repository/UserRepository.java +++ b/src/main/java/com/dodo/backend/user/repository/UserRepository.java @@ -1,9 +1,11 @@ package com.dodo.backend.user.repository; import com.dodo.backend.user.entity.User; +import com.dodo.backend.user.entity.UserStatus; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.Collection; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -20,5 +22,8 @@ public interface UserRepository extends JpaRepository { boolean existsByNickname(String nickname); + List findByUserStatusAndNotificationEnabledTrue(UserStatus userStatus); + + List findByUsersIdInAndUserStatusAndNotificationEnabledTrue(Collection usersIds, UserStatus userStatus); } diff --git a/src/test/java/com/dodo/backend/admin/controller/AdminControllerTest.java b/src/test/java/com/dodo/backend/admin/controller/AdminControllerTest.java index 08659a4..6df1f8a 100644 --- a/src/test/java/com/dodo/backend/admin/controller/AdminControllerTest.java +++ b/src/test/java/com/dodo/backend/admin/controller/AdminControllerTest.java @@ -2,10 +2,16 @@ import com.dodo.backend.admin.dto.request.AdminRequest.ReportStatusUpdateRequest; import com.dodo.backend.admin.dto.response.AdminResponse.AdminSimpleResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.AnnouncementDetailResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.AnnouncementListResponse; import com.dodo.backend.admin.dto.response.AdminResponse.BoardReportDetailResponse; import com.dodo.backend.admin.dto.response.AdminResponse.PageInfoResponse; import com.dodo.backend.admin.dto.response.AdminResponse.ReportListResponse; import com.dodo.backend.admin.service.AdminService; +import com.dodo.backend.notification.dto.request.NotificationRequest.NotificationScheduleCreateRequest; +import com.dodo.backend.notification.dto.response.NotificationResponse.NotificationScheduleCreateResponse; +import com.dodo.backend.notification.entity.NotificationScheduleStatus; +import com.dodo.backend.notification.service.NotificationScheduleService; import com.dodo.backend.report.entity.ReportStatus; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; @@ -14,12 +20,20 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.MethodParameter; import org.springframework.http.MediaType; -import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.User; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; +import java.time.LocalDateTime; import java.util.List; +import java.util.UUID; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -28,6 +42,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -40,13 +55,18 @@ class AdminControllerTest { @Mock private AdminService adminService; + @Mock + private NotificationScheduleService notificationScheduleService; + private MockMvc mockMvc; private ObjectMapper objectMapper; + private UUID adminId; @BeforeEach void setUp() { - mockMvc = MockMvcBuilders.standaloneSetup(new AdminController(adminService)) - .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()) + adminId = UUID.randomUUID(); + mockMvc = MockMvcBuilders.standaloneSetup(new AdminController(adminService, notificationScheduleService)) + .setCustomArgumentResolvers(authenticationPrincipalResolver()) .build(); objectMapper = new ObjectMapper(); } @@ -107,14 +127,110 @@ void updateReportStatus_Success() throws Exception { } /** - * 게시글 강제 삭제 API가 204를 반환하는지 검증합니다. + * 게시글 강제 삭제 API가 200과 성공 메시지를 반환하는지 검증합니다. */ @Test @DisplayName("게시글 강제 삭제 API 성공") void deleteBoard_Success() throws Exception { mockMvc.perform(delete("/admin/boards/{boardId}", 1L)) - .andExpect(status().isNoContent()); + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("게시글이 성공적으로 강제 삭제되었습니다.")); verify(adminService).deleteBoard(1L); } + + @Test + @DisplayName("알림 스케줄 등록 API 성공") + void createNotificationSchedule_Success() throws Exception { + LocalDateTime scheduledAt = LocalDateTime.of(2099, 1, 1, 14, 30); + NotificationScheduleCreateResponse response = NotificationScheduleCreateResponse.builder() + .message("알림 스케줄이 성공적으로 등록되었습니다.") + .scheduleId(1L) + .scheduleStatus(NotificationScheduleStatus.PENDING) + .scheduledAt(scheduledAt) + .build(); + + given(notificationScheduleService.createSchedule(eq(adminId), any(NotificationScheduleCreateRequest.class))) + .willReturn(response); + + String requestBody = """ + { + "title": "공지 알림", + "body": "새로운 공지가 등록되었습니다.", + "notificationType": "SYSTEM", + "targetType": "ALL", + "scheduledAt": "2099-01-01T14:30:00", + "repeatType": "NONE" + } + """; + + mockMvc.perform(post("/admin/notification-schedules") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("알림 스케줄이 성공적으로 등록되었습니다.")) + .andExpect(jsonPath("$.scheduleId").value(1)) + .andExpect(jsonPath("$.scheduleStatus").value("PENDING")); + + verify(notificationScheduleService).createSchedule(eq(adminId), any(NotificationScheduleCreateRequest.class)); + } + + /** + * 공지 목록 조회 API가 명세의 메시지를 반환하는지 검증합니다. + */ + @Test + @DisplayName("공지 목록 조회 API 성공") + void getAnnouncementList_Success() throws Exception { + AnnouncementListResponse response = AnnouncementListResponse.builder() + .pageInfo(PageInfoResponse.toDto(0, 10, 0)) + .data(List.of()) + .message("공지 목록을 조회했습니다.") + .build(); + given(adminService.getAnnouncementList(0, 10, "registrationUpdatedAt,desc")).willReturn(response); + + mockMvc.perform(get("/admin/announcements") + .param("page", "0") + .param("size", "10") + .param("sort", "registrationUpdatedAt,desc")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("공지 목록을 조회했습니다.")); + + verify(adminService).getAnnouncementList(0, 10, "registrationUpdatedAt,desc"); + } + + /** + * 공지 상세 조회 API가 명세의 메시지를 반환하는지 검증합니다. + */ + @Test + @DisplayName("공지 상세 조회 API 성공") + void getAnnouncementDetail_Success() throws Exception { + AnnouncementDetailResponse response = AnnouncementDetailResponse.builder() + .boardId(31L) + .boardTitle("공지 제목입니다.") + .boardContent("공지 전체 내용입니다.") + .message("공지 상세보기에 성공했습니다.") + .build(); + given(adminService.getAnnouncementDetail(31L)).willReturn(response); + + mockMvc.perform(get("/admin/announcements/{boardId}", 31L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("공지 상세보기에 성공했습니다.")); + + verify(adminService).getAnnouncementDetail(31L); + } + + private HandlerMethodArgumentResolver authenticationPrincipalResolver() { + return new HandlerMethodArgumentResolver() { + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthenticationPrincipal.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + return User.withUsername(adminId.toString()).password("").roles("ADMIN").build(); + } + }; + } } diff --git a/src/test/java/com/dodo/backend/admin/service/AdminServiceTest.java b/src/test/java/com/dodo/backend/admin/service/AdminServiceTest.java index feea5bc..25ee8d7 100644 --- a/src/test/java/com/dodo/backend/admin/service/AdminServiceTest.java +++ b/src/test/java/com/dodo/backend/admin/service/AdminServiceTest.java @@ -2,6 +2,8 @@ import com.dodo.backend.admin.dto.request.AdminRequest.ReportStatusUpdateRequest; import com.dodo.backend.admin.dto.request.AdminRequest.UserStatusUpdateRequest; +import com.dodo.backend.admin.dto.response.AdminResponse.AnnouncementDetailResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.AnnouncementListResponse; import com.dodo.backend.admin.dto.response.AdminResponse.BoardReportDetailResponse; import com.dodo.backend.admin.dto.response.AdminResponse.ReportListResponse; import com.dodo.backend.admin.entity.AdminReportType; @@ -26,6 +28,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; import java.time.LocalDateTime; import java.util.List; @@ -34,6 +38,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; @@ -143,6 +148,45 @@ void updateReportStatus_Success() { assertEquals(ReportStatus.COMPLETED, report.getReportStatus()); } + /** + * 공지 목록 조회 시 명세의 성공 메시지가 포함되는지 검증합니다. + */ + @Test + @DisplayName("공지 목록 조회 성공") + void getAnnouncementList_Success() { + User admin = createUser(UUID.randomUUID(), "관리자"); + Board announcement = createAnnouncement(31L, admin); + given(boardRepository.findAllByBoardTypeAndBoardStatus( + org.mockito.ArgumentMatchers.eq(BoardType.NOTICE), + org.mockito.ArgumentMatchers.eq(BoardStatus.PUBLISHED), + any(Pageable.class) + )).willReturn(new PageImpl<>(List.of(announcement))); + given(imageFileService.getBoardImageUrls(31L)).willReturn(List.of("https://example.com/images/announcement1.jpg")); + + AnnouncementListResponse response = adminService.getAnnouncementList(0, 10, "registrationUpdatedAt,desc"); + + assertEquals("공지 목록을 조회했습니다.", response.getMessage()); + assertEquals(1, response.getData().size()); + assertEquals(31L, response.getData().get(0).getBoardId()); + } + + /** + * 공지 상세 조회 시 명세의 성공 메시지가 포함되는지 검증합니다. + */ + @Test + @DisplayName("공지 상세 조회 성공") + void getAnnouncementDetail_Success() { + User admin = createUser(UUID.randomUUID(), "관리자"); + Board announcement = createAnnouncement(31L, admin); + given(boardRepository.findByBoardIdAndBoardType(31L, BoardType.NOTICE)).willReturn(Optional.of(announcement)); + given(imageFileService.getBoardImageUrls(31L)).willReturn(List.of("https://example.com/images/announcement1.jpg")); + + AnnouncementDetailResponse response = adminService.getAnnouncementDetail(31L); + + assertEquals("공지 상세보기에 성공했습니다.", response.getMessage()); + assertEquals(31L, response.getBoardId()); + } + private User createUser(UUID userId, String nickname) { return User.builder() .usersId(userId) @@ -162,6 +206,20 @@ private Board createBoard(Long boardId, User user) { .build(); } + private Board createAnnouncement(Long boardId, User user) { + return Board.builder() + .boardId(boardId) + .user(user) + .boardTitle("공지 제목입니다.") + .boardContent("공지 내용입니다.") + .viewCount(10) + .boardType(BoardType.NOTICE) + .boardStatus(BoardStatus.PUBLISHED) + .boardCreatedAt(LocalDateTime.of(2025, 10, 1, 10, 0)) + .modifiedAt(LocalDateTime.of(2025, 10, 1, 11, 30)) + .build(); + } + private Report createBoardReport(Long reportId, User reporter, Board board, ReportReason reason) { return Report.builder() .reportId(reportId) diff --git a/src/test/java/com/dodo/backend/auth/service/AuthServiceTest.java b/src/test/java/com/dodo/backend/auth/service/AuthServiceTest.java index 6532540..45774e6 100644 --- a/src/test/java/com/dodo/backend/auth/service/AuthServiceTest.java +++ b/src/test/java/com/dodo/backend/auth/service/AuthServiceTest.java @@ -1,5 +1,6 @@ package com.dodo.backend.auth.service; +import com.dodo.backend.auth.dto.request.AuthRequest.AdminLoginRequest; import com.dodo.backend.auth.dto.request.AuthRequest.DeviceAuthRequest; import com.dodo.backend.auth.dto.request.AuthRequest.LogoutRequest; import com.dodo.backend.auth.dto.request.AuthRequest.ReissueRequest; @@ -9,6 +10,10 @@ import com.dodo.backend.auth.repository.RefreshTokenRepository; import com.dodo.backend.common.jwt.JwtTokenProvider; import com.dodo.backend.pet.service.PetServiceImpl; +import com.dodo.backend.user.entity.User; +import com.dodo.backend.user.entity.UserRole; +import com.dodo.backend.user.entity.UserStatus; +import com.dodo.backend.user.repository.UserRepository; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -18,6 +23,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.util.ReflectionTestUtils; import java.util.Optional; import java.util.UUID; @@ -59,6 +65,46 @@ class AuthServiceTest { @Mock private PetServiceImpl petService; + @Mock + private UserRepository userRepository; + + @Test + @DisplayName("관리자 로그인 성공 - ADMIN 권한 토큰 발급") + void adminLogin_Success() { + UUID adminId = UUID.randomUUID(); + String email = "admin@dodo.com"; + String password = "admin-password"; + String accessToken = "admin-access-token"; + String refreshToken = "admin-refresh-token"; + + ReflectionTestUtils.setField(authService, "adminLoginEmail", email); + ReflectionTestUtils.setField(authService, "adminLoginPassword", password); + + AdminLoginRequest request = AdminLoginRequest.builder() + .email(email) + .password(password) + .build(); + User admin = User.builder() + .usersId(adminId) + .email(email) + .role(UserRole.ADMIN) + .userStatus(UserStatus.ACTIVE) + .build(); + + given(userRepository.findByEmail(email)).willReturn(Optional.of(admin)); + given(jwtTokenProvider.createAccessToken(adminId, "ADMIN")).willReturn(accessToken); + given(jwtTokenProvider.createRefreshToken(adminId)).willReturn(refreshToken); + given(jwtTokenProvider.getAccessTokenValidityInMilliseconds()).willReturn(3600000L); + + AdminLoginResponse response = authService.adminLogin(request); + + assertThat(response.getMessage()).isEqualTo("관리자 로그인이 완료되었습니다."); + assertThat(response.getAccessToken()).isEqualTo(accessToken); + assertThat(response.getRefreshToken()).isEqualTo(refreshToken); + assertThat(response.getRole()).isEqualTo("ADMIN"); + verify(refreshTokenRepository).save(any(RefreshToken.class)); + } + /** * 로그아웃 성공 시나리오를 테스트합니다. *

@@ -496,4 +542,4 @@ void deviceReissueToken_Fail_InvalidToken() { log.info("유효하지 않은 토큰 실패 테스트가 통과되었습니다."); } -} \ No newline at end of file +} diff --git a/src/test/java/com/dodo/backend/fcmtoken/controller/FcmTokenControllerTest.java b/src/test/java/com/dodo/backend/fcmtoken/controller/FcmTokenControllerTest.java new file mode 100644 index 0000000..c0bef7f --- /dev/null +++ b/src/test/java/com/dodo/backend/fcmtoken/controller/FcmTokenControllerTest.java @@ -0,0 +1,92 @@ +package com.dodo.backend.fcmtoken.controller; + +import com.dodo.backend.fcmtoken.dto.request.FcmTokenRequest.FcmTokenRegisterRequest; +import com.dodo.backend.fcmtoken.dto.response.FcmTokenResponse.FcmTokenSimpleResponse; +import com.dodo.backend.fcmtoken.entity.DeviceType; +import com.dodo.backend.fcmtoken.service.FcmTokenService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.MethodParameter; +import org.springframework.http.MediaType; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.User; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import java.util.UUID; + +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.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(MockitoExtension.class) +class FcmTokenControllerTest { + + @Mock + private FcmTokenService fcmTokenService; + + private MockMvc mockMvc; + private ObjectMapper objectMapper; + private UUID userId; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(new FcmTokenController(fcmTokenService)) + .setCustomArgumentResolvers(authenticationPrincipalResolver()) + .build(); + objectMapper = new ObjectMapper(); + userId = UUID.randomUUID(); + } + + @Test + @DisplayName("FCM 토큰 등록 API 성공") + void registerToken_Success() throws Exception { + given(fcmTokenService.registerToken(eq(userId), any(FcmTokenRegisterRequest.class))) + .willReturn(FcmTokenSimpleResponse.toDto("푸시 토큰이 성공적으로 등록되었습니다.")); + + mockMvc.perform(post("/fcm-tokens") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new FcmTokenRegisterRequest("token-value", DeviceType.ANDROID, "Galaxy")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("푸시 토큰이 성공적으로 등록되었습니다.")); + } + + @Test + @DisplayName("FCM 토큰 삭제 API 성공") + void deleteToken_Success() throws Exception { + mockMvc.perform(delete("/fcm-tokens/{token}", "token-value")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("푸시 토큰이 성공적으로 삭제되었습니다.")); + + verify(fcmTokenService).deleteToken(userId, "token-value"); + } + + private HandlerMethodArgumentResolver authenticationPrincipalResolver() { + return new HandlerMethodArgumentResolver() { + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthenticationPrincipal.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + return User.withUsername(userId.toString()).password("").roles("USER").build(); + } + }; + } +} diff --git a/src/test/java/com/dodo/backend/fcmtoken/service/FcmTokenServiceTest.java b/src/test/java/com/dodo/backend/fcmtoken/service/FcmTokenServiceTest.java new file mode 100644 index 0000000..f93d4b2 --- /dev/null +++ b/src/test/java/com/dodo/backend/fcmtoken/service/FcmTokenServiceTest.java @@ -0,0 +1,141 @@ +package com.dodo.backend.fcmtoken.service; + +import com.dodo.backend.fcmtoken.dto.request.FcmTokenRequest.FcmTokenRegisterRequest; +import com.dodo.backend.fcmtoken.dto.response.FcmTokenResponse.FcmTokenSimpleResponse; +import com.dodo.backend.fcmtoken.entity.DeviceType; +import com.dodo.backend.fcmtoken.entity.FcmToken; +import com.dodo.backend.fcmtoken.exception.FcmTokenException; +import com.dodo.backend.fcmtoken.repository.FcmTokenRepository; +import com.dodo.backend.user.entity.User; +import com.dodo.backend.user.service.UserService; +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 java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +/** + * FCM 토큰 서비스 로직을 검증하는 테스트 클래스입니다. + */ +@ExtendWith(MockitoExtension.class) +class FcmTokenServiceTest { + + @Mock + private FcmTokenRepository fcmTokenRepository; + + @Mock + private UserService userService; + + @InjectMocks + private FcmTokenServiceImpl fcmTokenService; + + /** + * 신규 FCM 토큰 등록 시 저장 메서드가 호출되는지 검증합니다. + */ + @Test + @DisplayName("FCM 토큰 신규 등록 성공") + void registerToken_CreateSuccess() { + UUID userId = UUID.randomUUID(); + User user = createUser(userId); + FcmTokenRegisterRequest request = new FcmTokenRegisterRequest("token-value", DeviceType.ANDROID, "Galaxy"); + + given(userService.getUserById(userId)).willReturn(user); + given(fcmTokenRepository.findByToken("token-value")).willReturn(Optional.empty()); + + FcmTokenSimpleResponse response = fcmTokenService.registerToken(userId, request); + + assertEquals("푸시 토큰이 성공적으로 등록되었습니다.", response.getMessage()); + verify(fcmTokenRepository).save(any(FcmToken.class)); + } + + /** + * 기존 FCM 토큰 등록 시 장치 정보가 갱신되는지 검증합니다. + */ + @Test + @DisplayName("FCM 토큰 갱신 성공") + void registerToken_UpdateSuccess() { + UUID userId = UUID.randomUUID(); + User user = createUser(userId); + FcmToken fcmToken = FcmToken.builder() + .user(user) + .token("token-value") + .deviceType(DeviceType.IOS) + .deviceName("iPhone") + .build(); + FcmTokenRegisterRequest request = new FcmTokenRegisterRequest("token-value", DeviceType.ANDROID, "Galaxy"); + + given(userService.getUserById(userId)).willReturn(user); + given(fcmTokenRepository.findByToken("token-value")).willReturn(Optional.of(fcmToken)); + + fcmTokenService.registerToken(userId, request); + + assertEquals(DeviceType.ANDROID, fcmToken.getDeviceType()); + assertEquals("Galaxy", fcmToken.getDeviceName()); + } + + /** + * FCM 토큰 삭제 시 로그인 사용자의 토큰만 삭제되는지 검증합니다. + */ + @Test + @DisplayName("FCM 토큰 삭제 성공") + void deleteToken_Success() { + UUID userId = UUID.randomUUID(); + User user = createUser(userId); + FcmToken fcmToken = FcmToken.builder() + .user(user) + .token("token-value") + .deviceType(DeviceType.ANDROID) + .build(); + + given(userService.getUserById(userId)).willReturn(user); + given(fcmTokenRepository.findByTokenAndUser("token-value", user)).willReturn(Optional.of(fcmToken)); + + fcmTokenService.deleteToken(userId, "token-value"); + + verify(fcmTokenRepository).delete(fcmToken); + } + + /** + * 존재하지 않는 FCM 토큰 삭제 시 예외가 발생하는지 검증합니다. + */ + @Test + @DisplayName("FCM 토큰 삭제 실패 - 토큰 없음") + void deleteToken_NotFound() { + UUID userId = UUID.randomUUID(); + User user = createUser(userId); + + given(userService.getUserById(userId)).willReturn(user); + given(fcmTokenRepository.findByTokenAndUser("missing-token", user)).willReturn(Optional.empty()); + + assertThrows(FcmTokenException.class, () -> fcmTokenService.deleteToken(userId, "missing-token")); + } + + /** + * 필수 값이 없는 FCM 토큰 등록 요청 시 예외가 발생하는지 검증합니다. + */ + @Test + @DisplayName("FCM 토큰 등록 실패 - 잘못된 요청") + void registerToken_InvalidRequest() { + UUID userId = UUID.randomUUID(); + FcmTokenRegisterRequest request = new FcmTokenRegisterRequest("", DeviceType.ANDROID, "Galaxy"); + + assertThrows(FcmTokenException.class, () -> fcmTokenService.registerToken(userId, request)); + } + + private User createUser(UUID userId) { + return User.builder() + .usersId(userId) + .nickname("테스터") + .build(); + } +} diff --git a/src/test/java/com/dodo/backend/notification/controller/NotificationControllerTest.java b/src/test/java/com/dodo/backend/notification/controller/NotificationControllerTest.java new file mode 100644 index 0000000..af5738c --- /dev/null +++ b/src/test/java/com/dodo/backend/notification/controller/NotificationControllerTest.java @@ -0,0 +1,135 @@ +package com.dodo.backend.notification.controller; + +import com.dodo.backend.notification.dto.request.NotificationRequest.NotificationReadUpdateRequest; +import com.dodo.backend.notification.dto.response.NotificationResponse.NotificationListResponse; +import com.dodo.backend.notification.dto.response.NotificationResponse.NotificationSimpleResponse; +import com.dodo.backend.notification.dto.response.NotificationResponse.PageInfoResponse; +import com.dodo.backend.notification.dto.response.NotificationResponse.UnreadNotificationCountResponse; +import com.dodo.backend.notification.service.NotificationService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.MethodParameter; +import org.springframework.http.MediaType; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.User; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 알림 컨트롤러 API 경로를 검증하는 테스트 클래스입니다. + */ +@ExtendWith(MockitoExtension.class) +class NotificationControllerTest { + + @Mock + private NotificationService notificationService; + + private MockMvc mockMvc; + private ObjectMapper objectMapper; + private UUID userId; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(new NotificationController(notificationService)) + .setCustomArgumentResolvers(authenticationPrincipalResolver()) + .build(); + objectMapper = new ObjectMapper(); + userId = UUID.randomUUID(); + } + + /** + * 알림 목록 조회 API가 200을 반환하는지 검증합니다. + */ + @Test + @DisplayName("알림 목록 조회 API 성공") + void getNotifications_Success() throws Exception { + NotificationListResponse response = NotificationListResponse.builder() + .pageInfo(PageInfoResponse.builder().page(1).size(20).totalElements(0).totalPages(0).build()) + .data(List.of()) + .build(); + given(notificationService.getNotifications(eq(userId), eq(1), eq(20), eq(null), eq(null))).willReturn(response); + + mockMvc.perform(get("/notifications")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.pageInfo.page").value(1)); + } + + /** + * 알림 읽음 처리 API가 200을 반환하는지 검증합니다. + */ + @Test + @DisplayName("알림 읽음 처리 API 성공") + void updateReadStatus_Success() throws Exception { + given(notificationService.updateReadStatus(eq(userId), eq(1L), org.mockito.ArgumentMatchers.any(NotificationReadUpdateRequest.class))) + .willReturn(NotificationSimpleResponse.toDto("알림이 성공적으로 읽음 처리되었습니다.")); + + mockMvc.perform(patch("/notifications/{notificationId}", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new NotificationReadUpdateRequest(true)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("알림이 성공적으로 읽음 처리되었습니다.")); + } + + /** + * 읽지 않은 알림 개수 조회 API가 200을 반환하는지 검증합니다. + */ + @Test + @DisplayName("읽지 않은 알림 개수 조회 API 성공") + void getUnreadCount_Success() throws Exception { + given(notificationService.getUnreadCount(userId)).willReturn(UnreadNotificationCountResponse.toDto(3L)); + + mockMvc.perform(get("/notifications/count/unread")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.unreadCount").value(3)); + } + + /** + * 알림 삭제 API가 200과 성공 메시지를 반환하는지 검증합니다. + */ + @Test + @DisplayName("알림 삭제 API 성공") + void deleteNotification_Success() throws Exception { + mockMvc.perform(delete("/notifications/{notificationId}", 1L) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("알림이 성공적으로 삭제되었습니다.")); + + verify(notificationService).deleteNotification(userId, 1L); + } + + private HandlerMethodArgumentResolver authenticationPrincipalResolver() { + return new HandlerMethodArgumentResolver() { + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthenticationPrincipal.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + return User.withUsername(userId.toString()).password("").roles("USER").build(); + } + }; + } +} diff --git a/src/test/java/com/dodo/backend/notification/service/NotificationServiceTest.java b/src/test/java/com/dodo/backend/notification/service/NotificationServiceTest.java new file mode 100644 index 0000000..62df8c8 --- /dev/null +++ b/src/test/java/com/dodo/backend/notification/service/NotificationServiceTest.java @@ -0,0 +1,138 @@ +package com.dodo.backend.notification.service; + +import com.dodo.backend.notification.dto.request.NotificationRequest.NotificationReadUpdateRequest; +import com.dodo.backend.notification.dto.response.NotificationResponse.NotificationListResponse; +import com.dodo.backend.notification.dto.response.NotificationResponse.UnreadNotificationCountResponse; +import com.dodo.backend.notification.entity.Notification; +import com.dodo.backend.notification.entity.NotificationType; +import com.dodo.backend.notification.repository.NotificationRepository; +import com.dodo.backend.user.entity.User; +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.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +/** + * 알림 서비스 로직을 검증하는 테스트 클래스입니다. + */ +@ExtendWith(MockitoExtension.class) +class NotificationServiceTest { + + @Mock + private NotificationRepository notificationRepository; + + @InjectMocks + private NotificationServiceImpl notificationService; + + /** + * 알림 목록 조회 시 페이지 정보와 알림 데이터가 반환되는지 검증합니다. + */ + @Test + @DisplayName("알림 목록 조회 성공") + void getNotifications_Success() { + UUID userId = UUID.randomUUID(); + User user = createUser(userId); + Notification notification = createNotification(1L, user, false, NotificationType.COMMENT); + + given(notificationRepository.findByUserUsersIdOrderByNotificationCreatedAtDescNotificationIdDesc( + org.mockito.ArgumentMatchers.eq(userId), + org.mockito.ArgumentMatchers.any(Pageable.class) + )) + .willReturn(new PageImpl<>(List.of(notification))); + + NotificationListResponse response = notificationService.getNotifications(userId, 1, 20, null, null); + + assertEquals(1, response.getPageInfo().getPage()); + assertEquals(1, response.getData().size()); + assertEquals(NotificationType.COMMENT, response.getData().get(0).getNotificationType()); + } + + /** + * 알림 읽음 처리 시 엔티티의 읽음 상태가 변경되는지 검증합니다. + */ + @Test + @DisplayName("알림 읽음 처리 성공") + void updateReadStatus_Success() { + UUID userId = UUID.randomUUID(); + User user = createUser(userId); + Notification notification = createNotification(1L, user, false, NotificationType.BOARD); + + given(notificationRepository.findById(1L)).willReturn(Optional.of(notification)); + + notificationService.updateReadStatus(userId, 1L, new NotificationReadUpdateRequest(true)); + + assertTrue(notification.getIsRead()); + } + + /** + * 읽지 않은 알림 개수 조회 결과를 검증합니다. + */ + @Test + @DisplayName("읽지 않은 알림 개수 조회 성공") + void getUnreadCount_Success() { + UUID userId = UUID.randomUUID(); + given(notificationRepository.countByUserUsersIdAndIsReadFalse(userId)).willReturn(3L); + + UnreadNotificationCountResponse response = notificationService.getUnreadCount(userId); + + assertEquals(3L, response.getUnreadCount()); + } + + /** + * 모든 알림 삭제 시 Repository 삭제 메서드가 호출되는지 검증합니다. + */ + @Test + @DisplayName("모든 알림 삭제 성공") + void deleteAll_Success() { + UUID userId = UUID.randomUUID(); + notificationService.deleteAll(userId); + + verify(notificationRepository).deleteAllByUserUsersId(userId); + } + + /** + * 모든 알림 읽음 처리 시 읽지 않은 알림이 읽음 상태로 변경되는지 검증합니다. + */ + @Test + @DisplayName("모든 알림 읽음 처리 성공") + void readAll_Success() { + UUID userId = UUID.randomUUID(); + notificationService.readAll(userId); + + verify(notificationRepository).markAllAsRead(userId); + } + + private User createUser(UUID userId) { + return User.builder() + .usersId(userId) + .nickname("테스터") + .build(); + } + + private Notification createNotification(Long notificationId, User user, boolean isRead, NotificationType type) { + return Notification.builder() + .notificationId(notificationId) + .user(user) + .notificationTitle("알림 제목") + .notificationBody("알림 내용") + .notificationType(type) + .relatedId(10L) + .isRead(isRead) + .notificationCreatedAt(LocalDateTime.of(2025, 10, 6, 12, 0)) + .build(); + } +} diff --git a/src/test/java/com/dodo/backend/report/controller/ReportControllerTest.java b/src/test/java/com/dodo/backend/report/controller/ReportControllerTest.java index bc2bc64..89d31b1 100644 --- a/src/test/java/com/dodo/backend/report/controller/ReportControllerTest.java +++ b/src/test/java/com/dodo/backend/report/controller/ReportControllerTest.java @@ -60,10 +60,10 @@ void tearDown() { } /** - * 게시글 신고 요청 시 201 상태 코드와 성공 메시지를 반환하는지 검증합니다. + * 게시글 신고 요청 시 200 상태 코드와 성공 메시지를 반환하는지 검증합니다. */ @Test - @DisplayName("게시글 신고 성공: 201 상태 코드와 성공 메시지를 반환한다.") + @DisplayName("게시글 신고 성공: 200 상태 코드와 성공 메시지를 반환한다.") void reportBoard_Success() throws Exception { UUID reporterId = UUID.randomUUID(); Long boardId = 1L; @@ -78,17 +78,17 @@ void reportBoard_Success() throws Exception { mockMvc.perform(post("/reports/board/{boardId}", boardId) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isCreated()) + .andExpect(status().isOk()) .andExpect(jsonPath("$.message").value("신고가 성공적으로 접수되었습니다.")); verify(reportService).reportBoard(eq(reporterId), eq(boardId), any(ReportCreateRequest.class)); } /** - * 유저 신고 요청 시 201 상태 코드와 성공 메시지를 반환하는지 검증합니다. + * 유저 신고 요청 시 200 상태 코드와 성공 메시지를 반환하는지 검증합니다. */ @Test - @DisplayName("유저 신고 성공: 201 상태 코드와 성공 메시지를 반환한다.") + @DisplayName("유저 신고 성공: 200 상태 코드와 성공 메시지를 반환한다.") void reportUser_Success() throws Exception { UUID reporterId = UUID.randomUUID(); UUID reportedUserId = UUID.randomUUID(); @@ -103,17 +103,17 @@ void reportUser_Success() throws Exception { mockMvc.perform(post("/reports/user/{userId}", reportedUserId) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isCreated()) + .andExpect(status().isOk()) .andExpect(jsonPath("$.message").value("신고가 성공적으로 접수되었습니다.")); verify(reportService).reportUser(eq(reporterId), eq(reportedUserId), any(ReportCreateRequest.class)); } /** - * 댓글 신고 요청 시 201 상태 코드와 성공 메시지를 반환하는지 검증합니다. + * 댓글 신고 요청 시 200 상태 코드와 성공 메시지를 반환하는지 검증합니다. */ @Test - @DisplayName("댓글 신고 성공: 201 상태 코드와 성공 메시지를 반환한다.") + @DisplayName("댓글 신고 성공: 200 상태 코드와 성공 메시지를 반환한다.") void reportComment_Success() throws Exception { UUID reporterId = UUID.randomUUID(); Long commentId = 10L; @@ -128,7 +128,7 @@ void reportComment_Success() throws Exception { mockMvc.perform(post("/reports/comment/{commentId}", commentId) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isCreated()) + .andExpect(status().isOk()) .andExpect(jsonPath("$.message").value("신고가 성공적으로 접수되었습니다.")); verify(reportService).reportComment(eq(reporterId), eq(commentId), any(ReportCreateRequest.class)); From b6bac58b6fa352116f29a2003dc62e8cb20b8347 Mon Sep 17 00:00:00 2001 From: limhb708 Date: Sun, 28 Jun 2026 20:33:43 +0900 Subject: [PATCH 3/3] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EC=95=88=EC=A0=95=EC=84=B1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 예약 알림 처리 트랜잭션을 스케줄 단위로 분리 - PROCESSING 선점 토큰으로 다중 서버 중복 실행 방지 - FCM 발송을 DB 트랜잭션 외부로 이동 - 알림 목록 조회를 Specification 기반 동적 쿼리로 정리 - 알림 유형 필터 대소문자 완화 및 페이지 기준 0-based 통일 --- .../controller/NotificationController.java | 2 +- .../entity/NotificationSchedule.java | 13 ++ .../entity/NotificationScheduleStatus.java | 1 + .../repository/NotificationRepository.java | 28 +-- .../NotificationScheduleRepository.java | 74 ++++++- .../service/ClaimedNotificationSchedule.java | 4 + .../service/NotificationScheduleDispatch.java | 15 ++ .../service/NotificationScheduleExecutor.java | 180 ++++++++++++++++++ .../NotificationScheduleServiceImpl.java | 117 ++++-------- .../service/NotificationServiceImpl.java | 50 +++-- .../NotificationControllerTest.java | 6 +- .../NotificationScheduleExecutorTest.java | 121 ++++++++++++ .../service/NotificationServiceTest.java | 30 ++- 13 files changed, 498 insertions(+), 143 deletions(-) create mode 100644 src/main/java/com/dodo/backend/notification/service/ClaimedNotificationSchedule.java create mode 100644 src/main/java/com/dodo/backend/notification/service/NotificationScheduleDispatch.java create mode 100644 src/main/java/com/dodo/backend/notification/service/NotificationScheduleExecutor.java create mode 100644 src/test/java/com/dodo/backend/notification/service/NotificationScheduleExecutorTest.java diff --git a/src/main/java/com/dodo/backend/notification/controller/NotificationController.java b/src/main/java/com/dodo/backend/notification/controller/NotificationController.java index 4b4b44c..8f6b272 100644 --- a/src/main/java/com/dodo/backend/notification/controller/NotificationController.java +++ b/src/main/java/com/dodo/backend/notification/controller/NotificationController.java @@ -53,7 +53,7 @@ public class NotificationController { @Operation(summary = "알림 목록 조회", description = "로그인 사용자의 알림 목록을 조회합니다.") @GetMapping public ResponseEntity getNotifications( - @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) Boolean isRead, @RequestParam(required = false) String type, diff --git a/src/main/java/com/dodo/backend/notification/entity/NotificationSchedule.java b/src/main/java/com/dodo/backend/notification/entity/NotificationSchedule.java index f73c931..627afbf 100644 --- a/src/main/java/com/dodo/backend/notification/entity/NotificationSchedule.java +++ b/src/main/java/com/dodo/backend/notification/entity/NotificationSchedule.java @@ -81,14 +81,27 @@ public class NotificationSchedule { @Column(name = "executed_at") private LocalDateTime executedAt; + @Column(name = "processing_token", length = 36) + private String processingToken; + + @Column(name = "processing_started_at") + private LocalDateTime processingStartedAt; + public void complete(LocalDateTime executedAt) { this.scheduleStatus = NotificationScheduleStatus.COMPLETED; this.executedAt = executedAt; + clearProcessing(); } public void reschedule(LocalDateTime nextScheduledAt, LocalDateTime executedAt) { this.scheduledAt = nextScheduledAt; this.executedAt = executedAt; this.scheduleStatus = NotificationScheduleStatus.PENDING; + clearProcessing(); + } + + private void clearProcessing() { + this.processingToken = null; + this.processingStartedAt = null; } } diff --git a/src/main/java/com/dodo/backend/notification/entity/NotificationScheduleStatus.java b/src/main/java/com/dodo/backend/notification/entity/NotificationScheduleStatus.java index 4cca127..10f086e 100644 --- a/src/main/java/com/dodo/backend/notification/entity/NotificationScheduleStatus.java +++ b/src/main/java/com/dodo/backend/notification/entity/NotificationScheduleStatus.java @@ -2,6 +2,7 @@ public enum NotificationScheduleStatus { PENDING, + PROCESSING, COMPLETED, CANCELED } diff --git a/src/main/java/com/dodo/backend/notification/repository/NotificationRepository.java b/src/main/java/com/dodo/backend/notification/repository/NotificationRepository.java index f0f9503..66602f0 100644 --- a/src/main/java/com/dodo/backend/notification/repository/NotificationRepository.java +++ b/src/main/java/com/dodo/backend/notification/repository/NotificationRepository.java @@ -1,44 +1,20 @@ package com.dodo.backend.notification.repository; import com.dodo.backend.notification.entity.Notification; -import com.dodo.backend.notification.entity.NotificationType; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; 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 java.util.Collection; import java.util.UUID; /** * 알림 엔티티의 영속성 처리를 담당하는 Repository입니다. */ @Repository -public interface NotificationRepository extends JpaRepository { - - Page findByUserUsersIdOrderByNotificationCreatedAtDescNotificationIdDesc(UUID userId, Pageable pageable); - - Page findByUserUsersIdAndIsReadOrderByNotificationCreatedAtDescNotificationIdDesc( - UUID userId, - Boolean isRead, - Pageable pageable - ); - - Page findByUserUsersIdAndNotificationTypeInOrderByNotificationCreatedAtDescNotificationIdDesc( - UUID userId, - Collection types, - Pageable pageable - ); - - Page findByUserUsersIdAndIsReadAndNotificationTypeInOrderByNotificationCreatedAtDescNotificationIdDesc( - UUID userId, - Boolean isRead, - Collection types, - Pageable pageable - ); +public interface NotificationRepository extends JpaRepository, JpaSpecificationExecutor { long countByUserUsersIdAndIsReadFalse(UUID userId); diff --git a/src/main/java/com/dodo/backend/notification/repository/NotificationScheduleRepository.java b/src/main/java/com/dodo/backend/notification/repository/NotificationScheduleRepository.java index 35d3500..57ade11 100644 --- a/src/main/java/com/dodo/backend/notification/repository/NotificationScheduleRepository.java +++ b/src/main/java/com/dodo/backend/notification/repository/NotificationScheduleRepository.java @@ -2,17 +2,87 @@ import com.dodo.backend.notification.entity.NotificationSchedule; import com.dodo.backend.notification.entity.NotificationScheduleStatus; +import org.springframework.data.domain.Pageable; 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 java.time.LocalDateTime; import java.util.List; +import java.util.Optional; @Repository public interface NotificationScheduleRepository extends JpaRepository { - List findTop50ByScheduleStatusAndScheduledAtLessThanEqualOrderByScheduledAtAsc( + @Query(""" + select ns.notificationScheduleId + from NotificationSchedule ns + where ns.scheduleStatus = :scheduleStatus + and ns.scheduledAt <= :scheduledAt + order by ns.scheduledAt asc + """) + List findDueScheduleIds( + @Param("scheduleStatus") NotificationScheduleStatus scheduleStatus, + @Param("scheduledAt") LocalDateTime scheduledAt, + Pageable pageable + ); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update NotificationSchedule ns + set ns.scheduleStatus = :processingStatus, + ns.processingToken = :processingToken, + ns.processingStartedAt = :processingStartedAt + where ns.notificationScheduleId in :scheduleIds + and ns.scheduleStatus = :pendingStatus + """) + int claimDueSchedules( + @Param("scheduleIds") List scheduleIds, + @Param("pendingStatus") NotificationScheduleStatus pendingStatus, + @Param("processingStatus") NotificationScheduleStatus processingStatus, + @Param("processingToken") String processingToken, + @Param("processingStartedAt") LocalDateTime processingStartedAt + ); + + List findByProcessingTokenOrderByScheduledAtAsc(String processingToken); + + Optional findByNotificationScheduleIdAndScheduleStatusAndProcessingToken( + Long notificationScheduleId, NotificationScheduleStatus scheduleStatus, - LocalDateTime scheduledAt + String processingToken + ); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update NotificationSchedule ns + set ns.scheduleStatus = :pendingStatus, + ns.processingToken = null, + ns.processingStartedAt = null + where ns.notificationScheduleId = :scheduleId + and ns.scheduleStatus = :processingStatus + and ns.processingToken = :processingToken + """) + int releaseClaim( + @Param("scheduleId") Long scheduleId, + @Param("processingToken") String processingToken, + @Param("pendingStatus") NotificationScheduleStatus pendingStatus, + @Param("processingStatus") NotificationScheduleStatus processingStatus + ); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update NotificationSchedule ns + set ns.scheduleStatus = :pendingStatus, + ns.processingToken = null, + ns.processingStartedAt = null + where ns.scheduleStatus = :processingStatus + and ns.processingStartedAt < :expiredBefore + """) + int releaseExpiredClaims( + @Param("expiredBefore") LocalDateTime expiredBefore, + @Param("pendingStatus") NotificationScheduleStatus pendingStatus, + @Param("processingStatus") NotificationScheduleStatus processingStatus ); } diff --git a/src/main/java/com/dodo/backend/notification/service/ClaimedNotificationSchedule.java b/src/main/java/com/dodo/backend/notification/service/ClaimedNotificationSchedule.java new file mode 100644 index 0000000..96b23dd --- /dev/null +++ b/src/main/java/com/dodo/backend/notification/service/ClaimedNotificationSchedule.java @@ -0,0 +1,4 @@ +package com.dodo.backend.notification.service; + +record ClaimedNotificationSchedule(Long scheduleId, String processingToken) { +} diff --git a/src/main/java/com/dodo/backend/notification/service/NotificationScheduleDispatch.java b/src/main/java/com/dodo/backend/notification/service/NotificationScheduleDispatch.java new file mode 100644 index 0000000..27deccd --- /dev/null +++ b/src/main/java/com/dodo/backend/notification/service/NotificationScheduleDispatch.java @@ -0,0 +1,15 @@ +package com.dodo.backend.notification.service; + +import com.dodo.backend.notification.entity.NotificationType; +import com.dodo.backend.user.entity.User; + +import java.util.List; + +record NotificationScheduleDispatch( + List targets, + String title, + String body, + NotificationType type, + Long relatedId +) { +} diff --git a/src/main/java/com/dodo/backend/notification/service/NotificationScheduleExecutor.java b/src/main/java/com/dodo/backend/notification/service/NotificationScheduleExecutor.java new file mode 100644 index 0000000..1f9c54a --- /dev/null +++ b/src/main/java/com/dodo/backend/notification/service/NotificationScheduleExecutor.java @@ -0,0 +1,180 @@ +package com.dodo.backend.notification.service; + +import com.dodo.backend.notification.entity.Notification; +import com.dodo.backend.notification.entity.NotificationSchedule; +import com.dodo.backend.notification.entity.NotificationScheduleRepeatType; +import com.dodo.backend.notification.entity.NotificationScheduleStatus; +import com.dodo.backend.notification.entity.NotificationScheduleTargetType; +import com.dodo.backend.notification.repository.NotificationRepository; +import com.dodo.backend.notification.repository.NotificationScheduleRepository; +import com.dodo.backend.user.entity.User; +import com.dodo.backend.user.entity.UserStatus; +import com.dodo.backend.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class NotificationScheduleExecutor { + + private static final int BATCH_SIZE = 50; + + private final NotificationScheduleRepository notificationScheduleRepository; + private final NotificationRepository notificationRepository; + private final UserRepository userRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public int releaseExpiredClaims(LocalDateTime expiredBefore) { + return notificationScheduleRepository.releaseExpiredClaims( + expiredBefore, + NotificationScheduleStatus.PENDING, + NotificationScheduleStatus.PROCESSING + ); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public List claimDueSchedules(LocalDateTime now) { + String processingToken = UUID.randomUUID().toString(); + List dueScheduleIds = notificationScheduleRepository.findDueScheduleIds( + NotificationScheduleStatus.PENDING, + now, + PageRequest.of(0, BATCH_SIZE) + ); + if (dueScheduleIds.isEmpty()) { + return List.of(); + } + + int claimedCount = notificationScheduleRepository.claimDueSchedules( + dueScheduleIds, + NotificationScheduleStatus.PENDING, + NotificationScheduleStatus.PROCESSING, + processingToken, + now + ); + if (claimedCount == 0) { + return List.of(); + } + + return notificationScheduleRepository.findByProcessingTokenOrderByScheduledAtAsc(processingToken).stream() + .map(schedule -> new ClaimedNotificationSchedule(schedule.getNotificationScheduleId(), processingToken)) + .toList(); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public Optional prepareDispatch(ClaimedNotificationSchedule claimedSchedule, LocalDateTime now) { + NotificationSchedule schedule = notificationScheduleRepository + .findByNotificationScheduleIdAndScheduleStatusAndProcessingToken( + claimedSchedule.scheduleId(), + NotificationScheduleStatus.PROCESSING, + claimedSchedule.processingToken() + ) + .orElse(null); + if (schedule == null) { + return Optional.empty(); + } + + List targets = findTargets(schedule); + if (!targets.isEmpty()) { + notificationRepository.saveAll(targets.stream() + .map(user -> Notification.builder() + .user(user) + .notificationTitle(schedule.getNotificationTitle()) + .notificationBody(schedule.getNotificationBody()) + .notificationType(schedule.getNotificationType()) + .relatedId(schedule.getNotificationScheduleId()) + .isRead(false) + .build()) + .toList()); + } + + updateScheduleAfterExecution(schedule, now); + if (targets.isEmpty()) { + return Optional.empty(); + } + + return Optional.of(new NotificationScheduleDispatch( + targets, + schedule.getNotificationTitle(), + schedule.getNotificationBody(), + schedule.getNotificationType(), + schedule.getNotificationScheduleId() + )); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void releaseClaim(ClaimedNotificationSchedule claimedSchedule) { + notificationScheduleRepository.releaseClaim( + claimedSchedule.scheduleId(), + claimedSchedule.processingToken(), + NotificationScheduleStatus.PENDING, + NotificationScheduleStatus.PROCESSING + ); + } + + private List findTargets(NotificationSchedule schedule) { + if (schedule.getTargetType() == NotificationScheduleTargetType.ALL) { + return userRepository.findByUserStatusAndNotificationEnabledTrue(UserStatus.ACTIVE); + } + + List targetUserIds = parseTargetUserIds(schedule.getTargetUserIds()); + if (targetUserIds.isEmpty()) { + return List.of(); + } + return userRepository.findByUsersIdInAndUserStatusAndNotificationEnabledTrue(targetUserIds, UserStatus.ACTIVE); + } + + private void updateScheduleAfterExecution(NotificationSchedule schedule, LocalDateTime now) { + if (schedule.getRepeatType() == NotificationScheduleRepeatType.DAILY) { + schedule.reschedule(nextDailyScheduleAt(schedule.getScheduledAt(), now), now); + return; + } + if (schedule.getRepeatType() == NotificationScheduleRepeatType.WEEKLY) { + schedule.reschedule(nextWeeklyScheduleAt(schedule.getScheduledAt(), now), now); + return; + } + schedule.complete(now); + } + + private LocalDateTime nextDailyScheduleAt(LocalDateTime scheduledAt, LocalDateTime now) { + LocalDateTime next = scheduledAt; + do { + next = next.plusDays(1); + } while (!next.isAfter(now)); + return next; + } + + private LocalDateTime nextWeeklyScheduleAt(LocalDateTime scheduledAt, LocalDateTime now) { + LocalDateTime next = scheduledAt; + do { + next = next.plusWeeks(1); + } while (!next.isAfter(now)); + return next; + } + + private List parseTargetUserIds(String targetUserIds) { + if (targetUserIds == null || targetUserIds.isBlank()) { + return List.of(); + } + try { + return Arrays.stream(targetUserIds.split(",")) + .map(String::trim) + .filter(value -> !value.isBlank()) + .map(UUID::fromString) + .toList(); + } catch (IllegalArgumentException e) { + log.warn("알림 스케줄 대상 사용자 ID 파싱 실패 - targetUserIds: {}", targetUserIds); + return List.of(); + } + } +} diff --git a/src/main/java/com/dodo/backend/notification/service/NotificationScheduleServiceImpl.java b/src/main/java/com/dodo/backend/notification/service/NotificationScheduleServiceImpl.java index a390269..8207fab 100644 --- a/src/main/java/com/dodo/backend/notification/service/NotificationScheduleServiceImpl.java +++ b/src/main/java/com/dodo/backend/notification/service/NotificationScheduleServiceImpl.java @@ -2,26 +2,24 @@ import com.dodo.backend.notification.dto.request.NotificationRequest.NotificationScheduleCreateRequest; import com.dodo.backend.notification.dto.response.NotificationResponse.NotificationScheduleCreateResponse; -import com.dodo.backend.notification.entity.Notification; import com.dodo.backend.notification.entity.NotificationSchedule; import com.dodo.backend.notification.entity.NotificationScheduleRepeatType; import com.dodo.backend.notification.entity.NotificationScheduleStatus; import com.dodo.backend.notification.entity.NotificationScheduleTargetType; import com.dodo.backend.notification.exception.NotificationException; -import com.dodo.backend.notification.repository.NotificationRepository; import com.dodo.backend.notification.repository.NotificationScheduleRepository; import com.dodo.backend.user.entity.User; -import com.dodo.backend.user.entity.UserStatus; import com.dodo.backend.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; -import java.util.Arrays; import java.util.List; +import java.util.Optional; import java.util.UUID; import static com.dodo.backend.notification.exception.NotificationErrorCode.INVALID_REQUEST; @@ -32,10 +30,13 @@ public class NotificationScheduleServiceImpl implements NotificationScheduleService { private final NotificationScheduleRepository notificationScheduleRepository; - private final NotificationRepository notificationRepository; private final UserRepository userRepository; + private final NotificationScheduleExecutor notificationScheduleExecutor; private final FcmNotificationSender fcmNotificationSender; + @Value("${notification.scheduler.processing-timeout-minutes:10}") + private long processingTimeoutMinutes; + @Transactional @Override public NotificationScheduleCreateResponse createSchedule(UUID adminId, NotificationScheduleCreateRequest request) { @@ -60,82 +61,43 @@ public NotificationScheduleCreateResponse createSchedule(UUID adminId, Notificat } @Scheduled(fixedDelayString = "${notification.scheduler.fixed-delay:60000}") - @Transactional public void executeDueSchedules() { LocalDateTime now = LocalDateTime.now(); - List dueSchedules = - notificationScheduleRepository.findTop50ByScheduleStatusAndScheduledAtLessThanEqualOrderByScheduledAtAsc( - NotificationScheduleStatus.PENDING, - now - ); - - dueSchedules.forEach(schedule -> executeSchedule(schedule, now)); - } - - private void executeSchedule(NotificationSchedule schedule, LocalDateTime now) { - List targets = findTargets(schedule); - if (!targets.isEmpty()) { - notificationRepository.saveAll(targets.stream() - .map(user -> Notification.builder() - .user(user) - .notificationTitle(schedule.getNotificationTitle()) - .notificationBody(schedule.getNotificationBody()) - .notificationType(schedule.getNotificationType()) - .relatedId(schedule.getNotificationScheduleId()) - .isRead(false) - .build()) - .toList()); - - fcmNotificationSender.sendToUsers( - targets, - schedule.getNotificationTitle(), - schedule.getNotificationBody(), - schedule.getNotificationType(), - schedule.getNotificationScheduleId() - ); + int releasedCount = notificationScheduleExecutor.releaseExpiredClaims(now.minusMinutes(processingTimeoutMinutes)); + if (releasedCount > 0) { + log.warn("만료된 알림 스케줄 처리 선점 상태를 해제했습니다. count: {}", releasedCount); } - updateScheduleAfterExecution(schedule, now); - } - - private List findTargets(NotificationSchedule schedule) { - if (schedule.getTargetType() == NotificationScheduleTargetType.ALL) { - return userRepository.findByUserStatusAndNotificationEnabledTrue(UserStatus.ACTIVE); - } - - List targetUserIds = parseTargetUserIds(schedule.getTargetUserIds()); - if (targetUserIds.isEmpty()) { - return List.of(); - } - return userRepository.findByUsersIdInAndUserStatusAndNotificationEnabledTrue(targetUserIds, UserStatus.ACTIVE); + List claimedSchedules = notificationScheduleExecutor.claimDueSchedules(now); + claimedSchedules.forEach(this::executeClaimedSchedule); } - private void updateScheduleAfterExecution(NotificationSchedule schedule, LocalDateTime now) { - if (schedule.getRepeatType() == NotificationScheduleRepeatType.DAILY) { - schedule.reschedule(nextDailyScheduleAt(schedule.getScheduledAt(), now), now); - return; - } - if (schedule.getRepeatType() == NotificationScheduleRepeatType.WEEKLY) { - schedule.reschedule(nextWeeklyScheduleAt(schedule.getScheduledAt(), now), now); + private void executeClaimedSchedule(ClaimedNotificationSchedule claimedSchedule) { + Optional dispatch; + try { + dispatch = notificationScheduleExecutor.prepareDispatch(claimedSchedule, LocalDateTime.now()); + } catch (Exception e) { + log.error("알림 스케줄 DB 처리 실패 - scheduleId: {}", claimedSchedule.scheduleId(), e); + notificationScheduleExecutor.releaseClaim(claimedSchedule); return; } - schedule.complete(now); - } - private LocalDateTime nextDailyScheduleAt(LocalDateTime scheduledAt, LocalDateTime now) { - LocalDateTime next = scheduledAt; - do { - next = next.plusDays(1); - } while (!next.isAfter(now)); - return next; + try { + dispatch.ifPresent(this::sendPushOutsideTransaction); + } catch (Exception e) { + log.warn("알림 스케줄 FCM 발송 실패 - scheduleId: {}, reason: {}", + claimedSchedule.scheduleId(), e.getMessage()); + } } - private LocalDateTime nextWeeklyScheduleAt(LocalDateTime scheduledAt, LocalDateTime now) { - LocalDateTime next = scheduledAt; - do { - next = next.plusWeeks(1); - } while (!next.isAfter(now)); - return next; + private void sendPushOutsideTransaction(NotificationScheduleDispatch dispatch) { + fcmNotificationSender.sendToUsers( + dispatch.targets(), + dispatch.title(), + dispatch.body(), + dispatch.type(), + dispatch.relatedId() + ); } private NotificationScheduleRepeatType resolveRepeatType(NotificationScheduleRepeatType repeatType) { @@ -163,19 +125,4 @@ private String toTargetUserIds(NotificationScheduleCreateRequest request) { .orElseThrow(() -> new NotificationException(INVALID_REQUEST)); } - private List parseTargetUserIds(String targetUserIds) { - if (targetUserIds == null || targetUserIds.isBlank()) { - return List.of(); - } - try { - return Arrays.stream(targetUserIds.split(",")) - .map(String::trim) - .filter(value -> !value.isBlank()) - .map(UUID::fromString) - .toList(); - } catch (IllegalArgumentException e) { - log.warn("알림 스케줄 대상 사용자 ID 파싱 실패 - targetUserIds: {}", targetUserIds); - return List.of(); - } - } } diff --git a/src/main/java/com/dodo/backend/notification/service/NotificationServiceImpl.java b/src/main/java/com/dodo/backend/notification/service/NotificationServiceImpl.java index 5e6a03c..e53b6c1 100644 --- a/src/main/java/com/dodo/backend/notification/service/NotificationServiceImpl.java +++ b/src/main/java/com/dodo/backend/notification/service/NotificationServiceImpl.java @@ -11,15 +11,20 @@ import com.dodo.backend.notification.exception.NotificationErrorCode; import com.dodo.backend.notification.exception.NotificationException; import com.dodo.backend.notification.repository.NotificationRepository; +import jakarta.persistence.criteria.Predicate; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Locale; import java.util.UUID; import static com.dodo.backend.notification.exception.NotificationErrorCode.INVALID_REQUEST; @@ -54,10 +59,17 @@ public class NotificationServiceImpl implements NotificationService { @Override public NotificationListResponse getNotifications(UUID userId, int page, int size, Boolean isRead, String type) { validatePageRequest(userId, page, size); - Pageable pageable = PageRequest.of(page - 1, size); + Pageable pageable = PageRequest.of( + page, + size, + Sort.by(Sort.Direction.DESC, "notificationCreatedAt").and(Sort.by(Sort.Direction.DESC, "notificationId")) + ); List types = parseTypes(type); - Page notifications = findNotifications(userId, isRead, types, pageable); + Page notifications = notificationRepository.findAll( + buildNotificationSpecification(userId, isRead, types), + pageable + ); return NotificationListResponse.builder() .pageInfo(PageInfoResponse.toDto(notifications, page)) .data(notifications.getContent().stream().map(NotificationItemResponse::toDto).toList()) @@ -135,22 +147,20 @@ public void deleteAll(UUID userId) { notificationRepository.deleteAllByUserUsersId(userId); } - private Page findNotifications(UUID userId, Boolean isRead, List types, Pageable pageable) { - if (isRead != null && !types.isEmpty()) { - return notificationRepository.findByUserUsersIdAndIsReadAndNotificationTypeInOrderByNotificationCreatedAtDescNotificationIdDesc( - userId, - isRead, - types, - pageable - ); - } - if (isRead != null) { - return notificationRepository.findByUserUsersIdAndIsReadOrderByNotificationCreatedAtDescNotificationIdDesc(userId, isRead, pageable); - } - if (!types.isEmpty()) { - return notificationRepository.findByUserUsersIdAndNotificationTypeInOrderByNotificationCreatedAtDescNotificationIdDesc(userId, types, pageable); - } - return notificationRepository.findByUserUsersIdOrderByNotificationCreatedAtDescNotificationIdDesc(userId, pageable); + private Specification buildNotificationSpecification(UUID userId, Boolean isRead, List types) { + return (root, query, criteriaBuilder) -> { + List predicates = new ArrayList<>(); + predicates.add(criteriaBuilder.equal(root.get("user").get("usersId"), userId)); + + if (isRead != null) { + predicates.add(criteriaBuilder.equal(root.get("isRead"), isRead)); + } + if (!types.isEmpty()) { + predicates.add(root.get("notificationType").in(types)); + } + + return criteriaBuilder.and(predicates.toArray(Predicate[]::new)); + }; } private Notification findOwnedNotification(UUID userId, Long notificationId, NotificationErrorCode forbiddenErrorCode) { @@ -172,7 +182,7 @@ private List parseTypes(String type) { List types = Arrays.stream(type.split(",")) .map(String::trim) .filter(value -> !value.isBlank()) - .map(NotificationType::valueOf) + .map(value -> NotificationType.valueOf(value.toUpperCase(Locale.ROOT))) .distinct() .toList(); @@ -186,7 +196,7 @@ private List parseTypes(String type) { } private void validatePageRequest(UUID userId, int page, int size) { - if (userId == null || page <= 0 || size <= 0 || size > MAX_PAGE_SIZE) { + if (userId == null || page < 0 || size <= 0 || size > MAX_PAGE_SIZE) { throw new NotificationException(INVALID_REQUEST); } } diff --git a/src/test/java/com/dodo/backend/notification/controller/NotificationControllerTest.java b/src/test/java/com/dodo/backend/notification/controller/NotificationControllerTest.java index af5738c..055022c 100644 --- a/src/test/java/com/dodo/backend/notification/controller/NotificationControllerTest.java +++ b/src/test/java/com/dodo/backend/notification/controller/NotificationControllerTest.java @@ -65,14 +65,14 @@ void setUp() { @DisplayName("알림 목록 조회 API 성공") void getNotifications_Success() throws Exception { NotificationListResponse response = NotificationListResponse.builder() - .pageInfo(PageInfoResponse.builder().page(1).size(20).totalElements(0).totalPages(0).build()) + .pageInfo(PageInfoResponse.builder().page(0).size(20).totalElements(0).totalPages(0).build()) .data(List.of()) .build(); - given(notificationService.getNotifications(eq(userId), eq(1), eq(20), eq(null), eq(null))).willReturn(response); + given(notificationService.getNotifications(eq(userId), eq(0), eq(20), eq(null), eq(null))).willReturn(response); mockMvc.perform(get("/notifications")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.pageInfo.page").value(1)); + .andExpect(jsonPath("$.pageInfo.page").value(0)); } /** diff --git a/src/test/java/com/dodo/backend/notification/service/NotificationScheduleExecutorTest.java b/src/test/java/com/dodo/backend/notification/service/NotificationScheduleExecutorTest.java new file mode 100644 index 0000000..4999d05 --- /dev/null +++ b/src/test/java/com/dodo/backend/notification/service/NotificationScheduleExecutorTest.java @@ -0,0 +1,121 @@ +package com.dodo.backend.notification.service; + +import com.dodo.backend.notification.entity.NotificationSchedule; +import com.dodo.backend.notification.entity.NotificationScheduleRepeatType; +import com.dodo.backend.notification.entity.NotificationScheduleStatus; +import com.dodo.backend.notification.entity.NotificationScheduleTargetType; +import com.dodo.backend.notification.entity.NotificationType; +import com.dodo.backend.notification.repository.NotificationRepository; +import com.dodo.backend.notification.repository.NotificationScheduleRepository; +import com.dodo.backend.user.entity.User; +import com.dodo.backend.user.entity.UserStatus; +import com.dodo.backend.user.repository.UserRepository; +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.data.domain.Pageable; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class NotificationScheduleExecutorTest { + + @Mock + private NotificationScheduleRepository notificationScheduleRepository; + + @Mock + private NotificationRepository notificationRepository; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private NotificationScheduleExecutor notificationScheduleExecutor; + + @Test + @DisplayName("예약 알림 선점 성공 - 토큰 기반 PROCESSING 상태로 변경") + void claimDueSchedules_Success() { + LocalDateTime now = LocalDateTime.of(2026, 6, 28, 10, 0); + NotificationSchedule schedule = createSchedule(1L, NotificationScheduleStatus.PROCESSING); + + given(notificationScheduleRepository.findDueScheduleIds( + eq(NotificationScheduleStatus.PENDING), + eq(now), + any(Pageable.class) + )).willReturn(List.of(1L)); + given(notificationScheduleRepository.claimDueSchedules( + eq(List.of(1L)), + eq(NotificationScheduleStatus.PENDING), + eq(NotificationScheduleStatus.PROCESSING), + anyString(), + eq(now) + )).willReturn(1); + given(notificationScheduleRepository.findByProcessingTokenOrderByScheduledAtAsc(anyString())) + .willReturn(List.of(schedule)); + + List claimedSchedules = notificationScheduleExecutor.claimDueSchedules(now); + + assertEquals(1, claimedSchedules.size()); + assertEquals(1L, claimedSchedules.get(0).scheduleId()); + } + + @Test + @DisplayName("예약 알림 DB 처리 성공 - 알림 저장 후 스케줄 완료") + void prepareDispatch_Success() { + LocalDateTime now = LocalDateTime.of(2026, 6, 28, 10, 0); + UUID userId = UUID.randomUUID(); + User user = User.builder() + .usersId(userId) + .userStatus(UserStatus.ACTIVE) + .notificationEnabled(true) + .build(); + NotificationSchedule schedule = createSchedule(1L, NotificationScheduleStatus.PROCESSING); + ClaimedNotificationSchedule claimedSchedule = new ClaimedNotificationSchedule(1L, "claim-token"); + + given(notificationScheduleRepository.findByNotificationScheduleIdAndScheduleStatusAndProcessingToken( + 1L, + NotificationScheduleStatus.PROCESSING, + "claim-token" + )).willReturn(Optional.of(schedule)); + given(userRepository.findByUserStatusAndNotificationEnabledTrue(UserStatus.ACTIVE)).willReturn(List.of(user)); + + Optional dispatch = notificationScheduleExecutor.prepareDispatch(claimedSchedule, now); + + assertTrue(dispatch.isPresent()); + assertEquals(List.of(user), dispatch.get().targets()); + assertEquals(NotificationScheduleStatus.COMPLETED, schedule.getScheduleStatus()); + assertNull(schedule.getProcessingToken()); + assertNull(schedule.getProcessingStartedAt()); + verify(notificationRepository).saveAll(any(List.class)); + } + + private NotificationSchedule createSchedule(Long scheduleId, NotificationScheduleStatus scheduleStatus) { + return NotificationSchedule.builder() + .notificationScheduleId(scheduleId) + .notificationTitle("공지 알림") + .notificationBody("새로운 공지가 등록되었습니다.") + .notificationType(NotificationType.SYSTEM) + .targetType(NotificationScheduleTargetType.ALL) + .repeatType(NotificationScheduleRepeatType.NONE) + .scheduleStatus(scheduleStatus) + .scheduledAt(LocalDateTime.of(2026, 6, 28, 10, 0)) + .processingToken("claim-token") + .processingStartedAt(LocalDateTime.of(2026, 6, 28, 9, 59)) + .build(); + } +} diff --git a/src/test/java/com/dodo/backend/notification/service/NotificationServiceTest.java b/src/test/java/com/dodo/backend/notification/service/NotificationServiceTest.java index 62df8c8..4bb16b2 100644 --- a/src/test/java/com/dodo/backend/notification/service/NotificationServiceTest.java +++ b/src/test/java/com/dodo/backend/notification/service/NotificationServiceTest.java @@ -15,6 +15,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; import java.time.LocalDateTime; import java.util.List; @@ -23,6 +24,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; @@ -48,15 +50,31 @@ void getNotifications_Success() { User user = createUser(userId); Notification notification = createNotification(1L, user, false, NotificationType.COMMENT); - given(notificationRepository.findByUserUsersIdOrderByNotificationCreatedAtDescNotificationIdDesc( - org.mockito.ArgumentMatchers.eq(userId), - org.mockito.ArgumentMatchers.any(Pageable.class) - )) + given(notificationRepository.findAll(any(Specification.class), any(Pageable.class))) .willReturn(new PageImpl<>(List.of(notification))); - NotificationListResponse response = notificationService.getNotifications(userId, 1, 20, null, null); + NotificationListResponse response = notificationService.getNotifications(userId, 0, 20, null, null); + + assertEquals(0, response.getPageInfo().getPage()); + assertEquals(1, response.getData().size()); + assertEquals(NotificationType.COMMENT, response.getData().get(0).getNotificationType()); + } + + /** + * 알림 유형 필터가 소문자나 혼합 대소문자로 전달되어도 조회가 실패하지 않는지 검증합니다. + */ + @Test + @DisplayName("알림 목록 조회 성공 - 유형 필터 대소문자 완화") + void getNotifications_CaseInsensitiveTypeFilter() { + UUID userId = UUID.randomUUID(); + User user = createUser(userId); + Notification notification = createNotification(1L, user, false, NotificationType.COMMENT); + + given(notificationRepository.findAll(any(Specification.class), any(Pageable.class))) + .willReturn(new PageImpl<>(List.of(notification))); + + NotificationListResponse response = notificationService.getNotifications(userId, 0, 20, null, "comment,Board"); - assertEquals(1, response.getPageInfo().getPage()); assertEquals(1, response.getData().size()); assertEquals(NotificationType.COMMENT, response.getData().get(0).getNotificationType()); }