diff --git a/src/main/java/com/dodo/backend/admin/controller/AdminController.java b/src/main/java/com/dodo/backend/admin/controller/AdminController.java new file mode 100644 index 0000000..58250db --- /dev/null +++ b/src/main/java/com/dodo/backend/admin/controller/AdminController.java @@ -0,0 +1,257 @@ +package com.dodo.backend.admin.controller; + +import com.dodo.backend.admin.dto.request.AdminRequest.AnnouncementCreateRequest; +import com.dodo.backend.admin.dto.request.AdminRequest.AnnouncementUpdateRequest; +import com.dodo.backend.admin.dto.request.AdminRequest.ReportStatusUpdateRequest; +import com.dodo.backend.admin.dto.request.AdminRequest.UserStatusUpdateRequest; +import com.dodo.backend.admin.dto.response.AdminResponse.AdminSimpleResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.AnnouncementDetailResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.AnnouncementListResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.BoardReportDetailResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.CommentReportDetailResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.ReportListResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.UserReportDetailResponse; +import com.dodo.backend.admin.entity.AdminReportType; +import com.dodo.backend.admin.service.AdminService; +import com.dodo.backend.report.entity.ReportStatus; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +/** + * 관리자 API 요청을 처리하는 컨트롤러입니다. + */ +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/admin") +@Tag(name = "Admin API", description = "관리자 기능 API") +public class AdminController { + + private final AdminService adminService; + + /** + * 특정 게시글의 신고 상세 내역을 조회합니다. + * + * @param boardId 신고 상세 내역을 조회할 게시글 ID + * @return 게시글 신고 상세 내역 + */ + @Operation(summary = "게시글 신고 상세 조회", description = "특정 게시글에 접수된 신고 상세 내역을 조회합니다.") + @GetMapping("/reports/board/{boardId}") + public ResponseEntity getBoardReportDetail(@PathVariable Long boardId) { + log.info("관리자 게시글 신고 상세 조회 요청 - BoardId: {}", boardId); + return ResponseEntity.ok(adminService.getBoardReportDetail(boardId)); + } + + /** + * 특정 유저의 신고 상세 내역을 조회합니다. + * + * @param userId 신고 상세 내역을 조회할 유저 ID + * @return 유저 신고 상세 내역 + */ + @Operation(summary = "유저 신고 상세 조회", description = "특정 유저에 접수된 신고 상세 내역을 조회합니다.") + @GetMapping("/reports/user/{userId}") + public ResponseEntity getUserReportDetail(@PathVariable UUID userId) { + log.info("관리자 유저 신고 상세 조회 요청 - UserId: {}", userId); + return ResponseEntity.ok(adminService.getUserReportDetail(userId)); + } + + /** + * 특정 댓글의 신고 상세 내역을 조회합니다. + * + * @param commentId 신고 상세 내역을 조회할 댓글 ID + * @return 댓글 신고 상세 내역 + */ + @Operation(summary = "댓글 신고 상세 조회", description = "특정 댓글에 접수된 신고 상세 내역을 조회합니다.") + @GetMapping("/reports/comment/{commentId}") + public ResponseEntity getCommentReportDetail(@PathVariable Long commentId) { + log.info("관리자 댓글 신고 상세 조회 요청 - CommentId: {}", commentId); + return ResponseEntity.ok(adminService.getCommentReportDetail(commentId)); + } + + /** + * 신고 목록을 조회합니다. + * + * @param reportType 조회할 신고 대상 유형 + * @param reportStatus 조회할 신고 처리 상태 + * @param page 조회할 페이지 번호 + * @param size 페이지당 신고 목록 개수 + * @param sort 정렬 조건 + * @return 신고 목록 조회 결과 + */ + @Operation(summary = "신고 목록 조회", description = "신고 유형과 처리 상태에 따라 신고 목록을 조회합니다.") + @GetMapping("/reports") + public ResponseEntity getReportList( + @RequestParam AdminReportType reportType, + @RequestParam(required = false) ReportStatus reportStatus, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @RequestParam(required = false) String sort + ) { + log.info("관리자 신고 목록 조회 요청 - Type: {}, Status: {}, Page: {}, Size: {}, Sort: {}", + reportType, reportStatus, page, size, sort); + return ResponseEntity.ok(adminService.getReportList(reportType, reportStatus, page, size, sort)); + } + + /** + * 유저 계정 상태를 변경합니다. + * + * @param userId 상태를 변경할 유저 ID + * @param request 변경할 유저 상태 요청 + * @return 상태 변경 성공 메시지 + */ + @Operation(summary = "유저 계정 상태 변경", description = "관리자가 유저 계정 상태를 변경합니다.") + @PatchMapping("/users/{userId}/status") + public ResponseEntity updateUserStatus( + @PathVariable UUID userId, + @Valid @RequestBody UserStatusUpdateRequest request + ) { + log.info("관리자 유저 상태 변경 요청 - UserId: {}, Status: {}", userId, request.getStatus()); + return ResponseEntity.ok(adminService.updateUserStatus(userId, request)); + } + + /** + * 게시글을 강제로 삭제합니다. + * + * @param boardId 삭제할 게시글 ID + * @return 응답 본문이 없는 204 응답 + */ + @Operation(summary = "게시글 강제 삭제", description = "관리자가 게시글을 삭제 상태로 변경합니다.") + @DeleteMapping("/boards/{boardId}") + public ResponseEntity deleteBoard(@PathVariable Long boardId) { + log.info("관리자 게시글 강제 삭제 요청 - BoardId: {}", boardId); + adminService.deleteBoard(boardId); + return ResponseEntity.noContent().build(); + } + + /** + * 댓글을 강제로 삭제합니다. + * + * @param commentId 삭제할 댓글 ID + * @return 응답 본문이 없는 204 응답 + */ + @Operation(summary = "댓글 강제 삭제", description = "관리자가 댓글을 강제로 삭제합니다.") + @DeleteMapping("/comments/{commentId}") + public ResponseEntity deleteComment(@PathVariable Long commentId) { + log.info("관리자 댓글 강제 삭제 요청 - CommentId: {}", commentId); + adminService.deleteComment(commentId); + return ResponseEntity.noContent().build(); + } + + /** + * 신고 처리 상태를 변경합니다. + * + * @param reportId 상태를 변경할 신고 ID + * @param request 변경할 신고 처리 상태 요청 + * @return 상태 변경 성공 메시지 + */ + @Operation(summary = "신고 처리 상태 변경", description = "관리자가 신고 처리 상태를 변경합니다.") + @PatchMapping("/reports/{reportId}/status") + public ResponseEntity updateReportStatus( + @PathVariable Long reportId, + @Valid @RequestBody ReportStatusUpdateRequest request + ) { + log.info("관리자 신고 상태 변경 요청 - ReportId: {}, Status: {}", reportId, request.getStatus()); + return ResponseEntity.ok(adminService.updateReportStatus(reportId, request)); + } + + /** + * 공지를 작성합니다. + * + * @param request 공지 작성 요청 + * @param userDetails 인증된 관리자 정보 + * @return 공지 작성 성공 메시지 + */ + @Operation(summary = "공지 작성", description = "관리자가 공지를 작성합니다.") + @PostMapping("/announcements") + public ResponseEntity createAnnouncement( + @Valid @RequestBody AnnouncementCreateRequest request, + @AuthenticationPrincipal UserDetails userDetails + ) { + UUID adminId = UUID.fromString(userDetails.getUsername()); + log.info("관리자 공지 작성 요청 - AdminId: {}", adminId); + return ResponseEntity + .status(HttpStatus.OK) + .body(adminService.createAnnouncement(adminId, request)); + } + + /** + * 공지를 삭제합니다. + * + * @param boardId 삭제할 공지 게시글 ID + * @return 응답 본문이 없는 204 응답 + */ + @Operation(summary = "공지 삭제", description = "관리자가 공지를 삭제합니다.") + @DeleteMapping("/announcements/{boardId}") + public ResponseEntity deleteAnnouncement(@PathVariable Long boardId) { + log.info("관리자 공지 삭제 요청 - BoardId: {}", boardId); + adminService.deleteAnnouncement(boardId); + return ResponseEntity.noContent().build(); + } + + /** + * 공지를 수정합니다. + * + * @param boardId 수정할 공지 게시글 ID + * @param request 공지 수정 요청 + * @return 응답 본문이 없는 204 응답 + */ + @Operation(summary = "공지 수정", description = "관리자가 공지를 수정합니다.") + @PatchMapping("/announcements/{boardId}") + public ResponseEntity updateAnnouncement( + @PathVariable Long boardId, + @RequestBody AnnouncementUpdateRequest request + ) { + log.info("관리자 공지 수정 요청 - BoardId: {}", boardId); + adminService.updateAnnouncement(boardId, request); + return ResponseEntity.noContent().build(); + } + + /** + * 공지 목록을 조회합니다. + * + * @param pageable 공지 목록 페이지 요청 정보 + * @return 공지 목록 조회 결과 + */ + @Operation(summary = "공지 목록 조회", description = "공지 목록을 페이지 단위로 조회합니다.") + @GetMapping("/announcements") + public ResponseEntity getAnnouncementList( + @ParameterObject @PageableDefault(size = 10) Pageable pageable + ) { + log.info("관리자 공지 목록 조회 요청 - Page: {}, Size: {}", pageable.getPageNumber(), pageable.getPageSize()); + return ResponseEntity.ok(adminService.getAnnouncementList(pageable)); + } + + /** + * 공지 상세를 조회합니다. + * + * @param boardId 조회할 공지 게시글 ID + * @return 공지 상세 정보 + */ + @Operation(summary = "공지 상세 조회", description = "공지 상세 내용을 조회합니다.") + @GetMapping("/announcements/{boardId}") + public ResponseEntity getAnnouncementDetail(@PathVariable Long boardId) { + log.info("관리자 공지 상세 조회 요청 - BoardId: {}", boardId); + return ResponseEntity.ok(adminService.getAnnouncementDetail(boardId)); + } +} diff --git a/src/main/java/com/dodo/backend/admin/dto/request/AdminRequest.java b/src/main/java/com/dodo/backend/admin/dto/request/AdminRequest.java new file mode 100644 index 0000000..fc1bb31 --- /dev/null +++ b/src/main/java/com/dodo/backend/admin/dto/request/AdminRequest.java @@ -0,0 +1,92 @@ +package com.dodo.backend.admin.dto.request; + +import com.dodo.backend.board.entity.NoticeTag; +import com.dodo.backend.report.entity.ReportStatus; +import com.dodo.backend.user.entity.UserStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 관리자 API에서 사용하는 요청 DTO를 모아 둔 클래스입니다. + */ +@Schema(description = "관리자 요청 DTO 그룹") +public class AdminRequest { + + /** + * 유저 계정 상태 변경 요청 DTO입니다. + */ + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "유저 계정 상태 변경 요청") + public static class UserStatusUpdateRequest { + + @NotNull(message = "상태 값은 필수입니다.") + @Schema(description = "변경할 유저 상태", example = "SUSPENDED") + private UserStatus status; + } + + /** + * 신고 처리 상태 변경 요청 DTO입니다. + */ + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "신고 처리 상태 변경 요청") + public static class ReportStatusUpdateRequest { + + @NotNull(message = "상태 값은 필수입니다.") + @Schema(description = "변경할 신고 처리 상태", example = "COMPLETED") + private ReportStatus status; + } + + /** + * 공지 작성 요청 DTO입니다. + */ + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "공지 작성 요청") + public static class AnnouncementCreateRequest { + + @NotBlank(message = "공지 제목은 필수입니다.") + @Schema(description = "공지 제목", example = "공지 제목") + private String boardTitle; + + @NotBlank(message = "공지 내용은 필수입니다.") + @Schema(description = "공지 내용", example = "공지 내용입니다.") + private String boardContent; + + @Schema(description = "공지 이미지 URL", example = "https://example.com/images/notice.jpg") + private String imageFileUrl; + + @Schema(description = "조회 수", example = "10") + private Integer viewCount; + + @Schema(description = "공지 태그", example = "INFO") + private NoticeTag noticeTag; + } + + /** + * 공지 수정 요청 DTO입니다. + */ + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "공지 수정 요청") + public static class AnnouncementUpdateRequest { + + @Schema(description = "수정할 공지 제목", example = "수정된 공지입니다.") + private String boardTitle; + + @Schema(description = "수정할 공지 내용", example = "수정수정수정") + private String boardContent; + + @Schema(description = "수정할 공지 이미지 URL", example = "https://example.com/images/bori.jpg") + private String imageFileUrl; + } +} diff --git a/src/main/java/com/dodo/backend/admin/dto/response/AdminResponse.java b/src/main/java/com/dodo/backend/admin/dto/response/AdminResponse.java new file mode 100644 index 0000000..d8435bb --- /dev/null +++ b/src/main/java/com/dodo/backend/admin/dto/response/AdminResponse.java @@ -0,0 +1,326 @@ +package com.dodo.backend.admin.dto.response; + +import com.dodo.backend.board.entity.Board; +import com.dodo.backend.comment.entity.Comment; +import com.dodo.backend.report.entity.Report; +import com.dodo.backend.report.entity.ReportReason; +import com.dodo.backend.report.entity.ReportStatus; +import com.dodo.backend.user.entity.User; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.domain.Page; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 관리자 API에서 사용하는 응답 DTO를 모아 둔 클래스입니다. + */ +@Schema(description = "관리자 응답 DTO 그룹") +public class AdminResponse { + + /** + * 단순 메시지 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "관리자 단순 응답") + public static class AdminSimpleResponse { + private String message; + + public static AdminSimpleResponse toDto(String message) { + return AdminSimpleResponse.builder() + .message(message) + .build(); + } + } + + /** + * 페이지 정보 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "페이지 정보") + public static class PageInfoResponse { + private int page; + private int size; + private long totalElements; + private int totalPages; + + public static PageInfoResponse toDto(int page, int size, long totalElements) { + int totalPages = size == 0 ? 0 : (int) Math.ceil((double) totalElements / size); + return PageInfoResponse.builder() + .page(page) + .size(size) + .totalElements(totalElements) + .totalPages(totalPages) + .build(); + } + + public static PageInfoResponse toDto(Page page) { + return PageInfoResponse.builder() + .page(page.getNumber()) + .size(page.getSize()) + .totalElements(page.getTotalElements()) + .totalPages(page.getTotalPages()) + .build(); + } + } + + /** + * 유저 요약 정보 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "유저 요약 정보") + public static class UserInfoResponse { + private String userId; + private String nickname; + + public static UserInfoResponse toDto(User user) { + if (user == null) { + return null; + } + return UserInfoResponse.builder() + .userId(user.getUsersId() == null ? null : user.getUsersId().toString()) + .nickname(user.getNickname()) + .build(); + } + } + + /** + * 신고 게시글 정보 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "신고 게시글 정보") + public static class BoardInfoResponse { + private String boardTitle; + private String boardContent; + private LocalDateTime boardCreatedAt; + + public static BoardInfoResponse toDto(Board board) { + return BoardInfoResponse.builder() + .boardTitle(board.getBoardTitle()) + .boardContent(board.getBoardContent()) + .boardCreatedAt(board.getBoardCreatedAt()) + .build(); + } + } + + /** + * 신고 상세 아이템 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "신고 상세 아이템") + public static class ReportDetailItemResponse { + private Long reportId; + private UserInfoResponse reporterInfo; + private ReportReason reportReason; + private ReportStatus reportStatus; + private LocalDateTime reportCreatedAt; + + public static ReportDetailItemResponse toDto(Report report) { + return ReportDetailItemResponse.builder() + .reportId(report.getReportId()) + .reporterInfo(UserInfoResponse.toDto(report.getReporter())) + .reportReason(report.getReportReason()) + .reportStatus(report.getReportStatus()) + .reportCreatedAt(report.getCreatedAt()) + .build(); + } + } + + /** + * 게시글 신고 상세 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "게시글 신고 상세 응답") + public static class BoardReportDetailResponse { + private Long boardId; + private BoardInfoResponse boardInfo; + private UserInfoResponse reportedUserInfo; + private long totalReportCount; + private List reports; + } + + /** + * 유저 신고 상세 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "유저 신고 상세 응답") + public static class UserReportDetailResponse { + private UserInfoResponse reportedUserInfo; + private long totalReportCount; + private List reports; + } + + /** + * 댓글 신고 상세 아이템 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "댓글 신고 상세 아이템") + public static class CommentReportItemResponse { + private Long reportId; + private String reporterNickname; + private ReportReason reportReason; + private LocalDateTime reportCreatedAt; + + public static CommentReportItemResponse toDto(Report report) { + return CommentReportItemResponse.builder() + .reportId(report.getReportId()) + .reporterNickname(report.getReporter() == null ? null : report.getReporter().getNickname()) + .reportReason(report.getReportReason()) + .reportCreatedAt(report.getCreatedAt()) + .build(); + } + } + + /** + * 댓글 신고 상세 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "댓글 신고 상세 응답") + public static class CommentReportDetailResponse { + private Long commentId; + private String commentContent; + private String reportedNickname; + private long totalReportCount; + private List reports; + + public static CommentReportDetailResponse toDto(Comment comment, List reports) { + return CommentReportDetailResponse.builder() + .commentId(comment.getCommentId()) + .commentContent(comment.getCommentContent()) + .reportedNickname(comment.getUser() == null ? null : comment.getUser().getNickname()) + .totalReportCount(reports.size()) + .reports(reports.stream().map(CommentReportItemResponse::toDto).toList()) + .build(); + } + } + + /** + * 신고 목록 대상 정보 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "신고 목록 대상 정보") + public static class ReportTargetInfoResponse { + private Object id; + private String summary; + } + + /** + * 신고 목록 아이템 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "신고 목록 아이템") + public static class ReportListItemResponse { + private String reportType; + private ReportTargetInfoResponse targetInfo; + private UserInfoResponse reportedUser; + private long totalReportCount; + private ReportReason representativeReason; + private ReportStatus reportStatus; + private LocalDateTime lastReportedAt; + } + + /** + * 신고 목록 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "신고 목록 응답") + public static class ReportListResponse { + private PageInfoResponse pageInfo; + private List data; + } + + /** + * 공지 목록 아이템 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "공지 목록 아이템") + public static class AnnouncementItemResponse { + private Long boardId; + private String boardTitle; + private String boardContent; + private String imageFileUrl; + private Integer viewCount; + private LocalDateTime boardCreatedAt; + + public static AnnouncementItemResponse toDto(Board board, String imageFileUrl) { + return AnnouncementItemResponse.builder() + .boardId(board.getBoardId()) + .boardTitle(board.getBoardTitle()) + .boardContent(board.getBoardContent()) + .imageFileUrl(imageFileUrl) + .viewCount(board.getViewCount()) + .boardCreatedAt(board.getBoardCreatedAt()) + .build(); + } + } + + /** + * 공지 목록 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "공지 목록 응답") + public static class AnnouncementListResponse { + private PageInfoResponse pageInfo; + private List data; + } + + /** + * 공지 상세 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "공지 상세 응답") + public static class AnnouncementDetailResponse { + private Long boardId; + private String boardTitle; + private String boardContent; + private String imageFileUrl; + private Integer viewCount; + private LocalDateTime boardCreatedAt; + private LocalDateTime boardModifiedAt; + + public static AnnouncementDetailResponse toDto(Board board, String imageFileUrl) { + return AnnouncementDetailResponse.builder() + .boardId(board.getBoardId()) + .boardTitle(board.getBoardTitle()) + .boardContent(board.getBoardContent()) + .imageFileUrl(imageFileUrl) + .viewCount(board.getViewCount()) + .boardCreatedAt(board.getBoardCreatedAt()) + .boardModifiedAt(board.getModifiedAt()) + .build(); + } + } +} diff --git a/src/main/java/com/dodo/backend/admin/entity/AdminReportType.java b/src/main/java/com/dodo/backend/admin/entity/AdminReportType.java new file mode 100644 index 0000000..a47e65b --- /dev/null +++ b/src/main/java/com/dodo/backend/admin/entity/AdminReportType.java @@ -0,0 +1,10 @@ +package com.dodo.backend.admin.entity; + +/** + * 관리자 신고 목록에서 조회할 신고 대상 유형입니다. + */ +public enum AdminReportType { + BOARD, + USER, + COMMENT +} diff --git a/src/main/java/com/dodo/backend/admin/exception/AdminErrorCode.java b/src/main/java/com/dodo/backend/admin/exception/AdminErrorCode.java new file mode 100644 index 0000000..d74b694 --- /dev/null +++ b/src/main/java/com/dodo/backend/admin/exception/AdminErrorCode.java @@ -0,0 +1,74 @@ +package com.dodo.backend.admin.exception; + +import com.dodo.backend.common.exception.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +/** + * 관리자 도메인에서 발생하는 예외 상황을 관리하는 에러 코드입니다. + */ +@Getter +@AllArgsConstructor +public enum AdminErrorCode implements BaseErrorCode { + + /** + * 요청 값이 올바르지 않은 경우 사용합니다. + */ + INVALID_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."), + + /** + * 신고당한 게시글이 존재하지 않는 경우 사용합니다. + */ + REPORTED_BOARD_NOT_FOUND(HttpStatus.NOT_FOUND, "신고당한 게시글이 존재하지 않습니다."), + + /** + * 신고당한 유저가 존재하지 않는 경우 사용합니다. + */ + REPORTED_USER_NOT_FOUND(HttpStatus.NOT_FOUND, "신고당한 유저가 존재하지 않습니다."), + + /** + * 신고당한 댓글이 존재하지 않는 경우 사용합니다. + */ + REPORTED_COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "신고당한 댓글이 존재하지 않습니다."), + + /** + * 유저가 존재하지 않는 경우 사용합니다. + */ + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 유저입니다."), + + /** + * 게시글이 존재하지 않는 경우 사용합니다. + */ + BOARD_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 게시글입니다."), + + /** + * 댓글이 존재하지 않는 경우 사용합니다. + */ + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 댓글입니다."), + + /** + * 신고 내역이 존재하지 않는 경우 사용합니다. + */ + REPORT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 신고 내역입니다."), + + /** + * 공지가 존재하지 않는 경우 사용합니다. + */ + ANNOUNCEMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 공지입니다."), + + /** + * 서버 내부 오류가 발생한 경우 사용합니다. + */ + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."); + + /** + * HTTP 상태 코드입니다. + */ + private final HttpStatus httpStatus; + + /** + * 에러 응답 메시지입니다. + */ + private final String message; +} diff --git a/src/main/java/com/dodo/backend/admin/exception/AdminException.java b/src/main/java/com/dodo/backend/admin/exception/AdminException.java new file mode 100644 index 0000000..9d290a2 --- /dev/null +++ b/src/main/java/com/dodo/backend/admin/exception/AdminException.java @@ -0,0 +1,23 @@ +package com.dodo.backend.admin.exception; + +import com.dodo.backend.common.exception.BaseErrorCode; +import lombok.Getter; + +/** + * 관리자 도메인 비즈니스 로직에서 발생하는 예외입니다. + */ +@Getter +public class AdminException extends RuntimeException { + + private final BaseErrorCode errorCode; + + /** + * 관리자 도메인 예외를 생성합니다. + * + * @param errorCode 에러 코드 + */ + public AdminException(BaseErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/dodo/backend/admin/service/AdminService.java b/src/main/java/com/dodo/backend/admin/service/AdminService.java new file mode 100644 index 0000000..75f13dc --- /dev/null +++ b/src/main/java/com/dodo/backend/admin/service/AdminService.java @@ -0,0 +1,50 @@ +package com.dodo.backend.admin.service; + +import com.dodo.backend.admin.dto.request.AdminRequest.AnnouncementCreateRequest; +import com.dodo.backend.admin.dto.request.AdminRequest.AnnouncementUpdateRequest; +import com.dodo.backend.admin.dto.request.AdminRequest.ReportStatusUpdateRequest; +import com.dodo.backend.admin.dto.request.AdminRequest.UserStatusUpdateRequest; +import com.dodo.backend.admin.dto.response.AdminResponse.AdminSimpleResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.AnnouncementDetailResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.AnnouncementListResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.BoardReportDetailResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.CommentReportDetailResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.ReportListResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.UserReportDetailResponse; +import com.dodo.backend.admin.entity.AdminReportType; +import com.dodo.backend.report.entity.ReportStatus; +import org.springframework.data.domain.Pageable; + +import java.util.UUID; + +/** + * 관리자 API 비즈니스 로직을 정의하는 서비스 인터페이스입니다. + */ +public interface AdminService { + + BoardReportDetailResponse getBoardReportDetail(Long boardId); + + UserReportDetailResponse getUserReportDetail(UUID userId); + + CommentReportDetailResponse getCommentReportDetail(Long commentId); + + ReportListResponse getReportList(AdminReportType reportType, ReportStatus reportStatus, int page, int size, String sort); + + AdminSimpleResponse updateUserStatus(UUID userId, UserStatusUpdateRequest request); + + void deleteBoard(Long boardId); + + void deleteComment(Long commentId); + + AdminSimpleResponse updateReportStatus(Long reportId, ReportStatusUpdateRequest request); + + AdminSimpleResponse createAnnouncement(UUID adminId, AnnouncementCreateRequest request); + + void deleteAnnouncement(Long boardId); + + void updateAnnouncement(Long boardId, AnnouncementUpdateRequest request); + + AnnouncementListResponse getAnnouncementList(Pageable pageable); + + AnnouncementDetailResponse getAnnouncementDetail(Long boardId); +} diff --git a/src/main/java/com/dodo/backend/admin/service/AdminServiceImpl.java b/src/main/java/com/dodo/backend/admin/service/AdminServiceImpl.java new file mode 100644 index 0000000..af16347 --- /dev/null +++ b/src/main/java/com/dodo/backend/admin/service/AdminServiceImpl.java @@ -0,0 +1,497 @@ +package com.dodo.backend.admin.service; + +import com.dodo.backend.admin.dto.request.AdminRequest.AnnouncementCreateRequest; +import com.dodo.backend.admin.dto.request.AdminRequest.AnnouncementUpdateRequest; +import com.dodo.backend.admin.dto.request.AdminRequest.ReportStatusUpdateRequest; +import com.dodo.backend.admin.dto.request.AdminRequest.UserStatusUpdateRequest; +import com.dodo.backend.admin.dto.response.AdminResponse.AdminSimpleResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.AnnouncementDetailResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.AnnouncementItemResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.AnnouncementListResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.BoardInfoResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.BoardReportDetailResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.CommentReportDetailResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.PageInfoResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.ReportDetailItemResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.ReportListItemResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.ReportListResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.ReportTargetInfoResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.UserInfoResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.UserReportDetailResponse; +import com.dodo.backend.admin.entity.AdminReportType; +import com.dodo.backend.admin.exception.AdminException; +import com.dodo.backend.board.entity.Board; +import com.dodo.backend.board.entity.BoardStatus; +import com.dodo.backend.board.entity.BoardType; +import com.dodo.backend.board.mapper.BoardMapper; +import com.dodo.backend.board.repository.BoardRepository; +import com.dodo.backend.comment.entity.Comment; +import com.dodo.backend.comment.repository.CommentRepository; +import com.dodo.backend.imagefile.service.ImageFileService; +import com.dodo.backend.report.entity.Report; +import com.dodo.backend.report.entity.ReportStatus; +import com.dodo.backend.report.repository.ReportRepository; +import com.dodo.backend.user.entity.User; +import com.dodo.backend.user.mapper.UserMapper; +import com.dodo.backend.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static com.dodo.backend.admin.exception.AdminErrorCode.ANNOUNCEMENT_NOT_FOUND; +import static com.dodo.backend.admin.exception.AdminErrorCode.BOARD_NOT_FOUND; +import static com.dodo.backend.admin.exception.AdminErrorCode.COMMENT_NOT_FOUND; +import static com.dodo.backend.admin.exception.AdminErrorCode.INVALID_REQUEST; +import static com.dodo.backend.admin.exception.AdminErrorCode.REPORTED_BOARD_NOT_FOUND; +import static com.dodo.backend.admin.exception.AdminErrorCode.REPORTED_COMMENT_NOT_FOUND; +import static com.dodo.backend.admin.exception.AdminErrorCode.REPORTED_USER_NOT_FOUND; +import static com.dodo.backend.admin.exception.AdminErrorCode.REPORT_NOT_FOUND; +import static com.dodo.backend.admin.exception.AdminErrorCode.USER_NOT_FOUND; + +/** + * {@link AdminService} 구현체입니다. + */ +@Service +@RequiredArgsConstructor +public class AdminServiceImpl implements AdminService { + + private static final int MAX_PAGE_SIZE = 100; + private static final String STATUS_UPDATE_SUCCESS_MESSAGE = "성공적으로 상태를 변경했습니다."; + + private final ReportRepository reportRepository; + private final BoardRepository boardRepository; + private final CommentRepository commentRepository; + private final UserRepository userRepository; + private final UserMapper userMapper; + private final BoardMapper boardMapper; + private final ImageFileService imageFileService; + + /** + * 특정 게시글의 신고 상세 내역을 조회합니다. + * + * @param boardId 신고 상세 내역을 조회할 게시글 ID + * @return 게시글 신고 상세 내역 + */ + @Transactional(readOnly = true) + @Override + public BoardReportDetailResponse getBoardReportDetail(Long boardId) { + validatePositiveId(boardId); + Board board = boardRepository.findById(boardId) + .orElseThrow(() -> new AdminException(REPORTED_BOARD_NOT_FOUND)); + List reports = reportRepository.findAllByBoard_BoardIdOrderByCreatedAtDesc(boardId); + + return BoardReportDetailResponse.builder() + .boardId(board.getBoardId()) + .boardInfo(BoardInfoResponse.toDto(board)) + .reportedUserInfo(UserInfoResponse.toDto(board.getUser())) + .totalReportCount(reports.size()) + .reports(reports.stream().map(ReportDetailItemResponse::toDto).toList()) + .build(); + } + + /** + * 특정 유저의 신고 상세 내역을 조회합니다. + * + * @param userId 신고 상세 내역을 조회할 유저 ID + * @return 유저 신고 상세 내역 + */ + @Transactional(readOnly = true) + @Override + public UserReportDetailResponse getUserReportDetail(UUID userId) { + if (userId == null) { + throw new AdminException(INVALID_REQUEST); + } + + User reportedUser = userRepository.findById(userId) + .orElseThrow(() -> new AdminException(REPORTED_USER_NOT_FOUND)); + List reports = reportRepository.findAllByReportedUser_UsersIdOrderByCreatedAtDesc(userId); + + return UserReportDetailResponse.builder() + .reportedUserInfo(UserInfoResponse.toDto(reportedUser)) + .totalReportCount(reports.size()) + .reports(reports.stream().map(ReportDetailItemResponse::toDto).toList()) + .build(); + } + + /** + * 특정 댓글의 신고 상세 내역을 조회합니다. + * + * @param commentId 신고 상세 내역을 조회할 댓글 ID + * @return 댓글 신고 상세 내역 + */ + @Transactional(readOnly = true) + @Override + public CommentReportDetailResponse getCommentReportDetail(Long commentId) { + validatePositiveId(commentId); + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new AdminException(REPORTED_COMMENT_NOT_FOUND)); + List reports = reportRepository.findAllByComment_CommentIdOrderByCreatedAtDesc(commentId); + + return CommentReportDetailResponse.toDto(comment, reports); + } + + /** + * 신고 유형별 신고 목록을 조회합니다. + * + * @param reportType 조회할 신고 대상 유형 + * @param reportStatus 조회할 신고 처리 상태 + * @param page 조회할 페이지 번호 + * @param size 페이지당 신고 목록 개수 + * @param sort 정렬 조건 + * @return 신고 목록 조회 결과 + */ + @Transactional(readOnly = true) + @Override + public ReportListResponse getReportList(AdminReportType reportType, ReportStatus reportStatus, int page, int size, String sort) { + validatePageRequest(reportType, page, size); + + List reports = findReports(reportType, reportStatus); + List groupedItems = groupReports(reportType, reports); + groupedItems.sort(buildReportListComparator(sort)); + + int fromIndex = Math.min(page * size, groupedItems.size()); + int toIndex = Math.min(fromIndex + size, groupedItems.size()); + List pageItems = groupedItems.subList(fromIndex, toIndex); + + return ReportListResponse.builder() + .pageInfo(PageInfoResponse.toDto(page, size, groupedItems.size())) + .data(pageItems) + .build(); + } + + /** + * 유저 계정 상태를 변경합니다. + * + * @param userId 상태를 변경할 유저 ID + * @param request 변경할 유저 상태 요청 + * @return 상태 변경 성공 메시지 + */ + @Transactional + @Override + public AdminSimpleResponse updateUserStatus(UUID userId, UserStatusUpdateRequest request) { + if (userId == null || request == null || request.getStatus() == null) { + throw new AdminException(INVALID_REQUEST); + } + if (!userRepository.existsById(userId)) { + throw new AdminException(USER_NOT_FOUND); + } + + userMapper.updateUserStatus(userId, request.getStatus().name()); + return AdminSimpleResponse.toDto(STATUS_UPDATE_SUCCESS_MESSAGE); + } + + /** + * 게시글을 강제로 삭제합니다. + * + * @param boardId 삭제할 게시글 ID + */ + @Transactional + @Override + public void deleteBoard(Long boardId) { + validatePositiveId(boardId); + if (!boardRepository.existsById(boardId)) { + throw new AdminException(BOARD_NOT_FOUND); + } + + boardMapper.deleteBoard(boardId, BoardStatus.DELETED.name()); + imageFileService.deleteBoardImages(boardId); + } + + /** + * 댓글을 강제로 삭제합니다. + * + * @param commentId 삭제할 댓글 ID + */ + @Transactional + @Override + public void deleteComment(Long commentId) { + validatePositiveId(commentId); + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new AdminException(COMMENT_NOT_FOUND)); + + reportRepository.deleteAllByComment(comment); + commentRepository.delete(comment); + } + + /** + * 신고 처리 상태를 변경합니다. + * + * @param reportId 상태를 변경할 신고 ID + * @param request 변경할 신고 처리 상태 요청 + * @return 상태 변경 성공 메시지 + */ + @Transactional + @Override + public AdminSimpleResponse updateReportStatus(Long reportId, ReportStatusUpdateRequest request) { + validatePositiveId(reportId); + if (request == null || request.getStatus() == null) { + throw new AdminException(INVALID_REQUEST); + } + + Report report = reportRepository.findById(reportId) + .orElseThrow(() -> new AdminException(REPORT_NOT_FOUND)); + report.updateReportStatus(request.getStatus()); + + return AdminSimpleResponse.toDto(STATUS_UPDATE_SUCCESS_MESSAGE); + } + + /** + * 공지를 작성합니다. + * + * @param adminId 공지를 작성하는 관리자 ID + * @param request 공지 작성 요청 + * @return 공지 작성 성공 메시지 + */ + @Transactional + @Override + public AdminSimpleResponse createAnnouncement(UUID adminId, AnnouncementCreateRequest request) { + validateAnnouncementCreateRequest(adminId, request); + + User admin = userRepository.findById(adminId) + .orElseThrow(() -> new AdminException(USER_NOT_FOUND)); + Board board = Board.builder() + .user(admin) + .boardTitle(request.getBoardTitle()) + .boardContent(request.getBoardContent()) + .viewCount(request.getViewCount() == null ? 0 : request.getViewCount()) + .boardStatus(BoardStatus.PUBLISHED) + .boardStatusUpdatedAt(LocalDateTime.now()) + .boardType(BoardType.NOTICE) + .noticeTag(request.getNoticeTag()) + .build(); + + Board savedBoard = boardRepository.save(board); + imageFileService.saveBoardImages(savedBoard, toImageList(request.getImageFileUrl())); + + return AdminSimpleResponse.toDto("공지가 성공적으로 작성되었습니다."); + } + + /** + * 공지를 삭제합니다. + * + * @param boardId 삭제할 공지 게시글 ID + */ + @Transactional + @Override + public void deleteAnnouncement(Long boardId) { + Board board = findAnnouncement(boardId); + board.updateBoardStatus(BoardStatus.DELETED); + imageFileService.deleteBoardImages(boardId); + } + + /** + * 공지를 수정합니다. + * + * @param boardId 수정할 공지 게시글 ID + * @param request 공지 수정 요청 + */ + @Transactional + @Override + public void updateAnnouncement(Long boardId, AnnouncementUpdateRequest request) { + validateAnnouncementUpdateRequest(request); + + Board board = findAnnouncement(boardId); + board.updateAnnouncement(request.getBoardTitle(), request.getBoardContent()); + if (request.getImageFileUrl() != null) { + imageFileService.replaceBoardImages(board, toImageList(request.getImageFileUrl())); + } + } + + /** + * 공지 목록을 조회합니다. + * + * @param pageable 공지 목록 페이지 요청 정보 + * @return 공지 목록 조회 결과 + */ + @Transactional(readOnly = true) + @Override + public AnnouncementListResponse getAnnouncementList(Pageable pageable) { + if (pageable == null || pageable.getPageNumber() < 0 || pageable.getPageSize() <= 0 || pageable.getPageSize() > MAX_PAGE_SIZE) { + throw new AdminException(INVALID_REQUEST); + } + + Page page = boardRepository.findAllByBoardTypeAndBoardStatus(BoardType.NOTICE, BoardStatus.PUBLISHED, pageable); + List items = page.getContent().stream() + .map(board -> AnnouncementItemResponse.toDto(board, firstImageUrl(board.getBoardId()))) + .toList(); + + return AnnouncementListResponse.builder() + .pageInfo(PageInfoResponse.toDto(page)) + .data(items) + .build(); + } + + /** + * 공지 상세를 조회합니다. + * + * @param boardId 조회할 공지 게시글 ID + * @return 공지 상세 정보 + */ + @Transactional(readOnly = true) + @Override + public AnnouncementDetailResponse getAnnouncementDetail(Long boardId) { + Board board = findAnnouncement(boardId); + return AnnouncementDetailResponse.toDto(board, firstImageUrl(boardId)); + } + + private List findReports(AdminReportType reportType, ReportStatus reportStatus) { + return switch (reportType) { + case BOARD -> reportStatus == null + ? reportRepository.findAllByBoardIsNotNull() + : reportRepository.findAllByBoardIsNotNullAndReportStatus(reportStatus); + case USER -> reportStatus == null + ? reportRepository.findAllByReportedUserIsNotNull() + : reportRepository.findAllByReportedUserIsNotNullAndReportStatus(reportStatus); + case COMMENT -> reportStatus == null + ? reportRepository.findAllByCommentIsNotNull() + : reportRepository.findAllByCommentIsNotNullAndReportStatus(reportStatus); + }; + } + + private List groupReports(AdminReportType reportType, List reports) { + Function classifier = switch (reportType) { + case BOARD -> report -> report.getBoard().getBoardId(); + case USER -> report -> report.getReportedUser().getUsersId(); + case COMMENT -> report -> report.getComment().getCommentId(); + }; + + return reports.stream() + .collect(Collectors.groupingBy(classifier)) + .values() + .stream() + .map(group -> toReportListItem(reportType, group)) + .collect(Collectors.toCollection(ArrayList::new)); + } + + private ReportListItemResponse toReportListItem(AdminReportType reportType, List reports) { + List sortedReports = new ArrayList<>(reports); + sortedReports.sort(Comparator.comparing(Report::getCreatedAt, Comparator.nullsLast(Comparator.naturalOrder())).reversed()); + Report latestReport = sortedReports.get(0); + + return ReportListItemResponse.builder() + .reportType(reportType.name()) + .targetInfo(buildTargetInfo(reportType, latestReport)) + .reportedUser(buildReportedUserInfo(reportType, latestReport)) + .totalReportCount(sortedReports.size()) + .representativeReason(latestReport.getReportReason()) + .reportStatus(resolveAggregateStatus(sortedReports)) + .lastReportedAt(latestReport.getCreatedAt()) + .build(); + } + + private ReportTargetInfoResponse buildTargetInfo(AdminReportType reportType, Report report) { + return switch (reportType) { + case BOARD -> ReportTargetInfoResponse.builder() + .id(report.getBoard().getBoardId()) + .summary(report.getBoard().getBoardTitle()) + .build(); + case USER -> ReportTargetInfoResponse.builder() + .id(report.getReportedUser().getUsersId().toString()) + .summary("사용자 닉네임: " + report.getReportedUser().getNickname()) + .build(); + case COMMENT -> ReportTargetInfoResponse.builder() + .id(report.getComment().getCommentId()) + .summary(report.getComment().getCommentContent()) + .build(); + }; + } + + private UserInfoResponse buildReportedUserInfo(AdminReportType reportType, Report report) { + return switch (reportType) { + case BOARD -> UserInfoResponse.toDto(report.getBoard().getUser()); + case USER -> UserInfoResponse.toDto(report.getReportedUser()); + case COMMENT -> UserInfoResponse.toDto(report.getComment().getUser()); + }; + } + + private ReportStatus resolveAggregateStatus(List reports) { + return reports.stream().anyMatch(report -> report.getReportStatus() == ReportStatus.PENDING) + ? ReportStatus.PENDING + : ReportStatus.COMPLETED; + } + + private Comparator buildReportListComparator(String sort) { + String normalized = sort == null || sort.isBlank() ? "lastReportedAt,desc" : sort; + String[] tokens = normalized.split(","); + String property = tokens[0].trim(); + boolean descending = tokens.length < 2 || !"asc".equalsIgnoreCase(tokens[1].trim()); + + Comparator comparator = switch (property) { + case "totalReportCount" -> Comparator.comparing(ReportListItemResponse::getTotalReportCount); + case "lastReportedAt" -> Comparator.comparing(ReportListItemResponse::getLastReportedAt, Comparator.nullsLast(Comparator.naturalOrder())); + default -> throw new AdminException(INVALID_REQUEST); + }; + + return descending ? comparator.reversed() : comparator; + } + + private Board findAnnouncement(Long boardId) { + validatePositiveId(boardId); + return boardRepository.findByBoardIdAndBoardType(boardId, BoardType.NOTICE) + .filter(board -> board.getBoardStatus() != BoardStatus.DELETED) + .orElseThrow(() -> new AdminException(ANNOUNCEMENT_NOT_FOUND)); + } + + private String firstImageUrl(Long boardId) { + List imageUrls = imageFileService.getBoardImageUrls(boardId); + return imageUrls.isEmpty() ? null : imageUrls.get(0); + } + + private List toImageList(String imageFileUrl) { + if (imageFileUrl == null || imageFileUrl.isBlank()) { + return List.of(); + } + return List.of(imageFileUrl); + } + + private void validateAnnouncementCreateRequest(UUID adminId, AnnouncementCreateRequest request) { + if (adminId == null || request == null || isBlank(request.getBoardTitle()) || isBlank(request.getBoardContent())) { + throw new AdminException(INVALID_REQUEST); + } + if (request.getViewCount() != null && request.getViewCount() < 0) { + throw new AdminException(INVALID_REQUEST); + } + } + + private void validateAnnouncementUpdateRequest(AnnouncementUpdateRequest request) { + if (request == null) { + throw new AdminException(INVALID_REQUEST); + } + boolean hasTitle = request.getBoardTitle() != null; + boolean hasContent = request.getBoardContent() != null; + boolean hasImage = request.getImageFileUrl() != null; + + if (!hasTitle && !hasContent && !hasImage) { + throw new AdminException(INVALID_REQUEST); + } + if ((hasTitle && request.getBoardTitle().isBlank()) || (hasContent && request.getBoardContent().isBlank())) { + throw new AdminException(INVALID_REQUEST); + } + } + + private void validatePageRequest(AdminReportType reportType, int page, int size) { + if (reportType == null || page < 0 || size <= 0 || size > MAX_PAGE_SIZE || page > Integer.MAX_VALUE / size) { + throw new AdminException(INVALID_REQUEST); + } + } + + private void validatePositiveId(Long id) { + if (id == null || id <= 0) { + throw new AdminException(INVALID_REQUEST); + } + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/src/main/java/com/dodo/backend/board/entity/Board.java b/src/main/java/com/dodo/backend/board/entity/Board.java index 0bb4ad2..0a2471a 100644 --- a/src/main/java/com/dodo/backend/board/entity/Board.java +++ b/src/main/java/com/dodo/backend/board/entity/Board.java @@ -13,7 +13,7 @@ /** * 게시글(Board) 정보를 관리하는 엔티티입니다. *

