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 6a5a998..d6ff619 100644 --- a/src/main/java/com/dodo/backend/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/dodo/backend/common/exception/GlobalExceptionHandler.java @@ -9,6 +9,7 @@ import com.dodo.backend.pet.exception.PetException; import com.dodo.backend.petweight.exception.PetWeightException; import com.dodo.backend.reaction.exception.ReactionException; +import com.dodo.backend.report.exception.ReportException; import com.dodo.backend.user.exception.UserErrorCode; import com.dodo.backend.user.exception.UserException; import com.dodo.backend.userpet.exception.UserPetException; @@ -129,6 +130,15 @@ protected ResponseEntity handleReactionException(ReactionExceptio return toResponseEntity(e.getErrorCode()); } + /** + * 신고(Report) 도메인 비즈니스 로직에서 발생하는 {@link ReportException}을 처리합니다. + */ + @ExceptionHandler(ReportException.class) + protected ResponseEntity handleReportException(ReportException e) { + log.error("ReportException occurred: {}", e.getErrorCode()); + return toResponseEntity(e.getErrorCode()); + } + /** * 이미지 파일(ImageFile) 도메인 비즈니스 로직에서 발생하는 {@link ImageFileException}을 처리합니다. */ diff --git a/src/main/java/com/dodo/backend/report/controller/ReportController.java b/src/main/java/com/dodo/backend/report/controller/ReportController.java new file mode 100644 index 0000000..859c9c1 --- /dev/null +++ b/src/main/java/com/dodo/backend/report/controller/ReportController.java @@ -0,0 +1,150 @@ +package com.dodo.backend.report.controller; + +import com.dodo.backend.common.exception.ErrorResponse; +import com.dodo.backend.report.dto.request.ReportRequest.ReportCreateRequest; +import com.dodo.backend.report.dto.response.ReportResponse.ReportSimpleResponse; +import com.dodo.backend.report.service.ReportService; +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.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.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; + +/** + * 신고 API 요청을 처리하는 컨트롤러입니다. + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/reports") +@Tag(name = "Report API", description = "신고 관련 API") +@Slf4j +public class ReportController { + + private final ReportService reportService; + + /** + * 특정 게시글을 신고합니다. + * + * @param boardId 신고 대상 게시글 ID + * @param request 신고 요청 DTO + * @param userDetails 인증 사용자 정보 + * @return 신고 처리 응답 + */ + @Operation(summary = "게시글 신고", description = "인증 사용자가 특정 게시글을 신고합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "신고가 성공적으로 접수되었습니다.", + content = @Content(schema = @Schema(implementation = ReportSimpleResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "401", description = "로그인이 필요한 기능입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "신고할 게시글을 찾을 수 없습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "409", description = "이미 신고한 대상입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류가 발생했습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @PostMapping("/board/{boardId}") + public ResponseEntity reportBoard( + @PathVariable Long boardId, + @Valid @RequestBody ReportCreateRequest request, + @AuthenticationPrincipal UserDetails userDetails + ) { + UUID reporterId = UUID.fromString(userDetails.getUsername()); + log.info("게시글 신고 요청 - Reporter: {}, BoardId: {}", reporterId, boardId); + + return ResponseEntity + .status(HttpStatus.CREATED) + .body(reportService.reportBoard(reporterId, boardId, request)); + } + + /** + * 특정 유저를 신고합니다. + * + * @param userId 신고 대상 유저 ID + * @param request 신고 요청 DTO + * @param userDetails 인증 사용자 정보 + * @return 신고 처리 응답 + */ + @Operation(summary = "유저 신고", description = "인증 사용자가 특정 유저를 신고합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "신고가 성공적으로 접수되었습니다.", + content = @Content(schema = @Schema(implementation = ReportSimpleResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "401", description = "로그인이 필요한 기능입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "신고할 유저를 찾을 수 없습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "409", description = "이미 신고한 유저입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류가 발생했습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @PostMapping("/user/{userId}") + public ResponseEntity reportUser( + @PathVariable UUID userId, + @Valid @RequestBody ReportCreateRequest request, + @AuthenticationPrincipal UserDetails userDetails + ) { + UUID reporterId = UUID.fromString(userDetails.getUsername()); + log.info("유저 신고 요청 - Reporter: {}, ReportedUser: {}", reporterId, userId); + + return ResponseEntity + .status(HttpStatus.CREATED) + .body(reportService.reportUser(reporterId, userId, request)); + } + + /** + * 특정 댓글을 신고합니다. + * + * @param commentId 신고 대상 댓글 ID + * @param request 신고 요청 DTO + * @param userDetails 인증 사용자 정보 + * @return 신고 처리 응답 + */ + @Operation(summary = "댓글 신고", description = "인증 사용자가 특정 댓글을 신고합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "신고가 성공적으로 접수되었습니다.", + content = @Content(schema = @Schema(implementation = ReportSimpleResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "401", description = "로그인이 필요한 기능입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "신고할 댓글을 찾을 수 없습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "409", description = "이미 신고한 댓글입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류가 발생했습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @PostMapping("/comment/{commentId}") + public ResponseEntity reportComment( + @PathVariable Long commentId, + @Valid @RequestBody ReportCreateRequest request, + @AuthenticationPrincipal UserDetails userDetails + ) { + UUID reporterId = UUID.fromString(userDetails.getUsername()); + log.info("댓글 신고 요청 - Reporter: {}, CommentId: {}", reporterId, commentId); + + return ResponseEntity + .status(HttpStatus.CREATED) + .body(reportService.reportComment(reporterId, commentId, request)); + } +} diff --git a/src/main/java/com/dodo/backend/report/dto/request/ReportRequest.java b/src/main/java/com/dodo/backend/report/dto/request/ReportRequest.java new file mode 100644 index 0000000..bef70f1 --- /dev/null +++ b/src/main/java/com/dodo/backend/report/dto/request/ReportRequest.java @@ -0,0 +1,29 @@ +package com.dodo.backend.report.dto.request; + +import com.dodo.backend.report.entity.ReportReason; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 신고 API에서 사용하는 요청 DTO를 모아 둔 클래스입니다. + */ +@Schema(description = "신고 요청 DTO 그룹") +public class ReportRequest { + + /** + * 신고 생성 요청 DTO입니다. + */ + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "신고 생성 요청") + public static class ReportCreateRequest { + + @NotNull(message = "신고 사유는 필수입니다.") + @Schema(description = "신고 사유", example = "SPAM") + private ReportReason reportReason; + } +} diff --git a/src/main/java/com/dodo/backend/report/dto/response/ReportResponse.java b/src/main/java/com/dodo/backend/report/dto/response/ReportResponse.java new file mode 100644 index 0000000..07490d9 --- /dev/null +++ b/src/main/java/com/dodo/backend/report/dto/response/ReportResponse.java @@ -0,0 +1,40 @@ +package com.dodo.backend.report.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 신고 API에서 사용하는 응답 DTO를 모아 둔 클래스입니다. + */ +@Schema(description = "신고 응답 DTO 그룹") +public class ReportResponse { + + /** + * 신고 처리 결과 메시지를 반환하는 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + @Schema(description = "신고 단순 처리 응답") + public static class ReportSimpleResponse { + + @Schema(description = "응답 메시지", example = "신고가 성공적으로 접수되었습니다.") + private String message; + + /** + * 신고 단순 처리 응답 DTO를 생성합니다. + * + * @param message 응답 메시지 + * @return 신고 단순 처리 응답 DTO + */ + public static ReportSimpleResponse toDto(String message) { + return ReportSimpleResponse.builder() + .message(message) + .build(); + } + } +} diff --git a/src/main/java/com/dodo/backend/report/entity/Report.java b/src/main/java/com/dodo/backend/report/entity/Report.java new file mode 100644 index 0000000..6d60e98 --- /dev/null +++ b/src/main/java/com/dodo/backend/report/entity/Report.java @@ -0,0 +1,83 @@ +package com.dodo.backend.report.entity; + +import com.dodo.backend.board.entity.Board; +import com.dodo.backend.comment.entity.Comment; +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.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +/** + * 신고 정보를 관리하는 엔티티입니다. + *

+ * 신고자와 신고 대상(게시글, 유저, 댓글), 신고 사유, 처리 상태와 생성 일시를 저장합니다. + */ +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@EntityListeners(AuditingEntityListener.class) +@Table( + name = "report", + uniqueConstraints = { + @UniqueConstraint(name = "uk_report_reporter_board", columnNames = {"reporter_id", "board_id"}), + @UniqueConstraint(name = "uk_report_reporter_reported_user", columnNames = {"reporter_id", "reported_user_id"}), + @UniqueConstraint(name = "uk_report_reporter_comment", columnNames = {"reporter_id", "comment_id"}) + } +) +public class Report { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "report_id") + private Long reportId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reporter_id", nullable = false) + private User reporter; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "board_id") + private Board board; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reported_user_id") + private User reportedUser; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "comment_id") + private Comment comment; + + @Enumerated(EnumType.STRING) + @Column(name = "report_reason", nullable = false) + private ReportReason reportReason; + + @Builder.Default + @Enumerated(EnumType.STRING) + @Column(name = "report_status", nullable = false) + private ReportStatus reportStatus = ReportStatus.PENDING; + + @CreatedDate + @Column(name = "report_created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/dodo/backend/report/entity/ReportReason.java b/src/main/java/com/dodo/backend/report/entity/ReportReason.java new file mode 100644 index 0000000..839ff37 --- /dev/null +++ b/src/main/java/com/dodo/backend/report/entity/ReportReason.java @@ -0,0 +1,51 @@ +package com.dodo.backend.report.entity; + +/** + * 신고 사유를 정의하는 열거형입니다. + */ +public enum ReportReason { + /** + * 스팸 또는 광고성 게시물입니다. + */ + SPAM, + + /** + * 음란물 또는 선정적 콘텐츠입니다. + */ + OBSCENITY, + + /** + * 욕설, 비방, 혐오 발언입니다. + */ + ABUSE, + + /** + * 부적절한 프로필입니다. + */ + INAPPROPRIATE_PROFILE, + + /** + * 스팸 또는 광고 목적의 계정/댓글입니다. + */ + SPAM_ADVERTISING, + + /** + * 사칭 또는 명의 도용입니다. + */ + IMPERSONATION, + + /** + * 혐오 발언입니다. + */ + HATE_SPEECH, + + /** + * 개인정보 노출입니다. + */ + PRIVATE_INFO_EXPOSURE, + + /** + * 괴롭힘 또는 지속적인 공격입니다. + */ + HARASSMENT +} diff --git a/src/main/java/com/dodo/backend/report/entity/ReportStatus.java b/src/main/java/com/dodo/backend/report/entity/ReportStatus.java new file mode 100644 index 0000000..59be8c1 --- /dev/null +++ b/src/main/java/com/dodo/backend/report/entity/ReportStatus.java @@ -0,0 +1,16 @@ +package com.dodo.backend.report.entity; + +/** + * 신고 처리 상태를 정의하는 열거형입니다. + */ +public enum ReportStatus { + /** + * 신고 접수 후 처리 대기 상태입니다. + */ + PENDING, + + /** + * 신고 처리가 완료된 상태입니다. + */ + COMPLETED +} diff --git a/src/main/java/com/dodo/backend/report/exception/ReportErrorCode.java b/src/main/java/com/dodo/backend/report/exception/ReportErrorCode.java new file mode 100644 index 0000000..a3e2de9 --- /dev/null +++ b/src/main/java/com/dodo/backend/report/exception/ReportErrorCode.java @@ -0,0 +1,54 @@ +package com.dodo.backend.report.exception; + +import com.dodo.backend.common.exception.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +/** + * 신고 도메인에서 발생하는 예외 상황을 관리하는 에러 코드입니다. + */ +@AllArgsConstructor +@Getter +public enum ReportErrorCode implements BaseErrorCode { + + /** + * 잘못된 신고 요청인 경우 사용합니다. + */ + INVALID_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."), + + /** + * 신고할 게시글을 찾을 수 없는 경우 사용합니다. + */ + BOARD_NOT_FOUND(HttpStatus.NOT_FOUND, "신고할 게시글을 찾을 수 없습니다."), + + /** + * 신고할 유저를 찾을 수 없는 경우 사용합니다. + */ + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "신고할 유저를 찾을 수 없습니다."), + + /** + * 신고할 댓글을 찾을 수 없는 경우 사용합니다. + */ + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "신고할 댓글을 찾을 수 없습니다."), + + /** + * 이미 신고한 대상인 경우 사용합니다. + */ + REPORT_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 신고한 대상입니다."), + + /** + * 서버 내부 오류가 발생한 경우 사용합니다. + */ + 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/report/exception/ReportException.java b/src/main/java/com/dodo/backend/report/exception/ReportException.java new file mode 100644 index 0000000..188d742 --- /dev/null +++ b/src/main/java/com/dodo/backend/report/exception/ReportException.java @@ -0,0 +1,22 @@ +package com.dodo.backend.report.exception; + +import lombok.Getter; + +/** + * 신고 도메인 비즈니스 로직에서 발생하는 예외입니다. + */ +@Getter +public class ReportException extends RuntimeException { + + private final ReportErrorCode errorCode; + + /** + * 신고 도메인 예외를 생성합니다. + * + * @param errorCode 신고 에러 코드 + */ + public ReportException(ReportErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/dodo/backend/report/repository/ReportRepository.java b/src/main/java/com/dodo/backend/report/repository/ReportRepository.java new file mode 100644 index 0000000..e84d098 --- /dev/null +++ b/src/main/java/com/dodo/backend/report/repository/ReportRepository.java @@ -0,0 +1,42 @@ +package com.dodo.backend.report.repository; + +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.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * {@link Report} 엔티티의 데이터베이스 접근을 담당하는 repository입니다. + */ +@Repository +public interface ReportRepository extends JpaRepository { + + /** + * 특정 유저가 특정 게시글을 이미 신고했는지 확인합니다. + * + * @param reporter 신고자 + * @param board 신고 대상 게시글 + * @return 이미 신고했으면 true + */ + boolean existsByReporterAndBoard(User reporter, Board board); + + /** + * 특정 유저가 특정 유저를 이미 신고했는지 확인합니다. + * + * @param reporter 신고자 + * @param reportedUser 신고 대상 유저 + * @return 이미 신고했으면 true + */ + boolean existsByReporterAndReportedUser(User reporter, User reportedUser); + + /** + * 특정 유저가 특정 댓글을 이미 신고했는지 확인합니다. + * + * @param reporter 신고자 + * @param comment 신고 대상 댓글 + * @return 이미 신고했으면 true + */ + boolean existsByReporterAndComment(User reporter, Comment comment); +} diff --git a/src/main/java/com/dodo/backend/report/service/ReportService.java b/src/main/java/com/dodo/backend/report/service/ReportService.java new file mode 100644 index 0000000..628ef26 --- /dev/null +++ b/src/main/java/com/dodo/backend/report/service/ReportService.java @@ -0,0 +1,46 @@ +package com.dodo.backend.report.service; + +import com.dodo.backend.report.dto.request.ReportRequest.ReportCreateRequest; +import com.dodo.backend.report.dto.response.ReportResponse.ReportSimpleResponse; +import com.dodo.backend.report.exception.ReportException; + +import java.util.UUID; + +/** + * 신고 도메인의 비즈니스 로직을 정의하는 서비스 인터페이스입니다. + */ +public interface ReportService { + + /** + * 특정 게시글을 신고합니다. + * + * @param reporterId 신고자 ID + * @param boardId 신고 대상 게시글 ID + * @param request 신고 요청 DTO + * @return 신고 처리 응답 DTO + * @throws ReportException 잘못된 요청, 게시글 없음, 중복 신고인 경우 + */ + ReportSimpleResponse reportBoard(UUID reporterId, Long boardId, ReportCreateRequest request); + + /** + * 특정 유저를 신고합니다. + * + * @param reporterId 신고자 ID + * @param userId 신고 대상 유저 ID + * @param request 신고 요청 DTO + * @return 신고 처리 응답 DTO + * @throws ReportException 잘못된 요청, 유저 없음, 중복 신고인 경우 + */ + ReportSimpleResponse reportUser(UUID reporterId, UUID userId, ReportCreateRequest request); + + /** + * 특정 댓글을 신고합니다. + * + * @param reporterId 신고자 ID + * @param commentId 신고 대상 댓글 ID + * @param request 신고 요청 DTO + * @return 신고 처리 응답 DTO + * @throws ReportException 잘못된 요청, 댓글 없음, 중복 신고인 경우 + */ + ReportSimpleResponse reportComment(UUID reporterId, Long commentId, ReportCreateRequest request); +} diff --git a/src/main/java/com/dodo/backend/report/service/ReportServiceImpl.java b/src/main/java/com/dodo/backend/report/service/ReportServiceImpl.java new file mode 100644 index 0000000..04fc216 --- /dev/null +++ b/src/main/java/com/dodo/backend/report/service/ReportServiceImpl.java @@ -0,0 +1,231 @@ +package com.dodo.backend.report.service; + +import com.dodo.backend.board.entity.Board; +import com.dodo.backend.board.exception.BoardException; +import com.dodo.backend.board.service.BoardService; +import com.dodo.backend.comment.entity.Comment; +import com.dodo.backend.comment.repository.CommentRepository; +import com.dodo.backend.report.dto.request.ReportRequest.ReportCreateRequest; +import com.dodo.backend.report.dto.response.ReportResponse.ReportSimpleResponse; +import com.dodo.backend.report.entity.Report; +import com.dodo.backend.report.entity.ReportReason; +import com.dodo.backend.report.exception.ReportException; +import com.dodo.backend.report.repository.ReportRepository; +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.EnumSet; +import java.util.Set; +import java.util.UUID; + +import static com.dodo.backend.report.entity.ReportReason.ABUSE; +import static com.dodo.backend.report.entity.ReportReason.IMPERSONATION; +import static com.dodo.backend.report.entity.ReportReason.INAPPROPRIATE_PROFILE; +import static com.dodo.backend.report.entity.ReportReason.OBSCENITY; +import static com.dodo.backend.report.entity.ReportReason.SPAM; +import static com.dodo.backend.report.entity.ReportReason.SPAM_ADVERTISING; +import static com.dodo.backend.report.exception.ReportErrorCode.BOARD_NOT_FOUND; +import static com.dodo.backend.report.exception.ReportErrorCode.COMMENT_NOT_FOUND; +import static com.dodo.backend.report.exception.ReportErrorCode.INVALID_REQUEST; +import static com.dodo.backend.report.exception.ReportErrorCode.REPORT_ALREADY_EXISTS; +import static com.dodo.backend.report.exception.ReportErrorCode.USER_NOT_FOUND; + +/** + * {@link ReportService} 구현체입니다. + *

