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());
}