- * 데이터베이스 {@code board} 테이블과 매핑되며, 작성자, 제목/본문, 조회수, + * 데이터베이스 {@code board} 테이블과 매핑되며, 작성자, 제목/본문, 조회 수, * 게시글 상태 및 게시판 유형 정보를 포함합니다. */ @Entity @@ -68,4 +68,28 @@ public class Board { @Column(name = "notice_tag") private NoticeTag noticeTag; + /** + * 공지 게시글 내용을 수정합니다. + * + * @param boardTitle 수정할 공지 제목 + * @param boardContent 수정할 공지 내용 + */ + public void updateAnnouncement(String boardTitle, String boardContent) { + if (boardTitle != null) { + this.boardTitle = boardTitle; + } + if (boardContent != null) { + this.boardContent = boardContent; + } + } + + /** + * 게시글 상태를 변경합니다. + * + * @param boardStatus 변경할 게시글 상태 + */ + public void updateBoardStatus(BoardStatus boardStatus) { + this.boardStatus = boardStatus; + this.boardStatusUpdatedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/com/dodo/backend/board/repository/BoardRepository.java b/src/main/java/com/dodo/backend/board/repository/BoardRepository.java index bd858f6..62b9c03 100644 --- a/src/main/java/com/dodo/backend/board/repository/BoardRepository.java +++ b/src/main/java/com/dodo/backend/board/repository/BoardRepository.java @@ -3,11 +3,13 @@ import com.dodo.backend.board.entity.Board; import com.dodo.backend.board.entity.BoardStatus; import com.dodo.backend.board.entity.BoardType; +import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; /** * {@link Board} 엔티티의 데이터베이스 접근을 담당하는 리포지토리 인터페이스입니다. @@ -28,4 +30,23 @@ List findByBoardTypeAndBoardStatusOrderByBoardCreatedAtDesc( BoardStatus boardStatus, Pageable pageable ); + + /** + * 게시글 유형과 상태에 맞는 게시글 목록을 페이지 단위로 조회합니다. + * + * @param boardType 조회할 게시글 유형 + * @param boardStatus 조회할 게시글 상태 + * @param pageable 페이지 및 정렬 정보 + * @return 조건에 맞는 게시글 페이지 + */ + Page findAllByBoardTypeAndBoardStatus(BoardType boardType, BoardStatus boardStatus, Pageable pageable); + + /** + * 게시글 ID와 게시글 유형으로 게시글을 조회합니다. + * + * @param boardId 게시글 ID + * @param boardType 게시글 유형 + * @return 조회된 게시글 + */ + Optional findByBoardIdAndBoardType(Long boardId, BoardType boardType); } diff --git a/src/main/java/com/dodo/backend/common/config/SecurityConfig.java b/src/main/java/com/dodo/backend/common/config/SecurityConfig.java index 41f93df..6406957 100644 --- a/src/main/java/com/dodo/backend/common/config/SecurityConfig.java +++ b/src/main/java/com/dodo/backend/common/config/SecurityConfig.java @@ -84,6 +84,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/swagger-ui.html", "/swagger-ui/**" ).denyAll() + .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) .exceptionHandling(exception -> exception diff --git a/src/main/java/com/dodo/backend/common/exception/GlobalExceptionHandler.java b/src/main/java/com/dodo/backend/common/exception/GlobalExceptionHandler.java index d6ff619..be9aff3 100644 --- a/src/main/java/com/dodo/backend/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/dodo/backend/common/exception/GlobalExceptionHandler.java @@ -1,6 +1,7 @@ package com.dodo.backend.common.exception; import com.dodo.backend.activityhistory.exception.ActivityHistoryException; +import com.dodo.backend.admin.exception.AdminException; import com.dodo.backend.auth.exception.AuthException; import com.dodo.backend.board.exception.BoardException; import com.dodo.backend.fence.exception.FenceException; @@ -130,6 +131,15 @@ protected ResponseEntity handleReactionException(ReactionExceptio return toResponseEntity(e.getErrorCode()); } + /** + * 관리자(Admin) 도메인 비즈니스 로직에서 발생하는 {@link AdminException}을 처리합니다. + */ + @ExceptionHandler(AdminException.class) + protected ResponseEntity handleAdminException(AdminException e) { + log.error("AdminException occurred: {}", e.getErrorCode()); + return toResponseEntity(e.getErrorCode()); + } + /** * 신고(Report) 도메인 비즈니스 로직에서 발생하는 {@link ReportException}을 처리합니다. */ diff --git a/src/main/java/com/dodo/backend/report/entity/Report.java b/src/main/java/com/dodo/backend/report/entity/Report.java index 6d60e98..95b4a2e 100644 --- a/src/main/java/com/dodo/backend/report/entity/Report.java +++ b/src/main/java/com/dodo/backend/report/entity/Report.java @@ -80,4 +80,13 @@ public class Report { @CreatedDate @Column(name = "report_created_at", nullable = false, updatable = false) private LocalDateTime createdAt; + + /** + * 신고 처리 상태를 변경합니다. + * + * @param reportStatus 변경할 신고 처리 상태 + */ + public void updateReportStatus(ReportStatus reportStatus) { + this.reportStatus = reportStatus; + } } diff --git a/src/main/java/com/dodo/backend/report/repository/ReportRepository.java b/src/main/java/com/dodo/backend/report/repository/ReportRepository.java index e84d098..59dcfe4 100644 --- a/src/main/java/com/dodo/backend/report/repository/ReportRepository.java +++ b/src/main/java/com/dodo/backend/report/repository/ReportRepository.java @@ -3,10 +3,15 @@ import com.dodo.backend.board.entity.Board; import com.dodo.backend.comment.entity.Comment; import com.dodo.backend.report.entity.Report; +import com.dodo.backend.report.entity.ReportStatus; import com.dodo.backend.user.entity.User; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.List; +import java.util.UUID; + /** * {@link Report} 엔티티의 데이터베이스 접근을 담당하는 repository입니다. */ @@ -39,4 +44,89 @@ public interface ReportRepository extends JpaRepository { * @return 이미 신고했으면 true */ boolean existsByReporterAndComment(User reporter, Comment comment); + + /** + * 특정 게시글에 대한 신고 목록을 조회합니다. + * + * @param boardId 게시글 ID + * @return 신고 목록 + */ + @EntityGraph(attributePaths = {"reporter", "board", "board.user"}) + List findAllByBoard_BoardIdOrderByCreatedAtDesc(Long boardId); + + /** + * 특정 유저에 대한 신고 목록을 조회합니다. + * + * @param userId 유저 ID + * @return 신고 목록 + */ + @EntityGraph(attributePaths = {"reporter", "reportedUser"}) + List findAllByReportedUser_UsersIdOrderByCreatedAtDesc(UUID userId); + + /** + * 특정 댓글에 대한 신고 목록을 조회합니다. + * + * @param commentId 댓글 ID + * @return 신고 목록 + */ + @EntityGraph(attributePaths = {"reporter", "comment", "comment.user"}) + List findAllByComment_CommentIdOrderByCreatedAtDesc(Long commentId); + + /** + * 게시글 신고 목록을 조회합니다. + * + * @return 게시글 신고 목록 + */ + @EntityGraph(attributePaths = {"reporter", "board", "board.user"}) + List findAllByBoardIsNotNull(); + + /** + * 특정 상태의 게시글 신고 목록을 조회합니다. + * + * @param reportStatus 신고 처리 상태 + * @return 게시글 신고 목록 + */ + @EntityGraph(attributePaths = {"reporter", "board", "board.user"}) + List findAllByBoardIsNotNullAndReportStatus(ReportStatus reportStatus); + + /** + * 유저 신고 목록을 조회합니다. + * + * @return 유저 신고 목록 + */ + @EntityGraph(attributePaths = {"reporter", "reportedUser"}) + List findAllByReportedUserIsNotNull(); + + /** + * 특정 상태의 유저 신고 목록을 조회합니다. + * + * @param reportStatus 신고 처리 상태 + * @return 유저 신고 목록 + */ + @EntityGraph(attributePaths = {"reporter", "reportedUser"}) + List findAllByReportedUserIsNotNullAndReportStatus(ReportStatus reportStatus); + + /** + * 댓글 신고 목록을 조회합니다. + * + * @return 댓글 신고 목록 + */ + @EntityGraph(attributePaths = {"reporter", "comment", "comment.user"}) + List findAllByCommentIsNotNull(); + + /** + * 특정 상태의 댓글 신고 목록을 조회합니다. + * + * @param reportStatus 신고 처리 상태 + * @return 댓글 신고 목록 + */ + @EntityGraph(attributePaths = {"reporter", "comment", "comment.user"}) + List findAllByCommentIsNotNullAndReportStatus(ReportStatus reportStatus); + + /** + * 댓글에 연결된 신고 내역을 삭제합니다. + * + * @param comment 삭제 대상 댓글 + */ + void deleteAllByComment(Comment comment); } diff --git a/src/test/java/com/dodo/backend/admin/controller/AdminControllerTest.java b/src/test/java/com/dodo/backend/admin/controller/AdminControllerTest.java new file mode 100644 index 0000000..08659a4 --- /dev/null +++ b/src/test/java/com/dodo/backend/admin/controller/AdminControllerTest.java @@ -0,0 +1,120 @@ +package com.dodo.backend.admin.controller; + +import com.dodo.backend.admin.dto.request.AdminRequest.ReportStatusUpdateRequest; +import com.dodo.backend.admin.dto.response.AdminResponse.AdminSimpleResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.BoardReportDetailResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.PageInfoResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.ReportListResponse; +import com.dodo.backend.admin.service.AdminService; +import com.dodo.backend.report.entity.ReportStatus; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 관리자 컨트롤러 API 경로를 검증하는 테스트 클래스입니다. + */ +@ExtendWith(MockitoExtension.class) +class AdminControllerTest { + + @Mock + private AdminService adminService; + + private MockMvc mockMvc; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(new AdminController(adminService)) + .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()) + .build(); + objectMapper = new ObjectMapper(); + } + + /** + * 게시글 신고 상세 조회 API가 200을 반환하는지 검증합니다. + */ + @Test + @DisplayName("게시글 신고 상세 조회 API 성공") + void getBoardReportDetail_Success() throws Exception { + BoardReportDetailResponse response = BoardReportDetailResponse.builder() + .boardId(1L) + .totalReportCount(0) + .reports(List.of()) + .build(); + given(adminService.getBoardReportDetail(1L)).willReturn(response); + + mockMvc.perform(get("/admin/reports/board/{boardId}", 1L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.boardId").value(1)); + + verify(adminService).getBoardReportDetail(1L); + } + + /** + * 신고 목록 조회 API가 200을 반환하는지 검증합니다. + */ + @Test + @DisplayName("신고 목록 조회 API 성공") + void getReportList_Success() throws Exception { + ReportListResponse response = ReportListResponse.builder() + .pageInfo(PageInfoResponse.toDto(0, 10, 0)) + .data(List.of()) + .build(); + given(adminService.getReportList(any(), eq(null), eq(0), eq(10), eq("lastReportedAt,desc"))).willReturn(response); + + mockMvc.perform(get("/admin/reports") + .param("reportType", "BOARD") + .param("sort", "lastReportedAt,desc")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.pageInfo.page").value(0)); + } + + /** + * 신고 처리 상태 변경 API가 200을 반환하는지 검증합니다. + */ + @Test + @DisplayName("신고 처리 상태 변경 API 성공") + void updateReportStatus_Success() throws Exception { + given(adminService.updateReportStatus(eq(10L), any(ReportStatusUpdateRequest.class))) + .willReturn(AdminSimpleResponse.toDto("성공적으로 상태를 변경했습니다.")); + + mockMvc.perform(patch("/admin/reports/{reportId}/status", 10L) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ReportStatusUpdateRequest(ReportStatus.COMPLETED)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("성공적으로 상태를 변경했습니다.")); + } + + /** + * 게시글 강제 삭제 API가 204를 반환하는지 검증합니다. + */ + @Test + @DisplayName("게시글 강제 삭제 API 성공") + void deleteBoard_Success() throws Exception { + mockMvc.perform(delete("/admin/boards/{boardId}", 1L)) + .andExpect(status().isNoContent()); + + verify(adminService).deleteBoard(1L); + } +} diff --git a/src/test/java/com/dodo/backend/admin/service/AdminServiceTest.java b/src/test/java/com/dodo/backend/admin/service/AdminServiceTest.java new file mode 100644 index 0000000..feea5bc --- /dev/null +++ b/src/test/java/com/dodo/backend/admin/service/AdminServiceTest.java @@ -0,0 +1,175 @@ +package com.dodo.backend.admin.service; + +import com.dodo.backend.admin.dto.request.AdminRequest.ReportStatusUpdateRequest; +import com.dodo.backend.admin.dto.request.AdminRequest.UserStatusUpdateRequest; +import com.dodo.backend.admin.dto.response.AdminResponse.BoardReportDetailResponse; +import com.dodo.backend.admin.dto.response.AdminResponse.ReportListResponse; +import com.dodo.backend.admin.entity.AdminReportType; +import com.dodo.backend.board.entity.Board; +import com.dodo.backend.board.entity.BoardStatus; +import com.dodo.backend.board.entity.BoardType; +import com.dodo.backend.board.mapper.BoardMapper; +import com.dodo.backend.board.repository.BoardRepository; +import com.dodo.backend.comment.repository.CommentRepository; +import com.dodo.backend.imagefile.service.ImageFileService; +import com.dodo.backend.report.entity.Report; +import com.dodo.backend.report.entity.ReportReason; +import com.dodo.backend.report.entity.ReportStatus; +import com.dodo.backend.report.repository.ReportRepository; +import com.dodo.backend.user.entity.User; +import com.dodo.backend.user.entity.UserStatus; +import com.dodo.backend.user.mapper.UserMapper; +import com.dodo.backend.user.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +/** + * 관리자 서비스 로직을 검증하는 테스트 클래스입니다. + */ +@ExtendWith(MockitoExtension.class) +class AdminServiceTest { + + @Mock + private ReportRepository reportRepository; + + @Mock + private BoardRepository boardRepository; + + @Mock + private CommentRepository commentRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private UserMapper userMapper; + + @Mock + private BoardMapper boardMapper; + + @Mock + private ImageFileService imageFileService; + + @InjectMocks + private AdminServiceImpl adminService; + + /** + * 게시글 신고 상세 조회 시 게시글과 신고 목록을 반환하는지 검증합니다. + */ + @Test + @DisplayName("게시글 신고 상세 조회 성공") + void getBoardReportDetail_Success() { + User writer = createUser(UUID.randomUUID(), "작성자"); + User reporter = createUser(UUID.randomUUID(), "신고자"); + Board board = createBoard(1L, writer); + Report report = createBoardReport(10L, reporter, board, ReportReason.ABUSE); + + given(boardRepository.findById(1L)).willReturn(Optional.of(board)); + given(reportRepository.findAllByBoard_BoardIdOrderByCreatedAtDesc(1L)).willReturn(List.of(report)); + + BoardReportDetailResponse response = adminService.getBoardReportDetail(1L); + + assertNotNull(response); + assertEquals(1L, response.getBoardId()); + assertEquals(1, response.getTotalReportCount()); + assertEquals("작성자", response.getReportedUserInfo().getNickname()); + } + + /** + * 신고 목록 조회 시 대상별로 신고가 묶이는지 검증합니다. + */ + @Test + @DisplayName("게시글 신고 목록 조회 성공") + void getReportList_Success() { + User writer = createUser(UUID.randomUUID(), "작성자"); + User reporter = createUser(UUID.randomUUID(), "신고자"); + Board board = createBoard(1L, writer); + Report report = createBoardReport(10L, reporter, board, ReportReason.SPAM); + + given(reportRepository.findAllByBoardIsNotNull()).willReturn(List.of(report)); + + ReportListResponse response = adminService.getReportList(AdminReportType.BOARD, null, 0, 10, "lastReportedAt,desc"); + + assertNotNull(response); + assertEquals(1, response.getData().size()); + assertEquals("BOARD", response.getData().get(0).getReportType()); + assertEquals(1, response.getData().get(0).getTotalReportCount()); + } + + /** + * 유저 상태 변경 시 UserMapper가 호출되는지 검증합니다. + */ + @Test + @DisplayName("유저 계정 상태 변경 성공") + void updateUserStatus_Success() { + UUID userId = UUID.randomUUID(); + given(userRepository.existsById(userId)).willReturn(true); + + adminService.updateUserStatus(userId, new UserStatusUpdateRequest(UserStatus.SUSPENDED)); + + verify(userMapper).updateUserStatus(userId, UserStatus.SUSPENDED.name()); + } + + /** + * 신고 상태 변경 시 엔티티의 상태가 변경되는지 검증합니다. + */ + @Test + @DisplayName("신고 처리 상태 변경 성공") + void updateReportStatus_Success() { + Report report = createBoardReport( + 10L, + createUser(UUID.randomUUID(), "신고자"), + createBoard(1L, createUser(UUID.randomUUID(), "작성자")), + ReportReason.SPAM + ); + given(reportRepository.findById(10L)).willReturn(Optional.of(report)); + + adminService.updateReportStatus(10L, new ReportStatusUpdateRequest(ReportStatus.COMPLETED)); + + assertEquals(ReportStatus.COMPLETED, report.getReportStatus()); + } + + private User createUser(UUID userId, String nickname) { + return User.builder() + .usersId(userId) + .nickname(nickname) + .build(); + } + + private Board createBoard(Long boardId, User user) { + return Board.builder() + .boardId(boardId) + .user(user) + .boardTitle("게시글 제목") + .boardContent("게시글 내용") + .boardType(BoardType.FREE) + .boardStatus(BoardStatus.PUBLISHED) + .boardCreatedAt(LocalDateTime.of(2025, 10, 1, 10, 0)) + .build(); + } + + private Report createBoardReport(Long reportId, User reporter, Board board, ReportReason reason) { + return Report.builder() + .reportId(reportId) + .reporter(reporter) + .board(board) + .reportReason(reason) + .reportStatus(ReportStatus.PENDING) + .createdAt(LocalDateTime.of(2025, 10, 1, 11, 0)) + .build(); + } +} diff --git a/src/test/java/com/dodo/backend/main/service/MainServiceTest.java b/src/test/java/com/dodo/backend/main/service/MainServiceTest.java index 04d6701..ea60201 100644 --- a/src/test/java/com/dodo/backend/main/service/MainServiceTest.java +++ b/src/test/java/com/dodo/backend/main/service/MainServiceTest.java @@ -127,7 +127,7 @@ void getMainPage_WithPetProfilesAndHealthReports() { given(boardRepository.findByBoardTypeAndBoardStatusOrderByBoardCreatedAtDesc( BoardType.NOTICE, BoardStatus.PUBLISHED, - PageRequest.of(0, 3) + PageRequest.of(0, 5) )).willReturn(List.of(notice)); given(imageFileService.getBoardImageUrls(notice.getBoardId())) .willReturn(List.of("https://example.com/notice/11.png")); @@ -174,7 +174,7 @@ void getMainPage_WithPetProfilesAndHealthReports() { verify(boardRepository).findByBoardTypeAndBoardStatusOrderByBoardCreatedAtDesc( BoardType.NOTICE, BoardStatus.PUBLISHED, - PageRequest.of(0, 3) + PageRequest.of(0, 5) ); verify(imageFileService).getBoardImageUrls(notice.getBoardId()); @@ -204,7 +204,7 @@ void getMainPage_WithoutPets_ReturnsEmptyLists() { given(boardRepository.findByBoardTypeAndBoardStatusOrderByBoardCreatedAtDesc( BoardType.NOTICE, BoardStatus.PUBLISHED, - PageRequest.of(0, 3) + PageRequest.of(0, 5) )).willReturn(Collections.emptyList()); // when @@ -220,7 +220,7 @@ void getMainPage_WithoutPets_ReturnsEmptyLists() { verify(boardRepository).findByBoardTypeAndBoardStatusOrderByBoardCreatedAtDesc( BoardType.NOTICE, BoardStatus.PUBLISHED, - PageRequest.of(0, 3) + PageRequest.of(0, 5) ); verifyNoInteractions(healthAnalysisService); verifyNoInteractions(imageFileService); @@ -270,7 +270,7 @@ void getMainPage_PetWithoutHealthAnalysis() { given(boardRepository.findByBoardTypeAndBoardStatusOrderByBoardCreatedAtDesc( BoardType.NOTICE, BoardStatus.PUBLISHED, - PageRequest.of(0, 3) + PageRequest.of(0, 5) )).willReturn(Collections.emptyList()); // when @@ -287,7 +287,7 @@ void getMainPage_PetWithoutHealthAnalysis() { verify(boardRepository).findByBoardTypeAndBoardStatusOrderByBoardCreatedAtDesc( BoardType.NOTICE, BoardStatus.PUBLISHED, - PageRequest.of(0, 3) + PageRequest.of(0, 5) ); log.info("테스트 종료: 건강 분석 이력 없음 응답 검증 완료");