+ * 게시글, 유저, 댓글 신고 생성과 중복 신고 검증을 처리합니다. + */ +@Service +@RequiredArgsConstructor +public class ReportServiceImpl implements ReportService { + + private static final String REPORT_SUCCESS_MESSAGE = "신고가 성공적으로 접수되었습니다."; + private static final Set BOARD_REPORT_REASONS = EnumSet.of(SPAM, OBSCENITY, ABUSE); + private static final Set USER_REPORT_REASONS = EnumSet.of(INAPPROPRIATE_PROFILE, SPAM_ADVERTISING, IMPERSONATION); + private static final Set COMMENT_REPORT_REASONS = EnumSet.of(SPAM_ADVERTISING, ABUSE, OBSCENITY); + + private final ReportRepository reportRepository; + private final BoardService boardService; + private final UserService userService; + private final CommentRepository commentRepository; + + /** + * 특정 게시글을 신고합니다. + * + * @param reporterId 신고자 ID + * @param boardId 신고 대상 게시글 ID + * @param request 신고 요청 DTO + * @return 신고 처리 응답 DTO + */ + @Transactional + @Override + public ReportSimpleResponse reportBoard(UUID reporterId, Long boardId, ReportCreateRequest request) { + validateRequest(reporterId, request); + validateId(boardId); + validateReason(request.getReportReason(), BOARD_REPORT_REASONS); + + User reporter = findReporter(reporterId); + Board board = findBoard(boardId); + + if (reportRepository.existsByReporterAndBoard(reporter, board)) { + throw new ReportException(REPORT_ALREADY_EXISTS); + } + + Report report = Report.builder() + .reporter(reporter) + .board(board) + .reportReason(request.getReportReason()) + .build(); + + reportRepository.save(report); + return ReportSimpleResponse.toDto(REPORT_SUCCESS_MESSAGE); + } + + /** + * 특정 유저를 신고합니다. + * + * @param reporterId 신고자 ID + * @param userId 신고 대상 유저 ID + * @param request 신고 요청 DTO + * @return 신고 처리 응답 DTO + */ + @Transactional + @Override + public ReportSimpleResponse reportUser(UUID reporterId, UUID userId, ReportCreateRequest request) { + validateRequest(reporterId, request); + if (userId == null || reporterId.equals(userId)) { + throw new ReportException(INVALID_REQUEST); + } + validateReason(request.getReportReason(), USER_REPORT_REASONS); + + User reporter = findReporter(reporterId); + User reportedUser = findReportedUser(userId); + + if (reportRepository.existsByReporterAndReportedUser(reporter, reportedUser)) { + throw new ReportException(REPORT_ALREADY_EXISTS); + } + + Report report = Report.builder() + .reporter(reporter) + .reportedUser(reportedUser) + .reportReason(request.getReportReason()) + .build(); + + reportRepository.save(report); + return ReportSimpleResponse.toDto(REPORT_SUCCESS_MESSAGE); + } + + /** + * 특정 댓글을 신고합니다. + * + * @param reporterId 신고자 ID + * @param commentId 신고 대상 댓글 ID + * @param request 신고 요청 DTO + * @return 신고 처리 응답 DTO + */ + @Transactional + @Override + public ReportSimpleResponse reportComment(UUID reporterId, Long commentId, ReportCreateRequest request) { + validateRequest(reporterId, request); + validateId(commentId); + validateReason(request.getReportReason(), COMMENT_REPORT_REASONS); + + User reporter = findReporter(reporterId); + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new ReportException(COMMENT_NOT_FOUND)); + + if (reportRepository.existsByReporterAndComment(reporter, comment)) { + throw new ReportException(REPORT_ALREADY_EXISTS); + } + + Report report = Report.builder() + .reporter(reporter) + .comment(comment) + .reportReason(request.getReportReason()) + .build(); + + reportRepository.save(report); + return ReportSimpleResponse.toDto(REPORT_SUCCESS_MESSAGE); + } + + /** + * 신고 요청의 공통 필수 값을 검증합니다. + * + * @param reporterId 신고자 ID + * @param request 신고 요청 DTO + */ + private void validateRequest(UUID reporterId, ReportCreateRequest request) { + if (reporterId == null || request == null || request.getReportReason() == null) { + throw new ReportException(INVALID_REQUEST); + } + } + + /** + * 숫자 ID 요청 값을 검증합니다. + * + * @param id 검증할 ID + */ + private void validateId(Long id) { + if (id == null || id <= 0) { + throw new ReportException(INVALID_REQUEST); + } + } + + /** + * 대상 유형에서 허용하는 신고 사유인지 검증합니다. + * + * @param reportReason 요청 신고 사유 + * @param allowedReasons 허용 신고 사유 목록 + */ + private void validateReason(ReportReason reportReason, Set allowedReasons) { + if (!allowedReasons.contains(reportReason)) { + throw new ReportException(INVALID_REQUEST); + } + } + + /** + * 신고자 유저를 조회합니다. + * + * @param reporterId 신고자 ID + * @return 신고자 유저 엔티티 + */ + private User findReporter(UUID reporterId) { + try { + return userService.getUserById(reporterId); + } catch (UserException e) { + throw new ReportException(INVALID_REQUEST); + } + } + + /** + * 신고 대상 게시글을 조회합니다. + * + * @param boardId 게시글 ID + * @return 게시글 엔티티 + */ + private Board findBoard(Long boardId) { + try { + return boardService.getBoardById(boardId); + } catch (BoardException e) { + throw new ReportException(BOARD_NOT_FOUND); + } + } + + /** + * 신고 대상 유저를 조회합니다. + * + * @param userId 신고 대상 유저 ID + * @return 신고 대상 유저 엔티티 + */ + private User findReportedUser(UUID userId) { + try { + return userService.getUserById(userId); + } catch (UserException e) { + throw new ReportException(USER_NOT_FOUND); + } + } +} 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 6b7bacd..04d6701 100644 --- a/src/test/java/com/dodo/backend/main/service/MainServiceTest.java +++ b/src/test/java/com/dodo/backend/main/service/MainServiceTest.java @@ -92,7 +92,10 @@ void getMainPage_WithPetProfilesAndHealthReports() { .pageSize(10) .build(); - Map fullContent = Map.of("score", 95); + Map fullContent = Map.of( + "recommendations", + List.of("산책 시간 15분 늘리기", "간식 10% 줄이기") + ); LocalDateTime analysisDate = LocalDateTime.of(2025, 1, 15, 10, 30); AnalysisListItem analysisListItem = AnalysisListItem.toDto( 101L, @@ -154,7 +157,7 @@ void getMainPage_WithPetProfilesAndHealthReports() { assertEquals(analysisListItem.getAnalysisId(), healthReport.getDashboardId()); assertEquals(analysisListItem.getHealthAnalysisTitle(), healthReport.getHealthReportTitle()); assertEquals(analysisListItem.getHealthAnalysisSummary(), healthReport.getHealthReportSummary()); - assertEquals("{\"score\":95}", healthReport.getHealthReportContent()); + assertEquals("산책 시간 15분 늘리기\n간식 10% 줄이기", healthReport.getHealthReportContent()); assertEquals(analysisDate.toLocalDate(), healthReport.getCheckupDate()); assertEquals(petSummary.getPetName(), healthReport.getPetName()); diff --git a/src/test/java/com/dodo/backend/report/controller/ReportControllerTest.java b/src/test/java/com/dodo/backend/report/controller/ReportControllerTest.java new file mode 100644 index 0000000..bc2bc64 --- /dev/null +++ b/src/test/java/com/dodo/backend/report/controller/ReportControllerTest.java @@ -0,0 +1,153 @@ +package com.dodo.backend.report.controller; + +import com.dodo.backend.report.dto.request.ReportRequest.ReportCreateRequest; +import com.dodo.backend.report.dto.response.ReportResponse.ReportSimpleResponse; +import com.dodo.backend.report.entity.ReportReason; +import com.dodo.backend.report.service.ReportService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +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.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +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 java.util.UUID; + +import static org.mockito.BDDMockito.given; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +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 ReportControllerTest { + + @Mock + private ReportService reportService; + + private MockMvc mockMvc; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + ReportController reportController = new ReportController(reportService); + mockMvc = MockMvcBuilders.standaloneSetup(reportController) + .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()) + .build(); + objectMapper = new ObjectMapper(); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + /** + * 게시글 신고 요청 시 201 상태 코드와 성공 메시지를 반환하는지 검증합니다. + */ + @Test + @DisplayName("게시글 신고 성공: 201 상태 코드와 성공 메시지를 반환한다.") + void reportBoard_Success() throws Exception { + UUID reporterId = UUID.randomUUID(); + Long boardId = 1L; + UserDetails userDetails = createUserDetails(reporterId); + ReportCreateRequest request = new ReportCreateRequest(ReportReason.SPAM); + ReportSimpleResponse serviceResponse = ReportSimpleResponse.toDto("신고가 성공적으로 접수되었습니다."); + setAuthentication(userDetails); + + given(reportService.reportBoard(eq(reporterId), eq(boardId), any(ReportCreateRequest.class))) + .willReturn(serviceResponse); + + mockMvc.perform(post("/reports/board/{boardId}", boardId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.message").value("신고가 성공적으로 접수되었습니다.")); + + verify(reportService).reportBoard(eq(reporterId), eq(boardId), any(ReportCreateRequest.class)); + } + + /** + * 유저 신고 요청 시 201 상태 코드와 성공 메시지를 반환하는지 검증합니다. + */ + @Test + @DisplayName("유저 신고 성공: 201 상태 코드와 성공 메시지를 반환한다.") + void reportUser_Success() throws Exception { + UUID reporterId = UUID.randomUUID(); + UUID reportedUserId = UUID.randomUUID(); + UserDetails userDetails = createUserDetails(reporterId); + ReportCreateRequest request = new ReportCreateRequest(ReportReason.IMPERSONATION); + ReportSimpleResponse serviceResponse = ReportSimpleResponse.toDto("신고가 성공적으로 접수되었습니다."); + setAuthentication(userDetails); + + given(reportService.reportUser(eq(reporterId), eq(reportedUserId), any(ReportCreateRequest.class))) + .willReturn(serviceResponse); + + mockMvc.perform(post("/reports/user/{userId}", reportedUserId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.message").value("신고가 성공적으로 접수되었습니다.")); + + verify(reportService).reportUser(eq(reporterId), eq(reportedUserId), any(ReportCreateRequest.class)); + } + + /** + * 댓글 신고 요청 시 201 상태 코드와 성공 메시지를 반환하는지 검증합니다. + */ + @Test + @DisplayName("댓글 신고 성공: 201 상태 코드와 성공 메시지를 반환한다.") + void reportComment_Success() throws Exception { + UUID reporterId = UUID.randomUUID(); + Long commentId = 10L; + UserDetails userDetails = createUserDetails(reporterId); + ReportCreateRequest request = new ReportCreateRequest(ReportReason.SPAM_ADVERTISING); + ReportSimpleResponse serviceResponse = ReportSimpleResponse.toDto("신고가 성공적으로 접수되었습니다."); + setAuthentication(userDetails); + + given(reportService.reportComment(eq(reporterId), eq(commentId), any(ReportCreateRequest.class))) + .willReturn(serviceResponse); + + mockMvc.perform(post("/reports/comment/{commentId}", commentId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.message").value("신고가 성공적으로 접수되었습니다.")); + + verify(reportService).reportComment(eq(reporterId), eq(commentId), any(ReportCreateRequest.class)); + } + + private void setAuthentication(UserDetails userDetails) { + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(new UsernamePasswordAuthenticationToken( + userDetails, + userDetails.getPassword(), + userDetails.getAuthorities() + )); + SecurityContextHolder.setContext(context); + } + + private UserDetails createUserDetails(UUID userId) { + return User.withUsername(userId.toString()) + .password("password") + .authorities(List.of()) + .build(); + } +} diff --git a/src/test/java/com/dodo/backend/report/service/ReportServiceTest.java b/src/test/java/com/dodo/backend/report/service/ReportServiceTest.java new file mode 100644 index 0000000..abeb6f8 --- /dev/null +++ b/src/test/java/com/dodo/backend/report/service/ReportServiceTest.java @@ -0,0 +1,196 @@ +package com.dodo.backend.report.service; + +import com.dodo.backend.board.entity.Board; +import com.dodo.backend.board.service.BoardService; +import com.dodo.backend.comment.entity.Comment; +import com.dodo.backend.comment.repository.CommentRepository; +import com.dodo.backend.report.dto.request.ReportRequest.ReportCreateRequest; +import com.dodo.backend.report.dto.response.ReportResponse.ReportSimpleResponse; +import com.dodo.backend.report.entity.ReportReason; +import com.dodo.backend.report.exception.ReportException; +import com.dodo.backend.report.repository.ReportRepository; +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 com.dodo.backend.report.exception.ReportErrorCode.INVALID_REQUEST; +import static com.dodo.backend.report.exception.ReportErrorCode.REPORT_ALREADY_EXISTS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +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.never; +import static org.mockito.Mockito.verify; + +/** + * 신고 서비스 로직을 검증하는 테스트 클래스입니다. + */ +@ExtendWith(MockitoExtension.class) +class ReportServiceTest { + + @Mock + private ReportRepository reportRepository; + + @Mock + private BoardService boardService; + + @Mock + private UserService userService; + + @Mock + private CommentRepository commentRepository; + + @InjectMocks + private ReportServiceImpl reportService; + + /** + * 게시글 신고 성공 시 신고를 저장하고 성공 메시지를 반환하는지 검증합니다. + */ + @Test + @DisplayName("게시글 신고 성공: 신고를 저장하고 성공 메시지를 반환한다.") + void reportBoard_Success() { + UUID reporterId = UUID.randomUUID(); + Long boardId = 1L; + User reporter = createUser(reporterId); + Board board = createBoard(boardId); + ReportCreateRequest request = new ReportCreateRequest(ReportReason.SPAM); + + given(userService.getUserById(reporterId)).willReturn(reporter); + given(boardService.getBoardById(boardId)).willReturn(board); + given(reportRepository.existsByReporterAndBoard(reporter, board)).willReturn(false); + + ReportSimpleResponse response = reportService.reportBoard(reporterId, boardId, request); + + assertNotNull(response); + assertEquals("신고가 성공적으로 접수되었습니다.", response.getMessage()); + verify(reportRepository).save(any()); + } + + /** + * 이미 신고한 게시글이면 예외가 발생하는지 검증합니다. + */ + @Test + @DisplayName("게시글 신고 실패: 이미 신고한 게시글이면 예외가 발생한다.") + void reportBoard_Fail_AlreadyExists() { + UUID reporterId = UUID.randomUUID(); + Long boardId = 1L; + User reporter = createUser(reporterId); + Board board = createBoard(boardId); + ReportCreateRequest request = new ReportCreateRequest(ReportReason.SPAM); + + given(userService.getUserById(reporterId)).willReturn(reporter); + given(boardService.getBoardById(boardId)).willReturn(board); + given(reportRepository.existsByReporterAndBoard(reporter, board)).willReturn(true); + + ReportException exception = assertThrows(ReportException.class, + () -> reportService.reportBoard(reporterId, boardId, request)); + + assertEquals(REPORT_ALREADY_EXISTS, exception.getErrorCode()); + verify(reportRepository, never()).save(any()); + } + + /** + * 게시글 신고에서 허용되지 않는 신고 사유면 예외가 발생하는지 검증합니다. + */ + @Test + @DisplayName("게시글 신고 실패: 게시글에 허용되지 않는 신고 사유면 예외가 발생한다.") + void reportBoard_Fail_InvalidReason() { + UUID reporterId = UUID.randomUUID(); + ReportCreateRequest request = new ReportCreateRequest(ReportReason.IMPERSONATION); + + ReportException exception = assertThrows(ReportException.class, + () -> reportService.reportBoard(reporterId, 1L, request)); + + assertEquals(INVALID_REQUEST, exception.getErrorCode()); + verify(reportRepository, never()).save(any()); + } + + /** + * 유저 신고 성공 시 신고를 저장하고 성공 메시지를 반환하는지 검증합니다. + */ + @Test + @DisplayName("유저 신고 성공: 신고를 저장하고 성공 메시지를 반환한다.") + void reportUser_Success() { + UUID reporterId = UUID.randomUUID(); + UUID reportedUserId = UUID.randomUUID(); + User reporter = createUser(reporterId); + User reportedUser = createUser(reportedUserId); + ReportCreateRequest request = new ReportCreateRequest(ReportReason.IMPERSONATION); + + given(userService.getUserById(reporterId)).willReturn(reporter); + given(userService.getUserById(reportedUserId)).willReturn(reportedUser); + given(reportRepository.existsByReporterAndReportedUser(reporter, reportedUser)).willReturn(false); + + ReportSimpleResponse response = reportService.reportUser(reporterId, reportedUserId, request); + + assertNotNull(response); + assertEquals("신고가 성공적으로 접수되었습니다.", response.getMessage()); + verify(reportRepository).save(any()); + } + + /** + * 자기 자신을 신고하면 예외가 발생하는지 검증합니다. + */ + @Test + @DisplayName("유저 신고 실패: 자기 자신을 신고하면 예외가 발생한다.") + void reportUser_Fail_SelfReport() { + UUID reporterId = UUID.randomUUID(); + ReportCreateRequest request = new ReportCreateRequest(ReportReason.IMPERSONATION); + + ReportException exception = assertThrows(ReportException.class, + () -> reportService.reportUser(reporterId, reporterId, request)); + + assertEquals(INVALID_REQUEST, exception.getErrorCode()); + verify(reportRepository, never()).save(any()); + } + + /** + * 댓글 신고 성공 시 신고를 저장하고 성공 메시지를 반환하는지 검증합니다. + */ + @Test + @DisplayName("댓글 신고 성공: 신고를 저장하고 성공 메시지를 반환한다.") + void reportComment_Success() { + UUID reporterId = UUID.randomUUID(); + Long commentId = 10L; + User reporter = createUser(reporterId); + Comment comment = createComment(commentId); + ReportCreateRequest request = new ReportCreateRequest(ReportReason.SPAM_ADVERTISING); + + given(userService.getUserById(reporterId)).willReturn(reporter); + given(commentRepository.findById(commentId)).willReturn(Optional.of(comment)); + given(reportRepository.existsByReporterAndComment(reporter, comment)).willReturn(false); + + ReportSimpleResponse response = reportService.reportComment(reporterId, commentId, request); + + assertNotNull(response); + assertEquals("신고가 성공적으로 접수되었습니다.", response.getMessage()); + verify(reportRepository).save(any()); + } + + private User createUser(UUID userId) { + return User.builder() + .usersId(userId) + .build(); + } + + private Board createBoard(Long boardId) { + return Board.builder() + .boardId(boardId) + .build(); + } + + private Comment createComment(Long commentId) { + return Comment.builder() + .commentId(commentId) + .build(); + } +}