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..8f6b272 --- /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 = "0") 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..627afbf --- /dev/null +++ b/src/main/java/com/dodo/backend/notification/entity/NotificationSchedule.java @@ -0,0 +1,107 @@ +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; + + @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/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..10f086e --- /dev/null +++ b/src/main/java/com/dodo/backend/notification/entity/NotificationScheduleStatus.java @@ -0,0 +1,8 @@ +package com.dodo.backend.notification.entity; + +public enum NotificationScheduleStatus { + PENDING, + PROCESSING, + 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..66602f0 --- /dev/null +++ b/src/main/java/com/dodo/backend/notification/repository/NotificationRepository.java @@ -0,0 +1,26 @@ +package com.dodo.backend.notification.repository; + +import com.dodo.backend.notification.entity.Notification; +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.UUID; + +/** + * 알림 엔티티의 영속성 처리를 담당하는 Repository입니다. + */ +@Repository +public interface NotificationRepository extends JpaRepository, JpaSpecificationExecutor { + + 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..57ade11 --- /dev/null +++ b/src/main/java/com/dodo/backend/notification/repository/NotificationScheduleRepository.java @@ -0,0 +1,88 @@ +package com.dodo.backend.notification.repository; + +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 { + + @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, + 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/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/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/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..8207fab --- /dev/null +++ b/src/main/java/com/dodo/backend/notification/service/NotificationScheduleServiceImpl.java @@ -0,0 +1,128 @@ +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.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.NotificationScheduleRepository; +import com.dodo.backend.user.entity.User; +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.List; +import java.util.Optional; +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 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) { + 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}") + public void executeDueSchedules() { + LocalDateTime now = LocalDateTime.now(); + int releasedCount = notificationScheduleExecutor.releaseExpiredClaims(now.minusMinutes(processingTimeoutMinutes)); + if (releasedCount > 0) { + log.warn("만료된 알림 스케줄 처리 선점 상태를 해제했습니다. count: {}", releasedCount); + } + + List claimedSchedules = notificationScheduleExecutor.claimDueSchedules(now); + claimedSchedules.forEach(this::executeClaimedSchedule); + } + + 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; + } + + try { + dispatch.ifPresent(this::sendPushOutsideTransaction); + } catch (Exception e) { + log.warn("알림 스케줄 FCM 발송 실패 - scheduleId: {}, reason: {}", + claimedSchedule.scheduleId(), e.getMessage()); + } + } + + private void sendPushOutsideTransaction(NotificationScheduleDispatch dispatch) { + fcmNotificationSender.sendToUsers( + dispatch.targets(), + dispatch.title(), + dispatch.body(), + dispatch.type(), + dispatch.relatedId() + ); + } + + 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)); + } + +} 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..e53b6c1 --- /dev/null +++ b/src/main/java/com/dodo/backend/notification/service/NotificationServiceImpl.java @@ -0,0 +1,215 @@ +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 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; +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, + size, + Sort.by(Sort.Direction.DESC, "notificationCreatedAt").and(Sort.by(Sort.Direction.DESC, "notificationId")) + ); + List types = parseTypes(type); + + 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()) + .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 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) { + 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(value -> NotificationType.valueOf(value.toUpperCase(Locale.ROOT))) + .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..055022c --- /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(0).size(20).totalElements(0).totalPages(0).build()) + .data(List.of()) + .build(); + 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(0)); + } + + /** + * 알림 읽음 처리 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/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 new file mode 100644 index 0000000..4bb16b2 --- /dev/null +++ b/src/test/java/com/dodo/backend/notification/service/NotificationServiceTest.java @@ -0,0 +1,156 @@ +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 org.springframework.data.jpa.domain.Specification; + +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.ArgumentMatchers.any; +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.findAll(any(Specification.class), any(Pageable.class))) + .willReturn(new PageImpl<>(List.of(notification))); + + 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.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));